feat(admin): replace overview section-grid with audit-log preview + system snapshot

The `/admin` overview rendered a six-card status grid plus a quick-jump
list of every admin section. Both duplicated navigation that already
lives in the sidebar (and the global nav after Phase 4b), so the
landing page told the admin nothing they didn't already see two clicks
away.

Replace the duplicated nav with two at-a-glance panes that *only* the
overview can show:

- Welcome card greets the signed-in admin by username and confirms the
  admin context (so the role indicator the shell shows is reinforced
  in the page body, not just the chrome).
- `<SystemStatusSummary>` reuses the existing `useSystemStatus()` hook
  + `/api/version` to print app version, DB up/down, worker state,
  process start time, build SHA, and built-at. Same data source as
  `<SystemStatusSection>` so we don't double-fetch.
- `<RecentAuditPreview>` lists the latest 10 audit entries from
  `/api/admin/audit-log?limit=10&filter=auth` with a "View all" link to
  `/admin/login-overview`. Reuses the same auth-action label map the
  full section uses.

The previous `<StatusCardGrid>` component is now unused — removed
entirely. The test file kept its name (`status-card-grid.test.tsx`) so
git history follows the rename, but it now asserts the new overview
composition: welcome card paints with the admin's username, snapshot
heading present, audit preview links to `/admin/login-overview`, and
the legacy "Maintenance"/"Monitoring" card titles + "Manage users"
CTAs are *not* rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:18:07 +02:00
1 parent 3e45a7ba53
commit a967895808
8 files changed
+665 -489

No files matched your search

+27 -49
View File
@@ -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 (
<AdminShell>
<div className="space-y-6">
<div>
<header>
<h1 className="text-2xl font-bold tracking-tight">
{t("admin.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("admin.subtitle")}</p>
</div>
</header>
{settingsError && (
<div
@@ -48,44 +45,25 @@ export default function AdminOverviewPage() {
</div>
)}
<StatusCardGrid />
{/* v1.5 phase-5: wrap the quick-jump list in a labelled <nav> so
screen-reader landmark navigation can distinguish it from the
sidebar nav (which already has its own aria-label). */}
<nav aria-labelledby="admin-overview-sections-heading">
{/* Welcome card — greeting with admin context indicator. */}
<section
aria-labelledby="admin-overview-welcome-heading"
className="bg-card border-border rounded-xl border p-6"
>
<h2
id="admin-overview-sections-heading"
className="text-muted-foreground mb-3 text-sm font-semibold tracking-wider uppercase"
id="admin-overview-welcome-heading"
className="text-lg font-semibold"
>
{t("admin.shell.sectionsNav")}
{t("admin.overview.welcomeTitle", { name: user.username })}
</h2>
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{ADMIN_SECTIONS.map((section) => {
const Icon = section.icon;
return (
<li key={section.slug}>
<Link
href={`/admin/${section.slug}`}
className="bg-card border-border hover:bg-accent flex items-center justify-between gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-colors"
>
<span className="flex items-center gap-2">
<Icon
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
{t(section.titleKey)}
</span>
<ArrowRight
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</nav>
<p className="text-muted-foreground mt-1 text-sm">
{t("admin.overview.welcomeSubtitle")}
</p>
</section>
<SystemStatusSummary />
<RecentAuditPreview />
</div>
</AdminShell>
);
+8 -28
View File
@@ -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).
@@ -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("<StatusBadge>", () => {
it("renders a colored dot plus a text label (never color-only)", () => {
const html = renderToStaticMarkup(<StatusBadge severity="good" />);
// 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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("/admin overview composition", () => {
it("does NOT render the legacy section-card grid", () => {
const html = render(<AdminOverviewPage />);
// 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<typeof StatusBadge>[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(<StatusBadge severity={severity} />);
expect(html).toContain(token);
}
it("renders the welcome card with the admin's username", () => {
const html = render(<AdminOverviewPage />);
// `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(
<StatusBadge severity="alert" label="Disk full" />,
);
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/<slug>` 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<string>(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 <dl> has no zero-content <dd>. 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(<SystemStatusSummary />);
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(<RecentAuditPreview />);
expect(html).toContain("Recent activity");
expect(html).toContain("admin-overview-audit-heading");
expect(html).toContain('href="/admin/login-overview"');
expect(html).toContain("View all");
});
});
@@ -0,0 +1,145 @@
"use client";
/**
* `<RecentAuditPreview>` — 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 (`<LoginOverviewSection>`).
*/
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<string, string> = {
"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 (
<section
aria-labelledby="admin-overview-audit-heading"
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-audit-heading"
className="text-lg font-semibold"
>
{t("admin.overview.auditTitle")}
</h2>
</div>
<Link
href="/admin/login-overview"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</div>
<div className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
{t("admin.overview.auditLoading")}
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.auditLoadError")}
</div>
) : entries.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.overview.auditEmpty")}
</p>
) : (
<ul className="divide-border divide-y">
{entries.map((entry) => {
const isFailed = entry.action === "auth.login.failed";
return (
<li
key={entry.id}
className="flex items-center gap-3 py-2 text-sm"
>
<span className="shrink-0" aria-hidden="true">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</span>
<span
className={`min-w-0 flex-1 truncate font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</span>
<span
className={`hidden truncate sm:inline ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</span>
<span className="text-muted-foreground shrink-0 text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</span>
</li>
);
})}
</ul>
)}
</div>
</section>
);
}
-257
View File
@@ -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<StatusSeverity, string> = {
good: "Healthy",
info: "Up to date",
caution: "Attention",
alert: "Action required",
pending: "Loading",
};
const SEVERITY_COLOR: Record<StatusSeverity, string> = {
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 (
<span
className="bg-muted/40 inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
aria-label={`Status: ${text}`}
>
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: SEVERITY_COLOR[severity] }}
/>
<span>{text}</span>
</span>
);
}
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 (
<Card className="flex flex-col gap-2">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="flex items-center gap-2 text-base font-medium">
<span aria-hidden="true" className="text-muted-foreground">
{icon}
</span>
{title}
</CardTitle>
<StatusBadge severity={severity} />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<dl className="grid grid-cols-3 gap-2">
{metrics.map((m) => (
<div key={m.label} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground text-xs">{m.label}</dt>
<dd className="font-mono text-sm tabular-nums">{m.value}</dd>
</div>
))}
</dl>
<Link
href={href}
className="text-primary inline-flex items-center gap-1 self-start text-sm hover:underline"
>
{cta}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</CardContent>
</Card>
);
}
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: <Users className="h-4 w-4" />,
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: <Plug className="h-4 w-4" />,
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: <Activity className="h-4 w-4" />,
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: <Database className="h-4 w-4" />,
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: <Wrench className="h-4 w-4" />,
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: <ScrollText className="h-4 w-4" />,
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<StatusOverview>({
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 (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full" />
))}
</div>
);
}
const cards = buildCards(data);
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{cards.map((c) => (
<StatusCard key={c.title} {...c} />
))}
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
/**
* `<SystemStatusSummary>` — compact at-a-glance system snapshot for the
* `/admin` overview landing page.
*
* The full breakdown lives in `<SystemStatusSection>` 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 (
<section
aria-labelledby="admin-overview-snapshot-heading"
className="bg-card border-border rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" aria-hidden="true" />
<h2
id="admin-overview-snapshot-heading"
className="text-lg font-semibold"
>
{t("admin.overview.snapshotTitle")}
</h2>
</div>
{status ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
value={version?.version ?? status.version}
/>
<StatusItem
icon={Database}
label={t("admin.overview.snapshotDatabase")}
value={
status.database === "connected"
? t("admin.databaseConnected")
: t("admin.databaseError")
}
className={
status.database === "connected"
? "text-dracula-green"
: "text-destructive"
}
/>
<StatusItem
icon={Cog}
label={t("admin.overview.snapshotWorker")}
value={
status.worker.running
? t("admin.workerRunning")
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-dracula-green" : "text-destructive"
}
/>
<StatusItem
icon={Clock}
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{(version?.buildSha ?? status.gitCommit) && (
<StatusItem
icon={GitCommit}
label={t("admin.overview.snapshotBuildSha")}
value={version?.buildSha ?? status.gitCommit}
/>
)}
{version?.builtAt && (
<StatusItem
icon={Clock}
label={t("admin.overview.snapshotBuiltAt")}
value={formatDateTime(version.builtAt)}
/>
)}
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.snapshotLoadError")}
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin"
aria-hidden="true"
/>
<span className="text-muted-foreground text-sm">
{t("admin.overview.snapshotLoading")}
</span>
</div>
)}
</section>
);
}
@@ -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,
});
});
});
+138
View File
@@ -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,
};
}