Added statistics panel

This commit is contained in:
Fabio Manganiello 2025-04-04 23:02:01 +02:00
parent 4f437a63ee
commit 36846164bd
Signed by: blacklight
GPG key ID: D90FBA7F76362774
13 changed files with 481 additions and 8 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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,
],
}

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1 @@
../../../src/responses/LocationStats.ts

View file

@ -0,0 +1 @@
../../../src/requests/StatsRequest.ts

View file

@ -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',

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -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>

View file

@ -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;