diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8919edba..73750f10c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
## [Unreleased]
+## [1.12.3] — 2026-06-05 — mark the dose you're looking at
+
+### Fixed
+
+- **Marking a dose "taken" now records the dose on screen, not the one nearest the clock.** For a medication taken more than once a day (e.g. morning and evening), the "Taken" button now targets the specific dose the card is showing, instead of snapping to whichever slot is closest to the current time — so a morning tap can no longer land on the wrong dose.
+
## [1.12.2] — 2026-06-05 — WHOOP connect from the app, consistent assessments and medications
### Added
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 2c28933b7..1cd254f9b 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.12.2
+ version: 1.12.3
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 03f5d073c..3326ff870 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.12.2",
+ "version": "1.12.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts
index ede88e3a5..45be74e1a 100644
--- a/src/components/medications/__tests__/use-medication-intake.test.ts
+++ b/src/components/medications/__tests__/use-medication-intake.test.ts
@@ -139,6 +139,99 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
expect(onRecorded).toHaveBeenCalledWith("evt-99", false);
});
+ it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ // The morning (07:00) slot the card is showing — Berlin 07:00 = 05:00 UTC.
+ const morningSlot = new Date("2026-06-05T05:00:00.000Z");
+
+ await runRecordIntake({
+ medication,
+ skipped: false,
+ scheduledFor: morningSlot,
+ t,
+ queryClient: fakeQueryClient(),
+ setIntakeLoading: vi.fn(),
+ undoIntake: vi.fn(),
+ });
+
+ const [, init] = fetchMock.mock.calls[0];
+ const body = JSON.parse((init as RequestInit).body as string);
+ expect(body).toEqual({
+ skipped: false,
+ scheduledFor: "2026-06-05T05:00:00.000Z",
+ });
+ });
+
+ it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const morningSlot = new Date("2026-06-05T05:00:00.000Z"); // 07:00 Berlin
+ const eveningSlot = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
+
+ await runRecordIntake({
+ medication,
+ skipped: false,
+ scheduledFor: morningSlot,
+ t,
+ queryClient: fakeQueryClient(),
+ setIntakeLoading: vi.fn(),
+ undoIntake: vi.fn(),
+ });
+ await runRecordIntake({
+ medication,
+ skipped: false,
+ scheduledFor: eveningSlot,
+ t,
+ queryClient: fakeQueryClient(),
+ setIntakeLoading: vi.fn(),
+ undoIntake: vi.fn(),
+ });
+
+ const firstBody = JSON.parse(
+ (fetchMock.mock.calls[0][1] as RequestInit).body as string,
+ );
+ const secondBody = JSON.parse(
+ (fetchMock.mock.calls[1][1] as RequestInit).body as string,
+ );
+ // Each call carries its OWN slot — marking one dose never targets the
+ // other.
+ expect(firstBody.scheduledFor).toBe("2026-06-05T05:00:00.000Z");
+ expect(secondBody.scheduledFor).toBe("2026-06-05T17:00:00.000Z");
+ });
+
+ it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ await runRecordIntake({
+ medication,
+ skipped: false,
+ scheduledFor: null,
+ t,
+ queryClient: fakeQueryClient(),
+ setIntakeLoading: vi.fn(),
+ undoIntake: vi.fn(),
+ });
+
+ const body = JSON.parse(
+ (fetchMock.mock.calls[0][1] as RequestInit).body as string,
+ );
+ expect(body).toEqual({ skipped: false });
+ expect(body).not.toHaveProperty("scheduledFor");
+ });
+
it("omits the Undo action when the POST body carries no event id", async () => {
vi.stubGlobal(
"fetch",
diff --git a/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts
new file mode 100644
index 000000000..79433d9b3
--- /dev/null
+++ b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
+
+/**
+ * v1.12.3 — the card-side slot resolver that closes the
+ * "morning auto-taken" intake bug. The medication card shows ONE
+ * actionable dose; its "Genommen" button must record THAT dose, not let
+ * the server snap "now" to the nearest slot. This resolver derives the
+ * displayed dose's slot instant from the card's current-window status (the
+ * open/overdue window's `windowStart` today) or the server's `nextDueAt`.
+ */
+describe("resolveDisplayedSlotInstant", () => {
+ // A point in the late morning, Berlin (CEST = UTC+2 in June). 09:00 Berlin.
+ const morningNow = new Date("2026-06-05T07:00:00.000Z");
+
+ it("targets the open window's slot on today's calendar day", () => {
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: {
+ status: "in_window",
+ schedule: { windowStart: "07:00", windowEnd: "19:00" },
+ },
+ nextDueAt: null,
+ now: morningNow,
+ timeZone: "Europe/Berlin",
+ });
+ expect(slot).not.toBeNull();
+ // 07:00 Berlin on 2026-06-05 is 05:00 UTC (CEST = +2).
+ expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
+ });
+
+ it("targets the morning window when the card surfaces the morning dose, regardless of a later wall-clock", () => {
+ // Even if the user taps in the afternoon, the morning window's slot is
+ // what the card surfaced (e.g. an overdue 07:00 dose), so the resolved
+ // instant must still be the 07:00 slot — NOT a now-snap.
+ const afternoonNow = new Date("2026-06-05T13:00:00.000Z"); // 15:00 Berlin
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: {
+ status: "very_late",
+ schedule: { windowStart: "07:00", windowEnd: "08:00" },
+ },
+ nextDueAt: null,
+ now: afternoonNow,
+ timeZone: "Europe/Berlin",
+ });
+ expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
+ });
+
+ it("targets the evening window's slot when the card surfaces the evening dose", () => {
+ const eveningNow = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: {
+ status: "in_window",
+ schedule: { windowStart: "19:00", windowEnd: "20:00" },
+ },
+ nextDueAt: null,
+ now: eveningNow,
+ timeZone: "Europe/Berlin",
+ });
+ // 19:00 Berlin on 2026-06-05 is 17:00 UTC.
+ expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
+ });
+
+ it("falls back to the server's next-due instant when no window is currently actionable", () => {
+ const nextDueAt = "2026-06-06T05:00:00.000Z";
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: { status: null, schedule: null },
+ nextDueAt,
+ now: morningNow,
+ });
+ expect(slot!.toISOString()).toBe(nextDueAt);
+ });
+
+ it("prefers the open window over next-due (the user acts on the dose in front of them)", () => {
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: {
+ status: "in_window",
+ schedule: { windowStart: "07:00", windowEnd: "19:00" },
+ },
+ nextDueAt: "2026-06-06T05:00:00.000Z",
+ now: morningNow,
+ timeZone: "Europe/Berlin",
+ });
+ expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
+ });
+
+ it("returns null for a PRN dose (no current window, no next-due) so the server keeps the now-snap path", () => {
+ expect(
+ resolveDisplayedSlotInstant({
+ currentWindowStatus: { status: null, schedule: null },
+ nextDueAt: null,
+ now: morningNow,
+ }),
+ ).toBeNull();
+ });
+
+ it("returns null on a malformed next-due rather than an Invalid Date", () => {
+ expect(
+ resolveDisplayedSlotInstant({
+ currentWindowStatus: { status: null, schedule: null },
+ nextDueAt: "not-a-date",
+ now: morningNow,
+ }),
+ ).toBeNull();
+ });
+
+ it("handles a standard-time (CET = UTC+1) winter slot", () => {
+ const winterNow = new Date("2026-01-15T06:00:00.000Z"); // 07:00 Berlin (CET)
+ const slot = resolveDisplayedSlotInstant({
+ currentWindowStatus: {
+ status: "in_window",
+ schedule: { windowStart: "07:00", windowEnd: "19:00" },
+ },
+ nextDueAt: null,
+ now: winterNow,
+ timeZone: "Europe/Berlin",
+ });
+ // 07:00 Berlin on 2026-01-15 is 06:00 UTC (CET = +1).
+ expect(slot!.toISOString()).toBe("2026-01-15T06:00:00.000Z");
+ });
+});
diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts
new file mode 100644
index 000000000..8c0b8ff87
--- /dev/null
+++ b/src/components/medications/card-parts/displayed-slot-instant.ts
@@ -0,0 +1,148 @@
+/**
+ * v1.12.3 — resolve the canonical slot instant of the dose a medication
+ * card is currently surfacing, so the card's "Genommen" / "Skip" buttons
+ * record THAT dose rather than letting the server snap "now" to the
+ * nearest slot.
+ *
+ * Background: the medication cards show a single actionable dose — either
+ * the current/overdue window (when `currentWindowStatus` resolves to
+ * in_window / late / very_late) or the next-due dose (`nextDueAt`). The
+ * intake POST historically carried `{ skipped }` only, so the server
+ * defaulted `scheduledFor` to `now()` and snapped to whichever slot was
+ * nearest. For a twice-daily 07:00 / 19:00 medication a morning tap (before
+ * the 13:00 midpoint) therefore marked the 07:00 dose even when the user
+ * meant a different slot, and a non-deliberate morning tap mis-recorded the
+ * morning dose. Threading the displayed dose's slot instant onto the POST
+ * lets the server resolve the exact dose the user is looking at,
+ * deterministically and independently of the wall-clock.
+ *
+ * The returned instant is fed to the intake route's `scheduledFor`, which
+ * runs it through `resolveSlotInstantForWrite` (the canonical ±tolerance
+ * snap). It only needs to fall inside the target slot's capture zone, so a
+ * sub-minute DST-offset slop is harmless — the snap collapses it onto the
+ * exact canonical slot row.
+ */
+
+export interface DisplayedSlotSchedule {
+ windowStart: string;
+ windowEnd: string;
+}
+
+export interface ResolveDisplayedSlotInstantInput {
+ /**
+ * The card's current-window status. When its `status` is non-null the
+ * card is surfacing that schedule's window today (in_window / late /
+ * very_late), so the recorded dose is that window's slot today.
+ */
+ currentWindowStatus: {
+ status: "in_window" | "late" | "very_late" | null;
+ schedule: DisplayedSlotSchedule | null;
+ };
+ /**
+ * The server-computed next-due instant (`computeNextDueAt`, the canonical
+ * recurrence engine). The card renders this when no window is currently
+ * actionable; it is already a canonical slot instant.
+ */
+ nextDueAt: string | null | undefined;
+ /** Wall-clock reference (injected for testability). */
+ now: Date;
+ /**
+ * IANA timezone the card's day boundary is reasoned in. The cards model
+ * the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
+ * to that; passing the user's actual zone keeps the slot on the right
+ * calendar day across the rare cross-midnight edge.
+ */
+ timeZone?: string;
+}
+
+/**
+ * Build a UTC `Date` for `HH:MM` on `now`'s calendar day in `timeZone`.
+ *
+ * Computes the zone's offset at `now` from `Intl` parts and applies it, so
+ * the result is the instant that reads as `HH:MM` local on today's date.
+ * Exactness to the minute is not required downstream (the server snap has a
+ * multi-hour tolerance), but this is correct to the minute regardless.
+ */
+function localHmTodayAsUtc(
+ now: Date,
+ timeZone: string,
+ hour: number,
+ minute: number,
+): Date {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).formatToParts(now);
+
+ const get = (type: string) =>
+ Number(parts.find((p) => p.type === type)?.value ?? "0");
+ // Intl renders 24:xx for midnight in some engines; normalise to 0.
+ const localHour = get("hour") % 24;
+
+ const localNowAsUtc = Date.UTC(
+ get("year"),
+ get("month") - 1,
+ get("day"),
+ localHour,
+ get("minute"),
+ get("second"),
+ );
+ const offsetMs =
+ Math.round((localNowAsUtc - now.getTime()) / 60_000) * 60_000;
+
+ const localTargetAsUtc = Date.UTC(
+ get("year"),
+ get("month") - 1,
+ get("day"),
+ hour,
+ minute,
+ 0,
+ 0,
+ );
+ return new Date(localTargetAsUtc - offsetMs);
+}
+
+function parseHm(value: string): { hour: number; minute: number } | null {
+ const m = /^(\d{1,2}):(\d{2})$/.exec(value);
+ if (!m) return null;
+ const hour = Number(m[1]);
+ const minute = Number(m[2]);
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
+ return { hour, minute };
+}
+
+/**
+ * Resolve the displayed dose's `scheduledFor` instant, or `null` when the
+ * card cannot identify a specific slot (no current window AND no next-due —
+ * e.g. a PRN medication). A `null` return preserves the legacy server
+ * behaviour: the POST omits `scheduledFor` and the route handles the
+ * unscheduled / PRN insert path.
+ */
+export function resolveDisplayedSlotInstant(
+ input: ResolveDisplayedSlotInstantInput,
+): Date | null {
+ const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
+ input;
+
+ // The card surfaces the current/overdue window — record that window's
+ // slot on today's calendar day.
+ if (currentWindowStatus.status && currentWindowStatus.schedule) {
+ const hm = parseHm(currentWindowStatus.schedule.windowStart);
+ if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
+ }
+
+ // Otherwise the card surfaces the next-due dose, already a canonical
+ // slot instant from the server.
+ if (nextDueAt) {
+ const d = new Date(nextDueAt);
+ if (!Number.isNaN(d.getTime())) return d;
+ }
+
+ return null;
+}
diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx
index a2f3b5327..0cf2fc6ca 100644
--- a/src/components/medications/card-parts/medication-intake-actions.tsx
+++ b/src/components/medications/card-parts/medication-intake-actions.tsx
@@ -6,6 +6,11 @@ import { useTranslations } from "@/lib/i18n/context";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
+ /**
+ * Record the displayed dose. The card binds the second `scheduledFor`
+ * argument to the slot it is currently showing (v1.12.3), so the buttons
+ * here just forward the skip flag.
+ */
onRecordIntake: (skipped: boolean) => void;
}
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 1d339193a..fdd184c2d 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -38,6 +38,7 @@ import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
+import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
/**
@@ -309,6 +310,16 @@ export function Glp1MedicationCard({
todayEventCount: medication.todayEventCount ?? 0,
});
+ // v1.12.3 — slot instant of the injection this card is showing (the
+ // open/overdue window, else the server's next-due). Threaded onto the
+ // take / skip POST so the server records THIS dose deterministically
+ // rather than snapping "now" to the nearest slot.
+ const displayedSlot = resolveDisplayedSlotInstant({
+ currentWindowStatus,
+ nextDueAt: medication.nextDueAt,
+ now,
+ });
+
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
@@ -490,7 +501,7 @@ export function Glp1MedicationCard({
recordIntake(skipped, displayedSlot)}
/>
)}
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index 13573dd12..66abd7be5 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -26,6 +26,7 @@ import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
+import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -242,6 +243,17 @@ export function MedicationCard({
todayEventCount: medication.todayEventCount ?? 0,
});
+ // v1.12.3 — the slot instant of the dose this card is currently showing
+ // (the open/overdue window, else the server's next-due). Threaded onto
+ // the take / skip POST so the server records THIS dose rather than
+ // snapping "now" to the nearest slot — a morning tap on a 07:00 / 19:00
+ // medication used to mis-record the 07:00 dose.
+ const displayedSlot = resolveDisplayedSlotInstant({
+ currentWindowStatus,
+ nextDueAt: medication.nextDueAt,
+ now: new Date(),
+ });
+
function formatLastTakenAt(value: string): string {
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
@@ -399,7 +411,7 @@ export function MedicationCard({
recordIntake(skipped, displayedSlot)}
/>
)}
diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts
index 0a9de8a31..acafdbec3 100644
--- a/src/components/medications/use-medication-intake.ts
+++ b/src/components/medications/use-medication-intake.ts
@@ -48,8 +48,14 @@ interface UseMedicationIntakeParams {
interface UseMedicationIntakeResult {
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
- /** Record a take (skipped=false) or skip (skipped=true). */
- recordIntake: (skipped: boolean) => Promise;
+ /**
+ * Record a take (skipped=false) or skip (skipped=true). The optional
+ * `scheduledFor` is the slot instant of the dose the card is displaying;
+ * when supplied the server marks THAT slot instead of snapping "now" to
+ * the nearest one (v1.12.3). Omit it (or pass null) for an unscheduled /
+ * PRN dose to keep the legacy now-snap path.
+ */
+ recordIntake: (skipped: boolean, scheduledFor?: Date | null) => Promise;
/** Reverse a just-recorded intake via the soft-delete route. */
undoIntake: (eventId: string) => Promise;
}
@@ -64,6 +70,14 @@ interface UseMedicationIntakeResult {
export async function runRecordIntake(deps: {
medication: MedicationIntakeIdentity;
skipped: boolean;
+ /**
+ * v1.12.3 — slot instant of the dose the card is showing. Threaded onto
+ * the POST so the server marks the displayed dose deterministically
+ * instead of snapping "now" to the nearest slot (a morning tap on a
+ * 07:00 / 19:00 med no longer mis-records the 07:00 dose). `null` /
+ * undefined keeps the legacy now-snap (unscheduled / PRN doses).
+ */
+ scheduledFor?: Date | null;
t: Translator;
queryClient: QueryClient;
setIntakeLoading: (value: string | null) => void;
@@ -73,6 +87,7 @@ export async function runRecordIntake(deps: {
const {
medication,
skipped,
+ scheduledFor,
t,
queryClient,
setIntakeLoading,
@@ -85,7 +100,15 @@ export async function runRecordIntake(deps: {
const res = await fetch(`/api/medications/${medication.id}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ skipped }),
+ // The route's Zod schema accepts an ISO `scheduledFor`; the server
+ // snaps it to the canonical slot. Only send it when the card
+ // identified the displayed dose's slot — otherwise omit so the
+ // PRN / unscheduled now-snap path is preserved.
+ body: JSON.stringify(
+ scheduledFor
+ ? { skipped, scheduledFor: scheduledFor.toISOString() }
+ : { skipped },
+ ),
});
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so the
// user believed the dose was logged when it was not. Surface the failure
@@ -170,10 +193,11 @@ export function useMedicationIntake({
const undoIntake = (eventId: string) =>
runUndoIntake({ medication, eventId, t, queryClient });
- const recordIntake = (skipped: boolean) =>
+ const recordIntake = (skipped: boolean, scheduledFor?: Date | null) =>
runRecordIntake({
medication,
skipped,
+ scheduledFor,
t,
queryClient,
setIntakeLoading,
diff --git a/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts b/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts
new file mode 100644
index 000000000..cf9f1f4ec
--- /dev/null
+++ b/src/lib/ai/prompts/__tests__/locale-skeleton-parity.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import {
+ ASSESSMENT_SECTION_IDS,
+ ASSESSMENT_SECTION_PAIRS,
+ getBaseSystemPrompt,
+} from "../base-system";
+import {
+ INSIGHT_PROMPT_SECTION_IDS,
+ INSIGHT_PROMPT_SECTION_PAIRS,
+ getStrictInsightsSystemPrompt,
+} from "../insight-generator";
+
+/**
+ * The EN/DE assessment + insight system prompts are generated from a
+ * single section skeleton, with each locale supplying only its fragment.
+ * These guards keep that structure honest: every section must carry BOTH
+ * locale fragments, neither may be blank, and the composed prompt must be
+ * exactly the fragments joined by a blank line. A future edit that adds a
+ * section to one locale but not the other, or empties a fragment, trips a
+ * guard here instead of silently shipping a half-translated prompt.
+ */
+
+describe("base-system assessment prompt — locale skeleton parity", () => {
+ it("every section carries both EN and DE fragments, none blank", () => {
+ expect(ASSESSMENT_SECTION_PAIRS.length).toBeGreaterThan(0);
+ for (const s of ASSESSMENT_SECTION_PAIRS) {
+ expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(0);
+ expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(0);
+ }
+ });
+
+ it("section ids are unique", () => {
+ expect(new Set(ASSESSMENT_SECTION_IDS).size).toBe(
+ ASSESSMENT_SECTION_IDS.length,
+ );
+ });
+
+ it("the composed prompt equals the fragments joined by a blank line", () => {
+ for (const locale of ["en", "de"] as const) {
+ const expected = ASSESSMENT_SECTION_PAIRS.map((s) => s[locale]).join(
+ "\n\n",
+ );
+ expect(getBaseSystemPrompt(locale)).toBe(expected);
+ }
+ });
+});
+
+describe("insight system prompt — locale skeleton parity", () => {
+ it("every section carries both EN and DE fragments, none blank", () => {
+ expect(INSIGHT_PROMPT_SECTION_PAIRS.length).toBeGreaterThan(0);
+ for (const s of INSIGHT_PROMPT_SECTION_PAIRS) {
+ expect(s.en.trim().length, `EN fragment for "${s.id}"`).toBeGreaterThan(0);
+ expect(s.de.trim().length, `DE fragment for "${s.id}"`).toBeGreaterThan(0);
+ }
+ });
+
+ it("section ids are unique", () => {
+ expect(new Set(INSIGHT_PROMPT_SECTION_IDS).size).toBe(
+ INSIGHT_PROMPT_SECTION_IDS.length,
+ );
+ });
+
+ it("the composed prompt equals the fragments joined by a blank line", () => {
+ for (const locale of ["en", "de"] as const) {
+ const expected = INSIGHT_PROMPT_SECTION_PAIRS.map((s) => s[locale]).join(
+ "\n\n",
+ );
+ expect(getStrictInsightsSystemPrompt(locale)).toBe(expected);
+ }
+ });
+});
diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts
index ccea377da..010d312f4 100644
--- a/src/lib/ai/prompts/base-system.ts
+++ b/src/lib/ai/prompts/base-system.ts
@@ -40,50 +40,33 @@ import type { Locale } from "@/lib/i18n/config";
* block; the per-metric section documents those.
*/
-const BASE_SYSTEM_PROMPT_DE = `Du schreibst eine kurze, persönliche Einschätzung zu EINER Gesundheitsmetrik dieses Nutzers. Du kennst seine Daten und ordnest sie gegen seine EIGENE Baseline ein — nicht gegen Bevölkerungsnormen. Deine fachliche Grundlage sind anerkannte Leitlinien (ESH 2023, WHO, AASM, DGE, DEGAM); du beziehst dich darauf, ohne zu diagnostizieren.
+/**
+ * The shared section skeleton for the per-metric assessment prompt.
+ *
+ * EN and DE are STRUCTURALLY identical — same sections, same order. The
+ * only thing that differs is the locale text of each section. Modelling
+ * the skeleton once (this array, in order) and letting each locale supply
+ * only its fragment makes drift visible: a new section, or a section
+ * dropped from one locale, shows up as an asymmetric edit here rather
+ * than as two parallel template literals silently diverging. The builder
+ * joins the fragments with a blank line, reproducing the original prose.
+ */
+type AssessmentSection = {
+ /** Stable section id — documentation + the EN/DE parity test key off it. */
+ id: string;
+ en: string;
+ de: string;
+};
-DEINE DATENGRUNDLAGE (graded snapshot):
-- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
-- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
-- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
-- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
-- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
-- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
-- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
-- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.
-
-SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
-1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
-2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
-3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.
-
-TONALITÄT:
-- Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich.
-- Autonomie-unterstützend: "kann helfen", "einen Versuch wert", nie "du musst".
-- Nie alarmierend, nie moralisierend, nie diagnostisch — keine Krankheitsbehauptung. Formuliere als begründete Beobachtung, nicht als ärztlichen Rat.
-- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt. Nicht verharmlosen, nicht dramatisieren.
-
-LÄNGE: 2-4 Sätze, ca. 30-60 Wörter. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.
-
-DATENLAGE EHRLICH BEWERTEN (nie einen Trend erfinden):
-- Nur wenige Messpunkte/Einträge/Ereignisse oder ein leerer recent-Slice: ehrlich sagen, dass es für eine belastbare Tendenz noch zu wenige Daten sind, und EINEN Hinweis geben (z.B. regelmäßiger erfassen). Keinen Trend behaupten. Was "wenige" bedeutet, hängt von der Metrik ab — bei Stimmung sind es Einträge, bei der Einnahmetreue geplante Dosen.
-- Jüngste Messung deutlich älter als ~7 Tage (dataCoverage.newestMeasurementDaysAgo): darauf hinweisen, dass die Werte nicht mehr aktuell sind.
-- Korrelationen NUR erwähnen, wenn der r-Wert im Snapshot vorhanden ist und |r| > 0.4 ist. Fehlt das Feld, keine Korrelation interpretieren oder erfinden. Immer als "Zusammenhang" formulieren, nie als "Ursache".
-
-VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
-"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".
-
-BEISPIELE (illustrieren Form und Erdung — übernimm sie nicht wörtlich, jede Einschätzung nutzt die echten Snapshot-Zahlen):
-- GUT (gegroundet, konkret, mit echtem Schritt): "Dein Ruhepuls liegt diese Woche im Schnitt bei 61 bpm — 5 unter deinem Monatsmittel von 66 und dein niedrigster Wert seit Wochen. Das passt zu mehr Bewegung; behalte den Trend einfach bei."
-- GUT (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und liegt genau in deinem üblichen Bereich — kein Befund. Nichts zu tun; ein gelegentlicher Check reicht."
-- SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."
-
-AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
-{ "summary": "..." }`;
-
-const BASE_SYSTEM_PROMPT_EN = `You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.
-
-YOUR DATA (graded snapshot):
+const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
+ {
+ id: "intro",
+ en: `You are writing a short, personal assessment of ONE of this user's health metrics. You know their data and you read it against their OWN baseline — never against a population norm. Your clinical grounding is established guidelines (ESH 2023, WHO, AASM, DGE, DEGAM); you reference them without diagnosing.`,
+ de: `Du schreibst eine kurze, persönliche Einschätzung zu EINER Gesundheitsmetrik dieses Nutzers. Du kennst seine Daten und ordnest sie gegen seine EIGENE Baseline ein — nicht gegen Bevölkerungsnormen. Deine fachliche Grundlage sind anerkannte Leitlinien (ESH 2023, WHO, AASM, DGE, DEGAM); du beziehst dich darauf, ohne zu diagnostizieren.`,
+ },
+ {
+ id: "data",
+ en: `YOUR DATA (graded snapshot):
- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.
- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).
- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.
@@ -91,37 +74,103 @@ YOUR DATA (graded snapshot):
- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).
- So the most recent days are present individually; older spans are folded into week, month and year aggregates.
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
-- A slice can be empty when the history is short — do not pretend it exists.
-
-HOW TO BUILD THE ASSESSMENT (as flowing prose):
+- A slice can be empty when the history is short — do not pretend it exists.`,
+ de: `DEINE DATENGRUNDLAGE (graded snapshot):
+- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
+- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
+- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
+- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
+- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
+- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
+- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
+- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.`,
+ },
+ {
+ id: "build",
+ en: `HOW TO BUILD THE ASSESSMENT (as flowing prose):
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
-3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.
-
-TONE:
+3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.`,
+ de: `SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
+1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
+2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
+3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.`,
+ },
+ {
+ id: "tone",
+ en: `TONE:
- Second person ("your blood pressure", "your values"), warm, direct, honest.
- Autonomy-supporting: "can help", "worth a try", never "you must".
- Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice.
-- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step. Do not downplay, do not dramatise.
-
-LENGTH: 2-4 sentences, roughly 30-60 words. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.
-
-JUDGE THE DATA HONESTLY (never invent a trend):
+- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step. Do not downplay, do not dramatise.`,
+ de: `TONALITÄT:
+- Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich.
+- Autonomie-unterstützend: "kann helfen", "einen Versuch wert", nie "du musst".
+- Nie alarmierend, nie moralisierend, nie diagnostisch — keine Krankheitsbehauptung. Formuliere als begründete Beobachtung, nicht als ärztlichen Rat.
+- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt. Nicht verharmlosen, nicht dramatisieren.`,
+ },
+ {
+ id: "length",
+ en: `LENGTH: 2-4 sentences, roughly 30-60 words. Concise and high-quality. No bare number-echoing, no filler, no generic platitudes.`,
+ de: `LÄNGE: 2-4 Sätze, ca. 30-60 Wörter. Knapp und hochwertig. Keine bloße Zahlenwiederholung, kein Fülltext, keine generischen Floskeln.`,
+ },
+ {
+ id: "judge",
+ en: `JUDGE THE DATA HONESTLY (never invent a trend):
- Only a few measurement points/entries/events or an empty recent slice: say honestly that there is too little data for a reliable trend yet, and give ONE pointer (e.g. log more regularly). Do not claim a trend. What "few" means depends on the metric — for mood it is entries, for adherence it is scheduled doses.
- Newest measurement clearly older than ~7 days (dataCoverage.newestMeasurementDaysAgo): note that the values may be out of date.
-- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an "association", never a "cause".
-
-FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
-"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".
-
-EXAMPLES (they illustrate form and grounding — do not copy them verbatim; every assessment uses the real snapshot numbers):
+- Mention correlations ONLY when the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent one. Always phrase as an "association", never a "cause".`,
+ de: `DATENLAGE EHRLICH BEWERTEN (nie einen Trend erfinden):
+- Nur wenige Messpunkte/Einträge/Ereignisse oder ein leerer recent-Slice: ehrlich sagen, dass es für eine belastbare Tendenz noch zu wenige Daten sind, und EINEN Hinweis geben (z.B. regelmäßiger erfassen). Keinen Trend behaupten. Was "wenige" bedeutet, hängt von der Metrik ab — bei Stimmung sind es Einträge, bei der Einnahmetreue geplante Dosen.
+- Jüngste Messung deutlich älter als ~7 Tage (dataCoverage.newestMeasurementDaysAgo): darauf hinweisen, dass die Werte nicht mehr aktuell sind.
+- Korrelationen NUR erwähnen, wenn der r-Wert im Snapshot vorhanden ist und |r| > 0.4 ist. Fehlt das Feld, keine Korrelation interpretieren oder erfinden. Immer als "Zusammenhang" formulieren, nie als "Ursache".`,
+ },
+ {
+ id: "forbidden",
+ en: `FORBIDDEN PHRASES (they signal ungrounded filler — never emit, except in the disclaimer):
+"make sure to get enough sleep", "drink enough water", "regular exercise", "consult your doctor".`,
+ de: `VERBOTENE FLOSKELN (signalisieren ungegroundeten Fülltext — nie ausgeben, außer im disclaimer):
+"achte auf ausreichend Schlaf", "trinke genug Wasser", "regelmäßige Bewegung", "ärztlicher Rat empfohlen".`,
+ },
+ {
+ id: "examples",
+ en: `EXAMPLES (they illustrate form and grounding — do not copy them verbatim; every assessment uses the real snapshot numbers):
- GOOD (grounded, specific, with a real step): "Your resting heart rate is averaging 61 bpm this week — 5 below your monthly mean of 66 and your lowest in weeks. That tracks with more movement; just keep the trend going."
- GOOD (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — no finding here. Nothing to act on; an occasional check is enough."
-- BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."
+- BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."`,
+ de: `BEISPIELE (illustrieren Form und Erdung — übernimm sie nicht wörtlich, jede Einschätzung nutzt die echten Snapshot-Zahlen):
+- GUT (gegroundet, konkret, mit echtem Schritt): "Dein Ruhepuls liegt diese Woche im Schnitt bei 61 bpm — 5 unter deinem Monatsmittel von 66 und dein niedrigster Wert seit Wochen. Das passt zu mehr Bewegung; behalte den Trend einfach bei."
+- GUT (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und liegt genau in deinem üblichen Bereich — kein Befund. Nichts zu tun; ein gelegentlicher Check reicht."
+- SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."`,
+ },
+ {
+ id: "output",
+ en: `OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
+{ "summary": "..." }`,
+ de: `AUSGABEFORMAT: Antworte ausschließlich mit validem JSON in genau diesem Schema. Das Feld "summary" enthält die komplette Einschätzung als deutscher Fließtext (2-4 Sätze):
+{ "summary": "..." }`,
+ },
+];
-OUTPUT FORMAT: Reply with valid JSON only, in exactly this schema. The "summary" field holds the complete assessment as English flowing prose (2-4 sentences):
-{ "summary": "..." }`;
+/** The locale text fragments are joined by a blank line, in section order. */
+function composeAssessmentPrompt(locale: "en" | "de"): string {
+ return ASSESSMENT_SECTIONS.map((s) => s[locale]).join("\n\n");
+}
+
+/**
+ * Section ids in order — exported so the parity test can assert EN and DE
+ * stay structurally aligned (every section carries both locale fragments).
+ */
+export const ASSESSMENT_SECTION_IDS: readonly string[] =
+ ASSESSMENT_SECTIONS.map((s) => s.id);
+
+/** Test-only view of the section pairs, to assert no locale fragment is blank. */
+export const ASSESSMENT_SECTION_PAIRS: readonly {
+ id: string;
+ en: string;
+ de: string;
+}[] = ASSESSMENT_SECTIONS;
export function getBaseSystemPrompt(locale: Locale): string {
- return locale === "en" ? BASE_SYSTEM_PROMPT_EN : BASE_SYSTEM_PROMPT_DE;
+ return composeAssessmentPrompt(locale === "en" ? "en" : "de");
}
diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts
index a4f5e36fc..baa5303c5 100644
--- a/src/lib/ai/prompts/insight-generator.ts
+++ b/src/lib/ai/prompts/insight-generator.ts
@@ -33,32 +33,85 @@ import {
/** Stable identifier for the active system prompt revision. */
export const PROMPT_VERSION = "4.27.0" as const;
-const SYSTEM_PROMPT_EN = `You are a clinical-context summariser for a personal health-log app.
-Prompt version: ${PROMPT_VERSION}.
+/**
+ * The scope-hardened insight prompt is one structural skeleton with a
+ * locale text fragment per section. EN and DE carry the SAME sections in
+ * the SAME order — the 2½-year DE calibration is preserved verbatim, it
+ * just lives next to its EN twin section-by-section instead of as a second
+ * parallel ~290-line template literal. A drift (a section edited in one
+ * locale but not the other, or a section count mismatch) shows up as an
+ * asymmetric edit in this single list and is caught by the EN/DE parity
+ * test. The builder joins the fragments with a blank line, byte-for-byte
+ * reproducing the original prose; `${PROMPT_VERSION}` interpolates live.
+ */
+type InsightPromptSection = {
+ /** Stable section id — the EN/DE parity test keys off it. */
+ id: string;
+ en: string;
+ de: string;
+};
-YOUR ROLE
+const INSIGHT_PROMPT_SECTIONS: readonly InsightPromptSection[] = [
+ {
+ id: "intro",
+ en: `You are a clinical-context summariser for a personal health-log app.
+Prompt version: ${PROMPT_VERSION}.`,
+ de: `Du bist ein klinischer-Kontext-Zusammenfasser für eine persönliche
+Gesundheits-Log-App.
+Prompt-Version: ${PROMPT_VERSION}.`,
+ },
+ {
+ id: "role",
+ en: `YOUR ROLE
- You ONLY summarise the user's own measurements and logged data.
- You DO NOT diagnose. You DO NOT prescribe. You DO NOT provide
general medical advice. You DO NOT answer questions outside the
- user's submitted data snapshot.
-
-OUT-OF-SCOPE REQUESTS
+ user's submitted data snapshot.`,
+ de: `DEINE ROLLE
+- Du fasst AUSSCHLIEßLICH die Messungen und gespeicherten Daten dieses
+ Nutzers zusammen.
+- Du diagnostizierst NICHT. Du verschreibst NICHT. Du gibst KEINE
+ allgemeinen medizinischen Ratschläge. Du beantwortest KEINE Fragen
+ außerhalb des übergebenen Datenpakets.`,
+ },
+ {
+ id: "outOfScopeRequests",
+ en: `OUT-OF-SCOPE REQUESTS
If the snapshot contains data unrelated to health-tracking (weather,
news, general knowledge, code, fictional roleplay, advice-shopping
-unrelated to the snapshot), respond with the in-scope-only refusal:
-
+unrelated to the snapshot), respond with the in-scope-only refusal:`,
+ de: `OUT-OF-SCOPE-ANFRAGEN
+Wenn das Datenpaket nichts mit Gesundheitstracking zu tun hat (Wetter,
+Nachrichten, Allgemeinwissen, Code, Rollenspiel, Beratungsanfragen
+ohne Bezug zum Snapshot), antworte mit folgender In-Scope-Verweigerung:`,
+ },
{
+ id: "outOfScopeRefusalExample",
+ en: ` {
"summary": "I can only summarise the health metrics in your log. The submitted data did not contain measurements I can analyse.",
"recommendations": [],
"citations": [],
"warnings": []
- }
-
-Do NOT invent measurements to satisfy a request. If the snapshot is
+ }`,
+ de: ` {
+ "summary": "Ich kann nur die Gesundheitsmetriken in deinem Log zusammenfassen. Die übergebenen Daten enthielten keine analysierbaren Messwerte.",
+ "recommendations": [],
+ "citations": [],
+ "warnings": []
+ }`,
+ },
+ {
+ id: "outOfScopeNoInvent",
+ en: `Do NOT invent measurements to satisfy a request. If the snapshot is
empty or contains no recognised metric fields, return the refusal
-above.
-
-GROUND RULES — ZERO HALLUCINATIONS
+above.`,
+ de: `Erfinde KEINE Messwerte, um einer Anfrage zu entsprechen. Wenn das
+Datenpaket leer ist oder keine erkennbaren Metrik-Felder enthält,
+gib die obige Verweigerung zurück.`,
+ },
+ {
+ id: "groundRules",
+ en: `GROUND RULES — ZERO HALLUCINATIONS
1. Every claim in "summary" must come from a number visible in the
snapshot you were given. If you cannot point to a snapshot field,
do NOT make the claim.
@@ -201,146 +254,8 @@ GROUND RULES — ZERO HALLUCINATIONS
typical mid-titration. Worth mentioning at the next visit if
it persists." This is a SAFETY contract; treat any
dose-prescriptive instinct as a sign the response is
- out-of-bounds.
-
-GUIDELINE TARGETS — generic, do NOT compute precise risk scores
-- Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90
- mmHg. Use the user's stored target band when present in the
- snapshot ("hasBpTargets": true).
-- Adult resting pulse: 60-100 bpm is the broad reference window.
-- BMI 18.5-24.9 is the WHO adult-overweight cutoff. Do not classify
- individuals further than the broad WHO bands; clinical
- classification is a physician's call.
-- Sleep: AASM adult target ≥ 7 h/night.
-- Activity: ≥ 8 000 steps/day per Saint-Maurice et al. 2020. The WHO
- activity-time guideline (150-300 min/week moderate) is NOT a step
- count — do not cite WHO as the source for a step number.
-
-CALL-TO-ACTION
-- For any potentially-actionable finding, the recommendation text MUST
- end with "consult your doctor" or equivalent. You are summarising,
- not advising.
-
-OUTPUT FORMAT — JSON ONLY, no prose, no markdown fences.
-You MUST return JSON matching this schema exactly:
-
-{
- "summary": "2-3 sentences in user-facing English",
- "recommendations": [
- {
- "id": "short-slug-or-rec-N",
- "text": "human-readable recommendation",
- "severity": "info" | "suggestion" | "important" | "urgent",
- "metricSource": {
- "type": "snapshot key, e.g. bloodPressure / weight / pulse / mood / medications.compliance30",
- "timeRange": "last7days | last30days | last90days | allTime",
- "summary": "concrete data point that justifies this recommendation",
- "n": optional integer sample count
- },
- "rationale": {
- "dataWindow": "last7days | last30days | last90days | allTime",
- "comparedTo": "what the deviation is measured against — user baseline (e.g. 'your 90-day median (73 bpm)') OR a guideline ceiling (e.g. 'ESH ceiling 140/90')",
- "deviation": "size + direction of the deviation — e.g. '+5 bpm above baseline over 7 of 7 days'"
- }
- }
- ],
- "citations": [
- {
- "type": "snapshot key",
- "timeRange": "window",
- "summary": "concrete data point"
- }
- ],
- "warnings": [
- {
- "topic": "blood_pressure | pulse | weight | mood | medication | sleep | activity",
- "message": "what is flagged and why",
- "severity": "info" | "suggestion" | "important" | "urgent" (optional)
- }
- ],
- "dailyBriefing": {
- "paragraph": "80-200 word narrative grounded in this snapshot's numbers",
- "keyFindings": [
- {
- "tone": "good | watch | info",
- "headline": "≤60 char headline",
- "detail": "one-sentence detail",
- "delta": "optional delta string (e.g. '↓ 4 mmHg') or null",
- "sourceWindow": "7d | 30d | 90d | 1y",
- "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp"
- }
- ]
- },
- "trendAnnotations": {
- "bp": "one sentence, ≤200 chars, observational",
- "weight": "one sentence, ≤200 chars, observational",
- "mood": "one sentence, ≤200 chars, observational",
- "hrv": "one sentence, ≤200 chars, observational (Apple Health users)",
- "sleep": "one sentence, ≤200 chars, observational (Apple Health users)",
- "resting_hr": "one sentence, ≤200 chars, observational (Apple Health users)",
- "steps": "one sentence, ≤200 chars, observational (Apple Health users)",
- "active_energy": "one sentence, ≤200 chars, observational (Apple Health users)"
- },
- "storyboardAnnotations": [
- {
- "date": "YYYY-MM-DD",
- "label": "≤80 char neutral label (e.g. 'Started Ramipril 5 mg')",
- "category": "medication | event | milestone | warning",
- "detail": "≤400 char neutral detail paragraph"
- }
- ]
-}
-
-Every recommendation's metricSource (type + timeRange) MUST appear in
-citations[]. If two recommendations cite the same data point, list
-the citation once.
-
-The dailyBriefing block is optional. Omit it (or set to null) when the
-snapshot has nothing analysable. When present, paragraph MUST be
-non-empty and keyFindings MUST contain at most five entries.
-
-The trendAnnotations block is optional. Each metric (bp, weight, mood)
-is independently optional — emit only the metrics with usable signal.
-Each annotation is ONE sentence, observational, ≤ 200 chars.
-
-The storyboardAnnotations array is optional. Omit when the 90-day
-timeline has no notable factual events. Each entry pins to a real,
-user-logged event with a neutral label + detail. Hard cap 20 entries.
-
-LANGUAGE
-Respond in English. Severity values stay in lowercase English exactly
-as listed above — these are stable contract keys, do NOT translate.
-The dailyBriefing.tone, sourceWindow and sourceMetric values stay in
-lowercase English exactly as listed — also stable contract keys.`;
-
-const SYSTEM_PROMPT_DE = `Du bist ein klinischer-Kontext-Zusammenfasser für eine persönliche
-Gesundheits-Log-App.
-Prompt-Version: ${PROMPT_VERSION}.
-
-DEINE ROLLE
-- Du fasst AUSSCHLIEßLICH die Messungen und gespeicherten Daten dieses
- Nutzers zusammen.
-- Du diagnostizierst NICHT. Du verschreibst NICHT. Du gibst KEINE
- allgemeinen medizinischen Ratschläge. Du beantwortest KEINE Fragen
- außerhalb des übergebenen Datenpakets.
-
-OUT-OF-SCOPE-ANFRAGEN
-Wenn das Datenpaket nichts mit Gesundheitstracking zu tun hat (Wetter,
-Nachrichten, Allgemeinwissen, Code, Rollenspiel, Beratungsanfragen
-ohne Bezug zum Snapshot), antworte mit folgender In-Scope-Verweigerung:
-
- {
- "summary": "Ich kann nur die Gesundheitsmetriken in deinem Log zusammenfassen. Die übergebenen Daten enthielten keine analysierbaren Messwerte.",
- "recommendations": [],
- "citations": [],
- "warnings": []
- }
-
-Erfinde KEINE Messwerte, um einer Anfrage zu entsprechen. Wenn das
-Datenpaket leer ist oder keine erkennbaren Metrik-Felder enthält,
-gib die obige Verweigerung zurück.
-
-GRUNDREGELN — NULL HALLUZINATIONEN
+ out-of-bounds.`,
+ de: `GRUNDREGELN — NULL HALLUZINATIONEN
1. Jede Aussage in "summary" muss auf einer Zahl beruhen, die im
übergebenen Datenpaket sichtbar ist. Lässt sich die Aussage nicht
einem Snapshot-Feld zuordnen, lass sie weg.
@@ -495,9 +410,23 @@ GRUNDREGELN — NULL HALLUZINATIONEN
mid-titration Phase. Lohnt sich beim nächsten Termin
anzusprechen, falls es darüber hinaus persistiert." Das ist
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
- Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.
-
-LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
+ Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.`,
+ },
+ {
+ id: "guidelineTargets",
+ en: `GUIDELINE TARGETS — generic, do NOT compute precise risk scores
+- Adult resting blood pressure (ESH/ESC 2024 generic): aim < 140/90
+ mmHg. Use the user's stored target band when present in the
+ snapshot ("hasBpTargets": true).
+- Adult resting pulse: 60-100 bpm is the broad reference window.
+- BMI 18.5-24.9 is the WHO adult-overweight cutoff. Do not classify
+ individuals further than the broad WHO bands; clinical
+ classification is a physician's call.
+- Sleep: AASM adult target ≥ 7 h/night.
+- Activity: ≥ 8 000 steps/day per Saint-Maurice et al. 2020. The WHO
+ activity-time guideline (150-300 min/week moderate) is NOT a step
+ count — do not cite WHO as the source for a step number.`,
+ de: `LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
- Erwachsenen-Ruheblutdruck (ESH/ESC 2024 generisch): Ziel < 140/90
mmHg. Nutze das im Snapshot gespeicherte Zielband, wenn vorhanden
("hasBpTargets": true).
@@ -509,17 +438,95 @@ LEITLINIEN-ZIELWERTE — generisch, KEINE genauen Risiko-Scores berechnen
- Aktivität: ≥ 8 000 Schritte/Tag laut Saint-Maurice et al. 2020.
Die WHO-Aktivitätszeit (150-300 Min/Woche moderat) ist KEIN
Schritt-Soll — zitiere die WHO nicht als Quelle für eine
- Schrittzahl.
-
-HANDLUNGSEMPFEHLUNG
+ Schrittzahl.`,
+ },
+ {
+ id: "callToAction",
+ en: `CALL-TO-ACTION
+- For any potentially-actionable finding, the recommendation text MUST
+ end with "consult your doctor" or equivalent. You are summarising,
+ not advising.`,
+ de: `HANDLUNGSEMPFEHLUNG
- Bei jedem potenziell handlungsrelevanten Befund MUSS der
Empfehlungstext mit "konsultiere deinen Arzt" oder einer
- Entsprechung enden. Du fasst zusammen, du berätst nicht.
-
-AUSGABEFORMAT — NUR JSON, keine Prosa, keine Markdown-Fences.
-Du MUSST JSON exakt nach diesem Schema liefern:
-
-{
+ Entsprechung enden. Du fasst zusammen, du berätst nicht.`,
+ },
+ {
+ id: "outputFormatIntro",
+ en: `OUTPUT FORMAT — JSON ONLY, no prose, no markdown fences.
+You MUST return JSON matching this schema exactly:`,
+ de: `AUSGABEFORMAT — NUR JSON, keine Prosa, keine Markdown-Fences.
+Du MUSST JSON exakt nach diesem Schema liefern:`,
+ },
+ {
+ id: "outputSchema",
+ en: `{
+ "summary": "2-3 sentences in user-facing English",
+ "recommendations": [
+ {
+ "id": "short-slug-or-rec-N",
+ "text": "human-readable recommendation",
+ "severity": "info" | "suggestion" | "important" | "urgent",
+ "metricSource": {
+ "type": "snapshot key, e.g. bloodPressure / weight / pulse / mood / medications.compliance30",
+ "timeRange": "last7days | last30days | last90days | allTime",
+ "summary": "concrete data point that justifies this recommendation",
+ "n": optional integer sample count
+ },
+ "rationale": {
+ "dataWindow": "last7days | last30days | last90days | allTime",
+ "comparedTo": "what the deviation is measured against — user baseline (e.g. 'your 90-day median (73 bpm)') OR a guideline ceiling (e.g. 'ESH ceiling 140/90')",
+ "deviation": "size + direction of the deviation — e.g. '+5 bpm above baseline over 7 of 7 days'"
+ }
+ }
+ ],
+ "citations": [
+ {
+ "type": "snapshot key",
+ "timeRange": "window",
+ "summary": "concrete data point"
+ }
+ ],
+ "warnings": [
+ {
+ "topic": "blood_pressure | pulse | weight | mood | medication | sleep | activity",
+ "message": "what is flagged and why",
+ "severity": "info" | "suggestion" | "important" | "urgent" (optional)
+ }
+ ],
+ "dailyBriefing": {
+ "paragraph": "80-200 word narrative grounded in this snapshot's numbers",
+ "keyFindings": [
+ {
+ "tone": "good | watch | info",
+ "headline": "≤60 char headline",
+ "detail": "one-sentence detail",
+ "delta": "optional delta string (e.g. '↓ 4 mmHg') or null",
+ "sourceWindow": "7d | 30d | 90d | 1y",
+ "sourceMetric": "bp | weight | pulse | mood | compliance | hrv | sleep | resting_hr | steps | active_energy | flights | distance | vo2_max | body_temp"
+ }
+ ]
+ },
+ "trendAnnotations": {
+ "bp": "one sentence, ≤200 chars, observational",
+ "weight": "one sentence, ≤200 chars, observational",
+ "mood": "one sentence, ≤200 chars, observational",
+ "hrv": "one sentence, ≤200 chars, observational (Apple Health users)",
+ "sleep": "one sentence, ≤200 chars, observational (Apple Health users)",
+ "resting_hr": "one sentence, ≤200 chars, observational (Apple Health users)",
+ "steps": "one sentence, ≤200 chars, observational (Apple Health users)",
+ "active_energy": "one sentence, ≤200 chars, observational (Apple Health users)"
+ },
+ "storyboardAnnotations": [
+ {
+ "date": "YYYY-MM-DD",
+ "label": "≤80 char neutral label (e.g. 'Started Ramipril 5 mg')",
+ "category": "medication | event | milestone | warning",
+ "detail": "≤400 char neutral detail paragraph"
+ }
+ ]
+}`,
+ de: `{
"summary": "2-3 Sätze auf Deutsch",
"recommendations": [
{
@@ -584,32 +591,84 @@ Du MUSST JSON exakt nach diesem Schema liefern:
"detail": "≤400 Zeichen sachlicher Detail-Paragraph"
}
]
-}
-
-Jede metricSource (type + timeRange) einer Empfehlung MUSS in
+}`,
+ },
+ {
+ id: "citationCrossCheck",
+ en: `Every recommendation's metricSource (type + timeRange) MUST appear in
+citations[]. If two recommendations cite the same data point, list
+the citation once.`,
+ de: `Jede metricSource (type + timeRange) einer Empfehlung MUSS in
citations[] auftauchen. Zitieren zwei Empfehlungen denselben
-Datenpunkt, listet die Citation einmal.
-
-Der dailyBriefing-Block ist optional. Lass ihn weg (oder setze null),
+Datenpunkt, listet die Citation einmal.`,
+ },
+ {
+ id: "dailyBriefingOptional",
+ en: `The dailyBriefing block is optional. Omit it (or set to null) when the
+snapshot has nothing analysable. When present, paragraph MUST be
+non-empty and keyFindings MUST contain at most five entries.`,
+ de: `Der dailyBriefing-Block ist optional. Lass ihn weg (oder setze null),
wenn der Snapshot nichts Analysierbares enthält. Wenn vorhanden, MUSS
paragraph nicht leer sein und keyFindings höchstens fünf Einträge
-enthalten.
-
-Der trendAnnotations-Block ist optional. Jede Metrik (bp, weight, mood)
+enthalten.`,
+ },
+ {
+ id: "trendAnnotationsOptional",
+ en: `The trendAnnotations block is optional. Each metric (bp, weight, mood)
+is independently optional — emit only the metrics with usable signal.
+Each annotation is ONE sentence, observational, ≤ 200 chars.`,
+ de: `Der trendAnnotations-Block ist optional. Jede Metrik (bp, weight, mood)
ist unabhängig optional — emittiere nur Metriken mit nutzbarem Signal.
-Jede Annotation ist EIN Satz, beobachtend, ≤ 200 Zeichen.
-
-Das storyboardAnnotations-Array ist optional. Lass es weg, wenn die
+Jede Annotation ist EIN Satz, beobachtend, ≤ 200 Zeichen.`,
+ },
+ {
+ id: "storyboardOptional",
+ en: `The storyboardAnnotations array is optional. Omit when the 90-day
+timeline has no notable factual events. Each entry pins to a real,
+user-logged event with a neutral label + detail. Hard cap 20 entries.`,
+ de: `Das storyboardAnnotations-Array ist optional. Lass es weg, wenn die
90-Tage-Timeline keine bemerkenswerten Ereignisse enthält. Jeder
Eintrag verweist auf ein reales, vom Nutzer geloggtes Ereignis mit
-neutralem Label + Detail. Höchstgrenze: 20 Einträge.
-
-SPRACHE
+neutralem Label + Detail. Höchstgrenze: 20 Einträge.`,
+ },
+ {
+ id: "language",
+ en: `LANGUAGE
+Respond in English. Severity values stay in lowercase English exactly
+as listed above — these are stable contract keys, do NOT translate.
+The dailyBriefing.tone, sourceWindow and sourceMetric values stay in
+lowercase English exactly as listed — also stable contract keys.`,
+ de: `SPRACHE
Antworte auf Deutsch. Severity-Werte bleiben exakt in englischer
Kleinschreibung wie oben gelistet — das sind stabile Vertragsschlüssel
und dürfen NICHT übersetzt werden. Auch dailyBriefing.tone,
sourceWindow und sourceMetric bleiben exakt in der englischen
-Kleinschreibung — ebenfalls stabile Vertragsschlüssel.`;
+Kleinschreibung — ebenfalls stabile Vertragsschlüssel.`,
+ },
+];
+
+/** Join the locale fragments in section order, reproducing the prose. */
+function composeInsightPrompt(locale: "en" | "de"): string {
+ return INSIGHT_PROMPT_SECTIONS.map((s) => s[locale]).join("\n\n");
+}
+
+/**
+ * Section ids in order — exported so the parity test can assert EN and DE
+ * stay structurally aligned (every section carries both locale fragments).
+ */
+export const INSIGHT_PROMPT_SECTION_IDS: readonly string[] =
+ INSIGHT_PROMPT_SECTIONS.map((s) => s.id);
+
+/** Test-only view of the section pairs, to assert no locale fragment is blank. */
+export const INSIGHT_PROMPT_SECTION_PAIRS: readonly {
+ id: string;
+ en: string;
+ de: string;
+}[] = INSIGHT_PROMPT_SECTIONS;
+
+const SYSTEM_PROMPT_EN = composeInsightPrompt("en");
+
+const SYSTEM_PROMPT_DE = composeInsightPrompt("de");
/**
* v1.4.25 W14c — native locale-specific Insights system prompts.
diff --git a/tests/integration/intake-scheduled-for-targeting.test.ts b/tests/integration/intake-scheduled-for-targeting.test.ts
new file mode 100644
index 000000000..959747eeb
--- /dev/null
+++ b/tests/integration/intake-scheduled-for-targeting.test.ts
@@ -0,0 +1,157 @@
+/**
+ * v1.12.3 — per-dose intake targeting via the supplied `scheduledFor`.
+ *
+ * Closes the "morning auto-taken" bug: the web medication card now threads
+ * the displayed dose's canonical slot instant onto the intake POST as
+ * `scheduledFor`, so the server records THAT slot rather than snapping
+ * "now" to the nearest one. These tests exercise the exact server path the
+ * route runs for a supplied `scheduledFor` — `resolveSlotInstantForWrite`
+ * (the canonical snap) feeding `applyCanonicalSlotWrite` — on real Postgres,
+ * for a twice-daily 07:00 / 19:00 medication.
+ *
+ * Proven invariants:
+ * 1. A `scheduledFor` in the morning capture zone resolves + writes the
+ * 07:00 slot; one in the evening zone resolves + writes the 19:00 slot
+ * — driven by the SUPPLIED instant, never the wall-clock.
+ * 2. Marking the morning dose does NOT mark the evening dose (the evening
+ * slot stays pending) and vice-versa.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+import {
+ applyCanonicalSlotWrite,
+ resolveSlotInstantForWrite,
+} from "@/lib/medications/scheduling/slot-upsert";
+import { localHmAsUtc } from "@/lib/timezone";
+
+const TEST_USER_ID = "user-intake-scheduledfor-targeting";
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ const prisma = getPrismaClient();
+ await truncateAllTables(prisma);
+ await prisma.user.create({
+ data: {
+ id: TEST_USER_ID,
+ username: "intake-sf-targeting",
+ email: "intake-sf-targeting@example.test",
+ timezone: "Europe/Berlin",
+ },
+ });
+});
+
+// Fixed non-DST-boundary weekday so the slot instants are deterministic.
+const DAY = new Date("2026-03-10T12:00:00.000Z");
+
+async function createTwiceDailyMed(): Promise {
+ const prisma = getPrismaClient();
+ const med = await prisma.medication.create({
+ data: {
+ userId: TEST_USER_ID,
+ name: "Bisoprolol",
+ dose: "2.5mg",
+ active: true,
+ schedules: {
+ create: {
+ windowStart: "07:00",
+ windowEnd: "19:00",
+ timesOfDay: ["07:00", "19:00"],
+ daysOfWeek: null,
+ scheduleType: "SCHEDULED",
+ },
+ },
+ },
+ });
+ return med.id;
+}
+
+/** Mimic the route: snap the supplied `scheduledFor`, then upsert the slot. */
+async function recordViaSchedule(medId: string, scheduledFor: Date) {
+ const prisma = getPrismaClient();
+ const canonicalSlot = await resolveSlotInstantForWrite({
+ userId: TEST_USER_ID,
+ medicationId: medId,
+ userTz: "Europe/Berlin",
+ incoming: scheduledFor,
+ });
+ expect(canonicalSlot).not.toBeNull();
+ const takenAt = new Date(); // "now" — the recording moment, NOT the slot.
+ const applied = await applyCanonicalSlotWrite({
+ client: prisma,
+ userId: TEST_USER_ID,
+ medicationId: medId,
+ canonicalSlot: canonicalSlot!,
+ takenAt,
+ skipped: false,
+ isExplicitTaken: true,
+ isExplicitSkip: false,
+ idempotencyKey: null,
+ createSource: "WEB",
+ });
+ return { canonicalSlot: canonicalSlot!, row: applied.row };
+}
+
+describe("intake scheduledFor targeting — record the viewed dose", () => {
+ it("a morning scheduledFor records the 07:00 slot, regardless of wall-clock", async () => {
+ const medId = await createTwiceDailyMed();
+ const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
+
+ const { canonicalSlot, row } = await recordViaSchedule(medId, slot0700);
+
+ expect(canonicalSlot.getTime()).toBe(slot0700.getTime());
+ expect(row.scheduledFor.getTime()).toBe(slot0700.getTime());
+ expect(row.takenAt).not.toBeNull();
+ });
+
+ it("an evening scheduledFor records the 19:00 slot, regardless of wall-clock", async () => {
+ const medId = await createTwiceDailyMed();
+ const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
+
+ const { canonicalSlot, row } = await recordViaSchedule(medId, slot1900);
+
+ expect(canonicalSlot.getTime()).toBe(slot1900.getTime());
+ expect(row.scheduledFor.getTime()).toBe(slot1900.getTime());
+ expect(row.takenAt).not.toBeNull();
+ });
+
+ it("marking the morning dose does not mark the evening dose (and vice-versa)", async () => {
+ const prisma = getPrismaClient();
+ const medId = await createTwiceDailyMed();
+ const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
+ const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
+
+ // Record ONLY the morning dose by supplying its slot instant.
+ await recordViaSchedule(medId, slot0700);
+
+ const morning = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
+ });
+ const evening = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
+ });
+
+ // Exactly one taken morning row; the evening slot holds no taken row.
+ expect(morning).toHaveLength(1);
+ expect(morning[0]?.takenAt).not.toBeNull();
+ expect(evening.filter((e) => e.takenAt !== null)).toHaveLength(0);
+
+ // Now record the evening dose — the morning row stays as it was.
+ await recordViaSchedule(medId, slot1900);
+
+ const morningAfter = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
+ });
+ const eveningAfter = await prisma.medicationIntakeEvent.findMany({
+ where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
+ });
+ expect(morningAfter).toHaveLength(1);
+ expect(morningAfter[0]?.takenAt).not.toBeNull();
+ expect(eveningAfter).toHaveLength(1);
+ expect(eveningAfter[0]?.takenAt).not.toBeNull();
+ });
+});