add snapshots and fix top cards following it

This commit is contained in:
Emily
2024-11-20 16:43:52 +01:00
parent ec974c3599
commit 606eb0b035
8 changed files with 191 additions and 109 deletions

View File

@@ -208,8 +208,7 @@ const pricingDrawer = usePricingDrawer();
<div v-if="snapshot" class="flex flex-col text-[.7rem] mt-2">
<div class="flex gap-1 items-center justify-center text-lyx-text-dark">
<div class="poppins">
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim()
}}
{{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
</div>
<div class="poppins"> to </div>
<div class="poppins">
@@ -217,7 +216,7 @@ const pricingDrawer = usePricingDrawer();
</div>
</div>
<div class="mt-2" v-if="snapshot._id.toString().startsWith('default') === false">
<div class="mt-2" v-if="('default' in snapshot == false)">
<UPopover placement="bottom">
<LyxUiButton type="danger" class="w-full text-center">
Delete current snapshot

View File

@@ -3,19 +3,13 @@
import DateService from '@services/DateService';
import type { Slice } from '@services/DateService';
const { snapshot, safeSnapshotDates } = useSnapshot()
const { snapshot, safeSnapshotDates, snapshotDuration } = useSnapshot()
const snapshotDays = computed(() => {
const to = new Date(safeSnapshotDates.value.to).getTime();
const from = new Date(safeSnapshotDates.value.from).getTime();
return (to - from) / 1000 / 60 / 60 / 24;
});
const chartSlice = computed(() => {
const snapshotSizeMs = new Date(snapshot.value.to).getTime() - new Date(snapshot.value.from).getTime();
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 6) return 'hour' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 30) return 'day' as Slice;
if (snapshotSizeMs < 1000 * 60 * 60 * 24 * 90) return 'day' as Slice;
if (snapshotDuration.value <= 3) return 'hour' as Slice;
if (snapshotDuration.value <= 3 * 7) return 'day' as Slice;
if (snapshotDuration.value <= 3 * 30) return 'week' as Slice;
return 'month' as Slice;
});
@@ -42,29 +36,30 @@ function transformResponse(input: { _id: string, count: number }[]) {
}
const visitsData = useFetch('/api/timeline/visits', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
});
const sessionsData = useFetch('/api/timeline/sessions', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
});
const sessionsDurationData = useFetch('/api/timeline/sessions_duration', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
});
const bouncingRateData = useFetch('/api/timeline/bouncing_rate', {
headers: useComputedHeaders({ slice: chartSlice.value }), lazy: true, transform: transformResponse
headers: useComputedHeaders({ slice: chartSlice }), lazy: true, transform: transformResponse
});
const avgVisitDay = computed(() => {
if (!visitsData.data.value) return '0.00';
const counts = visitsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(snapshotDays.value, 1);
const avg = counts / Math.max(snapshotDuration.value, 1);
return avg.toFixed(2);
});
const avgSessionsDay = computed(() => {
if (!sessionsData.data.value) return '0.00';
const counts = sessionsData.data.value.data.reduce((a, e) => e + a, 0);
const avg = counts / Math.max(snapshotDays.value, 1);
const avg = counts / Math.max(snapshotDuration.value, 1);
return avg.toFixed(2);
});
@@ -123,8 +118,8 @@ const avgSessionDuration = computed(() => {
</DashboardCountCard>
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer"
text="Visit duration" :value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
<DashboardCountCard :ready="!sessionsDurationData.pending.value" icon="far fa-timer" text="Visit duration"
:value="avgSessionDuration" :trend="sessionsDurationData.data.value?.trend"
:data="sessionsDurationData.data.value?.data" :labels="sessionsDurationData.data.value?.labels"
color="#f56523">
</DashboardCountCard>

View File

@@ -0,0 +1,79 @@
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
import * as fns from 'date-fns';
export type DefaultSnapshot = TProjectSnapshot & { default: true }
export type GenericSnapshot = TProjectSnapshot | DefaultSnapshot;
export function getDefaultSnapshots(project_id: TProjectSnapshot['project_id']) {
const today: DefaultSnapshot = {
project_id,
_id: '___today' as any,
name: 'Today',
from: fns.startOfDay(Date.now()),
to: fns.endOfDay(Date.now()),
color: '#CC11CC',
default: true
}
const lastDay: DefaultSnapshot = {
project_id,
_id: '___lastDay' as any,
name: 'Last Day',
from: fns.startOfDay(fns.subDays(Date.now(), 1)),
to: fns.endOfDay(fns.subDays(Date.now(), 1)),
color: '#CC11CC',
default: true
}
const currentWeek: DefaultSnapshot = {
project_id,
_id: '___currentWeek' as any,
name: 'Current Week',
from: fns.startOfWeek(Date.now()),
to: fns.endOfWeek(Date.now()),
color: '#CC11CC',
default: true
}
const lastWeek: DefaultSnapshot = {
project_id,
_id: '___lastWeek' as any,
name: 'Last Week',
from: fns.startOfWeek(fns.subWeeks(Date.now(), 1)),
to: fns.endOfWeek(fns.subWeeks(Date.now(), 1)),
color: '#CC11CC',
default: true
}
const currentMonth: DefaultSnapshot = {
project_id,
_id: '___currentMonth' as any,
name: 'Current Month',
from: fns.startOfMonth(Date.now()),
to: fns.endOfMonth(Date.now()),
color: '#CC11CC',
default: true
}
const lastMonth: DefaultSnapshot = {
project_id,
_id: '___lastMonth' as any,
name: 'Last Month',
from: fns.startOfMonth(fns.subMonths(Date.now(), 1)),
to: fns.endOfMonth(fns.subMonths(Date.now(), 1)),
color: '#CC11CC',
default: true
}
const snapshotList = [lastDay, today, lastMonth, currentMonth, lastWeek, currentWeek]
return snapshotList;
}

View File

@@ -25,7 +25,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
const useActivePid = customOptions?.useActivePid || true;
const headers = computed<Record<string, string>>(() => {
console.trace('Computed recalculated');
const parsedCustom: Record<string, string> = {}
const customKeys = Object.keys(customOptions?.custom || {});
for (const key of customKeys) {
@@ -43,5 +43,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) {
}
})
return headers;
}

View File

@@ -1,6 +1,6 @@
import type { TProjectSnapshot } from "@schema/project/ProjectSnapshot";
import fns from 'date-fns';
import { getDefaultSnapshots, type GenericSnapshot } from "./snapshots/BaseSnapshots";
import * as fns from 'date-fns';
const { projectId, project } = useProject();
@@ -10,59 +10,20 @@ const headers = computed(() => {
'x-pid': projectId.value ?? ''
}
});
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', {
headers
});
const remoteSnapshots = useFetch<TProjectSnapshot[]>('/api/project/snapshots', { headers });
watch(project, async () => {
await remoteSnapshots.refresh();
snapshot.value = isLiveDemo.value ? snapshots.value[0] : snapshots.value[1];
});
const snapshots = computed(() => {
const getDefaultSnapshots: () => TProjectSnapshot[] = () => [
{
project_id: project.value?._id as any,
_id: 'default0' as any,
name: 'All',
from: new Date(project.value?.created_at || 0),
to: new Date(Date.now()),
color: '#CCCCCC'
},
{
project_id: project.value?._id as any,
_id: 'current_month' as any,
name: 'Current month',
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
to: new Date(Date.now()),
color: '#00CC00'
},
{
project_id: project.value?._id as any,
_id: 'default2' as any,
name: 'Last week',
from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
to: new Date(Date.now()),
color: '#0F02D2'
},
{
project_id: project.value?._id as any,
_id: 'default3' as any,
name: 'Last day',
from: new Date(Date.now() - 1000 * 60 * 60 * 24),
to: new Date(Date.now()),
color: '#CC11CC'
}
]
return [
...getDefaultSnapshots(),
...(remoteSnapshots.data.value || [])
];
const snapshots = computed<GenericSnapshot[]>(() => {
const defaultSnapshots: GenericSnapshot[] = project.value?._id ? getDefaultSnapshots(project.value._id as any) : [];
return [...defaultSnapshots, ...(remoteSnapshots.data.value || [])];
})
const snapshot = ref<TProjectSnapshot>(isLiveDemo.value ? snapshots.value[0] : snapshots.value[1]);
const snapshot = ref<GenericSnapshot>(snapshots.value[1]);
const safeSnapshotDates = computed(() => {
const from = new Date(snapshot.value?.from || 0).toISOString();
@@ -77,7 +38,7 @@ async function updateSnapshots() {
const snapshotDuration = computed(() => {
const from = new Date(snapshot.value?.from || 0).getTime();
const to = new Date(snapshot.value?.to || 0).getTime();
return (to - from) / (1000 * 60 * 60 * 24);
return fns.differenceInDays(to, from);
});
export function useSnapshot() {

View File

@@ -16,7 +16,7 @@ export default defineEventHandler(async event => {
const timelineData = await executeTimelineAggregation({
projectId: project_id,
model: VisitModel,
from, to, slice,
from, to, slice
});
const timelineFilledMerged = fillAndMergeTimelineAggregationV2(timelineData, slice, from, to);
return timelineFilledMerged;

View File

@@ -2,7 +2,7 @@
import { Slice } from "@services/DateService";
import DateService from "@services/DateService";
import type mongoose from "mongoose";
import * as fns from 'date-fns'
export type TimelineAggregationOptions = {
projectId: mongoose.Schema.Types.ObjectId | mongoose.Types.ObjectId,
@@ -27,17 +27,20 @@ export async function executeAdvancedTimelineAggregation<T = {}>(options: Advanc
options.customProjection = options.customProjection || {};
options.customIdGroup = options.customIdGroup || {};
const { group, sort, fromParts } = DateService.getQueryDateRange(options.slice);
const { group, sort } = DateService.getQueryDateRange(options.slice);
if (!sort) throw Error('Slice is probably not correct');
const dateDistDays = (new Date(options.to).getTime() - new Date(options.from).getTime()) / (1000 * 60 * 60 * 24)
// 15 Days
if (options.slice === 'hour' && (dateDistDays > 15)) throw Error('Date gap too big for this slice');
// 1 Year
if (options.slice === 'day' && (dateDistDays > 365)) throw Error('Date gap too big for this slice');
const daysDiff = fns.differenceInDays(new Date(options.to), new Date(options.from));
// 3 Days
if (options.slice === 'hour' && (daysDiff > 3)) throw Error('Date gap too big for this slice');
// 3 Weeks
if (options.slice === 'day' && (daysDiff > 7 * 3)) throw Error('Date gap too big for this slice');
// 3 Months
if (options.slice === 'week' && (daysDiff > 30 * 3)) throw Error('Date gap too big for this slice');
// 3 Years
if (options.slice === 'month' && (dateDistDays > 365 * 3)) throw Error('Date gap too big for this slice');
if (options.slice === 'month' && (daysDiff > 365 * 3)) throw Error('Date gap too big for this slice');
const aggregation = [
@@ -48,9 +51,22 @@ export async function executeAdvancedTimelineAggregation<T = {}>(options: Advanc
...options.customMatch
}
},
{ $group: { _id: { ...group, ...options.customIdGroup }, count: { $sum: 1 }, ...options.customGroup } },
{
$group: {
_id: { ...group, ...options.customIdGroup },
count: { $sum: 1 },
firstDate: { $first: '$created_at' },
...options.customGroup
}
},
{ $sort: sort },
{ $project: { _id: { $dateFromParts: fromParts }, count: "$count", ...options.customProjection } }
{
$project: {
_id: "$firstDate",
count: "$count",
...options.customProjection
}
}
]
if (options.debug === true) {
@@ -75,7 +91,53 @@ export function fillAndMergeTimelineAggregation(timeline: { _id: string, count:
}
export function fillAndMergeTimelineAggregationV2(timeline: { _id: string, count: number }[], slice: Slice, from: string, to: string) {
const filledDates = DateService.createBetweenDates(from, to, slice);
const merged = DateService.mergeFilledDates(filledDates.dates, timeline, '_id', slice, { count: 0 });
const allDates = generateDateSlices(slice, new Date(from), new Date(to));
const merged = mergeDates(timeline, allDates, slice);
return merged;
}
function generateDateSlices(slice: Slice, fromDate: Date, toDate: Date) {
const slices: Date[] = [];
let currentDate = fromDate;
const addFunctions: { [key in Slice]: any } = { hour: fns.addHours, day: fns.addDays, week: fns.addWeeks, month: fns.addMonths, year: fns.addYears };
const addFunction = addFunctions[slice];
if (!addFunction) { throw new Error(`Invalid slice: ${slice}`); }
while (fns.isBefore(currentDate, toDate) || currentDate.getTime() === toDate.getTime()) {
slices.push(currentDate);
currentDate = addFunction(currentDate, 1);
}
return slices;
}
function mergeDates(timeline: { _id: string, count: number }[], dates: Date[], slice: Slice) {
const result: { _id: string, count: number }[] = [];
const isSames: { [key in Slice]: any } = { hour: fns.isSameHour, day: fns.isSameDay, week: fns.isSameWeek, month: fns.isSameMonth, year: fns.isSameYear, }
const isSame = isSames[slice];
if (!isSame) {
throw new Error(`Invalid slice: ${slice}`);
}
for (const element of timeline) {
const elementDate = new Date(element._id);
for (const date of dates) {
if (isSame(elementDate, date)) {
const existingEntry = result.find(item => isSame(new Date(item._id), date));
if (existingEntry) {
existingEntry.count += element.count;
} else {
result.push({
_id: date.toISOString(),
count: element.count,
});
}
}
}
}
return result;
}

View File

@@ -4,18 +4,11 @@ import dayjs from 'dayjs';
export type Slice = keyof typeof slicesData;
const slicesData = {
hour: {
fromOffset: 1000 * 60 * 60 * 24
},
day: {
fromOffset: 1000 * 60 * 60 * 24 * 7
},
month: {
fromOffset: 1000 * 60 * 60 * 24 * 30 * 12
},
year: {
fromOffset: 1000 * 60 * 60 * 24 * 30 * 12 * 10
}
hour: {},
day: {},
week: {},
month: {},
year: {}
}
@@ -32,32 +25,23 @@ class DateService {
return date.format();
}
getDefaultRange(slice: Slice) {
return {
from: new Date(Date.now() - slicesData[slice].fromOffset).toISOString(),
to: new Date().toISOString()
}
}
getQueryDateRange(slice: Slice) {
const group: Record<string, any> = {}
const sort: Record<string, any> = {}
const fromParts: Record<string, any> = {}
switch (slice) {
case 'hour':
group.hour = { $hour: '$created_at' }
fromParts.hour = "$_id.hour";
case 'day':
group.day = { $dayOfMonth: '$created_at' }
fromParts.day = "$_id.day";
case 'week':
group.week = { $isoWeek: '$created_at' }
case 'month':
group.month = { $month: '$created_at' }
fromParts.month = "$_id.month";
case 'year':
group.year = { $year: '$created_at' }
fromParts.year = "$_id.year";
}
switch (slice) {
@@ -68,6 +52,7 @@ class DateService {
sort['_id.year'] = 1;
sort['_id.month'] = 1;
break;
case 'week':
case 'day':
sort['_id.year'] = 1;
sort['_id.month'] = 1;
@@ -81,7 +66,7 @@ class DateService {
break;
}
return { group, sort, fromParts }
return { group, sort }
}
prepareDateRange(from: string, to: string, slice: Slice) {
@@ -115,7 +100,6 @@ class DateService {
return { dates: filledDates, from, to };
}
fillDates(dates: string[], slice: Slice) {
const allDates: dayjs.Dayjs[] = [];
const firstDate = dayjs(dates.at(0));