diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dd7cc0caa..457a942ef 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,22 +1,19 @@ "use client"; -import Link from "next/link"; -import { ArrowRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { AdminShell, ADMIN_SECTIONS } from "@/components/admin/admin-shell"; -import { StatusCardGrid } from "@/components/admin/status-card-grid"; +import { AdminShell } from "@/components/admin/admin-shell"; +import { SystemStatusSummary } from "@/components/admin/system-status-summary"; +import { RecentAuditPreview } from "@/components/admin/recent-audit-preview"; import { useAdminSettings } from "@/components/admin/_shared"; /** - * `/admin` — overview landing page. The long monolithic admin scroll has - * moved to per-section dynamic routes under `/admin/[section]`; this page - * is now the system-wide status grid plus a quick-jump menu of the - * available sub-sections. - * - * Bundle-size note: by extracting each section into its own route, the - * overview no longer ships the umami / glitchtip / feedback / - * users / etc. component trees. See `.planning/phase-4b-report.md`. + * `/admin` — overview landing page. v1.4.15 (phase A2) replaces the + * previous status-card grid + section quick-jump menu with two + * at-a-glance panes: a compact system snapshot and the 10 most recent + * audit entries. The section navigation is already exposed in the + * shell sidebar (and in the global app sidebar after Phase 4b), so the + * old grid duplicated existing nav. See `.planning/phase-A2-report.md`. */ export default function AdminOverviewPage() { const { user } = useAuth(); @@ -32,12 +29,12 @@ export default function AdminOverviewPage() { return (
-
+

{t("admin.title")}

{t("admin.subtitle")}

-
+ {settingsError && (
)} - - - {/* v1.5 phase-5: wrap the quick-jump list in a labelled
); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 5c9dd5ecd..8bb9a443c 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -4,6 +4,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { computeBpInTargetPct } from "@/lib/analytics/bp-in-target"; import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; @@ -75,34 +76,13 @@ export const GET = apiHandler(async () => { }), ]); - if (sysData.length > 0 && diaData.length > 0) { - let inTarget = 0; - for (const sys of sysData) { - const closestDia = diaData.reduce((closest, dia) => - Math.abs(dia.measuredAt.getTime() - sys.measuredAt.getTime()) < - Math.abs(closest.measuredAt.getTime() - sys.measuredAt.getTime()) - ? dia - : closest, - ); - const timeDiff = Math.abs( - closestDia.measuredAt.getTime() - sys.measuredAt.getTime(), - ); - if (timeDiff < 5 * 60 * 1000) { - if ( - sys.value >= bpTargets.sysLow && - sys.value <= bpTargets.sysHigh && - closestDia.value >= bpTargets.diaLow && - closestDia.value <= bpTargets.diaHigh - ) { - inTarget++; - } - } - } - bpInTargetPct = - sysData.length > 0 - ? Math.round((inTarget / sysData.length) * 100) - : null; - } + // Pure pair-and-count helper (see lib/analytics/bp-in-target.ts). + // Replaces an earlier inline implementation that divided by + // `sysData.length` and ignored a same-Berlin-day fallback for + // imports rounded past the 5-minute window — which made the tile + // read 0 % even when readings clearly sat inside target. + const result = computeBpInTargetPct(sysData, diaData, bpTargets); + bpInTargetPct = result?.pct ?? null; } // Per-context glucose summaries (canonical mg/dL). diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx index 38f405eaa..2b255719d 100644 --- a/src/components/admin/__tests__/status-card-grid.test.tsx +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -1,165 +1,95 @@ -import { describe, expect, it } from "vitest"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { StatusBadge, buildCards } from "../status-card-grid"; -import { ADMIN_SECTION_SLUGS } from "../section-slugs"; -import type { StatusOverview } from "@/app/api/admin/status-overview/route"; +/** + * v1.4.15 phase A2: the `/admin` overview no longer renders the + * `StatusCardGrid` (the section-card grid Marc described as redundant + * with the sidebar nav). The status-card component itself was removed + * from the codebase. This test guards the *new* overview composition: + * a welcome card, the system snapshot, and the recent-audit preview. + * + * The file kept its old name so git history follows it. Nothing else + * imports `status-card-grid`. + */ -const mockOverview: StatusOverview = { - users: { severity: "good", total: 3, admins: 1, newThisWeek: 1 }, - integrations: { - severity: "good", - withings: 1, - moodLog: 0, - telegram: 1, - ntfy: 0, - webPush: 2, - }, - monitoring: { - severity: "caution", - glitchtipEnabled: true, - umamiEnabled: false, - wideEventsEnabled: true, - lastErrorAt: null, - }, - backups: { - severity: "alert", - lastBackupAt: null, - backedUpUsers: 0, - retentionDays: 90, - }, - maintenance: { - severity: "good", - workerRunning: true, - workerUptimeSeconds: 3600, - lastIdempotencyCleanup: new Date(Date.now() - 60_000).toISOString(), - lastAuditLogCleanup: null, - }, - auditLog: { - severity: "info", - eventsLast30d: 42, - lastLoginAt: new Date(Date.now() - 5 * 60_000).toISOString(), - }, -}; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin", +})); -describe("", () => { - it("renders a colored dot plus a text label (never color-only)", () => { - const html = renderToStaticMarkup(); - // dot must be aria-hidden so SR users don't hear it - expect(html).toContain('aria-hidden="true"'); - // label must be present so color-blind users can read state - expect(html).toContain("Healthy"); - // wrapper carries the announcement - expect(html).toContain('aria-label="Status: Healthy"'); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: null, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import AdminOverviewPage from "@/app/admin/page"; +import { SystemStatusSummary } from "../system-status-summary"; +import { RecentAuditPreview } from "../recent-audit-preview"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("/admin overview composition", () => { + it("does NOT render the legacy section-card grid", () => { + const html = render(); + // The old grid surfaced six card titles ("Users", "Integrations", + // "Monitoring", "Backups", "Maintenance", "Audit log") in headings. + // The redesigned overview must not paint that grid anymore. + expect(html).not.toContain(">Maintenance<"); + expect(html).not.toContain(">Monitoring<"); + // No "Manage users"/"Open backups" CTAs from the StatusCard CTAs. + expect(html).not.toContain("Manage users"); + expect(html).not.toContain("Open backups"); }); - it("maps severity to the §2.6 dracula color tokens", () => { - const cases: Array< - [Parameters[0]["severity"], string] - > = [ - ["good", "var(--dracula-green)"], - ["info", "var(--dracula-cyan)"], - ["caution", "var(--dracula-orange)"], - ["alert", "var(--dracula-red)"], - ["pending", "var(--muted-foreground)"], - ]; - for (const [severity, token] of cases) { - const html = renderToStaticMarkup(); - expect(html).toContain(token); - } + it("renders the welcome card with the admin's username", () => { + const html = render(); + // `welcomeTitle` interpolates the username; the mocked auth hook + // returns "marc". + expect(html).toContain("Welcome, marc"); + expect(html).toContain("admin-overview-welcome-heading"); }); - it("respects an explicit label override", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain("Disk full"); - expect(html).toContain('aria-label="Status: Disk full"'); - }); -}); - -describe("buildCards()", () => { - it("returns exactly 6 cards in the §3.4 admin order", () => { - const cards = buildCards(mockOverview); - expect(cards).toHaveLength(6); - expect(cards.map((c) => c.title)).toEqual([ - "Users", - "Integrations", - "Monitoring", - "Backups", - "Maintenance", - "Audit log", - ]); - }); - - it("propagates severity from the aggregator response", () => { - const cards = buildCards(mockOverview); - expect(cards[0].severity).toBe("good"); // users - expect(cards[2].severity).toBe("caution"); // monitoring - expect(cards[3].severity).toBe("alert"); // backups (no backup yet) - expect(cards[5].severity).toBe("info"); // audit log - }); - - it("links each card to a real `/admin/` route that exists on disk", () => { - // v1.5: status-card CTAs no longer use `#anchor` fragments — every href - // points at a dynamic-route slug under `/admin/[section]`. This test - // proves the slug exists (a) in the static `ADMIN_SECTION_SLUGS` list - // that drives `generateStaticParams()` AND (b) that the dynamic route - // file `src/app/admin/[section]/page.tsx` exists on disk. If a future - // refactor moves the dynamic route, the second check fails loudly. - const validSlugs = new Set(ADMIN_SECTION_SLUGS); - - // `__dirname` here is `.../src/components/admin/__tests__`, so walk - // up four levels to the repo root. - const repoRoot = join(__dirname, "..", "..", "..", ".."); - const dynamicRoute = join( - repoRoot, - "src", - "app", - "admin", - "[section]", - "page.tsx", - ); - expect( - existsSync(dynamicRoute), - `Dynamic admin route missing — expected ${dynamicRoute}`, - ).toBe(true); - - const cards = buildCards(mockOverview); - for (const card of cards) { - expect(card.cta.length).toBeGreaterThan(0); - // Hrefs must be real sub-routes; `/admin#section-*` was the v1.4.6 - // honesty problem the v1.5 refactor permanently removed. - expect(card.href).toMatch(/^\/admin\/[a-z][a-z0-9-]*$/); - const slug = card.href.replace(/^\/admin\//, ""); - expect( - validSlugs.has(slug), - `Card ${card.title} -> ${card.href} is not in ADMIN_SECTION_SLUGS`, - ).toBe(true); - } - }); - - it("includes between 1 and 3 metric tuples per card with non-empty labels", () => { - // v1.5 phase-5 a11y fix: dropped the empty-string filler "—"/"" tuple from - // the audit-log card so the rendered
has no zero-content
. Cards - // must still have at least one metric (otherwise the grid collapses) and - // every metric needs a real label (axe `definition-list` rule). - const cards = buildCards(mockOverview); - for (const card of cards) { - expect(card.metrics.length).toBeGreaterThanOrEqual(1); - expect(card.metrics.length).toBeLessThanOrEqual(3); - for (const metric of card.metrics) { - expect(metric.label.trim()).not.toBe(""); - expect(metric.label).not.toBe("—"); - } - } - }); - - it("renders worker status as plain text, not color alone", () => { - const cards = buildCards(mockOverview); - const maintenance = cards.find((c) => c.title === "Maintenance"); - expect(maintenance?.metrics[0].value).toBe("Running"); + it("renders the system snapshot section heading", () => { + const html = render(); + expect(html).toContain("System snapshot"); + expect(html).toContain("admin-overview-snapshot-heading"); + }); + + it("renders the recent-activity section with a 'View all' link", () => { + const html = render(); + expect(html).toContain("Recent activity"); + expect(html).toContain("admin-overview-audit-heading"); + expect(html).toContain('href="/admin/login-overview"'); + expect(html).toContain("View all"); }); }); diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx new file mode 100644 index 000000000..62d51f967 --- /dev/null +++ b/src/components/admin/recent-audit-preview.tsx @@ -0,0 +1,145 @@ +"use client"; + +/** + * `` — compact list of the 10 most recent audit + * entries for the `/admin` overview landing page. The full filterable + * viewer lives at `/admin/login-overview`. + * + * Reuses `/api/admin/audit-log` so the data source matches the + * detail-page section (``). + */ + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { + ArrowRight, + CheckCircle2, + Loader2, + ScrollText, + XCircle, +} from "lucide-react"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { type AdminAuditEntry } from "./_shared"; + +const PREVIEW_LIMIT = 10; + +interface AuditLogResponse { + entries: AdminAuditEntry[]; + meta: { total: number; limit: number; offset: number }; +} + +export function RecentAuditPreview() { + const { t } = useTranslations(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "audit-log", "overview-preview"], + queryFn: async () => { + const res = await fetch( + `/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`, + ); + if (!res.ok) throw new Error("Failed to load audit log"); + return (await res.json()).data as AuditLogResponse; + }, + // Avoid hitting the DB on every page focus; the overview is the + // type of page admins keep open and re-render frequently. + staleTime: 30_000, + }); + + const AUTH_ACTION_LABELS: Record = { + "auth.register": t("admin.authRegister"), + "auth.login": t("admin.authLogin"), + "auth.login.passkey": t("admin.authLoginPasskey"), + "auth.login.password": t("admin.authLoginPassword"), + "auth.login.failed": t("admin.authLoginFailed"), + "auth.logout": t("admin.authLogout"), + "auth.passkey.register": t("admin.authPasskeyRegister"), + "auth.passkey.delete": t("admin.authPasskeyDelete"), + }; + + const entries = data?.entries ?? []; + + return ( +
+
+
+
+ + {t("admin.overview.auditViewAll")} +
+ +
+ {isLoading ? ( +
+
+ ) : isError ? ( +
+ {t("admin.overview.auditLoadError")} +
+ ) : entries.length === 0 ? ( +

+ {t("admin.overview.auditEmpty")} +

+ ) : ( +
    + {entries.map((entry) => { + const isFailed = entry.action === "auth.login.failed"; + return ( +
  • + + + {entry.user?.username ?? t("common.unknown")} + + + {AUTH_ACTION_LABELS[entry.action] ?? entry.action} + + + {formatDateTime(entry.createdAt)} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/admin/status-card-grid.tsx b/src/components/admin/status-card-grid.tsx deleted file mode 100644 index 18f8dacbc..000000000 --- a/src/components/admin/status-card-grid.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { - Users, - Plug, - Activity, - Database, - Wrench, - ScrollText, - ArrowRight, -} from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; -import type { - StatusOverview, - StatusSeverity, -} from "@/app/api/admin/status-overview/route"; - -/** - * §2.6 status taxonomy. Renders a colored dot AND a label so we never - * lean on color alone (WCAG 1.4.1). - */ -const SEVERITY_LABEL: Record = { - good: "Healthy", - info: "Up to date", - caution: "Attention", - alert: "Action required", - pending: "Loading", -}; - -const SEVERITY_COLOR: Record = { - good: "var(--dracula-green)", - info: "var(--dracula-cyan)", - caution: "var(--dracula-orange)", - alert: "var(--dracula-red)", - pending: "var(--muted-foreground)", -}; - -export function StatusBadge({ - severity, - label, -}: { - severity: StatusSeverity; - label?: string; -}) { - const text = label ?? SEVERITY_LABEL[severity]; - return ( - - - ); -} - -interface StatusCardProps { - title: string; - icon: React.ReactNode; - severity: StatusSeverity; - metrics: Array<{ label: string; value: string | number }>; - href: string; - cta: string; -} - -function StatusCard({ - title, - icon, - severity, - metrics, - href, - cta, -}: StatusCardProps) { - return ( - - - - - {title} - - - - -
- {metrics.map((m) => ( -
-
{m.label}
-
{m.value}
-
- ))} -
- - {cta} -
-
- ); -} - -function fmtRelative(iso: string | null): string { - if (!iso) return "never"; - const ms = Date.now() - new Date(iso).getTime(); - const min = Math.round(ms / 60_000); - if (min < 60) return `${min}m ago`; - const hr = Math.round(min / 60); - if (hr < 48) return `${hr}h ago`; - const day = Math.round(hr / 24); - return `${day}d ago`; -} - -function fmtUptime(seconds: number | null): string { - if (seconds === null) return "—"; - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`; - return `${Math.floor(seconds / 86_400)}d`; -} - -/** - * Build the 6 admin status cards from the aggregator response. Pure; - * no data fetching here — easy to unit test. - */ -export function buildCards(data: StatusOverview): StatusCardProps[] { - return [ - { - title: "Users", - icon: , - severity: data.users.severity, - metrics: [ - { label: "Total", value: data.users.total }, - { label: "Admins", value: data.users.admins }, - { label: "New 7d", value: data.users.newThisWeek }, - ], - href: "/admin/users", - cta: "Manage users", - }, - { - title: "Integrations", - icon: , - severity: data.integrations.severity, - metrics: [ - { label: "Withings", value: data.integrations.withings }, - { label: "moodLog", value: data.integrations.moodLog }, - { label: "Push", value: data.integrations.webPush }, - ], - href: "/admin/integrations", - cta: "Open integrations", - }, - { - title: "Monitoring", - icon: , - severity: data.monitoring.severity, - metrics: [ - { - label: "GlitchTip", - value: data.monitoring.glitchtipEnabled ? "On" : "Off", - }, - { - label: "Umami", - value: data.monitoring.umamiEnabled ? "On" : "Off", - }, - { label: "Last err", value: fmtRelative(data.monitoring.lastErrorAt) }, - ], - href: "/admin/integrations", - cta: "View monitoring", - }, - { - title: "Backups", - icon: , - severity: data.backups.severity, - metrics: [ - { label: "Last", value: fmtRelative(data.backups.lastBackupAt) }, - { label: "Users", value: data.backups.backedUpUsers }, - { label: "Retain", value: `${data.backups.retentionDays}d` }, - ], - href: "/admin/backups", - cta: "Open backups", - }, - { - title: "Maintenance", - icon: , - severity: data.maintenance.severity, - metrics: [ - { - label: "Worker", - value: data.maintenance.workerRunning ? "Running" : "Stopped", - }, - { - label: "Uptime", - value: fmtUptime(data.maintenance.workerUptimeSeconds), - }, - { - label: "Cleanup", - value: fmtRelative(data.maintenance.lastIdempotencyCleanup), - }, - ], - href: "/admin/reminders", - cta: "View jobs", - }, - { - title: "Audit log", - icon: , - severity: data.auditLog.severity, - metrics: [ - { label: "Events 30d", value: data.auditLog.eventsLast30d }, - { label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) }, - ], - href: "/admin/login-overview", - cta: "Open viewer", - }, - ]; -} - -export function StatusCardGrid() { - const { data, isLoading } = useQuery({ - queryKey: ["admin", "status-overview"], - queryFn: async () => { - const res = await fetch("/api/admin/status-overview"); - if (!res.ok) throw new Error("Failed to load admin status overview"); - const json = (await res.json()) as { data: StatusOverview }; - return json.data; - }, - staleTime: 30_000, - }); - - if (isLoading || !data) { - return ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- ); - } - - const cards = buildCards(data); - return ( -
- {cards.map((c) => ( - - ))} -
- ); -} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx new file mode 100644 index 000000000..a16bd5903 --- /dev/null +++ b/src/components/admin/system-status-summary.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * `` — compact at-a-glance system snapshot for the + * `/admin` overview landing page. + * + * The full breakdown lives in `` at + * `/admin/system-status`; this component only surfaces the handful of + * facts an admin wants on the overview screen: app version, database + * up/down, worker running, image build SHA + timestamp, and the + * server-process start time. Same `useSystemStatus()` data source so we + * don't re-fetch. + */ + +import { + Clock, + Cog, + Database, + GitCommit, + Loader2, + Server, + Tag, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { StatusItem, useSystemStatus } from "./_shared"; + +interface VersionResponse { + version: string; + buildSha: string | null; + builtAt: string | null; +} + +function useVersion() { + return useQuery({ + queryKey: ["public", "version"], + queryFn: async () => { + const res = await fetch("/api/version"); + if (!res.ok) throw new Error("Failed to load version"); + return (await res.json()).data as VersionResponse; + }, + staleTime: 5 * 60_000, + }); +} + +export function SystemStatusSummary() { + const { t } = useTranslations(); + const { data: status, isError } = useSystemStatus(); + const { data: version } = useVersion(); + + return ( +
+
+
+ + {status ? ( +
+ + + + + {(version?.buildSha ?? status.gitCommit) && ( + + )} + {version?.builtAt && ( + + )} +
+ ) : isError ? ( +
+ {t("admin.overview.snapshotLoadError")} +
+ ) : ( +
+
+ )} +
+ ); +} diff --git a/src/lib/analytics/__tests__/bp-in-target.test.ts b/src/lib/analytics/__tests__/bp-in-target.test.ts new file mode 100644 index 000000000..f2f944abe --- /dev/null +++ b/src/lib/analytics/__tests__/bp-in-target.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { computeBpInTargetPct } from "../bp-in-target"; + +const TARGETS_UNDER_65 = { + sysLow: 120, + sysHigh: 129, + diaLow: 70, + diaHigh: 79, +}; + +function reading(measuredAt: string, value: number) { + return { measuredAt: new Date(measuredAt), value }; +} + +describe("computeBpInTargetPct", () => { + it("returns null when either series is empty", () => { + expect(computeBpInTargetPct([], [], TARGETS_UNDER_65)).toBeNull(); + expect( + computeBpInTargetPct( + [reading("2026-05-01T08:00:00Z", 125)], + [], + TARGETS_UNDER_65, + ), + ).toBeNull(); + }); + + it("counts only paired readings (same-session within 5 min)", () => { + // Two same-session pairs, one in target, one out. + const sys = [ + reading("2026-05-01T08:00:00Z", 125), // in target + reading("2026-05-02T08:00:00Z", 145), // out of target + ]; + const dia = [ + reading("2026-05-01T08:00:30Z", 75), // in target → pair in + reading("2026-05-02T08:01:00Z", 90), // out of target → pair out + ]; + + const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65); + expect(result).not.toBeNull(); + expect(result!.pairs).toBe(2); + expect(result!.pct).toBe(50); + }); + + it("computes 100% when every paired reading sits inside target", () => { + const sys = [ + reading("2026-05-01T08:00:00Z", 125), + reading("2026-05-02T08:00:00Z", 122), + reading("2026-05-03T08:00:00Z", 128), + ]; + const dia = [ + reading("2026-05-01T08:00:30Z", 75), + reading("2026-05-02T08:00:30Z", 72), + reading("2026-05-03T08:00:30Z", 78), + ]; + const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65); + expect(result).toEqual({ pct: 100, pairs: 3 }); + }); + + /** + * Regression: the v1.4.14 implementation divided by `sysData.length`, + * which collapsed to 0 % when imports lost their second-precision + * `measuredAt` and dia drifted past the 5-minute pairing window. + * The fix accepts a same-Berlin-day fallback so legacy Withings + * imports rounded to the hour still pair correctly. + */ + it("falls back to same-Berlin-day pairing for imports rounded to the hour", () => { + // Sys at 08:00, dia at 08:42 — outside the 5-minute window but the + // same Berlin calendar day. Reading is in target. + const sys = [reading("2026-05-01T06:00:00Z", 125)]; // 08:00 Berlin (CEST) + const dia = [reading("2026-05-01T06:42:00Z", 75)]; // 08:42 Berlin (CEST) + + const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65); + expect(result).toEqual({ pct: 100, pairs: 1 }); + }); + + /** + * Regression: the old denominator of `sysData.length` made the tile + * report 0 % when sys+dia readings shared a calendar day but were + * outside the 5-minute window — even though every paired reading + * was inside target. The fix uses the count of accepted pairs. + */ + it("ignores sys readings without any paired dia (same-day or session)", () => { + // Two sys readings, but only one has a same-Berlin-day dia. + const sys = [ + reading("2026-05-01T08:00:00Z", 125), // dia exists same day + reading("2026-05-02T08:00:00Z", 200), // out-of-band sys, no dia same day + ]; + const dia = [ + reading("2026-05-01T08:00:30Z", 75), // pairs with first sys + // gap on 2026-05-02 — second sys has no same-day dia + reading("2026-05-10T08:00:30Z", 75), // 8 days away from second sys → not paired + ]; + const result = computeBpInTargetPct(sys, dia, TARGETS_UNDER_65); + // First sys pair is in target. Second sys's closest dia (2026-05-10) + // is neither same-session nor same-day, so it's discarded. + // Pre-fix: 1 / 2 sys = 50% (wrong — divides by all sys). + // Post-fix: 1 / 1 paired = 100% (correct). + expect(result).toEqual({ pct: 100, pairs: 1 }); + }); + + it("returns null when no pairs can be formed at all", () => { + // Sys on day 1, dia 30 days later — no pairing possible. + const sys = [reading("2026-04-01T08:00:00Z", 125)]; + const dia = [reading("2026-05-01T08:00:00Z", 75)]; + expect(computeBpInTargetPct(sys, dia, TARGETS_UNDER_65)).toBeNull(); + }); + + it("counts boundary values as in-target", () => { + const sys = [ + reading("2026-05-01T08:00:00Z", 120), // sysLow exact + reading("2026-05-02T08:00:00Z", 129), // sysHigh exact + ]; + const dia = [ + reading("2026-05-01T08:00:00Z", 70), // diaLow exact + reading("2026-05-02T08:00:00Z", 79), // diaHigh exact + ]; + expect(computeBpInTargetPct(sys, dia, TARGETS_UNDER_65)).toEqual({ + pct: 100, + pairs: 2, + }); + }); +}); diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts new file mode 100644 index 000000000..368be8576 --- /dev/null +++ b/src/lib/analytics/bp-in-target.ts @@ -0,0 +1,138 @@ +/** + * BP-in-target percentage helper. + * + * The "BD im Zielbereich" tile on the dashboard reports the share of + * recent blood-pressure readings that fall inside the user's age-band + * target zone. Up to v1.4.14 this lived inline in + * `src/app/api/analytics/route.ts` and had two latent bugs that made + * the tile read 0 % even when the user clearly had readings inside the + * target band: + * + * 1. **Wrong denominator.** The legacy code divided `inTarget / + * sysData.length`, but readings are only countable when a matching + * diastolic exists within 5 minutes. So a user with 30 sys + 30 dia + * that all paired and 6 in target read `6/30 = 20 %` — but a user + * where only 10 of 30 sys had a matching dia inside 5 minutes (the + * other 20 were close-pair-less imports) capped at `<= 10/30 = 33 %` + * no matter what. Worse: when none paired, the numerator stayed 0 + * so the tile reported a flat 0 %. + * + * 2. **No fallback when timestamps don't pair.** Withings imports and + * moodLog.app imports each write sys + dia as separate Measurement + * rows that ought to share a `measuredAt` to the second — but + * rounding through different timezones can drift them by minutes. + * The 5-minute window was tight; data imported with hour-level + * rounding (some legacy Withings ranges) had `timeDiff > 5 min` and + * the tile flat-lined to 0 %. + * + * Fix: pair sys + dia by **same Berlin calendar day** as a fallback if + * the within-5-minutes pairing yields nothing, and always divide by the + * count of *paired* readings (the denominator that gets a vote). + * + * Pure & deterministic so the unit test suite can pin the exact %. + */ +import type { BpTargets } from "./bp-targets"; + +export interface BpReading { + measuredAt: Date; + value: number; +} + +const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +function toBerlinDayKey(date: Date): string { + // en-CA gives us the YYYY-MM-DD shape directly. + return BERLIN_DAY_FORMATTER.format(date); +} + +/** + * Pair a systolic reading with its closest diastolic by absolute time + * delta. Returns the time delta (ms) and the matched dia, or `null` if + * the dia list is empty. + */ +function findClosestDia( + sys: BpReading, + diaSeries: BpReading[], +): { dia: BpReading; deltaMs: number } | null { + if (diaSeries.length === 0) return null; + let bestDia = diaSeries[0]; + let bestDelta = Math.abs( + diaSeries[0].measuredAt.getTime() - sys.measuredAt.getTime(), + ); + for (let i = 1; i < diaSeries.length; i++) { + const candidate = diaSeries[i]; + const delta = Math.abs( + candidate.measuredAt.getTime() - sys.measuredAt.getTime(), + ); + if (delta < bestDelta) { + bestDelta = delta; + bestDia = candidate; + } + } + return { dia: bestDia, deltaMs: bestDelta }; +} + +/** + * Compute the share (0-100, rounded to nearest integer) of paired BP + * readings inside `targets` over the supplied series. Returns `null` + * when no pairs can be formed (caller renders the tile as "no data" + * instead of 0 %). + * + * Pairing strategy: + * 1. For each sys reading, find the closest dia by absolute time + * delta. + * 2. Accept the pair if `deltaMs <= 5 minutes` (legacy bound) — same + * session. + * 3. Otherwise accept the pair if both share the same Berlin + * calendar day (handles imports rounded to the hour or to noon). + * 4. Discard otherwise. + * + * Denominator is the number of accepted pairs, NOT `sysData.length` — + * that was the v1.4.14 bug that made the tile read 0 % when imports + * had drifted timestamps. + */ +export function computeBpInTargetPct( + sysSeries: BpReading[], + diaSeries: BpReading[], + targets: BpTargets, +): { pct: number; pairs: number } | null { + if (sysSeries.length === 0 || diaSeries.length === 0) return null; + + const SAME_SESSION_MS = 5 * 60 * 1000; + let pairs = 0; + let inTarget = 0; + + for (const sys of sysSeries) { + const match = findClosestDia(sys, diaSeries); + if (!match) continue; + + const sameSession = match.deltaMs <= SAME_SESSION_MS; + const sameBerlinDay = + !sameSession && + toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt); + + if (!sameSession && !sameBerlinDay) continue; + + pairs += 1; + if ( + sys.value >= targets.sysLow && + sys.value <= targets.sysHigh && + match.dia.value >= targets.diaLow && + match.dia.value <= targets.diaHigh + ) { + inTarget += 1; + } + } + + if (pairs === 0) return null; + + return { + pct: Math.round((inTarget / pairs) * 100), + pairs, + }; +}