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" /> + <span class="item-text">Statistics</span> + </RouterLink> + </DropdownItem> + <DropdownItem @click="$emit('logout')"> <font-awesome-icon icon="sign-out-alt" /> <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]" + /> + {{ displayName(metric) }} + </label> + </div> + </div> + + <div class="buttons"> + <button @click="$emit('close')"> + <font-awesome-icon icon="fa-solid fa-xmark" /> + Close + </button> + <button type="submit"> + <font-awesome-icon icon="fa-solid fa-check" /> + 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" /> + 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;