Files
anouma/lib/analytics/range.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

72 lines
3.0 KiB
TypeScript

import type { DashboardRange } from "@/lib/analytics/dashboardQueries";
export type RangeKey = "today" | "7d" | "30d" | "90d" | "custom";
export const RANGE_OPTIONS: { key: RangeKey; label: string; days: number | "today" }[] = [
{ key: "today", label: "Heute", days: "today" },
{ key: "7d", label: "7 Tage", days: 7 },
{ key: "30d", label: "30 Tage", days: 30 },
{ key: "90d", label: "90 Tage", days: 90 },
];
const DAY_MS = 24 * 60 * 60 * 1000;
function startOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}
function endOfUTCDay(date: Date): Date {
return new Date(`${date.toISOString().slice(0, 10)}T23:59:59.999Z`);
}
function parseDateParam(value: string | null): Date | null {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
* see globals/AnalyticsSettings.ts) so the UI never claims to show data that
* has already been purged. Also computes the matching "previous period" for
* comparison (same length, immediately preceding).
*/
export function resolveRange(
searchParams: URLSearchParams,
maxDaysBack: number,
): { key: RangeKey; range: DashboardRange; compareRange: DashboardRange } {
const now = new Date();
const earliestAllowed = startOfUTCDay(new Date(now.getTime() - maxDaysBack * DAY_MS));
const rawKey = searchParams.get("range");
const key: RangeKey = (["today", "7d", "30d", "90d", "custom"] as const).includes(rawKey as RangeKey)
? (rawKey as RangeKey)
: "7d";
if (key === "custom") {
const fromParam = parseDateParam(searchParams.get("from"));
const toParam = parseDateParam(searchParams.get("to"));
let from = fromParam ? startOfUTCDay(fromParam) : startOfUTCDay(new Date(now.getTime() - 7 * DAY_MS));
let to = toParam ? endOfUTCDay(toParam) : now;
if (from < earliestAllowed) from = earliestAllowed;
if (to > now) to = now;
if (from > to) from = to;
const lengthMs = to.getTime() - from.getTime();
const compareRange: DashboardRange = { from: new Date(from.getTime() - lengthMs), to: new Date(from.getTime() - 1) };
return { key, range: { from, to }, compareRange };
}
if (key === "today") {
const from = startOfUTCDay(now);
const yesterday = new Date(from.getTime() - DAY_MS);
return { key, range: { from, to: now }, compareRange: { from: yesterday, to: new Date(from.getTime() - 1) } };
}
const days = RANGE_OPTIONS.find((o) => o.key === key)?.days;
const windowDays = typeof days === "number" ? Math.min(days, maxDaysBack) : 7;
const from = new Date(Math.max(now.getTime() - windowDays * DAY_MS, earliestAllowed.getTime()));
const compareRange: DashboardRange = { from: new Date(from.getTime() - windowDays * DAY_MS), to: new Date(from.getTime() - 1) };
return { key, range: { from, to: now }, compareRange };
}