feat: redesign CMS and unify German admin UI

This commit is contained in:
2026-09-17 16:51:24 +02:00
parent a2b3e99174
commit 7ec0c51ff5
129 changed files with 952 additions and 550 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ function bump(map: Record<string, number>, key: string | null | undefined) {
map[key] = (map[key] ?? 0) + 1;
}
/** Sorted (descending) [key, count] pairs, capped at `limit` — for dashboard tables. */
/** 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)
@@ -26,7 +26,7 @@ export function topEntries(map: Record<string, number>, limit = 10): [string, nu
}
/**
* Pure aggregation over a set of raw AnalyticsEvents rows (any date range —
* 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
+3 -3
View File
@@ -14,7 +14,7 @@ function addDays(date: Date, days: number): Date {
}
/**
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC —
* Rolls up every not-yet-aggregated, fully-completed calendar day (UTC -
* matching the UTC-day salt rotation in lib/analytics/salt.ts) from raw
* analytics-events into analytics-daily-stats, THEN purges raw events older
* than the configured retention, THEN purges old daily-stats rows. Order
@@ -22,7 +22,7 @@ function addDays(date: Date, days: number): Date {
* deleted, or that history is gone for good.
*
* Only aggregates days that don't already have a daily-stats row (each day
* is aggregated exactly once, ever) — safe to call this daily via
* is aggregated exactly once, ever) - safe to call this daily via
* app/(payload)/api/cron/analytics-cleanup/route.ts, and safe to re-run.
*/
export async function runAnalyticsCleanup(payload: Payload) {
@@ -60,7 +60,7 @@ export async function runAnalyticsCleanup(payload: Payload) {
{ createdAt: { less_than: dayEnd.toISOString() } },
],
},
limit: 0, // no limit — pull the whole day, small-site scale
limit: 0, // no limit - pull the whole day, small-site scale
depth: 0,
});
+3 -3
View File
@@ -2,15 +2,15 @@
/**
* Public developer API + consent storage for the analytics system. Importing
* this module has zero side effects — no network call, nothing initialized
* — until `track()` is actually called, and even then it's a no-op unless
* this module has zero side effects - no network call, nothing initialized
* - until `track()` is actually called, and even then it's a no-op unless
* consent was actually granted. Callers never check consent themselves:
*
* track("booking_started");
*
* The CMS-configured part of consent (whether the "statistics" category is
* enabled at all, and the current consent version) is pushed in once by
* components/Analytics.tsx via setAnalyticsConfig() — see that file. Until
* components/Analytics.tsx via setAnalyticsConfig() - see that file. Until
* that happens, `cmsConfig` is null and track() fails safe (no-op), so a
* component whose effects happen to run before Analytics.tsx's can never
* accidentally send data before the live config is known.
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Central, extensible registry of known analytics event names. Adding a new
* event elsewhere in the app never requires a migration — the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text — this
* event elsewhere in the app never requires a migration - the database
* column (collections/AnalyticsEvents.ts#eventName) is free-text - this
* list exists purely so `track()` calls get autocomplete/type-checking and
* the dashboard can show a nice German label instead of a raw event name.
*
@@ -18,7 +18,7 @@ export const ANALYTICS_EVENTS = {
export type KnownAnalyticsEvent = keyof typeof ANALYTICS_EVENTS;
/** Any string works (see the collection comment above) — known ones just get a label. */
/** Any string works (see the collection comment above) - known ones just get a label. */
export type AnalyticsEventName = KnownAnalyticsEvent | (string & {});
export function analyticsEventLabel(eventName: string): string {
+1 -1
View File
@@ -27,7 +27,7 @@ function parseDateParam(value: string | null): Date | null {
/**
* Resolves the dashboard's period filter (?range=&from=&to=) into an actual
* date range, clamped to `maxDaysBack` (the configured raw-event retention —
* 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).
+3 -3
View File
@@ -2,14 +2,14 @@ import { createHmac } from "node:crypto";
/**
* Derives a daily-rotating, non-reversible pseudonymous visitor identifier
* for session grouping — the same approach used by privacy-friendly
* for session grouping - the same approach used by privacy-friendly
* analytics tools (Plausible/Fathom-style): hash(ip + userAgent + host)
* salted with a value that changes every calendar day. Raw IP/UA are never
* persisted anywhere, only this hash — and since the salt rotates daily,
* persisted anywhere, only this hash - and since the salt rotates daily,
* the same visitor hashes differently tomorrow, so there is no cross-day
* linkage and no long-term individual profile.
*
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) — this
* ANALYTICS_SALT_SECRET is a dedicated secret (not PAYLOAD_SECRET) - this
* codebase keeps one secret per purpose on purpose (see
* MEETING_SESSION_SECRET's comment in .env.example).
*/
+1 -1
View File
@@ -7,7 +7,7 @@ const SESSION_IDLE_WINDOW_MS = 30 * 60 * 1000;
* Groups events into sessions without any client-side identifier: looks up
* the most recent event from the same `visitorHash` within the last 30
* minutes and reuses its sessionId, or mints a new one. One extra indexed
* read per tracked request — acceptable at this site's traffic volume, and
* read per tracked request - acceptable at this site's traffic volume, and
* simpler/safer than trying to hold a lock across an unauthenticated,
* high-frequency public endpoint (unlike lib/booking/lock.ts's advisory
* lock, an occasional extra spurious session here has no correctness
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Coarse device/browser classification from the User-Agent header. No
* library, no raw UA string ever stored — just one bucket per request,
* library, no raw UA string ever stored - just one bucket per request,
* computed on the fly and discarded after classification. Not meant to be
* precise (e.g. WebView vs. real browser); good enough for the "device
* type" / "browser" breakdown in the Analytics dashboard.
@@ -19,7 +19,7 @@ export function classifyDevice(userAgent: string): DeviceType {
export function classifyBrowser(userAgent: string): BrowserFamily {
const ua = userAgent.toLowerCase();
// Order matters: Edge/Chrome UAs also contain "safari"; Chrome UAs also
// contain "chrome" but so do Edge/Opera — check the most specific tokens first.
// contain "chrome" but so do Edge/Opera - check the most specific tokens first.
if (/edg\//.test(ua)) return "edge";
if (/chrome\/|crios\//.test(ua)) return "chrome";
if (/firefox\/|fxios\//.test(ua)) return "firefox";