implementing snapshots

This commit is contained in:
Emily
2024-07-26 01:29:58 +02:00
parent 2c9f5c45f8
commit e9505e24a0
12 changed files with 238 additions and 45 deletions

View File

@@ -29,6 +29,13 @@ const debugMode = process.dev;
const { isOpen, close } = useMenu(); const { isOpen, close } = useMenu();
const { snapshots, snapshot } = useSnapshot();
const snapshotsItems = computed(() => {
if (!snapshots.data.value) return []
return snapshots.data.value as any[];
})
</script> </script>
<template> <template>
@@ -49,7 +56,32 @@ const { isOpen, close } = useMenu();
</div> </div>
</div> </div>
<div class="px-4 w-full flex-col">
<USelectMenu class="w-full" v-model="snapshot" :options="snapshotsItems">
<template #label>
<div class="flex items-center gap-2">
<div :style="'background-color:' + snapshot?.color" class="w-2 h-2 rounded-full">
</div>
<div> {{ snapshot?.name }} </div>
</div>
</template>
<template #option="{ option }">
<div class="flex items-center gap-2">
<div :style="'background-color:' + option.color" class="w-2 h-2 rounded-full">
</div>
<div> {{ option.name }} </div>
</div>
</template>
</USelectMenu>
<div v-if="snapshot">
<div> {{ new Date(snapshot.from).toLocaleString('it-IT') }} </div>
<div> {{ new Date(snapshot.to).toLocaleString('it-IT') }}</div>
</div>
</div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-for="section of sections" class="flex flex-col gap-1"> <div v-for="section of sections" class="flex flex-col gap-1">

View File

@@ -17,7 +17,7 @@ const props = defineProps<{
<template> <template>
<Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full"> <Card class="flex flex-col overflow-hidden relative max-h-[12rem] aspect-[2/1] w-full">
<div class="flex p-4 items-start"> <div v-if="ready" class="flex p-4 items-start">
<div class="flex items-center mt-2 mr-4"> <div class="flex items-center mt-2 mr-4">
<i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i> <i :style="`color: ${props.color}`" :class="icon" class="text-[1.6rem] 2xl:text-[2rem]"></i>
</div> </div>
@@ -40,7 +40,7 @@ const props = defineProps<{
</div> </div>
</div> </div>
<div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="(props.data?.length || 0) > 0"> <div class="absolute bottom-0 left-0 w-full h-[50%] flex items-end" v-if="((props.data?.length || 0) > 0) && ready">
<DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []" <DashboardEmbedChartCard v-if="ready" :data="props.data || []" :labels="props.labels || []"
:color="props.color"> :color="props.color">
</DashboardEmbedChartCard> </DashboardEmbedChartCard>

View File

@@ -9,8 +9,22 @@ const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
const { snapshot } = useSnapshot();
const snapshotFrom = computed(() => {
return new Date(snapshot.value?.from || '0').toISOString();
});
const snapshotTo = computed(() => {
return new Date(snapshot.value?.to || Date.now()).toISOString();
});
async function loadData() { async function loadData() {
const response = await useTimeline('sessions', props.slice); ready.value = false;
const response = await useTimeline('sessions', props.slice,
snapshotFrom.value.toString(),
snapshotTo.value.toString()
);
if (!response) return; if (!response) return;
data.value = response.map(e => e.count); data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice)); labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
@@ -20,6 +34,7 @@ async function loadData() {
onMounted(async () => { onMounted(async () => {
await loadData(); await loadData();
watch(props, async () => { await loadData(); }); watch(props, async () => { await loadData(); });
watch(snapshot, async () => { await loadData(); });
}) })
</script> </script>

View File

@@ -4,24 +4,50 @@ import DateService from '@services/DateService';
const { data: metricsInfo } = useMetricsData(); const { data: metricsInfo } = useMetricsData();
type Data = {
data: number[],
labels: string[],
trend: number,
ready: boolean
}
const { snapshot } = useSnapshot()
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const snapshotFrom = computed(() => {
return new Date(snapshot.value?.from || '0').getTime();
});
const snapshotTo = computed(() => {
return new Date(snapshot.value?.to || Date.now()).getTime();
});
const avgVisitDay = computed(() => { const avgVisitDay = computed(() => {
if (!metricsInfo.value) return '0.00'; const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24; const counts = visitsData.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.visitsCount / Math.max(days, 1); const avg = counts / Math.max(days, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
const avgEventsDay = computed(() => { const avgEventsDay = computed(() => {
if (!metricsInfo.value) return '0.00'; const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
const days = (Date.now() - (metricsInfo.value?.firstEventDate || 0)) / 1000 / 60 / 60 / 24; const counts = eventsData.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.eventsCount / Math.max(days, 1); const avg = counts / Math.max(days, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
const avgSessionsDay = computed(() => { const avgSessionsDay = computed(() => {
if (!metricsInfo.value) return '0.00'; const days = (snapshotTo.value - snapshotFrom.value) / 1000 / 60 / 60 / 24;
const days = (Date.now() - (metricsInfo.value?.firstViewDate || 0)) / 1000 / 60 / 60 / 24; const counts = sessionsData.data.reduce((a, e) => e + a, 0);
const avg = metricsInfo.value.sessionsVisitsCount / Math.max(days, 1); const avg = counts / Math.max(days, 1);
return avg.toFixed(2); return avg.toFixed(2);
}); });
@@ -49,23 +75,19 @@ const avgSessionDuration = computed(() => {
return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s` return `${hours > 0 ? hours + 'h ' : ''}${minutes}m ${seconds.toFixed()}s`
}); });
type Data = {
data: number[],
labels: string[],
trend: number,
ready: boolean
}
const visitsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const eventsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
const sessionsDurationData = reactive<Data>({ data: [], labels: [], trend: 0, ready: false });
async function loadData(timelineEndpointName: string, target: Data) { async function loadData(timelineEndpointName: string, target: Data) {
const response = await useTimeline(timelineEndpointName as any, 'day'); target.ready = false;
const response = await useTimeline(timelineEndpointName as any, 'day',
snapshot.value?.from.toString() || "0",
snapshot.value?.to.toString() || Date.now().toString()
);
console.log(timelineEndpointName,response);
if (!response) return; if (!response) return;
target.data = response.map(e => e.count); target.data = response.map(e => e.count);
target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day')); target.labels = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, 'day'));
@@ -80,12 +102,23 @@ async function loadData(timelineEndpointName: string, target: Data) {
target.ready = true; target.ready = true;
} }
async function loadAllData() {
console.log('LOAD ALL DATA')
await Promise.all([
loadData('visits', visitsData),
loadData('events', eventsData),
loadData('sessions', sessionsData),
loadData('sessions_duration', sessionsDurationData),
])
}
onMounted(async () => { onMounted(async () => {
await loadData('visits', visitsData); await loadAllData();
await loadData('events', eventsData); watch(snapshot, async () => {
await loadData('sessions', sessionsData); await loadAllData();
await loadData('sessions_duration', sessionsDurationData); })
}); });
@@ -98,17 +131,18 @@ onMounted(async () => {
<div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo"> <div class="gap-6 px-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 m-cards-wrap:grid-cols-4" v-if="metricsInfo">
<DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits" <DashboardCountCard :ready="visitsData.ready" icon="far fa-earth" text="Total page visits"
:value="formatNumberK(metricsInfo.visitsCount)" :avg="formatNumberK(avgVisitDay) + '/day'" :value="formatNumberK(visitsData.data.reduce((a, e) => a + e, 0))"
:trend="visitsData.trend" :data="visitsData.data" :labels="visitsData.labels" color="#5655d7"> :avg="formatNumberK(avgVisitDay) + '/day'" :trend="visitsData.trend" :data="visitsData.data"
:labels="visitsData.labels" color="#5655d7">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events" <DashboardCountCard :ready="eventsData.ready" icon="far fa-flag" text="Total custom events"
:value="formatNumberK(metricsInfo.eventsCount)" :avg="formatNumberK(avgEventsDay) + '/day'" :value="formatNumberK(eventsData.data.reduce((a, e) => a + e, 0))" :avg="formatNumberK(avgEventsDay) + '/day'"
:trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86"> :trend="eventsData.trend" :data="eventsData.data" :labels="eventsData.labels" color="#1e9b86">
</DashboardCountCard> </DashboardCountCard>
<DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions" <DashboardCountCard :ready="sessionsData.ready" icon="far fa-user" text="Unique visits sessions"
:value="formatNumberK(metricsInfo.sessionsVisitsCount)" :avg="formatNumberK(avgSessionsDay) + '/day'" :value="formatNumberK(sessionsData.data.reduce((a, e) => a + e, 0))" :avg="formatNumberK(avgSessionsDay) + '/day'"
:trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8"> :trend="sessionsData.trend" :data="sessionsData.data" :labels="sessionsData.labels" color="#4abde8">
</DashboardCountCard> </DashboardCountCard>

View File

@@ -8,8 +8,23 @@ const ready = ref<boolean>(false);
const props = defineProps<{ slice: Slice }>(); const props = defineProps<{ slice: Slice }>();
const { snapshot } = useSnapshot();
const snapshotFrom = computed(() => {
return new Date(snapshot.value?.from || '0').toISOString();
});
const snapshotTo = computed(() => {
return new Date(snapshot.value?.to || Date.now()).toISOString();
});
async function loadData() { async function loadData() {
const response = await useTimeline('visits', props.slice); ready.value = false;
const response = await useTimeline('visits', props.slice,
snapshotFrom.value.toString(),
snapshotTo.value.toString()
);
if (!response) return; if (!response) return;
data.value = response.map(e => e.count); data.value = response.map(e => e.count);
labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice)); labels.value = response.map(e => DateService.getChartLabelFromISO(e._id, navigator.language, props.slice));
@@ -19,6 +34,7 @@ async function loadData() {
onMounted(async () => { onMounted(async () => {
await loadData(); await loadData();
watch(props, async () => { await loadData(); }); watch(props, async () => { await loadData(); });
watch(snapshot, async () => { await loadData(); });
}) })
</script> </script>

View File

@@ -6,13 +6,24 @@ const { data: websites, pending, refresh } = useWebsitesData();
const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value); const currentViewData = ref<(VisitsWebsiteAggregated[] | null)>(websites.value);
watch(pending, () => {
currentViewData.value = websites.value;
})
const isPagesView = ref<boolean>(false); const isPagesView = ref<boolean>(false);
const isLoading = ref<boolean>(false); const isLoading = ref<boolean>(false);
const { snapshot } = useSnapshot()
watch(pending, () => {
isLoading.value = true;
currentViewData.value = websites.value;
isLoading.value = false;
});
watch(snapshot, () => {
refresh();
});
async function showDetails(website: string) { async function showDetails(website: string) {
if (isPagesView.value == true) return; if (isPagesView.value == true) return;
isLoading.value = true; isLoading.value = true;
@@ -21,6 +32,7 @@ async function showDetails(website: string) {
const { data: pagesData, pending } = usePagesData(website, 10); const { data: pagesData, pending } = usePagesData(website, 10);
watch(pending, () => { watch(pending, () => {
isLoading.value = true;
currentViewData.value = pagesData.value; currentViewData.value = pagesData.value;
isLoading.value = false; isLoading.value = false;
}) })

View File

@@ -87,12 +87,18 @@ export function usePagesData(website: string, limit: number = 10) {
} }
const { safeSnapshotDates } = useSnapshot()
export function useWebsitesData(limit: number = 10) { export function useWebsitesData(limit: number = 10) {
const activeProject = useActiveProject(); const activeProject = useActiveProject();
const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, { const res = useFetch<VisitsWebsiteAggregated[]>(`/api/metrics/${activeProject.value?._id}/data/websites`, {
...signHeaders({ 'x-query-limit': limit.toString() }), ...signHeaders({
key: `websites_data:${limit}`, 'x-query-limit': limit.toString(),
lazy: true 'x-from': safeSnapshotDates.value.from,
'x-to': safeSnapshotDates.value.to
}),
key: `websites_data:${limit}:${safeSnapshotDates.value.from}:${safeSnapshotDates.value.to}`,
lazy: true,
}); });
return res; return res;
} }

View File

@@ -0,0 +1,26 @@
import type { TProjectSnapshot } from "@schema/ProjectSnapshot";
const snapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
...signHeaders(),
immediate: false
});
const snapshot = ref<TProjectSnapshot>();
watch(snapshots.data, () => {
if (!snapshots.data.value) return;
snapshot.value = snapshots.data.value[0];
});
const safeSnapshotDates = computed(() => {
const from = new Date(snapshot.value?.from || 0).toISOString();
const to = new Date(snapshot.value?.to || Date.now()).toISOString();
return { from, to }
})
export function useSnapshot() {
if (snapshots.status.value === 'idle') {
snapshots.execute();
}
return { snapshot, snapshots, safeSnapshotDates }
}

View File

@@ -91,7 +91,7 @@ const selectLabels = [
<DashboardTopCards></DashboardTopCards> <DashboardTopCards></DashboardTopCards>
<div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row"> <!-- <div class="mt-6 px-6 flex gap-6 flex-col 2xl:flex-row">
<CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits."> <CardTitled class="p-4 flex-1" title="Visits trends" sub="Shows trends in page visits.">
<template #header> <template #header>
@@ -117,7 +117,7 @@ const selectLabels = [
</div> </div>
</CardTitled> </CardTitled>
</div> </div> -->
<div class="flex w-full justify-center mt-6 px-6"> <div class="flex w-full justify-center mt-6 px-6">

View File

@@ -22,12 +22,25 @@ export default defineEventHandler(async event => {
const limit = getRequestHeader(event, 'x-query-limit'); const limit = getRequestHeader(event, 'x-query-limit');
const numLimit = parseInt(limit || '10'); const numLimit = parseInt(limit || '10');
const from = getRequestHeader(event, 'x-from');
const to = getRequestHeader(event, 'x-to');
if (!from || !to) return setResponseStatus(event, 400, 'x-from and x-to headers missing');
return await Redis.useCache({ return await Redis.useCache({
key: `websites:${project_id}:${numLimit}`, key: `websites:${project_id}:${numLimit}`,
exp: DATA_EXPIRE_TIME exp: DATA_EXPIRE_TIME
}, async () => { }, async () => {
const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([ const websites: VisitsWebsiteAggregated[] = await VisitModel.aggregate([
{ $match: { project_id: project._id }, }, {
$match: {
project_id: project._id,
created_at: {
$gte: new Date(from),
$lte: new Date(to)
}
},
},
{ $group: { _id: "$website", count: { $sum: 1, } } }, { $group: { _id: "$website", count: { $sum: 1, } } },
{ $sort: { count: -1 } }, { $sort: { count: -1 } },
{ $limit: numLimit } { $limit: numLimit }

View File

@@ -0,0 +1,19 @@
import { ProjectSnapshotModel, TProjectSnapshot } from "@schema/ProjectSnapshot";
import { UserSettingsModel } from "@schema/UserSettings";
export default defineEventHandler(async event => {
const userData = getRequestUser(event);
if (!userData?.logged) return setResponseStatus(event, 400, 'NotLogged');
const currentActiveProject = await UserSettingsModel.findOne({ user_id: userData.id });
if (!currentActiveProject) return setResponseStatus(event, 400, 'You need to select a project');
const project_id = currentActiveProject.active_project_id;
const snapshots = await ProjectSnapshotModel.find({ project_id });
return snapshots.map(e => e.toJSON()) as TProjectSnapshot[];
});

View File

@@ -0,0 +1,20 @@
import { model, Schema, Types } from 'mongoose';
export type TProjectSnapshot = {
_id: Schema.Types.ObjectId,
project_id: Schema.Types.ObjectId,
name: string,
from: Date,
to: Date,
color: string
}
const ProjectSnapshotSchema = new Schema<TProjectSnapshot>({
project_id: { type: Types.ObjectId, index: true },
name: { type: String, required: true },
from: { type: Date, required: true },
to: { type: Date, required: true },
color: { type: String, required: true },
});
export const ProjectSnapshotModel = model<TProjectSnapshot>('project_snapshots', ProjectSnapshotSchema);