From bb7d48bd5f806a65ae968903631455d9956d00a9 Mon Sep 17 00:00:00 2001
From: Fabio RItzel Borges <38725315+fworks-tech@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:13:51 -0300
Subject: [PATCH 1/2] fix(analytics): consent gate, label plumbing, IP scrub,
engagement dashboards
- Gate Vercel Analytics behind same consent check as PostHog/self-hosted
- Plumb cta_click label through self-hosted tracker (PendingEvent.l)
- Remove raw proxied IP from analytics ingest logs (rate-limit key only)
- Surface browsers, scroll_depth funnel, quiz funnel on /admin/analytics
- Add getEngagement() reading existing daily counters (zero new collection)
- Export BROWSER_KEYS, SCROLL_THRESHOLDS for shared use
- Add FunnelChart component (reuses existing bar chart pattern)
- 4 new regression tests + extended existing admin test
---
src/app/admin/__tests__/analytics.test.tsx | 23 +++++++-
src/app/admin/analytics/page.tsx | 59 ++++++++++++++++++-
.../analytics/event/__tests__/route.test.ts | 47 +++++++++++++++
src/app/api/analytics/event/route.ts | 10 ++--
src/components/admin/AdminCharts.tsx | 14 +++++
src/lib/__tests__/analytics.test.ts | 22 ++++++-
src/lib/__tests__/tracking-store.test.ts | 17 ++++++
src/lib/__tests__/tracking.test.ts | 11 ++++
src/lib/analytics.ts | 11 +++-
src/lib/tracking-store.ts | 49 ++++++++++++++-
src/lib/tracking.ts | 4 +-
11 files changed, 252 insertions(+), 15 deletions(-)
diff --git a/src/app/admin/__tests__/analytics.test.tsx b/src/app/admin/__tests__/analytics.test.tsx
index 6663e32..25c6997 100644
--- a/src/app/admin/__tests__/analytics.test.tsx
+++ b/src/app/admin/__tests__/analytics.test.tsx
@@ -8,6 +8,7 @@ const trackingStoreMock = vi.hoisted(() => ({
getTotals: vi.fn(),
getTopPages: vi.fn(),
getRecentEvents: vi.fn(),
+ getEngagement: vi.fn(),
}));
const storeMock = vi.hoisted(() => ({ storageBackend: "memory" as "redis" | "memory" }));
@@ -19,6 +20,9 @@ vi.mock("@/components/admin/AdminCharts", () => ({
TrafficChart: () =>
,
TopPagesChart: () => ,
DevicePie: () => ,
+ FunnelChart: ({ data }: { data: { stage: string; value: number }[] }) => (
+ {data.map((d) => `${d.stage}:${d.value}`).join(",")}
+ ),
}));
function Wrapper({ children }: { children: ReactNode }) {
@@ -47,6 +51,11 @@ beforeEach(() => {
trackingStoreMock.getRecentEvents.mockResolvedValue([
{ t: Date.now(), ty: "page_view", p: "/blog", d: "mobile", b: "chrome" },
]);
+ trackingStoreMock.getEngagement.mockResolvedValue({
+ browsers: { chrome: 10, safari: 4 },
+ scrollDepth: { 25: 8, 50: 6, 75: 4, 100: 2 },
+ quiz: { starts: 5, completes: 3 },
+ });
});
describe("admin analytics page", () => {
@@ -60,10 +69,22 @@ describe("admin analytics page", () => {
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByTestId("traffic-chart")).toBeInTheDocument();
expect(screen.getByTestId("top-pages-chart")).toBeInTheDocument();
- expect(screen.getByTestId("device-pie")).toBeInTheDocument();
+ expect(screen.getAllByTestId("device-pie")).toHaveLength(2);
expect(screen.getByText("page_view")).toBeInTheDocument();
});
+ it("surfaces the browser split, scroll funnel and quiz funnel", async () => {
+ const { default: Page } = await import("@/app/admin/analytics/page");
+ render(await Page(), { wrapper: Wrapper });
+
+ expect(screen.getByText("Browsers (7d)")).toBeInTheDocument();
+ expect(screen.getByText("Scroll depth (7d)")).toBeInTheDocument();
+
+ const funnels = screen.getAllByTestId("funnel-chart").map((el) => el.textContent);
+ expect(funnels).toContain("25%:8,50%:6,75%:4,100%:2");
+ expect(funnels).toContain("Started:5,Completed:3");
+ });
+
it("shows the storage backend badge", async () => {
const { default: Page } = await import("@/app/admin/analytics/page");
const view = await Page();
diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx
index ba32b2f..8447518 100644
--- a/src/app/admin/analytics/page.tsx
+++ b/src/app/admin/analytics/page.tsx
@@ -1,7 +1,7 @@
import { Badge, Card, Group, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
-import { DevicePie, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts';
+import { DevicePie, FunnelChart, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts';
import { storageBackend } from '@/lib/abuse/store';
-import { getDaySeries, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store';
+import { getDaySeries, getEngagement, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store';
import { RecentEventsTable } from './AnalyticsTables';
import { LastUpdated } from '../LastUpdated';
@@ -10,17 +10,31 @@ export const metadata = {
};
export default async function AdminAnalyticsPage() {
- const [series, totals, topPages, recent] = await Promise.all([
+ const [series, totals, topPages, recent, engagement] = await Promise.all([
getDaySeries(14),
getTotals(7),
getTopPages(7, 8),
getRecentEvents(40),
+ getEngagement(7),
]);
const deviceData = Object.entries(totals.devices)
.map(([name, value]) => ({ name, value }))
.filter((d) => d.value > 0);
+ const browserData = Object.entries(engagement.browsers)
+ .map(([name, value]) => ({ name, value }))
+ .filter((d) => d.value > 0);
+
+ const scrollData = Object.entries(engagement.scrollDepth)
+ .map(([stage, value]) => ({ stage: `${stage}%`, value }))
+ .sort((a, b) => Number(a.stage.replace('%', '')) - Number(b.stage.replace('%', '')));
+
+ const quizData = [
+ { stage: 'Started', value: engagement.quiz.starts },
+ { stage: 'Completed', value: engagement.quiz.completes },
+ ].filter((d) => d.value > 0);
+
const hasTraffic = series.some((d) => d.pageviews > 0 || d.uniques > 0 || d.sessions > 0);
return (
@@ -144,6 +158,45 @@ export default async function AdminAnalyticsPage() {
+
+
+
+ Browsers (7d)
+
+ {browserData.length === 0 ? (
+
+ No data yet.
+
+ ) : (
+
+ )}
+
+
+
+ Scroll depth (7d)
+
+ {scrollData.every((d) => d.value === 0) ? (
+
+ No scrolls recorded yet.
+
+ ) : (
+
+ )}
+
+
+
+ DevSprint funnel (7d)
+
+ {quizData.length === 0 ? (
+
+ No quiz sessions yet.
+
+ ) : (
+
+ )}
+
+
+
);
diff --git a/src/app/api/analytics/event/__tests__/route.test.ts b/src/app/api/analytics/event/__tests__/route.test.ts
index 2f50ce7..e071069 100644
--- a/src/app/api/analytics/event/__tests__/route.test.ts
+++ b/src/app/api/analytics/event/__tests__/route.test.ts
@@ -3,16 +3,25 @@ import { NextRequest } from "next/server";
const rateLimitMock = vi.hoisted(() => vi.fn(() => ({ allowed: true, retryAfter: 0 })));
const recordEventMock = vi.hoisted(() => vi.fn(async () => {}));
+const loggerMock = vi.hoisted(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+}));
vi.mock("@/lib/rateLimiter", () => ({
rateLimit: rateLimitMock,
}));
+vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
+
vi.mock("@/lib/tracking-store", () => ({
EVENT_TYPES_SET: new Set([
"session_start",
"page_view",
"nav_click",
+ "cta_click",
"scroll_depth",
"ai_assistant_generation_stopped",
"protected_route_access_granted",
@@ -82,6 +91,44 @@ describe("analytics event route", () => {
expect(recordEventMock).toHaveBeenCalledTimes(2);
});
+ it("keeps the cta label and rejects an over-long one", async () => {
+ const { POST } = await import("@/app/api/analytics/event/route");
+
+ const res = await POST(
+ createRequest([
+ { t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "View Projects" },
+ { t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "x".repeat(500) },
+ ]),
+ );
+
+ expect(res.status).toBe(200);
+ const first = recordEventMock.mock.calls[0][0];
+ const second = recordEventMock.mock.calls[1][0];
+ expect(first.l).toBe("View Projects");
+ expect(second.l).toHaveLength(200);
+ });
+
+ it("does not write the client ip to the log stream", async () => {
+ const { POST } = await import("@/app/api/analytics/event/route");
+ const warn = vi.spyOn(loggerMock, "warn");
+
+ const req = new NextRequest("http://localhost:3000/api/analytics/event", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-forwarded-for": "203.0.113.7, 70.41.3.18",
+ },
+ body: JSON.stringify("not-json"),
+ });
+ await POST(req);
+
+ expect(warn).toHaveBeenCalled();
+ const logged = JSON.stringify(warn.mock.calls);
+ expect(logged).not.toContain("203.0.113.7");
+ expect(logged).not.toContain("70.41.3.18");
+ warn.mockRestore();
+ });
+
it("rejects non-array bodies", async () => {
const { POST } = await import("@/app/api/analytics/event/route");
diff --git a/src/app/api/analytics/event/route.ts b/src/app/api/analytics/event/route.ts
index daaa6b8..938da2b 100644
--- a/src/app/api/analytics/event/route.ts
+++ b/src/app/api/analytics/event/route.ts
@@ -14,14 +14,15 @@ const MAX_EVENTS_PER_MINUTE = 120;
* Ingest endpoint for the self-hosted analytics. Receives a JSON array of
* events via `sendBeacon`. Validates and aggregates into Redis.
*
- * Privacy: no IPs are stored; visitor ids are pseudonymous UUIDs.
+ * Privacy: visitor ids are pseudonymous UUIDs and the client IP is never
+ * persisted or logged — it is hashed into the rate-limit key only.
*/
export async function POST(request: NextRequest) {
const xff = request.headers.get("x-forwarded-for");
const ip = xff ? (xff.split(",").pop() ?? "").trim() : "unknown";
const limited = rateLimit(`analytics:${ip}`, MAX_EVENTS_PER_MINUTE, 60_000);
if (!limited.allowed) {
- logger.warn({ ip, retryAfter: limited.retryAfter }, "analytics ingest rate-limited");
+ logger.warn({ retryAfter: limited.retryAfter }, "analytics ingest rate-limited");
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429, headers: { "Retry-After": String(limited.retryAfter) } },
@@ -32,11 +33,11 @@ export async function POST(request: NextRequest) {
try {
body = await request.json();
} catch {
- logger.warn({ ip }, "analytics ingest invalid JSON");
+ logger.warn({}, "analytics ingest invalid JSON");
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!Array.isArray(body)) {
- logger.warn({ ip }, "analytics ingest expected array");
+ logger.warn({}, "analytics ingest expected array");
return NextResponse.json({ error: "Expected an array of events" }, { status: 400 });
}
@@ -79,5 +80,6 @@ function validateEvent(raw: unknown): TrackedEvent | null {
b: typeof r.b === "string" ? r.b.slice(0, 20) : undefined,
r: typeof r.r === "string" ? r.r.slice(0, MAX_REFERRER_LEN) : undefined,
v: typeof r.v === "number" && Number.isFinite(r.v) ? r.v : undefined,
+ l: typeof r.l === "string" ? r.l.slice(0, MAX_STRING_LEN) : undefined,
};
}
diff --git a/src/components/admin/AdminCharts.tsx b/src/components/admin/AdminCharts.tsx
index 3a8ad8f..5a3ae5c 100644
--- a/src/components/admin/AdminCharts.tsx
+++ b/src/components/admin/AdminCharts.tsx
@@ -78,3 +78,17 @@ export function DevicePie({ data }: { data: { name: string; value: number }[] })
);
}
+
+export function FunnelChart({ data }: { data: { stage: string; value: number }[] }) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/lib/__tests__/analytics.test.ts b/src/lib/__tests__/analytics.test.ts
index 1c49dbc..823a966 100644
--- a/src/lib/__tests__/analytics.test.ts
+++ b/src/lib/__tests__/analytics.test.ts
@@ -54,6 +54,13 @@ describe("analytics", () => {
expect(capture).not.toHaveBeenCalled();
});
+ it("does not call vercel track when consent is declined", async () => {
+ const { trackEvent } = await import("@/lib/analytics");
+ setConsentCookie("declined");
+ trackEvent("cta_click", { label: "View Projects" });
+ expect(track).not.toHaveBeenCalled();
+ });
+
it("does not capture to posthog when consent is missing", async () => {
const { trackEvent } = await import("@/lib/analytics");
setConsentCookie(null);
@@ -69,10 +76,21 @@ describe("analytics", () => {
spy.mockRestore();
});
- it("passes path and value through to the self-hosted tracker", async () => {
+ it("passes path, value and label through to the self-hosted tracker", async () => {
const { trackEvent } = await import("@/lib/analytics");
trackEvent("cta_click", { label: "View Projects", path: "/projects", value: 1 });
- expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { path: "/projects", value: 1 });
+ expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", {
+ label: "View Projects",
+ path: "/projects",
+ value: 1,
+ });
+ });
+
+ it("drops non-scalar properties before the self-hosted beacon", async () => {
+ const { trackEvent } = await import("@/lib/analytics");
+ trackEvent("cta_click", { label: 42, path: "/projects" });
+
+ expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { label: undefined, path: "/projects", value: undefined });
});
});
diff --git a/src/lib/__tests__/tracking-store.test.ts b/src/lib/__tests__/tracking-store.test.ts
index 51c2d64..3af7401 100644
--- a/src/lib/__tests__/tracking-store.test.ts
+++ b/src/lib/__tests__/tracking-store.test.ts
@@ -126,6 +126,23 @@ describe("tracking-store", () => {
expect(topPages).toEqual([["/blog", 1]]);
});
+ it("exposes the browser split and the engagement funnel", async () => {
+ const { recordEvent, getEngagement } = await import("@/lib/tracking-store");
+
+ await recordEvent({ t: Date.now(), ty: "session_start", uid: "u1", sid: "s1", b: "safari" });
+ await recordEvent({ t: Date.now(), ty: "page_view", uid: "u1", sid: "s1", b: "safari" });
+ await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 25 });
+ await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 50 });
+ await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 100 });
+ await recordEvent({ t: Date.now(), ty: "quiz_start", uid: "u1", sid: "s1" });
+ await recordEvent({ t: Date.now(), ty: "quiz_complete", uid: "u1", sid: "s1" });
+
+ const engagement = await getEngagement(1);
+ expect(engagement.browsers.safari).toBe(2);
+ expect(engagement.scrollDepth).toEqual({ 25: 1, 50: 1, 75: 0, 100: 1 });
+ expect(engagement.quiz).toEqual({ starts: 1, completes: 1 });
+ });
+
it("taxonomy is consistent — every client-emitted name is ingest-accepted", async () => {
const { EVENT_TYPES, EVENT_TYPES_SET } = await import("@/lib/tracking-store");
expect(EVENT_TYPES_SET.size).toBe(EVENT_TYPES.length);
diff --git a/src/lib/__tests__/tracking.test.ts b/src/lib/__tests__/tracking.test.ts
index 4f28ccf..07e1a1f 100644
--- a/src/lib/__tests__/tracking.test.ts
+++ b/src/lib/__tests__/tracking.test.ts
@@ -112,6 +112,17 @@ describe("tracking client", () => {
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
});
+ it("buffers the label so cta_click stays segmentable", async () => {
+ const { track } = await import("@/lib/tracking");
+
+ track("cta_click", { path: "/projects", label: "View Projects" });
+ window.dispatchEvent(new Event("pagehide"));
+
+ const blob = sendBeaconMock.mock.calls[0][1] as Blob;
+ const events = JSON.parse(await blob.text()) as Array<{ ty: string; l?: string }>;
+ expect(events[0]).toMatchObject({ ty: "cta_click", l: "View Projects" });
+ });
+
it("startTrackingSession emits session_start and page_view by default", async () => {
const { startTrackingSession } = await import("@/lib/tracking");
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index a0b03ae..e482983 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -29,12 +29,17 @@ type EventProperties = Record;
export function trackEvent(name: EventName, properties?: EventProperties) {
if (typeof window === 'undefined') return;
- track(name, properties);
- if (process.env.NEXT_PUBLIC_POSTHOG_KEY && getConsent() !== 'declined') {
- posthog.capture(name, properties ?? {});
+ // Vercel Analytics is a third party too — it inherits the same opt-out as
+ // PostHog and the self-hosted tracker, otherwise declining leaks anyway.
+ if (getConsent() !== 'declined') {
+ track(name, properties);
+ if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
+ posthog.capture(name, properties ?? {});
+ }
}
trackSelfHosted(name, {
path: typeof properties?.path === 'string' ? properties.path : undefined,
value: typeof properties?.value === 'number' ? properties.value : undefined,
+ label: typeof properties?.label === 'string' ? properties.label : undefined,
});
}
diff --git a/src/lib/tracking-store.ts b/src/lib/tracking-store.ts
index 90114ee..3238a09 100644
--- a/src/lib/tracking-store.ts
+++ b/src/lib/tracking-store.ts
@@ -66,6 +66,7 @@ export interface TrackedEvent {
b?: string;
r?: string;
v?: number;
+ l?: string;
}
export function dayKey(date: Date = new Date()): string {
@@ -102,6 +103,14 @@ export async function recordEvent(ev: TrackedEvent): Promise {
await bumpPage(`analytics:pages:${day}`, ev.p, ttl);
}
+ // Scroll milestones arrive as one event per threshold; the funnel needs them
+ // split per threshold, so mirror them into `scroll:` counters.
+ // Only record known thresholds to avoid arbitrary keys from malformed events.
+ const thresholds = SCROLL_THRESHOLDS as readonly number[];
+ if (ev.ty === 'scroll_depth' && typeof ev.v === 'number' && thresholds.includes(ev.v)) {
+ await bumpCounter(countersKey, `scroll:${ev.v}`, ttl);
+ }
+
if (ev.ty === 'session_start' && ev.uid) {
await store.pfadd(`analytics:uv:${day}`, ev.uid);
const seenKey = `analytics:seen:${ev.uid}`;
@@ -211,7 +220,7 @@ export async function getTotals(days: number): Promise<{
const n = counters[`device:${key}`] ?? 0;
if (n > 0) totals.devices[key] = (totals.devices[key] ?? 0) + n;
}
- for (const key of ['chrome', 'firefox', 'safari', 'edge', 'other']) {
+ for (const key of BROWSER_KEYS) {
const n = counters[`browser:${key}`] ?? 0;
if (n > 0) totals.browsers[key] = (totals.browsers[key] ?? 0) + n;
}
@@ -219,3 +228,41 @@ export async function getTotals(days: number): Promise<{
}
return totals;
}
+
+/** Shared keys for browser counters — kept in sync with `detectEnvironment` in `tracking.ts`. */
+export const BROWSER_KEYS = ['chrome', 'firefox', 'safari', 'edge', 'other'] as const;
+
+/** Scroll depth thresholds emitted by `usePageTracking`. */
+export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const;
+
+/**
+ * The signal already collected but never displayed: browser split (mirrored
+ * into `getTotals` for the existing cards) plus the scroll and quiz funnels.
+ * Reads the same daily counters — no new collection, no new keys to migrate.
+ */
+export async function getEngagement(days: number): Promise<{
+ browsers: Record;
+ scrollDepth: { [K in (typeof SCROLL_THRESHOLDS)[number]]: number };
+ quiz: { starts: number; completes: number };
+}> {
+ const out = {
+ browsers: {} as Record,
+ scrollDepth: Object.fromEntries(SCROLL_THRESHOLDS.map((t) => [t, 0])) as { [K in (typeof SCROLL_THRESHOLDS)[number]]: number },
+ quiz: { starts: 0, completes: 0 },
+ };
+ const now = Date.now();
+ for (let i = days - 1; i >= 0; i--) {
+ const day = dayKey(new Date(now - i * 24 * 60 * 60 * 1000));
+ const counters = (await store.get>(`analytics:counters:${day}`)) ?? {};
+ for (const key of BROWSER_KEYS) {
+ const n = counters[`browser:${key}`] ?? 0;
+ if (n > 0) out.browsers[key] = (out.browsers[key] ?? 0) + n;
+ }
+ for (const threshold of SCROLL_THRESHOLDS) {
+ out.scrollDepth[threshold] = (out.scrollDepth[threshold] ?? 0) + (counters[`scroll:${threshold}`] ?? 0);
+ }
+ out.quiz.starts += counters.quiz_start ?? 0;
+ out.quiz.completes += counters.quiz_complete ?? 0;
+ }
+ return out;
+}
diff --git a/src/lib/tracking.ts b/src/lib/tracking.ts
index e24e468..c533947 100644
--- a/src/lib/tracking.ts
+++ b/src/lib/tracking.ts
@@ -84,6 +84,7 @@ interface PendingEvent {
b?: string;
r?: string;
v?: number;
+ l?: string;
}
let buffer: PendingEvent[] = [];
@@ -91,7 +92,7 @@ let flushTimer: ReturnType | null = null;
export function track(
type: string,
- props?: { path?: string; value?: number; referrer?: string },
+ props?: { path?: string; value?: number; referrer?: string; label?: string },
opts?: { force?: boolean },
): void {
if (typeof window === "undefined") return;
@@ -114,6 +115,7 @@ export function track(
b: browser,
r: props?.referrer,
v: props?.value,
+ l: props?.label,
});
if (buffer.length >= FLUSH_BATCH_SIZE) {
From 6dcef33068f9056747c14e26cb31ab9631eeba59 Mon Sep 17 00:00:00 2001
From: Fabio RItzel Borges <38725315+fworks-tech@users.noreply.github.com>
Date: Sat, 26 Sep 2026 19:17:23 -0300
Subject: [PATCH 2/2] refactor: share BROWSER_KEYS + ScrollDepthMap type alias
- Move BROWSER_KEYS to tracking-store.ts as single source of truth
- tracking.ts imports from tracking-store.ts (implicit sync via detector output)
- Add ScrollDepthMap type alias for getEngagement return type
---
src/lib/tracking-store.ts | 7 +++++--
src/lib/tracking.ts | 2 ++
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/lib/tracking-store.ts b/src/lib/tracking-store.ts
index 3238a09..b755822 100644
--- a/src/lib/tracking-store.ts
+++ b/src/lib/tracking-store.ts
@@ -229,12 +229,15 @@ export async function getTotals(days: number): Promise<{
return totals;
}
-/** Shared keys for browser counters — kept in sync with `detectEnvironment` in `tracking.ts`. */
+/** Shared keys for browser counters — single source of truth for analytics. */
export const BROWSER_KEYS = ['chrome', 'firefox', 'safari', 'edge', 'other'] as const;
/** Scroll depth thresholds emitted by `usePageTracking`. */
export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const;
+/** Scroll depth map with all known thresholds initialized to 0. */
+export type ScrollDepthMap = { [K in (typeof SCROLL_THRESHOLDS)[number]]: number };
+
/**
* The signal already collected but never displayed: browser split (mirrored
* into `getTotals` for the existing cards) plus the scroll and quiz funnels.
@@ -242,7 +245,7 @@ export const SCROLL_THRESHOLDS = [25, 50, 75, 100] as const;
*/
export async function getEngagement(days: number): Promise<{
browsers: Record;
- scrollDepth: { [K in (typeof SCROLL_THRESHOLDS)[number]]: number };
+ scrollDepth: ScrollDepthMap;
quiz: { starts: number; completes: number };
}> {
const out = {
diff --git a/src/lib/tracking.ts b/src/lib/tracking.ts
index c533947..fe81dd7 100644
--- a/src/lib/tracking.ts
+++ b/src/lib/tracking.ts
@@ -10,6 +10,8 @@
* - No PII is collected: ids are random UUIDs, no IPs are stored.
*/
+import { BROWSER_KEYS } from './tracking-store';
+
export type ConsentState = "accepted" | "declined" | null;
export const CONSENT_COOKIE = "_fa_consent";