Files
anouma/lib/analytics/dashboardQueries.ts
T
maro 65b6eeac51 Add privacy-friendly analytics and cookie consent, integrated into the CMS
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
2026-09-06 21:59:59 +02:00

70 lines
2.3 KiB
TypeScript

import type { Payload } from "payload";
import type { AnalyticsEvent } from "@/payload-types";
import { aggregateEvents, type AggregatedStats } from "@/lib/analytics/aggregate";
export type DashboardRange = { from: Date; to: Date };
export type DailyPoint = { date: string; pageViews: number; sessions: number };
export type DashboardStats = AggregatedStats & {
series: DailyPoint[];
};
function dateKey(iso: string): string {
return iso.slice(0, 10);
}
async function fetchEvents(payload: Payload, range: DashboardRange): Promise<AnalyticsEvent[]> {
const { docs } = await payload.find({
collection: "analytics-events",
where: {
and: [
{ createdAt: { greater_than_equal: range.from.toISOString() } },
{ createdAt: { less_than_equal: range.to.toISOString() } },
],
},
limit: 0,
depth: 0,
sort: "createdAt",
});
return docs;
}
function buildSeries(events: AnalyticsEvent[]): DailyPoint[] {
const byDay = new Map<string, { pageViews: number; sessions: Set<string> }>();
for (const ev of events) {
const key = dateKey(ev.createdAt);
const bucket = byDay.get(key) ?? { pageViews: 0, sessions: new Set<string>() };
if (ev.eventName === "page_view") bucket.pageViews += 1;
bucket.sessions.add(ev.sessionId);
byDay.set(key, bucket);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, bucket]) => ({ date, pageViews: bucket.pageViews, sessions: bucket.sessions.size }));
}
export async function getDashboardStats(payload: Payload, range: DashboardRange): Promise<DashboardStats> {
const events = await fetchEvents(payload, range);
return { ...aggregateEvents(events), series: buildSeries(events) };
}
export type PercentDelta = number | null; // null = no comparison basis (previous value was 0)
export function percentDelta(current: number, previous: number): PercentDelta {
if (previous === 0) return current === 0 ? 0 : null;
return Math.round(((current - previous) / previous) * 1000) / 10;
}
export async function getDashboardComparison(
payload: Payload,
range: DashboardRange,
compareRange: DashboardRange,
): Promise<{ current: DashboardStats; previous: DashboardStats }> {
const [current, previous] = await Promise.all([
getDashboardStats(payload, range),
getDashboardStats(payload, compareRange),
]);
return { current, previous };
}