From 36846164bdb1fb9aa8a60de13c19fdf3fd21dc8e Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Fri, 4 Apr 2025 23:02:01 +0200
Subject: [PATCH] Added statistics panel

---
 frontend/src/components/Header.vue            |   7 +
 frontend/src/components/stats/MetricsForm.vue |  88 ++++++
 frontend/src/mixins/Api.vue                   |   2 +
 frontend/src/mixins/Dates.vue                 |  19 +-
 frontend/src/mixins/Text.vue                  |  16 +
 frontend/src/mixins/api/Stats.vue             |  18 ++
 frontend/src/models/LocationStats.ts          |   1 +
 frontend/src/models/StatsRequest.ts           |   1 +
 frontend/src/router/index.ts                  |   7 +
 frontend/src/styles/elements.scss             |  40 +++
 frontend/src/styles/views.scss                |   5 +
 frontend/src/views/Stats.vue                  | 275 ++++++++++++++++++
 src/responses/LocationStats.ts                |  10 +-
 13 files changed, 481 insertions(+), 8 deletions(-)
 create mode 100644 frontend/src/components/stats/MetricsForm.vue
 create mode 100644 frontend/src/mixins/Text.vue
 create mode 100644 frontend/src/mixins/api/Stats.vue
 create mode 120000 frontend/src/models/LocationStats.ts
 create mode 120000 frontend/src/models/StatsRequest.ts
 create mode 100644 frontend/src/views/Stats.vue

diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue
index 1211b28..eacecaf 100644
--- a/frontend/src/components/Header.vue
+++ b/frontend/src/components/Header.vue
@@ -32,6 +32,13 @@
               </RouterLink>
             </DropdownItem>
 
+            <DropdownItem>
+              <RouterLink to="/stats">
+                <font-awesome-icon icon="chart-line" />&nbsp;&nbsp;
+                  <span class="item-text">Statistics</span>
+              </RouterLink>
+            </DropdownItem>
+
             <DropdownItem @click="$emit('logout')">
               <font-awesome-icon icon="sign-out-alt" />&nbsp;&nbsp;
               <span class="item-text">Logout</span>
diff --git a/frontend/src/components/stats/MetricsForm.vue b/frontend/src/components/stats/MetricsForm.vue
new file mode 100644
index 0000000..dc11a2e
--- /dev/null
+++ b/frontend/src/components/stats/MetricsForm.vue
@@ -0,0 +1,88 @@
+<template>
+  <form class="metrics-form" @submit.prevent="$emit('submit', newMetrics)">
+    <div class="metrics">
+      <div v-for="enabled, metric in newMetrics" :key="metric" class="metric">
+        <label :for="metric">
+          <input
+            type="checkbox"
+            :id="metric"
+            v-model="newMetrics[metric]"
+          />&nbsp;
+          {{ displayName(metric) }}
+        </label>
+      </div>
+    </div>
+
+    <div class="buttons">
+      <button @click="$emit('close')">
+        <font-awesome-icon icon="fa-solid fa-xmark" />&nbsp;
+        Close
+      </button>
+      <button type="submit">
+        <font-awesome-icon icon="fa-solid fa-check" />&nbsp;
+        Apply
+      </button>
+    </div>
+  </form>
+</template>
+
+<script lang="ts">
+import Text from '../../mixins/Text.vue';
+
+export default {
+  emits: ['close', 'submit'],
+  mixins: [Text],
+  props: {
+    metrics: {
+      type: Object,
+      required: true,
+    },
+  },
+
+  data() {
+    return {
+      newMetrics: { ...this.metrics },
+    }
+  },
+}
+</script>
+
+<style scoped lang="scss">
+.metrics-form {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+
+  .metrics {
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+    padding: 1rem;
+
+    .metric {
+      display: flex;
+      align-items: center;
+      gap: 0.5rem;
+
+      label {
+        display: flex;
+        flex: 1;
+      }
+
+      input {
+        margin-right: 0.5rem;
+      }
+    }
+  }
+
+  .buttons {
+    display: flex;
+    justify-content: space-between;
+    gap: 1rem;
+
+    button {
+      padding: 0.5rem 1rem;
+    }
+  }
+}
+</style>
diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue
index e10871b..a68aee9 100644
--- a/frontend/src/mixins/Api.vue
+++ b/frontend/src/mixins/Api.vue
@@ -3,6 +3,7 @@ import Auth from './api/Auth.vue'
 import Devices from './api/Devices.vue'
 import GPSData from './api/GPSData.vue'
 import Sessions from './api/Sessions.vue'
+import Stats from './api/Stats.vue'
 import Users from './api/Users.vue'
 
 export default {
@@ -11,6 +12,7 @@ export default {
     Devices,
     GPSData,
     Sessions,
+    Stats,
     Users,
   ],
 }
diff --git a/frontend/src/mixins/Dates.vue b/frontend/src/mixins/Dates.vue
index c526090..abe609c 100644
--- a/frontend/src/mixins/Dates.vue
+++ b/frontend/src/mixins/Dates.vue
@@ -1,12 +1,27 @@
 <script lang="ts">
 export default {
   methods: {
-    formatDate(date: Date | number | string | null | undefined): string {
+    formatDate(date: Date | number | string | null | undefined, opts: {
+      dayOfWeek?: boolean,
+      seconds?: boolean,
+    } = {
+      dayOfWeek: true,
+      seconds: true,
+    }): string {
       if (!date) {
         return '-'
       }
 
-      return new Date(date).toString().replace(/GMT.*/, '')
+      let dateStr = this.normalizeDate(date).toString().replace(/GMT.*/, '').trim() as string
+      if (!opts.dayOfWeek) {
+        dateStr = dateStr.slice(4)
+      }
+
+      if (!opts.seconds) {
+        dateStr = dateStr.slice(0, -3)
+      }
+
+      return dateStr
     },
 
     normalizeDate(date: any): Date | null {
diff --git a/frontend/src/mixins/Text.vue b/frontend/src/mixins/Text.vue
new file mode 100644
index 0000000..49b1ef8
--- /dev/null
+++ b/frontend/src/mixins/Text.vue
@@ -0,0 +1,16 @@
+<script lang="ts">
+export default {
+  methods: {
+    displayName(text: string) {
+      if (!text?.length || text === 'undefined' || text === 'null' || text === 'None') {
+        return '<missing>'
+      }
+
+      return text
+        .replace(/([A-Z])/g, ' $1')
+        .replace(/^./, (str) => str.toUpperCase())
+        .trim();
+    },
+  },
+}
+</script>
diff --git a/frontend/src/mixins/api/Stats.vue b/frontend/src/mixins/api/Stats.vue
new file mode 100644
index 0000000..5f73785
--- /dev/null
+++ b/frontend/src/mixins/api/Stats.vue
@@ -0,0 +1,18 @@
+<script lang="ts">
+import LocationStats from '../../models/LocationStats';
+import StatsRequest from '../../models/StatsRequest';
+import Common from './Common.vue';
+
+export default {
+  mixins: [Common],
+  methods: {
+    async getStats(query: StatsRequest): Promise<LocationStats[]> {
+      return (
+        await this.request('/stats', {
+          query: query as Record<string, any>
+        }) || []
+      ).map((record: any) => new LocationStats(record));
+    },
+  },
+}
+</script>
diff --git a/frontend/src/models/LocationStats.ts b/frontend/src/models/LocationStats.ts
new file mode 120000
index 0000000..85b600d
--- /dev/null
+++ b/frontend/src/models/LocationStats.ts
@@ -0,0 +1 @@
+../../../src/responses/LocationStats.ts
\ No newline at end of file
diff --git a/frontend/src/models/StatsRequest.ts b/frontend/src/models/StatsRequest.ts
new file mode 120000
index 0000000..ddcad1f
--- /dev/null
+++ b/frontend/src/models/StatsRequest.ts
@@ -0,0 +1 @@
+../../../src/requests/StatsRequest.ts
\ No newline at end of file
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 15f1b82..0ca43f4 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -4,6 +4,7 @@ import Devices from '../views/Devices.vue'
 import HomeView from '../views/HomeView.vue'
 import Login from '../views/Login.vue'
 import Logout from '../views/Logout.vue'
+import Stats from '../views/Stats.vue'
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -26,6 +27,12 @@ const router = createRouter({
       component: API,
     },
 
+    {
+      path: '/stats',
+      name: 'stats',
+      component: Stats,
+    },
+
     {
       path: '/login',
       name: 'login',
diff --git a/frontend/src/styles/elements.scss b/frontend/src/styles/elements.scss
index fd63aa1..f4cddd8 100644
--- a/frontend/src/styles/elements.scss
+++ b/frontend/src/styles/elements.scss
@@ -1,3 +1,5 @@
+$table-header-height: 2rem;
+
 // Common element styles
 button {
   margin: 0 0.5em;
@@ -84,3 +86,41 @@ form {
     }
   }
 }
+
+table {
+  width: 100%;
+  height: 100%;
+  border-collapse: collapse;
+
+  thead {
+    height: $table-header-height;
+
+    tr {
+      position: sticky;
+      top: 0;
+      background-color: var(--color-background);
+      z-index: 1;
+
+      th {
+        padding: 0.5rem;
+        text-align: left;
+        font-weight: bold;
+      }
+    }
+  }
+
+  tbody {
+    height: calc(100% - #{$table-header-height});
+
+    tr {
+      td {
+        padding: 0.5rem;
+        border-bottom: 1px solid var(--color-border);
+      }
+
+      &:hover {
+        background-color: var(--color-background-soft);
+      }
+    }
+  }
+}
diff --git a/frontend/src/styles/views.scss b/frontend/src/styles/views.scss
index 3f7f7a0..32462ec 100644
--- a/frontend/src/styles/views.scss
+++ b/frontend/src/styles/views.scss
@@ -7,6 +7,10 @@
   justify-content: center;
   padding: 2em;
 
+  @include until(tablet) {
+    padding: 0;
+  }
+
   .wrapper {
     height: 100%;
     display: flex;
@@ -32,6 +36,7 @@
     flex-direction: column;
     align-items: center;
     position: relative;
+    overflow: auto;
 
     @include media(tablet) {
       min-width: 30em;
diff --git a/frontend/src/views/Stats.vue b/frontend/src/views/Stats.vue
new file mode 100644
index 0000000..ee7112b
--- /dev/null
+++ b/frontend/src/views/Stats.vue
@@ -0,0 +1,275 @@
+<template>
+  <div class="stats view">
+    <div class="wrapper">
+      <h1>
+        <font-awesome-icon icon="chart-line" />&nbsp;
+        Statistics
+      </h1>
+
+      <div class="loading-container" v-if="loading">
+        <Loading />
+      </div>
+
+      <div class="list table-container" v-else>
+        <table class="table" v-if="stats.length">
+          <thead>
+            <tr>
+              <th class="key" v-for="attr in Object.keys(stats[0].key)" :key="attr">
+                {{ displayName(attr) }}
+              </th>
+              <th class="count"># of records</th>
+              <th class="date">First record</th>
+              <th class="date">Last record</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="stat in stats" :key="stat.key">
+              <td class="key" v-for="value, attr in stat.key" :key="attr">
+                {{ displayValue(attr, value) }}
+              </td>
+              <td class="count">{{ stat.count }}</td>
+              <td class="date">{{ displayDate(stat.startDate) }}</td>
+              <td class="date">{{ displayDate(stat.endDate) }}</td>
+            </tr>
+          </tbody>
+        </table>
+
+        <div class="no-data" v-else>
+          No data available
+        </div>
+      </div>
+    </div>
+
+    <Modal :visible="showSelectForm" @close="closeForm">
+      <template v-slot:title>
+        Select metrics
+      </template>
+
+      <MetricsForm
+        :metrics="metrics"
+        v-if="showSelectForm"
+        @close="closeForm"
+        @submit="onMetricsSubmit" />
+    </Modal>
+
+    <FloatingButton
+        icon="fas fa-table"
+        title="Select metrics"
+        :primary="true"
+        @click="showSelectForm = true" />
+  </div>
+</template>
+
+<script lang="ts">
+import { getCountryData, getEmojiFlag } from 'countries-list';
+import type { TCountryCode } from 'countries-list';
+
+import Api from '../mixins/Api.vue';
+import Dates from '../mixins/Dates.vue';
+import FloatingButton from '../elements/FloatingButton.vue';
+import Loading from '../elements/Loading.vue';
+import LocationStats from '../models/LocationStats';
+import MetricsForm from '../components/stats/MetricsForm.vue';
+import Modal from '../elements/Modal.vue';
+import StatsRequest from '../models/StatsRequest';
+import Text from '../mixins/Text.vue';
+
+export default {
+  mixins: [
+    Api,
+    Dates,
+    Text,
+  ],
+
+  components: {
+    FloatingButton,
+    Loading,
+    Modal,
+    MetricsForm,
+  },
+
+  data() {
+    return {
+      loading: false,
+      metrics: {
+        country: true,
+        locality: false,
+        address: false,
+        postalCode: false,
+        description: false,
+      },
+      showSelectForm: false,
+      stats: [] as LocationStats[],
+    }
+  },
+
+  computed: {
+    query() {
+      return new StatsRequest({
+        userId: this.$root.user.id,
+        groupBy: Object.entries(this.metrics)
+          .filter(([_, enabled]) => enabled)
+          .map(([metric]) => metric),
+      })
+    },
+  },
+
+  methods: {
+    async refresh() {
+      this.loading = true;
+      try {
+        this.stats = await this.getStats(this.query);
+      } finally {
+        this.loading = false;
+      }
+    },
+
+    setURLQuery() {
+      const enabledMetrics = Object.entries(this.metrics)
+        .filter(([_, enabled]) => enabled)
+        .map(([metric]) => metric);
+
+      window.history.replaceState(
+        window.history.state,
+        '',
+        `${window.location.pathname}#groupBy=${enabledMetrics.sort().join(',')}`,
+      );
+    },
+
+    setState() {
+      const hash = window.location.hash;
+      if (!hash) {
+        return;
+      }
+
+      const params = new URLSearchParams(hash.slice(1));
+      const groupBy = params.get('groupBy');
+      if (!groupBy) {
+        return;
+      }
+
+      const metrics = Object.fromEntries(
+        Object.entries(this.metrics).map(([key]) => [key, false]),
+      );
+
+      for (const metric of groupBy.split(',')) {
+        if (metrics[metric] !== undefined) {
+          metrics[metric] = true;
+        }
+      }
+
+      this.metrics = metrics;
+    },
+
+    closeForm() {
+      this.showSelectForm = false;
+    },
+
+    onMetricsSubmit(newMetrics: Record<string, boolean>) {
+      this.metrics = newMetrics;
+      this.closeForm();
+    },
+
+    displayValue(key: string, value: any) {
+      if (key === 'country') {
+        return this.displayCountry(value);
+      }
+
+      if (value instanceof Date) {
+        return this.displayDate(value);
+      }
+
+      return value;
+    },
+
+    displayCountry(countryCode?: string) {
+      if (!countryCode?.length) {
+        return "<missing>";
+      }
+
+      const cc = countryCode.toUpperCase() as TCountryCode;
+      const country = getCountryData(cc);
+      if (!country) {
+        return countryCode;
+      }
+
+      return `${getEmojiFlag(cc)} ${country.name}`;
+    },
+
+    displayDate(date: Date | string | number | null | undefined) {
+      return this.formatDate(date, { dayOfWeek: false, seconds: false });
+    },
+  },
+
+  watch: {
+    query: {
+      handler() {
+        this.setURLQuery();
+        this.refresh();
+      },
+      deep: true,
+    },
+
+    showSelectForm(newValue: boolean) {
+      if (newValue) {
+        this.$nextTick(() => {
+          this.$refs.metricsForm?.focus();
+        });
+      }
+    },
+  },
+
+  async created() {
+    this.setState();
+    this.setURLQuery();
+    await this.refresh();
+  },
+}
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/common.scss' as *;
+
+.stats.view {
+  .wrapper {
+    display: flex;
+    padding: 1rem;
+    flex-direction: column;
+    align-items: center;
+
+    @include until(desktop) {
+      width: 100%;
+      min-width: 100%;
+      max-width: 100%;
+    }
+
+    @include from(desktop) {
+      width: 80%;
+      max-width: 1000px;
+    }
+
+    h1 {
+      margin-bottom: 1rem;
+    }
+  }
+
+  table {
+    tbody {
+      tr {
+        td {
+          &.count {
+            text-align: right;
+            padding-right: 1.75rem;
+            font-weight: bold;
+            opacity: 0.8;
+          }
+
+          &.date {
+            opacity: 0.6;
+          }
+        }
+      }
+    }
+  }
+}
+</style>
diff --git a/src/responses/LocationStats.ts b/src/responses/LocationStats.ts
index cdc25dc..0e7b344 100644
--- a/src/responses/LocationStats.ts
+++ b/src/responses/LocationStats.ts
@@ -1,16 +1,14 @@
-import {Optional} from "~/types";
-
 class LocationStats {
   public key: Record<string, any>;
   public count: number;
-  public startDate: Optional<Date>;
-  public endDate: Optional<Date>;
+  public startDate: Date | undefined | null;
+  public endDate: Date | undefined | null;
 
   constructor(data: {
     key: Record<string, any>;
     count: number;
-    startDate: Optional<Date>;
-    endDate: Optional<Date>;
+    startDate?: Date | undefined | null;
+    endDate?: Date | undefined | null;
   }) {
     this.key = data.key;
     this.count = data.count;