Adds a first-party analytics system with no third-party trackers and no persistent per-visitor identifiers: events are grouped into sessions via a daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw). - New "Analytics" section in the Payload admin, gated by a canViewAnalytics permission on Users, following the existing custom-admin-view pattern used by the calendar feature - Sessions, page views, unique visitors, top/entry/exit pages, events, device/browser breakdown, date range filter with period comparison - Raw events and daily aggregate rollups as their own collections, with configurable retention and a cron endpoint that rolls up before purging - Cookie consent banner (necessary/preferences/statistics) with categories and copy editable from a new CMS global; a track() helper that is a complete no-op without statistics consent, so nothing is ever sent before a visitor opts in - track() calls wired into the booking flow, contact form, and navigation
95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
import type { AnalyticsEvent } from "@/payload-types";
|
|
|
|
export type AggregatedStats = {
|
|
sessions: number;
|
|
pageViews: number;
|
|
uniqueVisitors: number;
|
|
avgPagesPerSession: number;
|
|
eventCounts: Record<string, number>;
|
|
deviceCounts: Record<string, number>;
|
|
browserCounts: Record<string, number>;
|
|
topPaths: Record<string, number>;
|
|
entryPaths: Record<string, number>;
|
|
exitPaths: Record<string, number>;
|
|
};
|
|
|
|
function bump(map: Record<string, number>, key: string | null | undefined) {
|
|
if (!key) return;
|
|
map[key] = (map[key] ?? 0) + 1;
|
|
}
|
|
|
|
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
|
|
export function topEntries(map: Record<string, number>, limit = 10): [string, number][] {
|
|
return Object.entries(map)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
|
|
* one day for the cleanup rollup, or a whole dashboard-selected range for
|
|
* live queries). Kept side-effect free and DB-agnostic so both callers
|
|
* (lib/analytics/cleanup.ts and lib/analytics/dashboardQueries.ts) share
|
|
* one implementation.
|
|
*/
|
|
export function aggregateEvents(events: AnalyticsEvent[]): AggregatedStats {
|
|
const eventCounts: Record<string, number> = {};
|
|
const deviceCounts: Record<string, number> = {};
|
|
const browserCounts: Record<string, number> = {};
|
|
const topPaths: Record<string, number> = {};
|
|
const entryPaths: Record<string, number> = {};
|
|
const exitPaths: Record<string, number> = {};
|
|
|
|
const visitorHashes = new Set<string>();
|
|
const sessionIds = new Set<string>();
|
|
let pageViews = 0;
|
|
|
|
// Per-session first/last page_view, in chronological order, for entry/exit pages.
|
|
const sessionPageViews = new Map<string, { first: { at: string; path: string }; last: { at: string; path: string } }>();
|
|
|
|
const sorted = [...events].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
|
|
for (const ev of sorted) {
|
|
visitorHashes.add(ev.visitorHash);
|
|
sessionIds.add(ev.sessionId);
|
|
bump(eventCounts, ev.eventName);
|
|
bump(deviceCounts, ev.device);
|
|
bump(browserCounts, ev.browser);
|
|
|
|
if (ev.eventName === "page_view") {
|
|
pageViews += 1;
|
|
bump(topPaths, ev.path);
|
|
|
|
const existing = sessionPageViews.get(ev.sessionId);
|
|
if (!existing) {
|
|
sessionPageViews.set(ev.sessionId, {
|
|
first: { at: ev.createdAt, path: ev.path },
|
|
last: { at: ev.createdAt, path: ev.path },
|
|
});
|
|
} else {
|
|
existing.last = { at: ev.createdAt, path: ev.path };
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const { first, last } of sessionPageViews.values()) {
|
|
bump(entryPaths, first.path);
|
|
bump(exitPaths, last.path);
|
|
}
|
|
|
|
const sessions = sessionIds.size;
|
|
|
|
return {
|
|
sessions,
|
|
pageViews,
|
|
uniqueVisitors: visitorHashes.size,
|
|
avgPagesPerSession: sessions > 0 ? Math.round((pageViews / sessions) * 10) / 10 : 0,
|
|
eventCounts,
|
|
deviceCounts,
|
|
browserCounts,
|
|
topPaths,
|
|
entryPaths,
|
|
exitPaths,
|
|
};
|
|
}
|