From 9aa60fb2ffc3c25e7e20cdf7c1f12dcbe525f0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 18:57:01 +0200 Subject: [PATCH 1/4] fix(illness): keep chronic conditions out of rest mode A chronic ongoing condition has no end date, so counting it as rest mode kept the account in it for good: Day N counted from an onset years back, cadence nudges never resumed and every streak gap since the onset was bridged. Only active episodes that are not CHRONIC_ONGOING (acute, recurring, flare) now trigger rest mode, and Day N counts from the earliest of those. The achievements streak freeze applies the same filter to its own episode query. Chronic conditions stay context: a coincident vital deviation is still framed as explained by any active episode, and the Coach block and the briefing still name them, with restMode false. Refs #1216 --- docs/api/openapi.yaml | 5 +- .../illness-snapshot-rest-mode.test.ts | 65 +++++++ src/lib/ai/coach/illness-snapshot.ts | 11 +- src/lib/ai/coach/system-prompt.ts | 32 ++-- src/lib/gamification/achievements-result.ts | 6 + src/lib/illness/__tests__/rest-mode.test.ts | 89 +++++++++ src/lib/illness/rest-mode.ts | 114 +++++++++--- .../__tests__/illness-cycle-briefing.test.ts | 25 +++ .../__tests__/coincident-deviation.test.ts | 29 +-- .../insights/derived/coincident-deviation.ts | 7 +- src/lib/insights/illness-cycle-briefing.ts | 11 ++ src/lib/jobs/coach-nudge.ts | 6 +- src/lib/openapi/routes/daily.ts | 2 +- src/lib/openapi/routes/insights/schemas.ts | 2 +- tests/integration/rest-mode-chronic.test.ts | 173 ++++++++++++++++++ 15 files changed, 513 insertions(+), 64 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts create mode 100644 tests/integration/rest-mode-chronic.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1754488a8..169747551 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -75778,8 +75778,9 @@ components: - day additionalProperties: false - type: "null" - description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive - since v1.40." + description: "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is + active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day` + counts from the earliest onset among the episodes that do. Additive since v1.40." line: type: string description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or diff --git a/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts b/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts new file mode 100644 index 000000000..3dab6b398 --- /dev/null +++ b/src/lib/ai/coach/__tests__/illness-snapshot-rest-mode.test.ts @@ -0,0 +1,65 @@ +/** + * #1216 — the Coach illness block (which also answers the MCP + * `get_illness_recovery` tool) keeps every active condition as context, but + * its `restMode` flag follows Rest Mode: a chronic ongoing condition alone + * leaves it false, an acute episode or a flare on top sets it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/db", () => ({ + prisma: { + illnessEpisode: { findMany }, + symptomEvent: { groupBy: async () => [] }, + }, +})); +vi.mock("@/lib/illness/gate", () => ({ + isIllnessEnabled: vi.fn(async () => true), +})); + +import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot"; + +const NOW = new Date("2026-06-01T12:00:00.000Z"); + +function episode(lifecycle: string, label: string, onset: string) { + return { + label, + type: lifecycle === "CHRONIC_ONGOING" ? "CHRONIC" : "INFECTION", + lifecycle, + onsetAt: new Date(onset), + bodySiteEncrypted: null, + laterality: null, + }; +} + +const CHRONIC = episode("CHRONIC_ONGOING", "Sleep apnoea", "2015-08-01"); + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("buildIllnessSnapshotBlock — restMode (#1216)", () => { + it("a chronic condition alone is context, not Rest Mode", async () => { + findMany.mockResolvedValueOnce([CHRONIC]).mockResolvedValueOnce([]); + const block = await buildIllnessSnapshotBlock("u1", NOW); + expect(block?.restMode).toBe(false); + expect(block?.active.map((e) => e.label)).toEqual(["Sleep apnoea"]); + }); + + it("an acute episode on top sets Rest Mode", async () => { + findMany + .mockResolvedValueOnce([CHRONIC, episode("ACUTE", "Cold", "2026-05-29")]) + .mockResolvedValueOnce([]); + const block = await buildIllnessSnapshotBlock("u1", NOW); + expect(block?.restMode).toBe(true); + expect(block?.active).toHaveLength(2); + }); + + it("a flare under the chronic condition sets Rest Mode", async () => { + findMany + .mockResolvedValueOnce([CHRONIC, episode("FLARE", "Flare", "2026-05-30")]) + .mockResolvedValueOnce([]); + const block = await buildIllnessSnapshotBlock("u1", NOW); + expect(block?.restMode).toBe(true); + }); +}); diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index ae6c97e09..097439ff7 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -12,12 +12,14 @@ * Server-authoritative and module-gated: a non-illness / opted-out account * gets `null` (no block, no read). Since v1.40 it also names the person's own * symptoms of the last two weeks (their names, sanitised, and counts), which - * reach the model the way custom mood-tag names reach the mood analysis. The active set drives the `restMode` flag + * reach the model the way custom mood-tag names reach the mood analysis. The active set, chronic conditions + * included, is context; only its non-chronic part drives the `restMode` flag * the Coach reads; a short resolved-history tail lets it answer "how often do * I get sick" without re-deriving anything. */ import { prisma } from "@/lib/db"; import { isIllnessEnabled } from "@/lib/illness/gate"; +import { triggersRestMode } from "@/lib/illness/rest-mode"; import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; import { BASELINE_WINDOW_DAYS, @@ -90,7 +92,10 @@ function siteFields(row: { } export interface CoachIllnessBlock { - /** True when ≥ 1 episode is active right now (the Rest Mode flag). */ + /** + * The Rest Mode flag: true when ≥ 1 active episode is not CHRONIC_ONGOING. + * A chronic condition alone is context (it is in `active`), not Rest Mode. + */ restMode: boolean; /** Active (unresolved) episodes — the Coach frames its reply around these. */ active: Array<{ @@ -255,7 +260,7 @@ export async function buildIllnessSnapshotBlock( } return { - restMode: activeRows.length > 0, + restMode: activeRows.some(triggersRestMode), active: activeRows.map((e) => ({ label: sanitizeLabel(e.label), type: e.type, diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 264db9b17..5ee5600fe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -406,11 +406,16 @@ ISO-week means. figure the block does not carry. Quote progress only as the block gives it. If a fact seems outdated, gently check it rather than assume. - The SNAPSHOT MAY carry an "illness" block: { restMode, active[], - recentResolved[] }. When "restMode" is true the user has one or more - ACTIVE conditions right now (each with a label, type, lifecycle, and - onset date, plus "bodySite" and "laterality" when the user named where) — read an off-band vital or a low recovery as plausibly - illness-explained rather than alarming, do not push a heavier - measurement cadence, and lean toward rest-and-recover framing. Treat + recentResolved[] }. "active" lists every condition ongoing right now + (each with a label, type, lifecycle, and onset date, plus "bodySite" + and "laterality" when the user named where), chronic ones included; an + off-band vital may relate to any of them. When "restMode" is true at + least one of them is acute, recurring or a flare — read an off-band + vital or a low recovery as plausibly illness-explained rather than + alarming, do not push a heavier measurement cadence, and lean toward + rest-and-recover framing. A CHRONIC_ONGOING condition alone leaves + "restMode" false: the user lives with it and is not unwell because of + it today, so do not frame them as recovering. Treat the labels as the user's OWN descriptive context, never a diagnosis; "recentResolved" is light history for "how often do I get sick" questions. The block MAY also carry "symptoms": the user's OWN named @@ -924,12 +929,17 @@ ISO-Wochenmittel zusammen. Fortschritt nur so, wie der Block ihn angibt. Wirkt ein Fakt veraltet, frage behutsam nach, statt es anzunehmen. - Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[], - recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder - mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn, - dazu "bodySite" und "laterality", wenn der Nutzer die Stelle angab) — - lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als - krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere - Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die + recentResolved[] }. "active" nennt jede gerade laufende Erkrankung (je + mit Label, Typ, Lebenszyklus, Beginn, dazu "bodySite" und "laterality", + wenn der Nutzer die Stelle angab), chronische eingeschlossen; ein + abweichender Vitalwert kann mit jeder davon zusammenhängen. Ist + "restMode" true, ist mindestens eine davon akut, wiederkehrend oder ein + Schub — lies einen abweichenden Vitalwert oder eine niedrige Erholung + eher als krankheitsbedingt denn als alarmierend, dränge nicht auf eine + engere Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Eine + CHRONIC_ONGOING-Erkrankung allein lässt "restMode" false: der Nutzer + lebt damit und ist ihretwegen heute nicht krank, stelle ihn also nicht + als genesend dar. Behandle die Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose; "recentResolved" ist leichte Historie für "wie oft bin ich krank". Der Block KANN auch "symptoms" tragen: die EIGENEN benannten Symptome diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 1685916c1..e5d4cf9cd 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -12,6 +12,7 @@ * stays OUT of the cached factory — see v1.18.0 B5 notes on the route). */ import { prisma } from "@/lib/db"; +import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode"; import { requireAuth } from "@/lib/api-handler"; import type { ModuleKey } from "@/lib/modules/gate"; import { @@ -614,11 +615,16 @@ export async function buildAchievementsResult( // never freezes anything. Both ongoing and already-resolved episodes are // read: a past illness should not retroactively break a historical // streak, just as an active one should not break the current run. + // A chronic ongoing condition never freezes anything (#1216): it has no + // end date, so it would bridge every gap from its onset on and a streak + // could no longer break. A flare under it carries its own lifecycle and + // still does. moduleMap.illness !== false ? prisma.illnessEpisode.findMany({ where: { userId, deletedAt: null, + ...REST_MODE_EPISODE_WHERE, onsetAt: { lte: now }, OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }], }, diff --git a/src/lib/illness/__tests__/rest-mode.test.ts b/src/lib/illness/__tests__/rest-mode.test.ts index 2d46ceb0e..533addb84 100644 --- a/src/lib/illness/__tests__/rest-mode.test.ts +++ b/src/lib/illness/__tests__/rest-mode.test.ts @@ -22,6 +22,8 @@ import { resolveRestMode, toRestModeContext, REST_MODE_INACTIVE, + hasActiveIllnessEpisode, + triggersRestMode, } from "@/lib/illness/rest-mode"; beforeEach(() => { @@ -115,3 +117,90 @@ describe("resolveRestMode", () => { await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE); }); }); + +/** + * #1216 — a chronic ongoing condition has no end date by design, so it must + * never put the account into Rest Mode on its own: Day N would count from an + * onset years back, nudges would never resume and streaks could never break. + * Only active episodes that are not CHRONIC_ONGOING trigger it, and Day N + * counts from the earliest of those. + */ +describe("Rest Mode and chronic conditions (#1216)", () => { + const chronic = { + id: "c1", + label: "Sleep apnoea", + type: "CHRONIC" as const, + lifecycle: "CHRONIC_ONGOING" as const, + onsetAt: new Date("2015-08-01T00:00:00.000Z"), + }; + const acute = { + id: "a1", + label: "Cold", + type: "INFECTION" as const, + lifecycle: "ACUTE" as const, + onsetAt: new Date("2026-06-12T00:00:00.000Z"), + }; + const flare = { + id: "f1", + label: "Flare", + type: "AUTOIMMUNE" as const, + lifecycle: "FLARE" as const, + onsetAt: new Date("2026-06-14T00:00:00.000Z"), + }; + + it("a chronic condition alone is not Rest Mode", () => { + expect(toRestModeContext([chronic])).toEqual(REST_MODE_INACTIVE); + }); + + it("chronic + acute is Rest Mode, anchored on the acute onset", () => { + const ctx = toRestModeContext([chronic, acute]); + expect(ctx.active).toBe(true); + expect(ctx.since).toBe("2026-06-12T00:00:00.000Z"); + expect(ctx.episodeCount).toBe(1); + expect(ctx.episodes.map((e) => e.id)).toEqual(["a1"]); + }); + + it("a flare threaded under a chronic condition is Rest Mode", () => { + const ctx = toRestModeContext([chronic, flare]); + expect(ctx.active).toBe(true); + expect(ctx.since).toBe("2026-06-14T00:00:00.000Z"); + }); + + it("anchors on the earliest triggering onset whatever the input order", () => { + expect(toRestModeContext([flare, chronic, acute]).since).toBe( + "2026-06-12T00:00:00.000Z", + ); + }); + + it("resolveRestMode is inactive for a chronic-only account", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]); + await expect( + resolveRestMode("u1", new Date("2026-06-16T00:00:00.000Z")), + ).resolves.toEqual(REST_MODE_INACTIVE); + }); + + it("hasActiveIllnessEpisode still sees the chronic condition as context", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]); + await expect( + hasActiveIllnessEpisode("u1", new Date("2026-06-16T00:00:00.000Z")), + ).resolves.toBe(true); + }); + + it("hasActiveIllnessEpisode is fail-soft", async () => { + gate.isIllnessEnabled.mockResolvedValue(true); + db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("db down")); + await expect( + hasActiveIllnessEpisode("u1", new Date("2026-06-17T00:00:00.000Z")), + ).resolves.toBe(false); + }); + + it("triggersRestMode exempts only CHRONIC_ONGOING", () => { + expect( + ["ACUTE", "RECURRING", "FLARE", "CHRONIC_ONGOING"].map((lifecycle) => + triggersRestMode({ lifecycle }), + ), + ).toEqual([true, true, true, false]); + }); +}); diff --git a/src/lib/illness/rest-mode.ts b/src/lib/illness/rest-mode.ts index c1a1bf889..c271fa180 100644 --- a/src/lib/illness/rest-mode.ts +++ b/src/lib/illness/rest-mode.ts @@ -1,13 +1,13 @@ /** - * Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4). + * Rest Mode — the server-authoritative "the account is unwell right now" + * context (v1.18.1, P4). * - * CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never - * PENALISES. Rest Mode never changes a measured number — it only frames the - * narrative around it: the health score keeps its value but carries a "you - * were unwell" note, recovery keeps its number, achievement streaks freeze - * (rather than break) across the days you were ill, cadence-nudges pause, and - * a coincident vital deviation reads as illness-explained instead of an - * unexplained anomaly. + * CORE PRINCIPLE: an active illness episode ANNOTATES, it never PENALISES. + * Rest Mode never changes a measured number — it only frames the narrative + * around it: the health score keeps its value but carries a "you were unwell" + * note, recovery keeps its number, achievement streaks freeze (rather than + * break) across the days you were ill, cadence-nudges pause, and the Today + * strip counts the days since onset. * * This module is the ONE place that resolves "is the account in Rest Mode * right now, and which episodes back it" — so every surface annotates from @@ -16,18 +16,55 @@ * the suppression). The illness module gate is honoured: a disabled / opted- * out account is never in Rest Mode (the gate short-circuits before any read). * - * "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing), - * not soft-deleted, whose `onsetAt` is on or before the reference instant. - * CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing - * chronic condition is precisely the case where a measured dip should be - * framed, not penalised. (They are excluded only from the recovery-GAP math, - * which is P3 and lives elsewhere.) + * Two different questions are answered here, and they must not be confused: + * + * - "Which episodes are active?" (`getActiveIllnessEpisodes`): every + * `IllnessEpisode` with `resolvedAt = null`, not soft-deleted, whose + * `onsetAt` is on or before the reference instant. CHRONIC_ONGOING + * conditions are included. This is CONTEXT: a measured dip that coincides + * with a known chronic condition is still framed as explained by it + * (`hasActiveIllnessEpisode`), and the Coach still sees the condition. + * + * - "Is the account in Rest Mode?" (`resolveRestMode`): only the active + * episodes that are NOT CHRONIC_ONGOING (acute, recurring, flare). A + * chronic condition has no end date by design, so letting it trigger Rest + * Mode would put the account into it permanently: the Day N counter would + * count from an onset years back, nudges would never resume and streaks + * could never break. A flare threaded under a chronic condition carries its + * own lifecycle (FLARE), so a chronic condition getting worse still does. + * `since` and Day N count from the earliest onset among the episodes that + * trigger Rest Mode, never from a chronic onset. + * + * Every reader that freezes, pauses or counts on Rest Mode's behalf goes + * through `triggersRestMode` / `REST_MODE_EPISODE_WHERE`, including the + * achievements streak freeze, which runs its own windowed query. */ import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isIllnessEnabled } from "@/lib/illness/gate"; import { memoizePerRequest } from "@/lib/request-cache"; +/** + * The lifecycle that never triggers Rest Mode on its own: a condition the + * person lives with. See the module header. + */ +export const REST_MODE_EXEMPT_LIFECYCLE = "CHRONIC_ONGOING" as const; + +/** + * Prisma `where` fragment selecting the episodes that can trigger Rest Mode. + * For readers that query episodes themselves (the achievements streak freeze + * reads resolved ones too, over a window) instead of going through + * `resolveRestMode`. + */ +export const REST_MODE_EPISODE_WHERE = { + lifecycle: { not: REST_MODE_EXEMPT_LIFECYCLE }, +} as const; + +/** Whether an episode, while active, puts the account into Rest Mode. */ +export function triggersRestMode(episode: { lifecycle: string }): boolean { + return episode.lifecycle !== REST_MODE_EXEMPT_LIFECYCLE; +} + /** Minimal shape the Rest Mode resolver needs from an episode row. */ export type ActiveIllnessEpisode = Pick< IllnessEpisode, @@ -44,13 +81,19 @@ export type ActiveIllnessEpisode = Pick< * This is the server-authoritative shape iOS mirrors verbatim. */ export interface RestModeContext { - /** True when ≥ 1 illness episode is active as of the reference instant. */ + /** + * True when ≥ 1 active episode that is not CHRONIC_ONGOING exists as of the + * reference instant. + */ active: boolean; - /** ISO onset of the earliest active episode, or null when none. */ + /** ISO onset of the earliest episode backing Rest Mode, or null when none. */ since: string | null; - /** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */ + /** Count of episodes backing Rest Mode (usually 1; ≥ 2 when they overlap). */ episodeCount: number; - /** The active episodes, label + type + onset only — never a decrypted note. */ + /** + * The episodes backing Rest Mode, label + type + onset only — never a + * decrypted note. Chronic conditions are not among them. + */ episodes: Array<{ id: string; label: string; @@ -69,7 +112,9 @@ export const REST_MODE_INACTIVE: RestModeContext = { }; /** - * Read the active illness episodes for an account as of `asOf`. Gated through + * Read the active illness episodes for an account as of `asOf`, chronic + * conditions included — the context set, not the Rest Mode set (that is + * `toRestModeContext` over this). Gated through * the illness module: a disabled / not-opted-in account returns `[]` without * touching the episode table. The reference instant is injectable so the * resolver is deterministic for tests and so a nightly job can resolve "as of @@ -118,10 +163,18 @@ async function readActiveIllnessEpisodes( }); } -/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */ +/** + * Fold a set of active episodes into the renderable Rest Mode context. Pure. + * Only the episodes that trigger Rest Mode count: a chronic condition alone + * leaves the account out of it, and `since` is the earliest onset among the + * episodes that do (the input is onset-ascending). + */ export function toRestModeContext( - episodes: ActiveIllnessEpisode[], + activeEpisodes: ActiveIllnessEpisode[], ): RestModeContext { + const episodes = activeEpisodes + .filter(triggersRestMode) + .sort((a, b) => a.onsetAt.getTime() - b.onsetAt.getTime()); if (episodes.length === 0) return REST_MODE_INACTIVE; return { active: true, @@ -155,3 +208,22 @@ export async function resolveRestMode( return REST_MODE_INACTIVE; } } + +/** + * Whether any illness episode is active as of `asOf`, chronic conditions + * included. For framing a measured deviation as explained by a known + * condition, which a chronic one does as much as an acute one. Not Rest Mode: + * nothing may freeze, pause or count on this. Fail-soft like + * `resolveRestMode`. + */ +export async function hasActiveIllnessEpisode( + userId: string, + asOf: Date = new Date(), + client: PrismaClient = defaultPrisma, +): Promise { + try { + return (await getActiveIllnessEpisodes(userId, asOf, client)).length > 0; + } catch { + return false; + } +} diff --git a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts index 6f5b66a23..90cb0396f 100644 --- a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts +++ b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts @@ -85,6 +85,31 @@ describe("buildBriefingIllnessCyclePrompt", () => { expect(out).toContain("SYSTEM CONTEXT"); }); + it("names a chronic-only condition as context, not Rest Mode (#1216)", () => { + const out = buildBriefingIllnessCyclePrompt( + { + illness: { + restMode: false, + active: [ + { + label: "Sleep apnoea", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: "x", + }, + ], + recentResolved: [], + }, + cycle: null, + }, + "en", + ); + expect(out).toContain("Sleep apnoea"); + expect(out).toContain("chronic condition"); + expect(out).not.toContain("The user is in Rest Mode"); + expect(out).not.toContain("measure more often"); + }); + it("emits a cycle day/phase line", () => { const out = buildBriefingIllnessCyclePrompt( { diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 76bdf0548..df95d5f05 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -17,14 +17,11 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ readBestGranularityRollups: vi.fn().mockResolvedValue(null), })); -// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active. +// v1.18.1 P4 — the illness reframe. Default no active episode; the reframe +// test flips it. Since #1216 the reframe reads active episodes directly +// (chronic conditions included), not Rest Mode. const restModeMock = vi.hoisted(() => ({ - resolveRestMode: vi.fn(async () => ({ - active: false as boolean, - since: null as string | null, - episodeCount: 0, - episodes: [] as unknown[], - })), + hasActiveIllnessEpisode: vi.fn(async () => false as boolean), })); vi.mock("@/lib/illness/rest-mode", () => restModeMock); @@ -43,12 +40,7 @@ const findMany = prisma.measurement.findMany as ReturnType; beforeEach(() => { vi.clearAllMocks(); findMany.mockResolvedValue([]); - restModeMock.resolveRestMode.mockResolvedValue({ - active: false, - since: null, - episodeCount: 0, - episodes: [], - }); + restModeMock.hasActiveIllnessEpisode.mockResolvedValue(false); }); /** RHR + HRV each flat, then a final day that pushes both outside the band. */ @@ -184,13 +176,8 @@ describe("computeCoincidentDeviation", () => { } }); - it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => { - restModeMock.resolveRestMode.mockResolvedValue({ - active: true, - since: "2026-05-30T00:00:00.000Z", - episodeCount: 1, - episodes: [], - }); + it("reframes a fired flag as illness-explained while an episode is active (vitals unchanged)", async () => { + restModeMock.hasActiveIllnessEpisode.mockResolvedValue(true); findMany.mockImplementation(firingRows); const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW, @@ -249,7 +236,7 @@ describe("computeCoincidentDeviation", () => { }); expect(result.status === "ok" && result.value.fired).toBe(false); expect(result.status === "ok" && result.value.illnessExplained).toBe(false); - expect(restModeMock.resolveRestMode).not.toHaveBeenCalled(); + expect(restModeMock.hasActiveIllnessEpisode).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index bb6353542..cc70b3c53 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -45,7 +45,7 @@ import { readDayAggregates } from "@/lib/measurements/day-aggregates"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; -import { resolveRestMode } from "@/lib/illness/rest-mode"; +import { hasActiveIllnessEpisode } from "@/lib/illness/rest-mode"; import { isPlausibleMetricValue, plausibleMetricRange, @@ -463,8 +463,11 @@ export async function computeCoincidentDeviation( // v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an // episode is active. Only resolved when the flag fired (no read on the // common quiet day). Annotation only — the vital deviations are unchanged. + // Any active episode counts, a chronic condition included: it explains a + // coincident dip as much as an acute one, even though it does not put the + // account into Rest Mode (#1216). const illnessExplained = fired - ? (await resolveRestMode(userId, now)).active + ? await hasActiveIllnessEpisode(userId, now) : false; const { coverage: cov, confidence } = deriveCoverage({ diff --git a/src/lib/insights/illness-cycle-briefing.ts b/src/lib/insights/illness-cycle-briefing.ts index f7fd7974d..56aadd6bc 100644 --- a/src/lib/insights/illness-cycle-briefing.ts +++ b/src/lib/insights/illness-cycle-briefing.ts @@ -82,6 +82,17 @@ export function buildBriefingIllnessCyclePrompt( ? `- Aktive Erkrankung(en) gerade: ${labels}. Der Nutzer ist im Rest Mode — lies abweichende Vitalwerte oder eine niedrige Erholung als krankheitsbedingt, nicht alarmierend, und dränge NICHT auf häufigeres Messen.` : `- Active illness episode(s) right now: ${labels}. The user is in Rest Mode — read off-band vitals or low recovery as illness-explained rather than alarming, and do NOT push a "measure more often" cadence.`, ); + } else if (ctx.illness.active.length > 0) { + // Chronic ongoing conditions only: context for a measured dip, but not + // Rest Mode, so no cadence pause and no "unwell" framing (#1216). + const labels = ctx.illness.active + .map((e) => `${e.label} (${e.type}, ${e.lifecycle})`) + .join("; "); + lines.push( + de + ? `- Bekannte chronische Erkrankung(en): ${labels}. Ein abweichender Vitalwert kann damit zusammenhängen; das ist Kontext, kein akutes Kranksein und kein Rest Mode.` + : `- Known chronic condition(s): ${labels}. An off-band vital may relate to them; this is context, not an acute illness and not Rest Mode.`, + ); } else if (ctx.illness.recentResolved.length > 0) { lines.push( de diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 9b008ed54..e0c6598fe 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -942,8 +942,10 @@ export async function runCoachNudgeTick( } // Gate 7 — Rest Mode pause (v1.18.1 P4). A user with an active - // illness/condition episode should not be told to measure more often; - // the cadence-nudge pauses for the duration of the episode. Module-gated + // illness episode should not be told to measure more often; the + // cadence-nudge pauses for the duration of the episode. A chronic + // ongoing condition alone does not pause it (#1216): it never ends, so + // the nudge would never come back. Module-gated // (a non-illness account is never in Rest Mode) and only reached past // every cheaper gate, so the read fires for at most a nudge-eligible // user per tick. diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 3917d885e..92845948e 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -210,7 +210,7 @@ export const dailyDigestResponse = z }) .nullable() .describe( - "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive since v1.40.", + "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day` counts from the earliest onset among the episodes that do. Additive since v1.40.", ), line: z .string() diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 486ca50aa..f13be444c 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2204,7 +2204,7 @@ export const healthScoreReport = z .nullable() .optional() .describe( - "Set on every response this route produces (null when no illness episode is active). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.", + "Set on every response this route produces (null when no illness episode is active, or only chronic ongoing ones are: a condition the person lives with frames a dip but never puts the account into Rest Mode). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.", ), }) .meta({ diff --git a/tests/integration/rest-mode-chronic.test.ts b/tests/integration/rest-mode-chronic.test.ts new file mode 100644 index 000000000..201d458fc --- /dev/null +++ b/tests/integration/rest-mode-chronic.test.ts @@ -0,0 +1,173 @@ +/** + * #1216 — a chronic ongoing condition alone never puts the account into Rest + * Mode, against real Postgres. + * + * A CHRONIC_ONGOING episode has no end date by design, so counting it would + * put the account into Rest Mode for good: the Today strip read "Rest mode · + * Day 3948", nudges stayed paused and every streak gap since the onset was + * bridged. Pinned here on the three readers that decide it: the resolver + * (which the score, recovery, nudge and Today surfaces read), the Today + * digest's Day N, and the achievements streak freeze, which runs its own + * episode query. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { buildAchievementsResult } from "@/lib/gamification/achievements-result"; +import { REST_MODE_INACTIVE, resolveRestMode } from "@/lib/illness/rest-mode"; +import type { ModuleKey } from "@/lib/modules/registry"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER = "rest-mode-chronic"; +const DAY = 24 * 60 * 60 * 1000; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + __resetAllCachesForTests(); + await getPrismaClient().user.create({ + data: { + id: USER, + username: USER, + email: `${USER}@example.test`, + timezone: "UTC", + createdAt: new Date("2026-03-01T00:00:00.000Z"), + }, + }); +}); + +function episode(data: Partial) { + return getPrismaClient().illnessEpisode.create({ + data: { + userId: USER, + label: "Condition", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + onsetAt: new Date("2015-08-01T00:00:00.000Z"), + ...data, + }, + }); +} + +describe("resolveRestMode on a real record", () => { + const NOW = new Date("2026-10-04T12:00:00.000Z"); + + it("a chronic condition alone is not Rest Mode", async () => { + await episode({}); + expect(await resolveRestMode(USER, NOW, getPrismaClient())).toEqual( + REST_MODE_INACTIVE, + ); + }); + + it("chronic + acute is Rest Mode, anchored on the acute onset", async () => { + await episode({}); + const acute = await episode({ + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-10-02T18:00:00.000Z"), + }); + const ctx = await resolveRestMode(USER, NOW, getPrismaClient()); + expect(ctx.active).toBe(true); + expect(ctx.since).toBe("2026-10-02T18:00:00.000Z"); + expect(ctx.episodes.map((e) => e.id)).toEqual([acute.id]); + }); + + it("a flare threaded under the chronic condition is Rest Mode", async () => { + const parent = await episode({}); + await episode({ + label: "Flare", + lifecycle: "FLARE", + parentConditionId: parent.id, + onsetAt: new Date("2026-10-03T08:00:00.000Z"), + }); + const ctx = await resolveRestMode(USER, NOW, getPrismaClient()); + expect(ctx.active).toBe(true); + expect(ctx.since).toBe("2026-10-03T08:00:00.000Z"); + }); +}); + +describe("the Today digest's Rest Mode fact", () => { + const NOW = new Date("2026-10-04T06:00:00.000Z"); + + async function digest() { + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER }, + }); + return loadDailyDigest(user, NOW, { locale: "en" }); + } + + it("shows no Rest Mode for a chronic condition alone", async () => { + await episode({}); + const d = await digest(); + expect(d.restMode).toBeNull(); + expect(d.today.some((f) => f.kind === "rest_mode")).toBe(false); + }); + + it("counts Day N from the acute onset, not the chronic one", async () => { + await episode({}); + await episode({ + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-10-02T18:00:00.000Z"), + }); + const d = await digest(); + expect(d.restMode).toEqual({ day: 3 }); + }); +}); + +describe("the achievements streak freeze", () => { + const MODULES = {} as Record; + + /** Logins two days apart: a one-day gap the freeze may or may not bridge. */ + async function loginsAroundAGap() { + const today = new Date(); + today.setUTCHours(12, 0, 0, 0); + const gapDay = new Date(today.getTime() - 4 * DAY); + await getPrismaClient().auditLog.createMany({ + data: [5, 3].map((d) => ({ + userId: USER, + action: "auth.login.password", + createdAt: new Date(today.getTime() - d * DAY), + })), + }); + return gapDay; + } + + async function loginStreak() { + const user = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: USER }, + }); + const result = await buildAchievementsResult( + user as Parameters[0], + MODULES, + ); + return result.metrics.loginDayStreak; + } + + it("does not bridge a gap on a chronic-only day", async () => { + await loginsAroundAGap(); + await episode({}); + expect(await loginStreak()).toBe(1); + }); + + it("still bridges a gap covered by an acute episode", async () => { + const gapDay = await loginsAroundAGap(); + await episode({}); + await episode({ + label: "Cold", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date(gapDay.getTime() - 2 * 60 * 60 * 1000), + resolvedAt: new Date(gapDay.getTime() + 6 * 60 * 60 * 1000), + }); + expect(await loginStreak()).toBe(3); + }); +}); From cc507deafaf790b5ecd7731284debe6cf2ab5fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 18:57:06 +0200 Subject: [PATCH 2/4] fix(notifications): log every refused notification answer A quick answer whose request never reached the route left no trace: a redirect in front of the app turns ntfy's POST into a GET, which Next answered 405 before any wide event, and the worker-only and demo-mode refusals in the proxy wrote nothing either. The proxy now answers a non-POST on the action path itself with 405 and Allow: POST, and every refusal it makes there writes one warn line with a reason and the path without its token. The route's rate-limit refusal carries a reason too. The regression test sends the action ntfy publishes, in the shape ntfy sends it, through the real proxy and route against Postgres. Refs #1203 --- .../proxy-notification-action.test.ts | 123 ++++++++++++++++++ .../api/notifications/action/[token]/route.ts | 4 + src/proxy.ts | 65 +++++++++ .../notification-action-mood.test.ts | 83 ++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/__tests__/proxy-notification-action.test.ts diff --git a/src/__tests__/proxy-notification-action.test.ts b/src/__tests__/proxy-notification-action.test.ts new file mode 100644 index 000000000..c0ec388e2 --- /dev/null +++ b/src/__tests__/proxy-notification-action.test.ts @@ -0,0 +1,123 @@ +/** + * The proxy's side of a notification quick answer (#1203). + * + * ntfy's `http` action POSTs to `/api/notifications/action/` with no + * cookie, no Origin and an empty body. That request must pass the proxy + * untouched. Every request the proxy refuses on this path must leave one log + * line with a reason, because the route's own wide event never runs for it + * and the server log is the operator's only evidence. The token must never be + * in that line. + */ +import { NextRequest } from "next/server"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance, +} from "vitest"; + +const processType = vi.hoisted(() => ({ web: true })); +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => processType.web, +})); + +import { proxy } from "../proxy"; + +const TOKEN = + "na1.cuserid0000000000000000.mood-good.1792017927608.ACWxZHsEVXyh-Yghadj-2Q.PRmiFi7-C4ZkQ45E9YHl95GjSXoeV3xZMwLJ90Ts4QY"; +const URL_ = `https://health.example.test/api/notifications/action/${TOKEN}`; + +/** The request ntfy's Android app sends for an `http` action. */ +function ntfyRequest(method = "POST"): NextRequest { + return new NextRequest(URL_, { + method, + headers: { "user-agent": "ntfy/1.16.0 (okhttp)", "content-length": "0" }, + ...(method === "POST" ? { body: "" } : {}), + }); +} + +let warn: MockInstance<(...args: unknown[]) => void>; + +beforeEach(() => { + processType.web = true; + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + warn.mockRestore(); + vi.unstubAllEnvs(); +}); + +function loggedLines(): Array> { + return warn.mock.calls.map((args: unknown[]) => JSON.parse(String(args[0]))); +} + +describe("proxy on /api/notifications/action/", () => { + it("passes ntfy's POST through to the route without a refusal", () => { + const response = proxy(ntfyRequest()); + + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-next")).toBe("1"); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each(["GET", "HEAD", "PUT", "DELETE"])( + "refuses %s with 405 and logs why, without the token", + (method) => { + const response = proxy(ntfyRequest(method)); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + const lines = loggedLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ + level: "warn", + action: { name: "notification.action.refused" }, + meta: { reason: "method_not_allowed" }, + http: { + method, + status: 405, + path: "/api/notifications/action/[REDACTED]", + }, + }); + expect(String(warn.mock.calls[0][0])).not.toContain(TOKEN); + expect(String(warn.mock.calls[0][0])).not.toContain("PRmiFi7"); + }, + ); + + it("logs the demo-mode refusal", () => { + vi.stubEnv("DEMO_MODE", "true"); + const response = proxy(ntfyRequest()); + + expect(response.status).toBe(403); + expect(loggedLines()[0]).toMatchObject({ + meta: { reason: "demo_mode" }, + http: { status: 403 }, + }); + }); + + it("logs the worker-only refusal", () => { + processType.web = false; + const response = proxy(ntfyRequest()); + + expect(response.status).toBe(503); + expect(loggedLines()[0]).toMatchObject({ + meta: { reason: "worker_only" }, + http: { status: 503 }, + }); + }); + + it("logs nothing for a refusal on any other path", () => { + vi.stubEnv("DEMO_MODE", "true"); + proxy( + new NextRequest("https://health.example.test/api/measurements", { + method: "POST", + body: "{}", + }), + ); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts index cbf074576..d74b5fc1f 100644 --- a/src/app/api/notifications/action/[token]/route.ts +++ b/src/app/api/notifications/action/[token]/route.ts @@ -71,6 +71,10 @@ export const POST = apiHandler( RATE_WINDOW_MS, ); if (!rl.allowed) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "rate_limited" }, + }); const response = apiError("Too many requests", 429); for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { response.headers.set(k, v); diff --git a/src/proxy.ts b/src/proxy.ts index b973d8c2f..3389400b3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -310,6 +310,46 @@ function applyBaselineSecurityHeaders(response: NextResponse): NextResponse { return response; } +/** + * The notification quick-answer route (`/api/notifications/action/`). + * Its caller is a notification app on a phone, not a browser, so when a tap + * does nothing the operator's only evidence is the server log. Every refusal + * this proxy makes on that path therefore writes one line with a reason, + * because the route's own wide event never runs for them. The token is never + * written: the path is logged as the prefix plus a placeholder. + */ +const NOTIFICATION_ACTION_PREFIX = "/api/notifications/action/"; + +type NotificationActionRefusal = + "method_not_allowed" | "worker_only" | "demo_mode"; + +function logNotificationActionRefusal( + request: NextRequest, + reason: NotificationActionRefusal, + status: number, +): void { + if (!request.nextUrl.pathname.startsWith(NOTIFICATION_ACTION_PREFIX)) return; + console.warn( + JSON.stringify({ + timestamp: new Date().toISOString(), + level: "warn", + kind: "http", + message: + reason === "method_not_allowed" + ? "Notification answer refused: the route takes POST only. A redirect in front of the app (http to https, or a sign-in gateway) turns the POST into a GET." + : "Notification answer refused before the route ran.", + action: { name: "notification.action.refused" }, + meta: { reason }, + http: { + method: request.method, + path: `${NOTIFICATION_ACTION_PREFIX}[REDACTED]`, + status, + user_agent: request.headers.get("user-agent") ?? undefined, + }, + }), + ); +} + export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; @@ -317,6 +357,7 @@ export function proxy(request: NextRequest) { // serving requests that would either crash (no DB pool ready for writes from // the worker) or duplicate work that the dedicated web container is doing. if (!shouldRunWeb() && pathname !== "/api/health") { + logNotificationActionRefusal(request, "worker_only", 503); return applyBaselineSecurityHeaders( NextResponse.json( { @@ -364,6 +405,29 @@ export function proxy(request: NextRequest) { ); } + // A notification quick answer is a POST and nothing else. ntfy's `http` + // action sends one; anything else here is a request a redirect rewrote (an + // HTTP client follows a 301/302 with a GET) or a stray probe. Next would + // answer 405 on its own, but before any wide event, so the refusal is made + // here where it can be logged. `OPTIONS` stays with Next. + if ( + pathname.startsWith(NOTIFICATION_ACTION_PREFIX) && + request.method !== "POST" && + request.method !== "OPTIONS" + ) { + logNotificationActionRefusal(request, "method_not_allowed", 405); + return applyBaselineSecurityHeaders( + NextResponse.json( + { + data: null, + error: "Method not allowed", + meta: { errorCode: "notification.action.method_not_allowed" }, + }, + { status: 405, headers: { Allow: "POST" } }, + ), + ); + } + // Demo mode: block all mutations except login if (process.env.DEMO_MODE === "true") { const method = request.method.toUpperCase(); @@ -377,6 +441,7 @@ export function proxy(request: NextRequest) { (entry) => pathname === entry.path && method === entry.method, ) ) { + logNotificationActionRefusal(request, "demo_mode", 403); return applyBaselineSecurityHeaders( NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }), ); diff --git a/tests/integration/notification-action-mood.test.ts b/tests/integration/notification-action-mood.test.ts index 0806e8390..59fd707f7 100644 --- a/tests/integration/notification-action-mood.test.ts +++ b/tests/integration/notification-action-mood.test.ts @@ -116,3 +116,86 @@ describe("POST /api/notifications/action/{token}", () => { ).toBe(0); }); }); + +/** + * #1203 — the request in ntfy's own shape, from the published message to the + * row. The action is taken from the JSON body `buildNtfyMessage` publishes, + * and sent the way ntfy's Android app sends an `http` action: POST, empty + * body, no cookie, no Origin, an okhttp user agent. It goes through the real + * proxy first and then the real route, with the route param taken from the + * URL path the way Next derives it. + */ +describe("a ntfy http action through the proxy and the route", () => { + async function ntfyAction() { + const { buildMoodQuickActions } = + await import("@/lib/notifications/action-token"); + const { buildNtfyMessage } = + await import("@/lib/notifications/senders/ntfy"); + const quickActions = buildMoodQuickActions({ + userId: USER, + origin: "https://health.example.test", + labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" }, + }); + const message = buildNtfyMessage("topic", { + userId: USER, + eventType: "MOOD_REMINDER", + title: "Mood", + message: "How are you?", + quickActions, + } as Parameters[1]); + // Round-trip through JSON: what the phone holds is the parsed publish body. + const published = JSON.parse(JSON.stringify(message)) as typeof message; + const okay = published.actions?.find((a) => a.label === "Okay"); + if (!okay) throw new Error("no Okay action in the published message"); + return okay; + } + + async function send(method: string, url: string) { + const { proxy } = await import("@/proxy"); + const request = new NextRequest(url, { + method, + headers: { "user-agent": "okhttp/4.12.0", "content-length": "0" }, + ...(method === "POST" ? { body: "" } : {}), + }); + const edge = proxy(request); + if (edge.headers.get("x-middleware-next") !== "1") return edge; + const token = decodeURIComponent( + new URL(url).pathname.split("/").pop() ?? "", + ); + const { POST } = + await import("@/app/api/notifications/action/[token]/route"); + return POST(request, { params: Promise.resolve({ token }) }); + } + + it("logs the mood when the phone POSTs the published action", async () => { + const action = await ntfyAction(); + expect(action).toMatchObject({ action: "http", method: "POST" }); + + const res = await send(action.method, action.url); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + data: { logged: true, action: "mood-okay" }, + }); + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + select: { source: true, score: true }, + }); + expect(rows).toEqual([{ source: "NOTIFICATION", score: 3 }]); + }); + + it("refuses a GET (a POST a redirect rewrote) at the proxy and writes nothing", async () => { + const action = await ntfyAction(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const res = await send("GET", action.url); + expect(res.status).toBe(405); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain("method_not_allowed"); + } finally { + warn.mockRestore(); + } + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(0); + }); +}); From 63882bf3ec29fb77a5912f28a3c43884b93eceef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 18:58:55 +0200 Subject: [PATCH 3/4] fix(labs): give the draw date a full-width row The lab form placed the draw date in the second column of a two-column grid. DateTimeField sets its date and time halves side by side from sm up, so the date half was squeezed until a date could only be set through the calendar. The value and the date now stack in one column, as in the measurement form, and the date can be typed again. Refs #1214 --- .../__tests__/lab-form-date-field.test.tsx | 56 +++++++++++++++++++ src/components/labs/lab-form.tsx | 7 ++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/components/labs/__tests__/lab-form-date-field.test.tsx diff --git a/src/components/labs/__tests__/lab-form-date-field.test.tsx b/src/components/labs/__tests__/lab-form-date-field.test.tsx new file mode 100644 index 000000000..a392cec34 --- /dev/null +++ b/src/components/labs/__tests__/lab-form-date-field.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * #1214 — the lab form's draw date is the same full-width, typeable date + * field as the measurement form's timestamp. + * + * It already was the same component (`DateTimeField`), but it sat in the + * second column of a two-column grid, and `DateTimeField` puts its date and + * time halves side by side from `sm:` up, so the date half was squeezed until + * a date could only be set through the calendar. SSR markup (the suite's + * dialect) pins the layout: no two-column grid in the form, the date field is + * the shared primitive, and its date half is an editable text input, not a + * read-only picker trigger. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), +})); + +import { LabForm } from "../lab-form"; + +function render(): string { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" draw date", () => { + it("does not halve the date field's width with a second column", () => { + expect(render()).not.toMatch(/grid-cols-2/); + }); + + it("uses the shared date-time field with a typeable date half", () => { + const html = render(); + expect(html).toContain('data-slot="date-time-field"'); + // The id sits on the date half's hidden mirror; the input a person + // types into is the overlay right after it. + const field = html.slice(html.indexOf('id="lab-takenAt"')); + const overlay = /]*type="hidden")[^>]*>/.exec(field)?.[0]; + expect(overlay).toBeDefined(); + expect(overlay).toContain('type="text"'); + expect(overlay).not.toMatch(/\s(readonly|disabled)(=|\s|\/?>)/i); + }); +}); diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 2ae8a105f..e746e94b1 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -448,7 +448,12 @@ export function LabForm({ -
+ {/* One column at every width, as in the measurement form. A second + column halved the draw date's cell, and `DateTimeField` sets its + date and time halves side by side from `sm:` up, so the date + itself was squeezed until it could only be set through the + calendar (#1214). */} +
{resultType === "numeric" ? ( <> From 8b18b1c9febfa632bf58c75f16e6be20e5c49561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 14 Oct 2026 18:59:33 +0200 Subject: [PATCH 4/4] test(e2e): fail clearly on a medications POST body that is not JSON The route stub parsed the body inside the un-awaited route callback, so a malformed body threw there and the request hung. The parse error is now recorded and the route is fulfilled; reading capture.postBody in the spec throws with the parse error. --- e2e/medications-wizard-helpers.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts index d411cea39..921fa101a 100644 --- a/e2e/medications-wizard-helpers.ts +++ b/e2e/medications-wizard-helpers.ts @@ -30,7 +30,20 @@ interface StubbedMedication { } export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) { - const capture: { postBody: unknown } = { postBody: null }; + // A body that is not JSON must fail the spec where it reads `postBody`, + // with the parse error, not throw inside the un-awaited route callback. + const capture = { + rawPostBody: null as unknown, + parseError: null as string | null, + get postBody(): unknown { + if (this.parseError !== null) { + throw new Error( + `POST /api/medications sent a body that is not JSON: ${this.parseError}`, + ); + } + return this.rawPostBody; + }, + }; const listEntry = { id: stubbed.id, @@ -68,7 +81,11 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) { return; } if (req.method() === "POST") { - capture.postBody = JSON.parse(req.postData() ?? "null"); + try { + capture.rawPostBody = JSON.parse(req.postData() ?? "null"); + } catch (err) { + capture.parseError = err instanceof Error ? err.message : String(err); + } await route.fulfill({ status: 201, contentType: "application/json",