);
}
diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx
index 61a46d8a0..995619b62 100644
--- a/src/components/admin/app-log-preview-section.tsx
+++ b/src/components/admin/app-log-preview-section.tsx
@@ -46,6 +46,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { formatDateTime } from "@/lib/format";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -115,7 +116,7 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
-
+
-
+
);
}
diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx
index 8b49aaee4..a54adbe21 100644
--- a/src/components/admin/assistant-section.tsx
+++ b/src/components/admin/assistant-section.tsx
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -99,7 +100,7 @@ export function AssistantSection() {
const disabledSubs = !masterOn || mutation.isPending;
return (
-
+
-
+
);
}
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index f63244557..bc01e77e5 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -39,7 +39,9 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
+import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { ListRow } from "@/components/ui/list-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
@@ -332,7 +334,7 @@ export function BackupsSection() {
}
return (
-
+
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
"Run now" button follows, and the explainer + docs link move
into the header's description slot. Previously the description
@@ -550,48 +552,51 @@ export function BackupsSection() {
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
-
+
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
timing chips inline instead of competing as the page icon. */}
@@ -404,7 +405,7 @@ export function RemindersSection() {
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index dfa955015..c2677c2d7 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -6,6 +6,7 @@ import { MessagesSquare, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { PageHeader } from "@/components/ui/page-header";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
@@ -122,13 +123,13 @@ function CoachConversationsBody() {
return (
-
-
- {t("insights.coach.historyTitle")}
-
+
+ {t("insights.coach.historyTitle")}
+
+ }
+ />
-
-
-
- {t("labs.title")}
-
-
- {t("labs.subtitle")}
-
-
-
- {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
- point, left of the primary Add and linking to the Labs settings
- page (view, sort order, biomarker CRUD + reorder). Mirrors the
- medication page header's wrench glyph + slot + 44px tap floor. */}
-
- {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
- available: scan a document (OCR) or add a value by hand. The scan
- option only appears when the capability probe reports a usable
- mode (vision, or local OCR opted-in for text-only providers), so
- the surface stays simple for everyone else. When scanning is
- unavailable, Add opens the manual form directly. */}
- {ocrCapability.data?.available ? (
-
-
-
-
-
- setScanOpen(true)}>
-
- {t("labs.ocr.addScan")}
-
- setDialogOpen(true)}>
-
- {t("labs.ocr.addManual")}
-
-
-
- ) : (
+ {t("labs.title")}}
+ description={t("labs.subtitle")}
+ actions={
+ <>
+ {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
+ point, left of the primary Add and linking to the Labs settings
+ page (view, sort order, biomarker CRUD + reorder). Mirrors the
+ medication page header's wrench glyph + slot + 44px tap floor. */}
- )}
-
-
+ {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
+ available: scan a document (OCR) or add a value by hand. The scan
+ option only appears when the capability probe reports a usable
+ mode (vision, or local OCR opted-in for text-only providers), so
+ the surface stays simple for everyone else. When scanning is
+ unavailable, Add opens the manual form directly. */}
+ {ocrCapability.data?.available ? (
+
+
+
+
+
+ setScanOpen(true)}>
+
+ {t("labs.ocr.addScan")}
+
+ setDialogOpen(true)}>
+
+ {t("labs.ocr.addManual")}
+
+
+
+ ) : (
+
+ )}
+ >
+ }
+ />
-
-
-
+
{t("measurements.title")}
-
- {/* v1.4.34 IW-G — subtitle now stays visible on mobile so
- the H1 isn't an unframed label. `text-xs sm:text-sm`
- preserves the desktop hierarchy. */}
-
- {/* v1.4.34 IW-G — subtitle stays visible on mobile so the
- H1 isn't an unframed label. */}
-
- {t("mood.subtitle")}
-
-
-
- {/* v1.17 — the wrench is the one customize entry point: it
- links to /settings/mood, which owns the tag groups, custom
- tags, visibility, and picker order. Same glyph, slot (left
- of the add button) and responsive 44-px mobile tap floor
- as the medications header. */}
-
+ }
+ />
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 72d0b7540..7d686cac7 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -22,11 +22,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
-import { cn } from "@/lib/utils";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -68,106 +68,102 @@ export function DashboardHeader({
: t("dashboard.welcomeBack", { greeting: timeGreeting });
return (
- /* With the greeting the title block is two lines on narrow
- viewports: centre the buttons against it on mobile, top-align on
- sm+ (the pre-hero posture). Without the greeting the single-line
- centre alignment stands. */
-
- {/* Customize shortcut to the dashboard-customization settings
- (the tile/layout editor at /settings/dashboard). Sits to
- the left of the add button as a monochrome ghost icon, with
- a `min-h-11 min-w-11` mobile floor so it meets the 44 px
- touch-target contract the add button also honours, shrinking
- back to the 40 px icon footprint on sm+. */}
-
-
+ ) : undefined
+ }
+ actions={
+ <>
+ {/* Customize shortcut to the dashboard-customization settings
+ (the tile/layout editor at /settings/dashboard). Sits to
+ the left of the add button as a monochrome ghost icon, with
+ a `min-h-11 min-w-11` mobile floor so it meets the 44 px
+ touch-target contract the add button also honours, shrinking
+ back to the 40 px icon footprint on sm+. */}
+
-
-
-
-
-
- {/* v1.4.33 maintainer-item-7 — restore proportional sizing
- across viewports. The v1.4.27 fix pinned a `size="sm"
- min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
- contract on mobile, but `size="sm"` is h-8 (32 px) and
- the `min-h-11` override stretched the cap vertically
- while keeping the small horizontal padding — the
- button read as klobig on Pixel 5. Switch to
- `size="default"` (h-10 = 40 px) on mobile with a
- responsive `min-h-11 sm:min-h-9` so the button is
- 44 px tall under finger pressure and shrinks back to
- the desktop-friendly 36 px on `sm:` upwards. The icon
- + label keep the same visual contract. */}
-
-
- {t("common.add")}
-
-
-
- {/* Menu items must each carry a self-contained verb-phrase
- ("Log measurement", "Log mood") — the trigger above already
- says "Add", and the icon is `aria-hidden`, so the visible
- text is the only thing distinguishing the rows. v1.4.15
- phase-A3 fix #1 hardened this with a unit guard at
- `src/app/__tests__/quick-add-labels.test.ts` — both labels
- must differ from each other AND from `common.add`. */}
- onQuickEntry("measurement")}>
-
- {t("dashboard.quickAddMeasurement")}
-
- onQuickEntry("mood")}>
-
- {t("dashboard.quickAddMood")}
-
- {/* v1.4.37 W7b — third quick-add row: medication intake.
- Same Sheet-on-mobile / Dialog-on-desktop primitive as
- the other two; the menu label is a self-contained
- verb-phrase so it doesn't collide with the trigger or
- the sibling rows (cf. quick-add-labels.test.ts). */}
- onQuickEntry("medicationIntake")}>
-
- {t("dashboard.quickAddMedicationIntake")}
-
-
-
-
-
+
+
+
+
+
+ {/* v1.4.33 maintainer-item-7 — restore proportional sizing
+ across viewports. The v1.4.27 fix pinned a `size="sm"
+ min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
+ contract on mobile, but `size="sm"` is h-8 (32 px) and
+ the `min-h-11` override stretched the cap vertically
+ while keeping the small horizontal padding — the
+ button read as klobig on Pixel 5. Switch to
+ `size="default"` (h-10 = 40 px) on mobile with a
+ responsive `min-h-11 sm:min-h-9` so the button is
+ 44 px tall under finger pressure and shrinks back to
+ the desktop-friendly 36 px on `sm:` upwards. The icon
+ + label keep the same visual contract. */}
+
+
+ {t("common.add")}
+
+
+
+ {/* Menu items must each carry a self-contained verb-phrase
+ ("Log measurement", "Log mood") — the trigger above already
+ says "Add", and the icon is `aria-hidden`, so the visible
+ text is the only thing distinguishing the rows. v1.4.15
+ phase-A3 fix #1 hardened this with a unit guard at
+ `src/app/__tests__/quick-add-labels.test.ts` — both labels
+ must differ from each other AND from `common.add`. */}
+ onQuickEntry("measurement")}>
+
+ {t("dashboard.quickAddMeasurement")}
+
+ onQuickEntry("mood")}>
+
+ {t("dashboard.quickAddMood")}
+
+ {/* v1.4.37 W7b — third quick-add row: medication intake.
+ Same Sheet-on-mobile / Dialog-on-desktop primitive as
+ the other two; the menu label is a self-contained
+ verb-phrase so it doesn't collide with the trigger or
+ the sibling rows (cf. quick-add-labels.test.ts). */}
+ onQuickEntry("medicationIntake")}
+ >
+
+ {t("dashboard.quickAddMedicationIntake")}
+
+
+
+ >
+ }
+ />
);
}
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index 8f8d16772..d786c6aeb 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -17,13 +17,7 @@
*/
import { useId, useMemo, useState } from "react";
import Link from "next/link";
-import {
- ChevronDown,
- Plus,
- RefreshCw,
- Stethoscope,
- Wrench,
-} from "lucide-react";
+import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -35,6 +29,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
+import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -379,46 +375,39 @@ export function IllnessView() {
return (
-
-
-
- {t("illness.title")}
-
-
- {t("illness.subtitle")}
-
-
-
- {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
- Illness settings page (view + reorder). */}
-
- {t("illness.title")}}
+ description={t("illness.subtitle")}
+ actions={
+ <>
+ {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
+ Illness settings page (view + reorder). */}
+
-
-
-
- {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
- other module, not the bespoke "neue Episode". */}
- setNewOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("common.add")}
-
-
-
+
+
+
+
+ {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
+ other module, not the bespoke "neue Episode". */}
+ setNewOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("common.add")}
+
+ >
+ }
+ />
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
the one-time acknowledgment now lives at onboarding and the legal
@@ -430,23 +419,12 @@ export function IllnessView() {
+ ) : isError ? (
+ // A read failure is NOT an empty list — surface the error + Retry so
+ // an outage never reads as "you have no measurements".
+ void refetch()} />
) : !data?.measurements?.length ? (
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx
index 454f333dc..8f40e14cf 100644
--- a/src/components/mental-health/mental-wellbeing.tsx
+++ b/src/components/mental-health/mental-wellbeing.tsx
@@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
+import { PageHeader } from "@/components/ui/page-header";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
@@ -107,14 +108,12 @@ export function MentalWellbeing() {
{phase === "choose" && (
<>
-
- {/* Title kept for screen readers / document outline only — the
- visible heading + its tooltip were removed (§ Marc v1.25.5). */}
-
{t("mentalHealth.pageTitle")}
-
- {t("mentalHealth.pageDescription")}
-
-
+ {/* v1.26 — visible page header, consistent with every other module
+ (the title was previously sr-only; the description stays). */}
+
+ // A read failure is NOT an empty list — surface the error + Retry so
+ // an outage never reads as "you have no mood entries".
+ void refetch()}
+ />
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
// EmptyState. Filter-aware copy splits "no mood entries yet"
diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx
new file mode 100644
index 000000000..66469203e
--- /dev/null
+++ b/src/components/ui/page-header.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+
+import { BackLink } from "@/components/ui/back-link";
+import { cn } from "@/lib/utils";
+
+/**
+ * The one canonical page header. Every module surface renders its title
+ * through this so the header vocabulary reads identically app-wide: the H1
+ * is always `text-2xl font-bold tracking-tight`, the description is always
+ * `text-muted-foreground text-sm`, and no icon sits beside the H1. An
+ * optional back-link rides above the title, and an `actions` slot holds the
+ * page's primary buttons to the right of the title block.
+ */
+export function PageHeader({
+ title,
+ description,
+ backLink,
+ actions,
+ className,
+}: {
+ title: ReactNode;
+ description?: ReactNode;
+ backLink?: { href: string; label: string; dataSlot?: string };
+ actions?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {backLink ? : null}
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {actions ? (
+
{actions}
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx
new file mode 100644
index 000000000..98edbd8a3
--- /dev/null
+++ b/src/components/ui/query-error-card.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { AlertTriangle, RotateCw } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * Read-failure card for list / data surfaces.
+ *
+ * Mirrors ``'s shape + tone (AlertTriangle, a title line, a
+ * muted description, an optional Retry button) but renders inside a `Card` so
+ * a list page can tell "load failed" apart from an honest empty state — a
+ * failed query must never fall through to the "no data yet" copy. Defaults are
+ * the generic `common.*` strings so a caller can drop it in with just
+ * `onRetry`; a surface with its own "couldn't load X" string passes it through
+ * `title` / `description`.
+ */
+export function QueryErrorCard({
+ title,
+ description,
+ onRetry,
+ retryLabel,
+ className,
+}: {
+ title?: ReactNode;
+ description?: ReactNode;
+ onRetry?: () => void;
+ retryLabel?: string;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
index 5a5915f1b..c51bd675a 100644
--- a/src/components/settings/integrations/fitbit-card.tsx
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -201,7 +201,7 @@ export function FitbitCard({
{t("settings.fitbitExperimentalBadge")}
From 40859613aa7fcfcb769f3f260480ea236346e325 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:16:02 +0200
Subject: [PATCH 10/16] fix(insights): bound the tiered rollup series to the
requested window
The week/month/year rollup read anchored on now and always returned the
trailing window, so a historic from/to range (API and iOS callers) came
back with buckets outside the requested span. Thread the resolved from/to
bounds through so the read is bounded on both ends, matching the day-tier
and live-SQL fallback contract; tier selection is unchanged.
---
.../daily-series-read-tiered.test.ts | 5 +-
src/lib/measurements/daily-series-read.ts | 7 +-
.../__tests__/measurement-read-wmy.test.ts | 72 +++++++++++++++++--
src/lib/rollups/measurement-read-wmy.ts | 42 ++++++++---
4 files changed, 108 insertions(+), 18 deletions(-)
diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts
index dca7b0525..7a494c151 100644
--- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts
+++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts
@@ -112,7 +112,10 @@ describe("readDailySeries — long-range tier step-up", () => {
});
expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1);
- expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650);
+ // v1.26.0 SEAM-N2 — the resolved `[from, to]` bounds are threaded so the
+ // tier reads the REQUESTED window, not a trailing "to now" slice.
+ expect(mocks.readTieredRollupSeries.mock.calls[0][0].from).toBe(from);
+ expect(mocks.readTieredRollupSeries.mock.calls[0][0].to).toBe(to);
// Whole-history coverage: the earliest 2017 bucket survives.
expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z");
expect(result).toHaveLength(2);
diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts
index 8759c4e72..98cafad59 100644
--- a/src/lib/measurements/daily-series-read.ts
+++ b/src/lib/measurements/daily-series-read.ts
@@ -79,7 +79,12 @@ export async function readDailySeries(opts: {
const tiered = await readTieredRollupSeries({
userId,
type,
- windowDays: windowDaysFull,
+ // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier
+ // reads the REQUESTED window (which may be entirely historic), not a
+ // trailing "to now" slice. `windowDaysFull` still gates entry above and
+ // drives the tier width inside the reader.
+ from,
+ to,
priorityJson,
});
if (tiered && tiered.rows.length > 0) {
diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
index 733937bb6..67826add9 100644
--- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
+++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts
@@ -296,12 +296,23 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => {
* miss; and that the wire shape mirrors the daily reader.
*/
describe("readTieredRollupSeries", () => {
+ const DAY_MS = 86_400_000;
+ // A fixed "now-ish" anchor for the current-window cases; the historic case
+ // uses its own explicit past bounds. Nothing in the reader reads
+ // `Date.now()` any more, so these are just span endpoints.
+ const NOW = new Date("2026-06-21T00:00:00.000Z");
+ const win = (days: number, to: Date = NOW) => ({
+ from: new Date(to.getTime() - days * DAY_MS),
+ to,
+ });
+
it("returns null on a non-positive window without touching the db", async () => {
expect(
await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 0,
+ from: NOW,
+ to: NOW,
}),
).toBeNull();
expect(findMany).not.toHaveBeenCalled();
@@ -320,7 +331,7 @@ describe("readTieredRollupSeries", () => {
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 3650,
+ ...win(3650),
});
expect(result).not.toBeNull();
@@ -332,12 +343,59 @@ describe("readTieredRollupSeries", () => {
expect(result?.rows.length).toBe(10);
});
+ // v1.26.0 SEAM-N2 — a historic window entirely in the past must bound the
+ // rollup read on BOTH ends. Pre-fix the reader anchored on `now − windowDays`
+ // and would have queried `bucketStart >= now − 730d` (mid-2024 onward),
+ // returning recent buckets for a 2020–2022 ask.
+ it("bounds the rollup read on BOTH ends for a historic past window", async () => {
+ const from = new Date("2020-01-01T00:00:00.000Z");
+ const to = new Date("2022-01-01T00:00:00.000Z");
+ const inWindow: RollupBucketRow[] = [
+ bucket("2020-02-01T00:00:00.000Z", { count: 12, mean: 80 }),
+ bucket("2021-06-01T00:00:00.000Z", { count: 12, mean: 81 }),
+ bucket("2021-12-01T00:00:00.000Z", { count: 12, mean: 82 }),
+ ];
+ findMany.mockResolvedValueOnce(inWindow);
+
+ const result = await readTieredRollupSeries({
+ userId: "u",
+ type: "WEIGHT",
+ from,
+ to,
+ });
+
+ // The `bucketStart` filter carries BOTH the requested lower AND upper
+ // bound — not a trailing "to now" slice.
+ const where = findMany.mock.calls[0][0].where;
+ expect(where.bucketStart).toEqual({ gte: from, lte: to });
+ // Every returned bucket_start falls inside the requested window.
+ for (const row of result?.rows ?? []) {
+ const t = new Date(row.measuredAt).getTime();
+ expect(t).toBeGreaterThanOrEqual(from.getTime());
+ expect(t).toBeLessThanOrEqual(to.getTime());
+ }
+ // Tier width is 2 years (731 days) → still the MONTH tier for >730 d.
+ expect(result?.granularity).toBe("MONTH");
+ });
+
+ // The web-chart "All" path passes `to ≈ now`; prove that case still bounds
+ // on both ends (no regression) — the historic fix must not perturb it.
+ it("bounds a current-window (to = now) read on both ends too", async () => {
+ findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]);
+ const { from, to } = win(3650);
+ await readTieredRollupSeries({ userId: "u", type: "WEIGHT", from, to });
+ expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({
+ gte: from,
+ lte: to,
+ });
+ });
+
it("reads the WEEK tier for a 1–2 year window", async () => {
findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]);
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 540,
+ ...win(540),
});
expect(result?.granularity).toBe("WEEK");
expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK");
@@ -351,7 +409,7 @@ describe("readTieredRollupSeries", () => {
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 3650,
+ ...win(3650),
});
expect(result?.granularity).toBe("DAY");
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
@@ -364,7 +422,7 @@ describe("readTieredRollupSeries", () => {
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 3650,
+ ...win(3650),
});
expect(result).toBeNull();
// MONTH, WEEK, DAY all probed (never coarser than the target).
@@ -384,7 +442,7 @@ describe("readTieredRollupSeries", () => {
const result = await readTieredRollupSeries({
userId: "u",
type: "ACTIVITY_STEPS",
- windowDays: 3650,
+ ...win(3650),
});
expect(result?.rows[0].value).toBe(240_000);
expect(result?.rows[0].minValue).toBeUndefined();
@@ -403,7 +461,7 @@ describe("readTieredRollupSeries", () => {
const result = await readTieredRollupSeries({
userId: "u",
type: "WEIGHT",
- windowDays: 3650,
+ ...win(3650),
});
expect(result?.rows[0].value).toBe(81.5);
expect(result?.rows[0].minValue).toBe(78);
diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts
index 46eb1235d..779adf443 100644
--- a/src/lib/rollups/measurement-read-wmy.ts
+++ b/src/lib/rollups/measurement-read-wmy.ts
@@ -164,6 +164,10 @@ export async function readBestGranularityRollups(
type,
floor.granularity,
since,
+ // Trailing-window semantics: no upper bound. This router serves the
+ // "last N days to now" probes (summaries-slice / health-score); the
+ // requested-window bounding lives on `readTieredRollupSeries`.
+ null,
priority,
);
if (rows && rows.length > 0) {
@@ -229,20 +233,27 @@ async function readGranularity(
userId: string,
type: MeasurementType,
granularity: RollupGranularity,
- since: Date,
+ from: Date,
+ to: Date | null,
userPriorityJson: unknown,
): Promise {
// Bounded `findMany`: `(userId, type, granularity, bucketStart, source)`
// is the composite primary key so the planner picks the index path
- // every time. `bucketStart >= since` is the same shape
- // `readRollupBuckets` uses; we don't carry an upper bound because
- // the helpers are always reading "trailing window to now".
+ // every time. When `to` is supplied the `bucketStart` filter is bounded
+ // on BOTH ends (`gte from` AND `lte to`) so a caller asking for an
+ // arbitrary historic window — `[2020-01-01, 2022-01-01]` — reads the
+ // buckets INSIDE that window rather than the trailing "to now" slice.
+ // This mirrors the DAY-tier `readRollup` in `daily-series-read.ts`
+ // (`bucketStart: { gte: from, lte: to }`) so the tiered read reproduces
+ // the same window contract the live-SQL fallback bounds on both ends.
+ // `to === null` keeps the legacy trailing-window semantics for the
+ // `readBestGranularityRollups` router.
const rows = await prisma.measurementRollup.findMany({
where: {
userId,
type,
granularity,
- bucketStart: { gte: since },
+ bucketStart: to === null ? { gte: from } : { gte: from, lte: to },
},
orderBy: { bucketStart: "asc" },
select: {
@@ -346,20 +357,32 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"];
* (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a
* future tier ever risks an unbounded count it must step coarser rather
* than truncate; no silent cap lives on this path.
+ *
+ * v1.26.0 SEAM-N2 — the reader is bounded to the REQUESTED `[from, to]`
+ * window on BOTH ends, not a trailing "now − windowDays … now" slice.
+ * The caller passes arbitrary ISO instants (a historic "All" range whose
+ * `to` need not be ≈ now), so anchoring on `Date.now()` returned the wrong
+ * buckets entirely. The tier SELECTION still keys off the window WIDTH
+ * (`windowDays`, derived from the span) — only the window the tier READS
+ * changed. This matches the DAY-tier `readRollup` + the live-SQL
+ * `readLiveDaily` fallback, both of which bound on `[from, to]`.
*/
export async function readTieredRollupSeries(opts: {
userId: string;
type: MeasurementType;
- windowDays: number;
+ from: Date;
+ to: Date;
priorityJson?: unknown;
}): Promise<{
granularity: RollupGranularity;
rows: TieredSeriesRow[];
} | null> {
- const { userId, type, windowDays, priorityJson } = opts;
+ const { userId, type, from, to, priorityJson } = opts;
+ // Tier selection keys off the window WIDTH; derive it the same way the
+ // caller (`daily-series-read`) does so the chosen tier is identical.
+ const windowDays = Math.ceil((to.getTime() - from.getTime()) / 86_400_000);
if (!Number.isFinite(windowDays) || windowDays <= 0) return null;
- const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
const priority =
priorityJson !== undefined
? priorityJson
@@ -375,7 +398,8 @@ export async function readTieredRollupSeries(opts: {
userId,
type,
granularity,
- since,
+ from,
+ to,
priority,
);
if (rows && rows.length > 0) {
From fda0e504f069fcce1f826acc52363b2efe624d80 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:16:03 +0200
Subject: [PATCH 11/16] fix(insights): compute the adherence storyline from the
schedule-anchored engine
The Coach adherence storyline derived its rate from the raw coverage
rollup, whose denominator counts logged slots rather than scheduled
doses, so a user who logged only taken doses read ~100% and the dip gate
suppressed a real decline. Extract the dashboard tile's schedule-anchored,
cadence-aware compliance into one shared engine and route both surfaces
through it, so Coach and the dashboard report the same number.
---
src/app/api/medications/intake/route.ts | 168 +--------------
.../analytics/schedule-anchored-compliance.ts | 171 +++++++++++++++
.../adherence-storyline-engine-parity.test.ts | 196 ++++++++++++++++++
.../insights/derived/adherence-storyline.ts | 18 +-
4 files changed, 387 insertions(+), 166 deletions(-)
create mode 100644 src/lib/analytics/schedule-anchored-compliance.ts
create mode 100644 src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts
index d50e5766c..fb7f2bd4c 100644
--- a/src/app/api/medications/intake/route.ts
+++ b/src/app/api/medications/intake/route.ts
@@ -25,16 +25,15 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
-import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
- buildComplianceMedicationContext,
- expectedSlotCountForDay,
- lastNonSkippedTakenAt,
-} from "@/lib/analytics/compliance";
-import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day";
+ buildScheduleAnchoredComplianceBuckets,
+ type ScheduleAnchoredComplianceBucket,
+} from "@/lib/analytics/schedule-anchored-compliance";
+import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
applyCanonicalSlotWrite,
@@ -204,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// makes the rate genuinely reflect taken-of-expected and lets the 7/30/90
// windows diverge with partial adherence.
const result = await cached(
- caches.medicationsIntake as ServerCache,
+ caches.medicationsIntake as ServerCache,
`${user.id}|compliance|${days}|${userTz}`,
() => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz),
annotate,
@@ -218,161 +217,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess(result);
});
-interface ComplianceBucket {
- date: string;
- scheduled: number;
- taken: number;
-}
-
-/**
- * v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard
- * tile. Replaces the rollup/legacy path whose `scheduled` was the count of
- * logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every
- * window because every logged row was both numerator and denominator).
- *
- * `scheduled` is now the canonical recurrence engine's expected-dose count
- * for the day, summed across the user's active medications, so days the
- * schedule expected a dose that the user missed pull the rate down — and the
- * 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the
- * count of taken (non-skipped, non-auto-missed) doses that day, capped at the
- * day's expected count so a duplicate log can't push a day above 100%.
- *
- * Pure-ish over a single pinned `now` so a 15-minute cached row is internally
- * consistent. Bounded: one active-medications query + one window-events query,
- * then per-medication-per-day engine expansion over the trailing window.
- */
-async function buildScheduleAnchoredComplianceBuckets(
- userId: string,
- days: number,
- userTz: string,
-): Promise {
- const now = new Date();
- const nowMs = now.getTime();
- const start = new Date(nowMs - days * 86_400_000);
-
- const medications = await prisma.medication.findMany({
- where: { userId, active: true },
- include: {
- schedules: true,
- // v1.16.3 — archived schedule eras for era-aware expected counts.
- scheduleRevisions: { orderBy: { validFrom: "asc" } },
- // v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
- pauseEras: { select: { pausedAt: true, resumedAt: true } },
- },
- });
-
- const events = await prisma.medicationIntakeEvent.findMany({
- // v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
- where: { userId, deletedAt: null, scheduledFor: { gte: start } },
- select: {
- medicationId: true,
- scheduledFor: true,
- takenAt: true,
- skipped: true,
- autoMissed: true,
- },
- });
-
- // Pre-compute each local day's [start, end) bounds + key, oldest → newest.
- // Each day's representative instant anchors on the user's LOCAL NOON of
- // the current day and steps back in 24-hour increments: noon stays inside
- // the intended local day across DST shifts (23/25-hour days), and —
- // unlike the previous `now − 12 h` anchor — it cannot slip into
- // yesterday when the user's local time is before noon, which silently
- // dropped TODAY from the buckets for every pre-noon read.
- const { start: todayStart } = getUserTodayBounds(now, userTz);
- const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000;
- const dayKeys: string[] = [];
- const dayBounds = new Map();
- for (let i = days - 1; i >= 0; i--) {
- const representative = new Date(todayNoonMs - i * 86_400_000);
- const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
- representative,
- userTz,
- );
- const key = userDayKey(dayStart, userTz);
- if (dayBounds.has(key)) continue;
- dayKeys.push(key);
- dayBounds.set(key, {
- start: dayStart,
- end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
- });
- }
-
- const totals = new Map();
- for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
-
- // Group events by medication so each med's engine context is built once.
- const eventsByMed = new Map<
- string,
- {
- scheduledFor: Date;
- takenAt: Date | null;
- skipped: boolean;
- autoMissed: boolean;
- }[]
- >();
- for (const e of events) {
- const list = eventsByMed.get(e.medicationId) ?? [];
- list.push({
- scheduledFor: e.scheduledFor,
- takenAt: e.takenAt,
- skipped: e.skipped,
- autoMissed: e.autoMissed,
- });
- eventsByMed.set(e.medicationId, list);
- }
-
- for (const med of medications) {
- if (med.schedules.length === 0) continue;
- const medEvents = eventsByMed.get(med.id) ?? [];
- const ctx = buildComplianceMedicationContext(
- med,
- lastNonSkippedTakenAt(medEvents),
- userTz,
- );
-
- for (const key of dayKeys) {
- const bounds = dayBounds.get(key)!;
- // Skip days before the medication existed so a young med doesn't paint
- // missed-denominator days it could not have been dosed on.
- if (bounds.end <= med.createdAt) continue;
-
- const scheduled = expectedSlotCountForDay(
- med.schedules,
- bounds.start,
- bounds.end,
- ctx,
- medEvents,
- );
- if (scheduled === 0) continue;
-
- // Taken doses that landed in this day's window (non-skipped, non-auto-
- // missed), capped at the expected count so a duplicate log can't push a
- // single day above 100%.
- const takenThisDay = medEvents.filter(
- (e) =>
- e.takenAt !== null &&
- !e.skipped &&
- !e.autoMissed &&
- e.scheduledFor >= bounds.start &&
- e.scheduledFor < bounds.end,
- ).length;
-
- const bucket = totals.get(key)!;
- bucket.scheduled += scheduled;
- bucket.taken += Math.min(takenThisDay, scheduled);
- }
- }
-
- return dayKeys
- .map((date) => {
- const v = totals.get(date)!;
- return { date, scheduled: v.scheduled, taken: v.taken };
- })
- .sort((a, b) => a.date.localeCompare(b.date));
-}
-
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts
new file mode 100644
index 000000000..da736adab
--- /dev/null
+++ b/src/lib/analytics/schedule-anchored-compliance.ts
@@ -0,0 +1,171 @@
+/**
+ * v1.26.0 SEAM-N3 — the ONE schedule-anchored, cadence-aware daily
+ * compliance computation.
+ *
+ * Extracted verbatim from the `GET /api/medications/intake?scope=compliance`
+ * dashboard-tile handler so every surface that surfaces a "taken of expected"
+ * adherence number reads through the SAME engine instead of the raw coverage
+ * rollup (`readMedicationCompliance`), whose `scheduled` counts LOGGED intake
+ * slots — a denominator that collapses to ~100% for a user who logs only the
+ * doses they took and never mints `takenAt:null` reminder rows for the ones
+ * they missed. See the adherence storyline (`adherence-storyline.ts`) and the
+ * dashboard tile: both now consume this.
+ *
+ * `scheduled` is the canonical recurrence engine's expected-dose count for the
+ * day, summed across the user's active medications, so days the schedule
+ * expected a dose that the user missed pull the rate down — and the 7/30/90
+ * windows genuinely diverge with partial adherence. `taken` stays the count of
+ * taken (non-skipped, non-auto-missed) doses that day, capped at the day's
+ * expected count so a duplicate log can't push a day above 100%.
+ *
+ * Pure-ish over a single pinned `now` so a cached row is internally
+ * consistent. Bounded: one active-medications query + one window-events query,
+ * then per-medication-per-day engine expansion over the trailing window.
+ */
+import { prisma } from "@/lib/db";
+import {
+ buildComplianceMedicationContext,
+ expectedSlotCountForDay,
+ lastNonSkippedTakenAt,
+} from "@/lib/analytics/compliance";
+import { getUserTodayBounds } from "@/lib/tz/local-day";
+import { userDayKey } from "@/lib/tz/resolver";
+
+export interface ScheduleAnchoredComplianceBucket {
+ /** YYYY-MM-DD in the user's tz. */
+ date: string;
+ scheduled: number;
+ taken: number;
+}
+
+export async function buildScheduleAnchoredComplianceBuckets(
+ userId: string,
+ days: number,
+ userTz: string,
+ now: Date = new Date(),
+): Promise {
+ const nowMs = now.getTime();
+ const start = new Date(nowMs - days * 86_400_000);
+
+ const medications = await prisma.medication.findMany({
+ where: { userId, active: true },
+ include: {
+ schedules: true,
+ // v1.16.3 — archived schedule eras for era-aware expected counts.
+ scheduleRevisions: { orderBy: { validFrom: "asc" } },
+ // v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
+ pauseEras: { select: { pausedAt: true, resumedAt: true } },
+ },
+ });
+
+ const events = await prisma.medicationIntakeEvent.findMany({
+ // v1.7.0 sync — exclude tombstoned rows from the compliance buckets.
+ where: { userId, deletedAt: null, scheduledFor: { gte: start } },
+ select: {
+ medicationId: true,
+ scheduledFor: true,
+ takenAt: true,
+ skipped: true,
+ autoMissed: true,
+ },
+ });
+
+ // Pre-compute each local day's [start, end) bounds + key, oldest → newest.
+ // Each day's representative instant anchors on the user's LOCAL NOON of
+ // the current day and steps back in 24-hour increments: noon stays inside
+ // the intended local day across DST shifts (23/25-hour days), and —
+ // unlike the previous `now − 12 h` anchor — it cannot slip into
+ // yesterday when the user's local time is before noon, which silently
+ // dropped TODAY from the buckets for every pre-noon read.
+ const { start: todayStart } = getUserTodayBounds(now, userTz);
+ const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000;
+ const dayKeys: string[] = [];
+ const dayBounds = new Map();
+ for (let i = days - 1; i >= 0; i--) {
+ const representative = new Date(todayNoonMs - i * 86_400_000);
+ const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
+ representative,
+ userTz,
+ );
+ const key = userDayKey(dayStart, userTz);
+ if (dayBounds.has(key)) continue;
+ dayKeys.push(key);
+ dayBounds.set(key, {
+ start: dayStart,
+ end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end)
+ });
+ }
+
+ const totals = new Map();
+ for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 });
+
+ // Group events by medication so each med's engine context is built once.
+ const eventsByMed = new Map<
+ string,
+ {
+ scheduledFor: Date;
+ takenAt: Date | null;
+ skipped: boolean;
+ autoMissed: boolean;
+ }[]
+ >();
+ for (const e of events) {
+ const list = eventsByMed.get(e.medicationId) ?? [];
+ list.push({
+ scheduledFor: e.scheduledFor,
+ takenAt: e.takenAt,
+ skipped: e.skipped,
+ autoMissed: e.autoMissed,
+ });
+ eventsByMed.set(e.medicationId, list);
+ }
+
+ for (const med of medications) {
+ if (med.schedules.length === 0) continue;
+ const medEvents = eventsByMed.get(med.id) ?? [];
+ const ctx = buildComplianceMedicationContext(
+ med,
+ lastNonSkippedTakenAt(medEvents),
+ userTz,
+ );
+
+ for (const key of dayKeys) {
+ const bounds = dayBounds.get(key)!;
+ // Skip days before the medication existed so a young med doesn't paint
+ // missed-denominator days it could not have been dosed on.
+ if (bounds.end <= med.createdAt) continue;
+
+ const scheduled = expectedSlotCountForDay(
+ med.schedules,
+ bounds.start,
+ bounds.end,
+ ctx,
+ medEvents,
+ );
+ if (scheduled === 0) continue;
+
+ // Taken doses that landed in this day's window (non-skipped, non-auto-
+ // missed), capped at the expected count so a duplicate log can't push a
+ // single day above 100%.
+ const takenThisDay = medEvents.filter(
+ (e) =>
+ e.takenAt !== null &&
+ !e.skipped &&
+ !e.autoMissed &&
+ e.scheduledFor >= bounds.start &&
+ e.scheduledFor < bounds.end,
+ ).length;
+
+ const bucket = totals.get(key)!;
+ bucket.scheduled += scheduled;
+ bucket.taken += Math.min(takenThisDay, scheduled);
+ }
+ }
+
+ return dayKeys
+ .map((date) => {
+ const v = totals.get(date)!;
+ return { date, scheduled: v.scheduled, taken: v.taken };
+ })
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
new file mode 100644
index 000000000..ff5fc0e81
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
@@ -0,0 +1,196 @@
+/**
+ * v1.26.0 SEAM-N3 — the adherence storyline must read the SAME
+ * schedule-anchored, cadence-aware adherence number the dashboard tile does.
+ *
+ * The regression this pins: the storyline used to derive its adherence % from
+ * `readMedicationCompliance`, the RAW coverage rollup whose `scheduled` counts
+ * LOGGED intake slots. A user who logs only the doses they TOOK — never
+ * minting `takenAt:null` reminder rows for the doses they missed — therefore
+ * read ~100 % adherence (every logged row was both numerator and denominator),
+ * and the dip gate (`adherencePct > 80 → null`) wrongly suppressed the whole
+ * storyline. Anchoring on the recurrence engine's EXPECTED-dose count makes the
+ * missed days pull the rate below 100 %.
+ *
+ * The real compliance engine (`@/lib/analytics/compliance`) and the shared
+ * `buildScheduleAnchoredComplianceBuckets` run unmocked here — only the DB and
+ * the vital-series reader are stubbed — so the test exercises the actual
+ * expected-dose expansion, not a re-mock of it.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ medicationFindMany: vi.fn(),
+ intakeFindMany: vi.fn(),
+ probeRollupCoverage: vi.fn(),
+ readDayMeanSeries: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ medication: { findMany: mocks.medicationFindMany },
+ medicationIntakeEvent: { findMany: mocks.intakeFindMany },
+ },
+}));
+
+vi.mock("@/lib/rollups/measurement-coverage", () => ({
+ probeRollupCoverage: mocks.probeRollupCoverage,
+}));
+
+vi.mock("@/lib/insights/derived/baseline", () => ({
+ readDayMeanSeries: mocks.readDayMeanSeries,
+}));
+
+import { buildAdherenceStoryline } from "@/lib/insights/derived/adherence-storyline";
+import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
+
+const DAY_MS = 86_400_000;
+const NOW = new Date("2026-06-21T12:00:00.000Z");
+const TZ = "UTC";
+
+/** A legacy daily schedule — one expected dose/day at 08:00, every day. */
+const dailySchedule = {
+ id: "sched-1",
+ windowStart: "08:00",
+ windowEnd: "09:00",
+ daysOfWeek: null,
+ timesOfDay: [],
+ rrule: null,
+ rollingIntervalDays: null,
+ reminderGraceMinutes: null,
+ scheduleType: "SCHEDULED",
+ cyclicOnWeeks: null,
+ cyclicOffWeeks: null,
+ doseWindows: null,
+};
+
+const medication = {
+ id: "med-1",
+ name: "ramipril",
+ treatmentClass: "GENERIC",
+ active: true,
+ oneShot: false,
+ startsOn: null,
+ endsOn: null,
+ // Created well before the window so no day is skipped for being pre-creation.
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ schedules: [dailySchedule],
+ scheduleRevisions: [],
+ pauseEras: [],
+};
+
+/** ISO 08:00 on a given YYYY-MM-DD. */
+const at8 = (day: string) => new Date(`${day}T08:00:00.000Z`);
+
+beforeEach(() => {
+ mocks.medicationFindMany.mockReset();
+ mocks.intakeFindMany.mockReset();
+ mocks.probeRollupCoverage.mockReset();
+ mocks.readDayMeanSeries.mockReset();
+
+ // The same medication row satisfies both call sites: the storyline's
+ // `select: { name, treatmentClass }` and the engine's `include: {...}`.
+ mocks.medicationFindMany.mockResolvedValue([medication]);
+
+ // Only-taken intakes: 7 taken doses across 14 scheduled days. The 7 missed
+ // days carry NO row at all (the user never minted a takenAt:null miss row).
+ const takenDays = [
+ "2026-06-08",
+ "2026-06-10",
+ "2026-06-12",
+ "2026-06-14",
+ "2026-06-16",
+ "2026-06-18",
+ "2026-06-20",
+ ];
+ mocks.intakeFindMany.mockResolvedValue(
+ takenDays.map((d) => ({
+ medicationId: "med-1",
+ scheduledFor: at8(d),
+ takenAt: new Date(`${d}T08:05:00.000Z`),
+ skipped: false,
+ autoMissed: false,
+ })),
+ );
+
+ mocks.probeRollupCoverage.mockResolvedValue(new Map());
+
+ // Vital before/after: 8 prior days ~124, 8 recent days ~134 — a material
+ // move on a ~7.4 robust spread, so a storyline is produced once the dip
+ // gate lets it through.
+ const priorDays: { day: string; mean: number }[] = [];
+ for (let i = 0; i < 8; i++) {
+ const d = new Date(
+ new Date("2026-05-25T00:00:00.000Z").getTime() + i * DAY_MS,
+ );
+ priorDays.push({ day: d.toISOString().slice(0, 10), mean: 124 });
+ }
+ const recentDays: { day: string; mean: number }[] = [];
+ for (let i = 0; i < 8; i++) {
+ const d = new Date(
+ new Date("2026-06-08T00:00:00.000Z").getTime() + i * DAY_MS,
+ );
+ recentDays.push({ day: d.toISOString().slice(0, 10), mean: 134 });
+ }
+ mocks.readDayMeanSeries.mockResolvedValue({
+ points: [...priorDays, ...recentDays],
+ source: "live",
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("adherence storyline ↔ dashboard-tile engine parity", () => {
+ it("derives adherence from the schedule-anchored engine (missed doses pull it below 100)", async () => {
+ // The dashboard-tile number, straight from the shared engine.
+ const buckets = await buildScheduleAnchoredComplianceBuckets(
+ "user-1",
+ 14,
+ TZ,
+ NOW,
+ );
+ let scheduled = 0;
+ let taken = 0;
+ for (const b of buckets) {
+ if (b.scheduled > 0) {
+ scheduled += b.scheduled;
+ taken += b.taken;
+ }
+ }
+ // Sanity: 14 expected days, 7 taken → genuinely below 100 %. The raw
+ // coverage rollup would have read scheduled == taken == 7 → 100 %.
+ expect(scheduled).toBeGreaterThan(taken);
+ const dashboardPct = (100 * taken) / scheduled;
+ expect(dashboardPct).toBeLessThan(100);
+
+ // The storyline must surface (the dip gate no longer wrongly suppresses it)
+ // and its adherence number must equal the dashboard-tile number.
+ const story = await buildAdherenceStoryline("user-1", TZ, NOW);
+ expect(story).not.toBeNull();
+ expect(story!.adherencePct).toBeLessThan(100);
+ expect(story!.adherencePct).toBe(Math.round(dashboardPct));
+ });
+
+ it("still suppresses the storyline when adherence is genuinely high (dip gate honoured)", async () => {
+ // Log a taken dose on EVERY one of the 14 scheduled days → ~100 %.
+ const allDays: { day: string }[] = [];
+ for (let i = 0; i < 14; i++) {
+ const d = new Date(NOW.getTime() - i * DAY_MS);
+ allDays.push({ day: d.toISOString().slice(0, 10) });
+ }
+ mocks.intakeFindMany.mockResolvedValue(
+ allDays.map(({ day }) => ({
+ medicationId: "med-1",
+ scheduledFor: at8(day),
+ takenAt: new Date(`${day}T08:05:00.000Z`),
+ skipped: false,
+ autoMissed: false,
+ })),
+ );
+
+ const story = await buildAdherenceStoryline("user-1", TZ, NOW);
+ // High adherence → the dip gate (adherencePct > 80) returns null.
+ expect(story).toBeNull();
+ });
+});
diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts
index d2c4f0575..abc06010d 100644
--- a/src/lib/insights/derived/adherence-storyline.ts
+++ b/src/lib/insights/derived/adherence-storyline.ts
@@ -26,7 +26,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
-import { readMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups";
+import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
inferMedTargetClass,
MED_TARGET_MAP,
@@ -171,11 +172,20 @@ export async function buildAdherenceStoryline(
.filter((c): c is { label: string; cls: MedTargetClass } => c.cls !== null);
if (candidates.length === 0) return null;
- // 2. Overall recent adherence (slot-level, cadence-aware via the rollup).
- const buckets = await readMedicationCompliance(
+ // 2. Overall recent adherence — schedule-anchored + cadence-aware, the SAME
+ // engine the dashboard tile reads (`buildScheduleAnchoredComplianceBuckets`).
+ // v1.26.0 SEAM-N3 — this replaced `readMedicationCompliance`, whose
+ // `scheduled` was a RAW coverage count of LOGGED intake slots: a user who
+ // logs only the doses they took (never minting `takenAt:null` reminder
+ // rows for missed ones) read ~100% adherence, and the dip gate below
+ // (adherencePct > 80 → null) wrongly suppressed the storyline. The
+ // schedule-anchored `scheduled` is the recurrence engine's EXPECTED-dose
+ // count, so genuinely missed doses pull the rate down. One engine, one
+ // number — the storyline's % now equals the dashboard tile's %.
+ const buckets = await buildScheduleAnchoredComplianceBuckets(
userId,
ADHERENCE_WINDOW_DAYS,
- timezone ?? "Europe/Berlin",
+ timezone ?? DEFAULT_TIMEZONE,
now,
);
let scheduled = 0;
From ba8118546d9ebf2cb57e0604c3e58124dd721d47 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:16:03 +0200
Subject: [PATCH 12/16] build(lint): ban raw palette colors and guard the
design contract
Add the healthlog/no-raw-palette-color rule so UI code routes color
through the semantic tokens instead of Tailwind palette shades, and
sweep the remaining offenders (password-strength scale, injection-site
accents, redundant dark overrides). Add guard tests for width stability,
the single page-header vocabulary, and motion-reduce coverage on
indefinite animations.
---
eslint-plugins/healthlog/index.js | 9 +-
.../healthlog/no-raw-palette-color.js | 122 ++++++++++++++++++
eslint.config.mjs | 7 +
.../design-consistency-guards.test.tsx | 92 +++++++++++++
.../motion-reduce-spin-coverage.test.ts | 61 +++++++++
src/components/admin/reminders-section.tsx | 16 +--
src/components/clinician/clinician-view.tsx | 2 +-
.../__tests__/intake-history-list-v2.test.tsx | 4 +-
.../medications/injection-site-picker.tsx | 2 +-
.../medications/intake-history-list-v2.tsx | 2 +-
.../medications/log-injection-site-dialog.tsx | 2 +-
src/components/ui/password-strength.tsx | 19 +--
12 files changed, 312 insertions(+), 26 deletions(-)
create mode 100644 eslint-plugins/healthlog/no-raw-palette-color.js
create mode 100644 src/components/__tests__/design-consistency-guards.test.tsx
diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js
index d5849ae5f..1119c7d7b 100644
--- a/eslint-plugins/healthlog/index.js
+++ b/eslint-plugins/healthlog/index.js
@@ -2,9 +2,10 @@
* @fileoverview Aggregates the project-local `healthlog/*` ESLint rules
* into a single plugin object for the flat config.
*
- * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
- * - `safe-fetch-required` — outbound fetch must route through safeFetch.
- * - `api-fetch-required` — client /api/ calls must route through apiFetch.
+ * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
+ * - `safe-fetch-required` — outbound fetch must route through safeFetch.
+ * - `api-fetch-required` — client /api/ calls must route through apiFetch.
+ * - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
*/
"use strict";
@@ -12,11 +13,13 @@
const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
+const noRawPaletteColor = require("./no-raw-palette-color.js");
module.exports = {
rules: {
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
"safe-fetch-required": safeFetchRequired,
"api-fetch-required": apiFetchRequired,
+ "no-raw-palette-color": noRawPaletteColor,
},
};
diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js
new file mode 100644
index 000000000..6fe38790a
--- /dev/null
+++ b/eslint-plugins/healthlog/no-raw-palette-color.js
@@ -0,0 +1,122 @@
+/**
+ * @fileoverview ESLint rule — raw Tailwind palette color utilities are
+ * banned in app UI; route every tone through a semantic token.
+ *
+ * v1.26.0 — the design-consistency wave collapsed the app's colour
+ * vocabulary onto semantic tokens (`text-warning` / `text-info` /
+ * `text-success` / `text-destructive` and their `bg-*` / `border-*` /
+ * opacity forms, plus `stroke-[var(--warning)]` for SVG accents). A raw
+ * `text-amber-500` / `bg-green-500` / `dark:text-red-400` re-introduces a
+ * hard-coded Tailwind stock tone that drifts off the token system, breaks
+ * light/dark parity, and often fails AA against the card surface. This
+ * rule flags those utilities at authoring time and in CI so the tokenised
+ * palette cannot regress.
+ *
+ * Match: a className fragment
+ * (text|bg|border|ring|stroke|fill|from|to|via)--
+ * where is one of the Tailwind stock hues and is a
+ * numeric 50…950 shade. The `-` suffix is what pins it to the raw
+ * palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic
+ * tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and
+ * so never match.
+ *
+ * Scope: `src/components/**` + `src/app/**`. The check inspects string
+ * literals and template-literal chunks (covering `className="…"`,
+ * `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic
+ * match, mirroring the queryKey-factory / safe-fetch-required rules.
+ *
+ * Exempt files (narrow, path-suffix matched):
+ * - `src/app/global-error.tsx` — the App-Router global error boundary
+ * renders WITHOUT the token stylesheet loaded, so a future edit there
+ * legitimately needs raw palette / inline colour.
+ * Test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) are exempt too.
+ *
+ * @see src/app/globals.css — the semantic token definitions.
+ */
+
+"use strict";
+
+// Files exempt from the rule. Posix-style suffix match against the
+// absolute filename via `String#includes`, mirroring the sibling rules.
+const EXEMPT_FILES = [
+ // Runs without the token stylesheet — see the header note.
+ "src/app/global-error.tsx",
+];
+
+// Only enforce inside the app UI roots.
+const ENFORCED_ROOTS = ["src/components/", "src/app/"];
+
+// Raw Tailwind palette utility: a color property prefix, a stock hue, and
+// a numeric 50…950 shade. The `\b` bounds let a `dark:` / `hover:` / group
+// modifier prefix and a `/10` opacity suffix ride along while still
+// pinning the numeric shade that distinguishes raw palette from tokens.
+const RAW_PALETTE_RE =
+ /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/;
+
+function toPosix(filename) {
+ return filename.replace(/\\/g, "/");
+}
+
+function isTestFile(posix) {
+ return (
+ /\.test\.[cm]?[jt]sx?$/.test(posix) ||
+ /\.spec\.[cm]?[jt]sx?$/.test(posix) ||
+ posix.includes("/__tests__/") ||
+ posix.includes("/__mocks__/")
+ );
+}
+
+function isEnforced(filename) {
+ const posix = toPosix(filename);
+ if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
+ if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
+ if (isTestFile(posix)) return false;
+ return true;
+}
+
+/** @type {import("eslint").Rule.RuleModule} */
+const noRawPaletteColorRule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).",
+ },
+ schema: [],
+ messages: {
+ rawPalette:
+ 'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].',
+ },
+ },
+ create(context) {
+ const filename = context.filename ?? context.getFilename?.();
+ if (!filename || !isEnforced(filename)) {
+ return {};
+ }
+
+ function checkText(node, text) {
+ if (typeof text !== "string") return;
+ const m = RAW_PALETTE_RE.exec(text);
+ if (m) {
+ context.report({
+ node,
+ messageId: "rawPalette",
+ data: { match: m[0] },
+ });
+ }
+ }
+
+ return {
+ Literal(node) {
+ if (typeof node.value === "string") {
+ checkText(node, node.value);
+ }
+ },
+ TemplateElement(node) {
+ checkText(node, node.value.cooked ?? node.value.raw);
+ },
+ };
+ },
+};
+
+module.exports = noRawPaletteColorRule;
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 560d86560..3662faa09 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -44,6 +44,13 @@ const eslintConfig = defineConfig([
// envelope unwrap, one ApiError shape. Wrapper internals and test
// files are exempt; see the rule file for the allowlist.
"healthlog/api-fetch-required": "error",
+ // v1.26.0 — the design-consistency wave locked the app's colour
+ // vocabulary onto semantic tokens. Raw Tailwind palette utilities
+ // (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under
+ // src/components + src/app are banned so the tokenised palette can't
+ // regress. `src/app/global-error.tsx` (renders without the token
+ // stylesheet) and test files are exempt; see the rule file.
+ "healthlog/no-raw-palette-color": "error",
},
},
]);
diff --git a/src/components/__tests__/design-consistency-guards.test.tsx b/src/components/__tests__/design-consistency-guards.test.tsx
new file mode 100644
index 000000000..3187b2754
--- /dev/null
+++ b/src/components/__tests__/design-consistency-guards.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
+
+/**
+ * v1.26.0 — design-consistency guardrails.
+ *
+ * These pin the invariants the design-consistency wave established so a
+ * later edit cannot silently undo them:
+ * 1. the authenticated shell's scroll container keeps its
+ * scrollbar-gutter reservation and single centred max-width box (the
+ * width-stability contract — a 4th width-shift regression must not
+ * recur);
+ * 2. the canonical `` renders exactly one, correctly-styled
+ * `
` (one-H1-per-page vocabulary);
+ * 3. `` renders its retry affordance so a failed read
+ * never falls through to an honest-empty state.
+ */
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("width stability — auth-shell scroll container", () => {
+ const source = readFileSync(
+ join(process.cwd(), "src", "components", "layout", "auth-shell.tsx"),
+ "utf8",
+ );
+
+ it("keeps [scrollbar-gutter:stable] on the scroll ", () => {
+ // The reserved gutter is what stops the whole viewport widening by a
+ // scrollbar's width when a short page (e.g. Integrations) hides the
+ // scroll. Without it the centred container shifts on every route swap.
+ expect(source).toContain("[scrollbar-gutter:stable]");
+ });
+
+ it("keeps the single centred `mx-auto max-w-screen-xl` container", () => {
+ // Every authenticated surface shares ONE normalised content box. The
+ // literal ordering `mx-auto max-w-screen-xl` is the class the shell
+ // ships; a duplicate or differently-capped box would reintroduce the
+ // per-route width drift this guard exists to prevent.
+ expect(source).toContain("mx-auto max-w-screen-xl");
+ const occurrences = source.split("mx-auto max-w-screen-xl").length - 1;
+ expect(occurrences).toBe(1);
+ });
+});
+
+describe(" — one canonical H1", () => {
+ it("renders exactly one
with the canonical type scale", () => {
+ const html = render(
+ ,
+ );
+ const h1Count = (html.match(/
]*class="[^"]*text-2xl font-bold tracking-tight[^"]*"/,
+ );
+ expect(html).toContain("Dashboard");
+ });
+
+ it("renders no second heading when only a title is supplied", () => {
+ const html = render();
+ expect((html.match(/
— retry affordance", () => {
+ it("renders a retry button when onRetry is provided", () => {
+ const html = render( {}} />);
+ expect(html).toContain('data-slot="query-error-card"');
+ expect(html).toContain('role="alert"');
+ // The retry affordance is a real so a failed read is
+ // recoverable in-place.
+ expect(html).toContain(" {
+ const html = render();
+ expect(html).toContain('data-slot="query-error-card"');
+ expect(html).not.toContain("` / chart
+ * placeholder animates only while data is in flight and then unmounts, so
+ * its pulse is transient rather than perpetual; those files own their own
+ * reduced-motion story (and today still carry the modifier anyway). The
+ * allowlist is the escape hatch for that one legitimate class of use — an
+ * indefinite site elsewhere still has to carry the modifier.
+ *
+ * The check is textual and skips comment lines, mirroring the
+ * `animate-spin` sweep above.
+ */
+describe("motion-reduce coverage — indefinite pulse/bounce/ping pairs with motion-reduce:animate-none", () => {
+ const files = collectTsxFiles(ROOT);
+
+ // Load-terminating skeleton surfaces: their animation is bounded by the
+ // data-load lifecycle, so they are exempt from the perpetual-animation
+ // rule. Path-suffix matched against the absolute filename.
+ const SKELETON_ALLOWLIST = [
+ "src/components/ui/skeleton.tsx",
+ "src/components/charts/chart-skeleton.tsx",
+ ];
+
+ const INDEFINITE_RE = /animate-(pulse|bounce|ping)/;
+
+ it("every indefinite animation site declares motion-reduce:animate-none", () => {
+ const violations: string[] = [];
+ for (const file of files) {
+ const posix = file.replace(/\\/g, "/");
+ if (SKELETON_ALLOWLIST.some((f) => posix.includes(f))) continue;
+ const src = readFileSync(file, "utf8");
+ const lines = src.split("\n");
+ lines.forEach((line, idx) => {
+ if (!INDEFINITE_RE.test(line)) return;
+ // Skip comment / JSDoc prose that merely names the utility.
+ const trimmed = line.trim();
+ if (trimmed.startsWith("//") || trimmed.startsWith("*")) return;
+ if (!line.includes("motion-reduce:animate-none")) {
+ violations.push(`${file}:${idx + 1}: ${trimmed}`);
+ }
+ });
+ }
+ expect(
+ violations,
+ `The following indefinite animate-pulse/bounce/ping sites are missing ` +
+ `motion-reduce:animate-none. Append the modifier so motion-sensitive ` +
+ `users see a static element (or allowlist the file if it is a ` +
+ `load-terminating skeleton):\n` +
+ violations.join("\n"),
+ ).toEqual([]);
+ });
+});
+
/**
* v1.11.3 — `.animate-insight-in` reduced-motion guard.
*
diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx
index 3c8b39f0b..da51690c7 100644
--- a/src/components/admin/reminders-section.tsx
+++ b/src/components/admin/reminders-section.tsx
@@ -81,10 +81,10 @@ function NotificationHealthPanel() {
>
{c.channel}
-
+
{t("admin.notificationHealth.ok")}: {c.ok}
-
+
{t("admin.notificationHealth.error")}: {c.error}
@@ -323,9 +323,9 @@ export function RemindersSection() {
{testNotification.data.results.map((r, i) => (
diff --git a/src/components/medications/__tests__/intake-history-list-v2.test.tsx b/src/components/medications/__tests__/intake-history-list-v2.test.tsx
index 3f6ff3a27..f409b3586 100644
--- a/src/components/medications/__tests__/intake-history-list-v2.test.tsx
+++ b/src/components/medications/__tests__/intake-history-list-v2.test.tsx
@@ -184,10 +184,10 @@ describe(" — populated rows", () => {
expect(html).toContain("lucide-check");
expect(html).toContain("lucide-skip-forward");
- // The green styling pins the taken branch visually so a future
+ // The success styling pins the taken branch visually so a future
// refactor cannot silently collapse the two chips back into one
// neutral badge.
- expect(html).toMatch(/bg-green-500\/20[^"]*text-green-400/);
+ expect(html).toMatch(/bg-success\/15[^"]*text-success/);
// Skipped rows render as a muted outline chip — never as the
// secondary "Eingenommen" badge.
diff --git a/src/components/medications/injection-site-picker.tsx b/src/components/medications/injection-site-picker.tsx
index a3f083f63..0c301058d 100644
--- a/src/components/medications/injection-site-picker.tsx
+++ b/src/components/medications/injection-site-picker.tsx
@@ -252,7 +252,7 @@ export function InjectionSitePicker({
cy={coord.y}
r="7"
fill="none"
- className="stroke-amber-500"
+ className="stroke-[var(--warning)]"
strokeWidth="1.2"
/>
)}
diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx
index 4ee6ce5e3..eb7f17fd5 100644
--- a/src/components/medications/intake-history-list-v2.tsx
+++ b/src/components/medications/intake-history-list-v2.tsx
@@ -360,7 +360,7 @@ export function IntakeHistoryListV2({
{isTaken ? (
{t("medications.intakeHistoryStatusTaken")}
diff --git a/src/components/medications/log-injection-site-dialog.tsx b/src/components/medications/log-injection-site-dialog.tsx
index 39283d577..5ea4eeed1 100644
--- a/src/components/medications/log-injection-site-dialog.tsx
+++ b/src/components/medications/log-injection-site-dialog.tsx
@@ -152,7 +152,7 @@ export function LogInjectionSiteDialog({
cy="8"
r="6"
fill="none"
- className="stroke-amber-500"
+ className="stroke-[var(--warning)]"
strokeWidth="1.8"
/>
diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx
index 32011560f..1860049c4 100644
--- a/src/components/ui/password-strength.tsx
+++ b/src/components/ui/password-strength.tsx
@@ -10,16 +10,17 @@ interface PasswordStrengthProps {
minLength?: number;
}
-// The bar fill stays vivid (it's a non-text indicator), but the label text
-// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones
-// failed contrast against the white card. Weakest is destructive, the middle
-// band warning, the strong band success.
+// Both the bar fill and the label route through the AA-tuned semantic tokens
+// — the raw Tailwind 500 tones failed contrast against the white card and
+// drift off the app's token vocabulary. Weakest two bands are destructive,
+// the middle band warning, the strong two bands success; fill and text share
+// the same tone per score so the meter reads as one signal.
const SCORE_COLORS = [
- { color: "bg-red-500", textColor: "text-destructive" },
- { color: "bg-orange-500", textColor: "text-destructive" },
- { color: "bg-yellow-500", textColor: "text-warning" },
- { color: "bg-green-500", textColor: "text-success" },
- { color: "bg-emerald-600", textColor: "text-success" },
+ { color: "bg-destructive", textColor: "text-destructive" },
+ { color: "bg-destructive", textColor: "text-destructive" },
+ { color: "bg-warning", textColor: "text-warning" },
+ { color: "bg-success", textColor: "text-success" },
+ { color: "bg-success", textColor: "text-success" },
];
export function PasswordStrength({
From 7ca265745421eb4c7568233160ac85375430026d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:30:19 +0200
Subject: [PATCH 13/16] fix(api): keep logout resilient and drop the
over-strict batch time bound
A transient database fault during logout must still clear the session
cookie; record the delete failure on the wide event instead of throwing
before the cookie is cleared, so a user can never stay authenticated
after logging out. Remove the batch measurement future/past instant
refine: cumulative HealthKit rows carry an end-of-day endDate that is
legitimately hours ahead for the current day, and a whole-array parse
would 422 the entire sync. Note the workout batch's deferred Withings
path must write its source directly.
---
src/app/api/measurements/batch/route.ts | 20 ++------------------
src/app/api/workouts/batch/route.ts | 8 ++++++--
src/lib/auth/__tests__/session.test.ts | 8 ++++++--
src/lib/auth/session.ts | 13 +++++++++----
4 files changed, 23 insertions(+), 26 deletions(-)
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index aac262593..3748b429c 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -60,7 +60,6 @@ import {
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { validateMeasurementRange } from "@/lib/validations/measurement";
-import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
@@ -116,23 +115,8 @@ const batchEntrySchema = z.object({
hkIdentifier: z.string().min(1).max(120),
value: z.number().finite(),
unit: z.string().min(1).max(60),
- // Same plausibility bound the single-entry POST applies to `measuredAt`
- // via `validateEntryInstant` (shared `entry-instant` helper): no future
- // instant beyond a 5-min clock-skew tolerance, nothing before 1900. The
- // batch keeps the ISO string (`mapAppleHealthEntry` parses it), so the
- // bound rides on a `.refine` over the parsed instant rather than a
- // transform — the wire shape is unchanged and the `stats:` overwrite
- // semantics downstream are untouched.
- startDate: z.iso
- .datetime({ offset: true })
- .refine((s) => isPlausibleEntryInstant(new Date(s)), {
- message: "startDate must not be in the future or predate 1900",
- }),
- endDate: z.iso
- .datetime({ offset: true })
- .refine((s) => isPlausibleEntryInstant(new Date(s)), {
- message: "endDate must not be in the future or predate 1900",
- }),
+ startDate: z.iso.datetime({ offset: true }),
+ endDate: z.iso.datetime({ offset: true }),
sleepStage: z.number().int().min(0).max(20).optional(),
// v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue`
// codepoint for an EVENT-class category sample (irregular-rhythm,
diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts
index a490b2e3d..05fe3c853 100644
--- a/src/app/api/workouts/batch/route.ts
+++ b/src/app/api/workouts/batch/route.ts
@@ -7,10 +7,14 @@
* shape locked by `createBatchWorkoutSchema` in
* `src/lib/validations/workout.ts`.
* 2. The Withings activity sync (W17b, deferred) — server-to-server
- * ingest with `source: "WITHINGS"` and `externalId` formatted as
+ * ingest with `externalId` formatted as
* `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`.
* Withings ships no route geometry, so the nested `route` is
- * always absent for WITHINGS-sourced entries.
+ * always absent for WITHINGS-sourced entries. NOTE: the request
+ * schema's `source` is narrowed to the client-writable set
+ * (MANUAL / APPLE_HEALTH), so this server path must write its
+ * `WITHINGS`-sourced rows directly via `prisma.workout.upsert`
+ * (as the WHOOP / Fitbit syncs already do), not through this route.
*
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
* sync engine can re-use the same retry / cursor plumbing:
diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts
index 57a01f035..ee27a18aa 100644
--- a/src/lib/auth/__tests__/session.test.ts
+++ b/src/lib/auth/__tests__/session.test.ts
@@ -91,13 +91,17 @@ describe("destroySession", () => {
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
- it("propagates a real delete failure instead of reporting a false logout", async () => {
+ it("clears the cookie even when the row delete fails on a transient fault", async () => {
cookieState.sessionId = "sess-live";
vi.mocked(prisma.session.delete).mockRejectedValue(
Object.assign(new Error("connection reset"), { code: "P1001" }) as never,
);
- await expect(destroySession()).rejects.toThrow(/connection reset/);
+ // A non-P2025 delete failure is recorded on the wide event, not thrown,
+ // so logout never leaves the client authenticated with the cookie intact.
+ await expect(destroySession()).resolves.toBeUndefined();
+ expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
+ expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
});
});
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index 7dd461668..4e82e949a 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -181,11 +181,16 @@ export async function destroySession(): Promise {
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (sessionId) {
// Logout is idempotent: a session row that no longer exists (P2025) is a
- // no-op success — the cookie clear below still runs. Any other failure is
- // a real delete error that must NOT be masked as a successful logout, so
- // it propagates rather than being swallowed.
+ // no-op. Any other failure is a real delete error — record it on the wide
+ // event rather than swallowing it silently, but never block the cookie
+ // clear below: a transient DB fault must not leave the client logged in
+ // with the cookie intact.
await prisma.session.delete({ where: { id: sessionId } }).catch((err) => {
- if (!isP2025(err)) throw err;
+ if (!isP2025(err)) {
+ getEvent()?.addWarning(
+ `destroySession delete failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
});
}
cookieStore.delete(SESSION_COOKIE);
From f118b6f8c7ba3bea92684ef0c7bfa896af3b84fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:30:19 +0200
Subject: [PATCH 14/16] refactor(insights): route the dashboard chart colors
through theme tokens
The authenticated dashboard's chart lines, reference bands, and blood-
pressure target zones still carried raw Dracula hex, so they rendered as
low-contrast neon on the light theme. Route them through the same tokens
the insights pages use; dark rendering is unchanged.
---
src/app/page.tsx | 57 ++++++++++++++++++++++++++++++++++--------------
1 file changed, 41 insertions(+), 16 deletions(-)
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4bcb6a2e5..a2154cc1d 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -561,19 +561,19 @@ export default function DashboardPage() {
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
- color: "#ff79c6",
+ color: "var(--chart-3)",
opacity: 0.21,
label: t("charts.systolic"),
- textColor: "#ff79c6",
+ textColor: "var(--chart-3)",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
- color: "#8be9fd",
+ color: "var(--chart-4)",
opacity: 0.21,
label: t("charts.diastolic"),
- textColor: "#8be9fd",
+ textColor: "var(--chart-4)",
lineOpacity: 0.24,
},
]
@@ -1174,7 +1174,7 @@ export default function DashboardPage() {
chartKey="weight"
types={["WEIGHT"]}
title={t("dashboard.weight")}
- colors={["#bd93f9"]}
+ colors={["var(--chart-1)"]}
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
@@ -1196,15 +1196,40 @@ export default function DashboardPage() {
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
- colors={["#f1fa8c"]}
+ colors={["var(--dracula-yellow)"]}
unit="kg/m²"
valueMode="bmi"
valueBands={[
- { min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
- { min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
- { min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
- { min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
- { min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
+ {
+ min: 0,
+ max: 17,
+ color: "var(--destructive)",
+ opacity: 0.16,
+ },
+ {
+ min: 17,
+ max: 18.5,
+ color: "var(--warning)",
+ opacity: 0.18,
+ },
+ {
+ min: 18.5,
+ max: 24.9,
+ color: "var(--success)",
+ opacity: 0.2,
+ },
+ {
+ min: 24.9,
+ max: 29.9,
+ color: "var(--warning)",
+ opacity: 0.18,
+ },
+ {
+ min: 29.9,
+ max: 120,
+ color: "var(--destructive)",
+ opacity: 0.16,
+ },
]}
compareBaseline={compareBaseline}
/>
@@ -1227,7 +1252,7 @@ export default function DashboardPage() {
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("dashboard.bloodPressure")}
- colors={["#ff79c6", "#8be9fd"]}
+ colors={["var(--chart-3)", "var(--chart-4)"]}
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
@@ -1256,7 +1281,7 @@ export default function DashboardPage() {
// expected-high workout HR as "outside target").
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
title={t("dashboard.pulse")}
- colors={["#50fa7b"]}
+ colors={["var(--success)"]}
unit="bpm"
valueBands={hasRestingHr ? pulseBands : undefined}
compareBaseline={compareBaseline}
@@ -1280,7 +1305,7 @@ export default function DashboardPage() {
chartKey="bodyFat"
types={["BODY_FAT"]}
title={t("dashboard.bodyFat")}
- colors={["#ffb86c"]}
+ colors={["var(--warning)"]}
unit="%"
valueBands={bodyFatBands}
compareBaseline={compareBaseline}
@@ -1319,7 +1344,7 @@ export default function DashboardPage() {
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("dashboard.sleep") ?? "Sleep"}
- colors={["#8be9fd"]}
+ colors={["var(--chart-4)"]}
unit="h"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
@@ -1342,7 +1367,7 @@ export default function DashboardPage() {
chartKey="steps"
types={["ACTIVITY_STEPS"]}
title={t("dashboard.steps") ?? "Steps"}
- colors={["#50fa7b"]}
+ colors={["var(--success)"]}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
From 88474adcd0870a3b49523db672e0f06f70d87257 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:30:19 +0200
Subject: [PATCH 15/16] refactor(ui): finish the page-header adoption and align
the demote label
Route the notifications main view and the documents header through the
canonical page-header vocabulary the rest of the release adopted, and
align the admin demote-role label to the standard Nutzer term.
---
messages/de.json | 2 +-
src/app/notifications/page.tsx | 12 ++++--------
src/components/documents/inbound-documents-view.tsx | 4 +++-
3 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 444284b28..d8c57323b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5563,7 +5563,7 @@
"userActions": "Aktionen",
"editUser": "Nutzer bearbeiten",
"editUserTitle": "Nutzer bearbeiten: {name}",
- "demoteToUser": "Zu User degradieren",
+ "demoteToUser": "Zu Nutzer degradieren",
"promoteToAdmin": "Zum Admin befördern",
"toUser": "Zum Nutzer",
"toAdmin": "Zum Admin",
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index 66113ea13..6100eea70 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -187,14 +187,10 @@ export default function NotificationsPage() {
return (
-
-
- {t("notifications.title")}
-
-
- {t("notifications.subtitle")}
-
-
+
{/* Desktop: table layout */}
diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx
index e5f233c82..aa24edcce 100644
--- a/src/components/documents/inbound-documents-view.tsx
+++ b/src/components/documents/inbound-documents-view.tsx
@@ -238,7 +238,9 @@ export function InboundDocumentsView() {
-
{t("documents.title")}
+
+ {t("documents.title")}
+
{t("documents.subtitle")}
From 2f61ad7a29a52220d5c578a30c3b72313566dd2b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 2 Jul 2026 00:32:00 +0200
Subject: [PATCH 16/16] =?UTF-8?q?chore(release):=20v1.26.0=20=E2=80=94=20l?=
=?UTF-8?q?ight=20theme=20and=20a=20consistent=20interface?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 22 ++++++++++++++++++++++
docs/api/openapi.yaml | 2 +-
package.json | 2 +-
3 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f36d844b..5d2bd0b42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,28 @@
## [Unreleased]
+## [1.26.0] — 2026-07-02 — Light theme and a consistent interface
+
+### Changed
+
+- The light theme is calmer. Text now sits on a soft off-white instead of pure white, the near-black body colour is lifted to a dark tinted grey, cards separate from the background by a gentle step rather than a hard edge, and charts and status colours draw from the theme's own palette — so nothing glares and light mode reads as one harmonious whole. The dark theme is unchanged.
+- Every page shares one header now: the same title size, the same one-line description beneath it, and the same back control. Admin sections, cards and list rows follow the same spacing everywhere, so the app feels of a piece as you move between modules.
+- The remaining forms that still opened the device's own date picker (allergies, environment, documents) now use the in-app calendar, matching the rest of the app.
+- Wording is consistent across the interface — one word per action and one term per concept throughout.
+- Mental Wellbeing now shows a visible page title, like every other module.
+
+### Fixed
+
+- When a list can't load, it shows a clear "couldn't load — retry" card instead of looking like there is no data; lists also show a placeholder skeleton while loading rather than a blank spinner.
+- The Coach's medication-adherence storyline now reports the same figure as the dashboard tile. An account that only logged doses it took could previously read as fully on track and hide a real decline.
+- Charts over a custom historic date range now return the range you asked for; a range far in the past could come back showing the most recent stretch instead.
+- Logging out now always clears the session on this device, even if the server hits a transient database error while doing so.
+
+### Security
+
+- Error reports sent to an operator's monitoring service no longer carry the webhook path secret. This only applies if outbound error reporting is enabled.
+- The workout import endpoint no longer accepts a device-owned source from a client, so a synced-device label cannot be forged.
+
## [1.25.13] — 2026-07-01 — Briefing and medication fixes
### Fixed
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e553979c5..7192ae660 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.25.13
+ version: 1.26.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/package.json b/package.json
index 4493b1b45..0a27d02e6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.25.13",
+ "version": "1.26.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",