diff --git a/dashboard/components/CVerticalNavigation.vue b/dashboard/components/CVerticalNavigation.vue index 107a744..6f0e135 100644 --- a/dashboard/components/CVerticalNavigation.vue +++ b/dashboard/components/CVerticalNavigation.vue @@ -208,8 +208,7 @@ const pricingDrawer = usePricingDrawer();
- {{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() - }} + {{ new Date(snapshot.from).toLocaleString().split(',')[0].trim() }}
to
@@ -217,7 +216,7 @@ const pricingDrawer = usePricingDrawer();
-
+
Delete current snapshot diff --git a/dashboard/components/dashboard/TopCards.vue b/dashboard/components/dashboard/TopCards.vue index ba7d63e..5414d2b 100644 --- a/dashboard/components/dashboard/TopCards.vue +++ b/dashboard/components/dashboard/TopCards.vue @@ -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(() => { - diff --git a/dashboard/composables/snapshots/BaseSnapshots.ts b/dashboard/composables/snapshots/BaseSnapshots.ts new file mode 100644 index 0000000..534fe8c --- /dev/null +++ b/dashboard/composables/snapshots/BaseSnapshots.ts @@ -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; + +} diff --git a/dashboard/composables/useCustomFetch.ts b/dashboard/composables/useCustomFetch.ts index 04b9932..207d811 100644 --- a/dashboard/composables/useCustomFetch.ts +++ b/dashboard/composables/useCustomFetch.ts @@ -25,7 +25,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) { const useActivePid = customOptions?.useActivePid || true; const headers = computed>(() => { - + console.trace('Computed recalculated'); const parsedCustom: Record = {} const customKeys = Object.keys(customOptions?.custom || {}); for (const key of customKeys) { @@ -43,5 +43,7 @@ export function useComputedHeaders(customOptions?: CustomOptions) { } }) + + return headers; } \ No newline at end of file diff --git a/dashboard/composables/useSnapshot.ts b/dashboard/composables/useSnapshot.ts index b92c193..e0b4d35 100644 --- a/dashboard/composables/useSnapshot.ts +++ b/dashboard/composables/useSnapshot.ts @@ -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('/api/project/snapshots', { - headers -}); + +const remoteSnapshots = useFetch('/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(() => { + const defaultSnapshots: GenericSnapshot[] = project.value?._id ? getDefaultSnapshots(project.value._id as any) : []; + return [...defaultSnapshots, ...(remoteSnapshots.data.value || [])]; }) -const snapshot = ref(isLiveDemo.value ? snapshots.value[0] : snapshots.value[1]); +const snapshot = ref(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() { diff --git a/dashboard/server/api/timeline/visits.ts b/dashboard/server/api/timeline/visits.ts index fea47e2..f531b2b 100644 --- a/dashboard/server/api/timeline/visits.ts +++ b/dashboard/server/api/timeline/visits.ts @@ -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; diff --git a/dashboard/server/services/TimelineService.ts b/dashboard/server/services/TimelineService.ts index 477b234..da1ff06 100644 --- a/dashboard/server/services/TimelineService.ts +++ b/dashboard/server/services/TimelineService.ts @@ -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(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(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; -} \ No newline at end of file +} + +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; +} diff --git a/shared/services/DateService.ts b/shared/services/DateService.ts index c186617..6cfe5da 100644 --- a/shared/services/DateService.ts +++ b/shared/services/DateService.ts @@ -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 = {} const sort: Record = {} - const fromParts: Record = {} 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));