From 65f90160c7546bacf3aa9520ef92dbccdaba6761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 11:00:22 +0200 Subject: [PATCH 1/4] fix(daily): refresh the Today digest after a dose, drop the hero ring cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today digest went stale after logging a dose: nothing invalidated the `["daily","digest"]` query, and it reads meds from the same server snapshot the intake routes already hard-evict — so the "dose due" rail item lingered until a hard reload. Add the digest key to the medication-dependent bundle and force the inactive digest (unmounted while the user is on the medication card) to refetch alongside the dashboard snapshot; the server snapshot eviction seam is reused, so the refetch returns post-write dose state immediately. Remove the score-ring cluster from the Today hero — it read as uneven and wasted tile space. The health-score ring is the hero's only ring now; the score-ring selection contract stays server-side for the native client. Tighten the hero's vertical rhythm so the worth-a-look rail sits close under the lead instead of floating below a half-empty column. Wire the remaining sleep transports into the morning-refresh trigger: Google Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a last-night segment landing, matching the Withings / WHOOP / Apple seams — those sources had no trigger at all. Make the trigger observable: each early-return gate now annotates a distinct `daily.morning_refresh.skipped` reason (the not-last-night gate also carries the samples' resolved local dates, today's key and the tz) so a real landing shows exactly which gate fired. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/page-client.tsx | 34 +---- .../daily/__tests__/today-hero.test.tsx | 32 +---- src/components/daily/today-hero.tsx | 131 ++---------------- .../__tests__/use-medication-intake.test.ts | 32 +++++ .../medications/use-medication-intake.ts | 30 ++-- src/lib/__tests__/query-keys.test.ts | 8 ++ .../__tests__/morning-refresh-trigger.test.ts | 43 +++++- src/lib/daily/morning-refresh-trigger.ts | 64 +++++++-- src/lib/fitbit/sync-sleep.ts | 11 ++ .../sync-sleep-morning-refresh.test.ts | 112 +++++++++++++++ src/lib/google-health/sync-sleep.ts | 13 ++ src/lib/oura/sync.ts | 11 ++ src/lib/polar/sync.ts | 11 ++ src/lib/query-keys/index.ts | 10 ++ 20 files changed, 350 insertions(+), 210 deletions(-) create mode 100644 src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts diff --git a/messages/de.json b/messages/de.json index 43373eedb..5f73c945f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Vollständiges Briefing lesen", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", - "ringLink": "{metric}-Details öffnen", - "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen" + "ringLink": "{metric}-Details öffnen" }, "milestone": { "metric": { diff --git a/messages/en.json b/messages/en.json index 937805e02..c23a5ead6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Read the full briefing", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", - "ringLink": "Open {metric} details", - "ringDosesAria": "{taken} of {scheduled} doses taken today" + "ringLink": "Open {metric} details" }, "milestone": { "metric": { diff --git a/messages/es.json b/messages/es.json index e56efdbf9..2c0f8d1c6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Leer el informe completo", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", - "ringLink": "Abrir detalles de {metric}", - "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy" + "ringLink": "Abrir detalles de {metric}" }, "milestone": { "metric": { diff --git a/messages/fr.json b/messages/fr.json index 9f16448a4..6f06aca64 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Lire le briefing complet", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", - "ringLink": "Ouvrir les détails de {metric}", - "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui" + "ringLink": "Ouvrir les détails de {metric}" }, "milestone": { "metric": { diff --git a/messages/it.json b/messages/it.json index b3a7cc930..c91cc3c60 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Leggi il briefing completo", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", - "ringLink": "Apri i dettagli di {metric}", - "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi" + "ringLink": "Apri i dettagli di {metric}" }, "milestone": { "metric": { diff --git a/messages/pl.json b/messages/pl.json index d1d1ae6ea..a5fe37ce7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8608,8 +8608,7 @@ "readFullBriefing": "Przeczytaj pełny briefing", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", - "ringLink": "Otwórz szczegóły: {metric}", - "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj" + "ringLink": "Otwórz szczegóły: {metric}" }, "milestone": { "metric": { diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index e05e2a2dd..73125d354 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -27,11 +27,8 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { cn } from "@/lib/utils"; import { resolveDashboardLayout, - resolveHeroRingOrder, - HEALTH_SCORE_RING_ID, type DashboardLayout, } from "@/lib/dashboard-layout"; -import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -235,31 +232,10 @@ export default function DashboardPageClient() { const insightsEnabled = useModuleEnabled("insights"); const digestQuery = useDailyDigest(isAuthenticated && insightsEnabled); - // v1.29.0 — the Settings-picked hero score rings, resurfaced on the Today - // hero. The snapshot resolves the selection server-side (data- and - // module-gated, selection order preserved); `resolveHeroRingOrder` - // reconciles the user's persisted drag order against what actually - // resolved — the SAME reconciler the Settings picker uses. The - // health-score ring stays the hero's fixed anchor, so its position in the - // stored order is skipped here; the legacy non-snapshot path carries no - // resolved rings and degrades to the health ring alone. - const heroScoreRings = useMemo(() => { - const snap = snapshotQuery.data; - const rings = snap?.scoreRings ?? []; - if (rings.length === 0) return []; - const order = resolveHeroRingOrder( - snap?.layout?.heroRingOrder, - rings.map((r) => r.id), - ); - const byId = new Map(rings.map((r) => [r.id, r])); - const ordered: DashboardScoreRing[] = []; - for (const id of order) { - if (id === HEALTH_SCORE_RING_ID) continue; - const ring = byId.get(id); - if (ring) ordered.push(ring); - } - return ordered; - }, [snapshotQuery.data]); + // v1.29.1 — the v1.29.0 selected-score-ring cluster is removed from the web + // hero (Marc, live-use: uneven, wasted tile space). The snapshot still + // resolves `scoreRings` server-side for the iOS selection contract; the web + // hero just no longer renders them, so no client-side ring ordering here. const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries", @@ -812,7 +788,7 @@ export default function DashboardPageClient() { (!mounted || digestQuery.isLoading ? ( ) : digestQuery.data ? ( - + ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 3c2770c8e..393b954c3 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -6,7 +6,6 @@ import { I18nProvider } from "@/lib/i18n/context"; import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; import type { PriorityItem } from "@/lib/daily/priority-item"; -import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; // The hero now wires the coach check-in card's keep / let-go taps through // `useCoachCheckinAction`, so it needs a QueryClient in the tree. @@ -141,34 +140,13 @@ describe("", () => { expect(html).toBe(""); }); - it("renders the selected score rings as a cluster in the caller's order", () => { - const rings: DashboardScoreRing[] = [ - { id: "SLEEP_SCORE", score: 74, band: "yellow" }, - { - id: "MED_COMPLIANCE", - score: 33, - band: "yellow", - doses: { taken: 1, scheduled: 3 }, - }, - ]; - const html = render(); - expect(html).toContain('data-slot="today-hero-ring-cluster"'); - // Caller order (= the user's resolved hero ring order) is preserved. - const sleepAt = html.indexOf('data-ring="SLEEP_SCORE"'); - const medsAt = html.indexOf('data-ring="MED_COMPLIANCE"'); - expect(sleepAt).toBeGreaterThan(-1); - expect(medsAt).toBeGreaterThan(sleepAt); - // Each ring links to its existing detail surface. - expect(html).toContain('href="/insights/scores/sleep"'); - expect(html).toContain('href="/medications"'); - // The dose ring shows today's tally, not a mystery percentage. - expect(html).toContain("1/3"); - expect(html).toContain("1 of 3 doses taken today"); - }); - - it("renders no ring cluster when the selection is empty", () => { + // v1.29.1 — the v1.29.0 selected-score-ring cluster was removed from the web + // hero (Marc, live-use: uneven, wasted tile space). Only the main + // health-score ring paints now; the cluster's data-slots are gone. + it("renders no score-ring cluster — only the health-score ring", () => { const html = render(); expect(html).not.toContain('data-slot="today-hero-ring-cluster"'); + expect(html).not.toContain('data-slot="today-hero-ring"'); // The health-score ring alone still paints, exactly as before. expect(html).toContain('data-slot="today-hero-score"'); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index c52c5bcf1..a3deed51a 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -14,10 +14,11 @@ * - the day's read: the health `ScoreRing` (`flat`, md) with its * server-computed band and an honest provisional/final face — a null * score paints the ring's own provisional state, never a zero; - * - the selected score rings (v1.29.0): the user's Settings-picked hero - * rings (max 3), resolved + gated by the dashboard snapshot and ordered - * by `resolveHeroRingOrder`, as a calm sm-ring cluster beneath the - * health ring — the picker configures the web hero again; + * - v1.29.1 (Marc, live-use): the v1.29.0 selected-score-ring cluster is + * removed from the web hero — it read as uneven and wasted tile space. + * The health `ScoreRing` is the hero's only ring now. The score-ring + * SELECTION contract stays server-side (`selectedScoreRings` on the + * snapshot still feeds iOS); the web hero simply stops rendering it; * - the briefing lead in plain language (via `ProseBlocks`, no markdown) * with a "read the full briefing" affordance, plus the top signal's * present-tense headline + delta when the digest carries one; @@ -38,55 +39,17 @@ import { Moon } from "lucide-react"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; -import type { RingHue } from "@/components/insights/derived/ring-hues"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { PriorityCard } from "@/components/daily/priority-card"; import { useCoachCheckinAction } from "@/hooks/use-coach-checkin"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import type { ScoreRingId } from "@/lib/dashboard-layout"; -import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; import type { DailyDigest } from "@/lib/daily/digest"; import { COACH_CHECKIN_KEEP_INTENT, COACH_CHECKIN_LETGO_INTENT, } from "@/lib/daily/coach-checkin-intents"; -/** - * v1.29.0 — the selected score rings return to the hero. The maps mirror the - * retired legacy-hero renderer verbatim so every ring paints EXACTLY the hue, - * label, and destination its insights sibling owns: - * - * - hue: the wellness-strip vocabulary (readiness green / recovery cyan / - * sleep purple); `MED_COMPLIANCE` rides `meds` = `--primary`, the one - * constant tone every medication surface paints — never a band gradient - * that would flash yellow over pending morning doses. - * - label: the existing score labels; the dose ring names today's tally. - * - href: each ring opens the SAME detail surface the wellness strip / - * medications page already own — the picker configures real navigation, - * not decoration. - */ -const RING_HUE_BY_ID: Partial> = { - READINESS: "readiness", - RECOVERY_SCORE: "recovery", - SLEEP_SCORE: "sleep", - MED_COMPLIANCE: "meds", -}; - -const RING_LABEL_KEY: Record = { - READINESS: "insights.derived.composite.READINESS.title", - RECOVERY_SCORE: "insights.derived.scores.recovery", - SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "dashboard.hero.ringDoses", -}; - -const RING_HREF: Record = { - READINESS: "/insights/scores/readiness", - RECOVERY_SCORE: "/insights/scores/recovery", - SLEEP_SCORE: "/insights/scores/sleep", - MED_COMPLIANCE: "/medications", -}; - /** Format the server-computed score delta as a signed, muted chip string. */ function formatDelta( delta: number, @@ -98,22 +61,7 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } -export function TodayHero({ - digest, - rings = [], -}: { - digest: DailyDigest; - /** - * v1.29.0 — the user's selected hero score rings (Settings → Dashboard), - * resolved server-side by the dashboard snapshot (data- and module-gated) - * and ordered by the caller via `resolveHeroRingOrder`, so the Settings - * picker's selection + drag order configure the web hero again. The - * health-score ring stays the hero's fixed anchor; this cluster renders - * beneath it. Optional and additive — an empty selection (or the legacy - * non-snapshot path) keeps the hero exactly as before. - */ - rings?: DashboardScoreRing[]; -}) { +export function TodayHero({ digest }: { digest: DailyDigest }) { const { t } = useTranslations(); const { keep, letGo } = useCoachCheckinAction(); @@ -161,10 +109,15 @@ export function TodayHero({ "p-4 md:p-6", )} > -
+ {/* v1.29.1 — tightened after the v1.29.0 ring cluster was removed: the + hero read as half-empty (short lead → gap → rail). Compact section + gaps let the worth-a-look rail sit close under the lead so the card + reads filled, not padded out. */} +
{/* The day's read — the numeric face on the trailing edge, the - narrative lead leading. */} -
+ narrative lead leading. On md+ the lead sits flush-top with the + score ring (items-start) rather than floating centred beside it. */} +
{/* Hero numeric face: the read leads large in the foreground token, calm and legible — the day's read, not a slogan. */} @@ -240,62 +193,6 @@ export function TodayHero({
- {/* v1.29.0 — the selected score rings, resurfaced. A calm inline - cluster beneath the health ring (right-aligned with it on md+, - centred on mobile), honouring the user's Settings selection and - drag order. Each ring is the shared `ScoreRing` primitive in its - wellness hue, `flat` (no sweep) so the cluster paints at once, - and links to the metric's existing detail surface. Selection - empty → the row simply isn't there — the hero reads as before. */} - {rings.length > 0 ? ( -
- {rings.map((ring) => ( - - {ring.id === "MED_COMPLIANCE" && ring.doses ? ( - // Dose ring — today's tally ("1/3") over the constant - // med-family arc; the arc sweeps on the 0..100 progress. - // `band="green"` stays the stable data-band anchor — a - // pending morning dose is not an alert state. - - ) : ( - - )} - - ))} -
- ) : null} - {/* Freshness note (plan §2.4) — provisional day, last night's sleep not yet folded in. Muted, non-blocking, refreshes in place when the morning job lands. */} diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index c54b4a8ba..8a5572b38 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -176,6 +176,38 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => { }); }); + it("forces the inactive Today digest to refetch so the dose-window item clears without a reload (v1.29.1)", async () => { + // The Today hero's digest reads meds from the same server snapshot the + // intake route hard-evicts, but it lives on the dashboard — unmounted while + // the user is on the medication card. Without an explicit inactive refetch + // the digest's "dose due" rail item lingered until a hard reload. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: { id: "evt-1" } }), + text: vi + .fn() + .mockResolvedValue(JSON.stringify({ data: { id: "evt-1" } })), + }), + ); + const queryClient = fakeQueryClient(); + + await runRecordIntake({ + medication, + skipped: false, + t, + queryClient, + setIntakeLoading: vi.fn(), + undoIntake: vi.fn(), + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }); + }); + 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, diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 91d933f63..33f92ef0a 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -16,20 +16,32 @@ import { apiDelete, apiPost } from "@/lib/api/api-fetch"; * Invalidate every read that reflects a dose's taken/due state after an * intake write. `invalidateKeys` invalidates with the default * `refetchType: "active"`, which only refetches mounted queries — so the - * dashboard snapshot, inactive while the user is on the medication card or - * detail page, is marked stale but never refetched. On navigating back the - * snapshot remounts under `refetchOnMount: false`, so the pre-write cache is - * served and the "due" prompt lingers until a hard reload. Force the inactive - * snapshot to refetch so the dashboard clears as soon as the dose is recorded. + * dashboard snapshot AND the Today digest, both inactive while the user is on + * the medication card or detail page, are marked stale but never refetched. On + * navigating back each remounts under `refetchOnMount: false`, so the pre-write + * cache is served and the "due" prompt (snapshot dose tally) plus the digest's + * dose-window rail item both linger until a hard reload. Force the inactive + * queries to refetch so the dashboard clears as soon as the dose is recorded. + * + * v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday` + * reads the SAME server snapshot cell the intake route already hard-evicts, so + * the refetch returns post-write dose state immediately (no server change + * needed — the eviction seam is reused). */ async function invalidateMedicationReads( queryClient: QueryClient, ): Promise { await invalidateKeys(queryClient, medicationDependentKeys); - await queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - refetchType: "inactive", - }); + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "inactive", + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.dailyDigest(), + refetchType: "inactive", + }), + ]); } type Translator = ( diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 1d6585801..4539ec330 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -317,6 +317,14 @@ describe("dependent-key bundles", () => { const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k)); expect(keyStrings).toContain(JSON.stringify(["dashboard", "snapshot"])); }); + + // v1.29.1 — the Today digest reads `medsToday` from the same server + // snapshot the intake routes hard-evict, but nothing invalidated the client + // query, so the digest's dose-window rail item lingered until a hard reload. + it("medicationDependentKeys bundles the daily digest (v1.29.1)", () => { + const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k)); + expect(keyStrings).toContain(JSON.stringify(["daily", "digest"])); + }); }); /** diff --git a/src/lib/daily/__tests__/morning-refresh-trigger.test.ts b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts index 17c206559..54fb339c1 100644 --- a/src/lib/daily/__tests__/morning-refresh-trigger.test.ts +++ b/src/lib/daily/__tests__/morning-refresh-trigger.test.ts @@ -31,6 +31,23 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: () => userFindUniqueMock() } }, })); +// v1.29.1 — every gate now annotates a distinct `reason`; the spy captures the +// wide-event so each early return is observable in prod. +const annotateMock = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + annotate: (event: unknown) => annotateMock(event), +})); + +/** The `meta` of the single wide-event the trigger emitted this call. */ +function lastAnnotateMeta(): Record { + const call = annotateMock.mock.calls.at(-1); + return (call?.[0] as { meta?: Record })?.meta ?? {}; +} +function lastAnnotateName(): string | undefined { + const call = annotateMock.mock.calls.at(-1); + return (call?.[0] as { action?: { name?: string } })?.action?.name; +} + import { isLastNightLocal, maybeEnqueueMorningRefresh, @@ -84,6 +101,7 @@ describe("maybeEnqueueMorningRefresh", () => { resolveModuleMapMock.mockResolvedValue({}); userFindUniqueMock.mockReset(); userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: null }); + annotateMock.mockReset(); }); it("enqueues exactly one debounced refresh for many last-night samples", async () => { @@ -101,30 +119,47 @@ describe("maybeEnqueueMorningRefresh", () => { expect((opts as { singletonKey: string }).singletonKey).toBe( "morning-refresh:u1:2026-07-16", ); + // The success path stays observable too. + expect(lastAnnotateName()).toBe("daily.morning_refresh.triggered"); }); - it("does NOT trigger for an old/backfilled sleep only", async () => { + it("does NOT trigger for an old/backfilled sleep only, and annotates the gate", async () => { await maybeEnqueueMorningRefresh("u1", [oldNight, oldNight], NOW); expect(sendMock).not.toHaveBeenCalled(); + expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped"); + const meta = lastAnnotateMeta(); + expect(meta.reason).toBe("not_last_night"); + // The diagnostic payload: today's local key, tz, and the samples' resolved + // local dates so a real landing shows exactly why the gate rejected them. + expect(meta.today).toBe("2026-07-16"); + expect(meta.tz).toBe("America/Los_Angeles"); + // 06:00Z on the 10th = 23:00 on the 9th in LA (UTC-7). + expect(meta.sample_local_dates).toEqual(["2026-07-09", "2026-07-09"]); }); - it("no-ops when the sleep module is off", async () => { + it("no-ops when the sleep module is off, and annotates the gate", async () => { resolveModuleMapMock.mockResolvedValue({ sleep: false }); await maybeEnqueueMorningRefresh("u1", [lastNight], NOW); expect(sendMock).not.toHaveBeenCalled(); + expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped"); + expect(lastAnnotateMeta().reason).toBe("sleep_module_off"); }); - it("skips the enqueue when the day is already finalised (marker == today)", async () => { + it("skips the enqueue when the day is already finalised (marker == today), and annotates the gate", async () => { userFindUniqueMock.mockResolvedValue({ morningDigestRefreshedOn: "2026-07-16", }); await maybeEnqueueMorningRefresh("u1", [lastNight], NOW); expect(sendMock).not.toHaveBeenCalled(); + expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped"); + expect(lastAnnotateMeta().reason).toBe("already_refreshed"); }); - it("no-ops with no sleep samples", async () => { + it("no-ops with no sleep samples, and annotates the gate", async () => { await maybeEnqueueMorningRefresh("u1", [], NOW); expect(sendMock).not.toHaveBeenCalled(); + expect(lastAnnotateName()).toBe("daily.morning_refresh.skipped"); + expect(lastAnnotateMeta().reason).toBe("no_sleep_rows"); }); it("never throws when a dependency fails", async () => { diff --git a/src/lib/daily/morning-refresh-trigger.ts b/src/lib/daily/morning-refresh-trigger.ts index a24483855..53e59d420 100644 --- a/src/lib/daily/morning-refresh-trigger.ts +++ b/src/lib/daily/morning-refresh-trigger.ts @@ -4,18 +4,18 @@ * The nightly insight pre-pass (`insight-pregenerate`, 04:30) runs before last * night's sleep has synced, so the morning digest + health score reference the * day WITHOUT the current sleep. This trigger closes that gap the event-driven - * way: when a completed sleep segment for LAST NIGHT lands from any of the - * three sleep transports (Withings sync-sleep / the WHOOP sync / the - * Apple-Health measurement batch), it enqueues a debounced + * way: when a completed sleep segment for LAST NIGHT lands from any sleep + * transport (Withings sync-sleep / the WHOOP sync / the Apple-Health + * measurement batch / Google Health / Fitbit / Oura / Polar), it enqueues a debounced * `morning-digest-refresh` job that re-runs the sleep-dependent generation and * flips the day `provisional → final`. * * There is no single canonical sleep-persist function in the tree — each - * transport upserts through its own helper — so the three write seams each call - * `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row they just + * transport upserts through its own helper — so every write seam calls + * `maybeEnqueueMorningRefresh` with the measuredAt of every sleep row it just * wrote. The debounce (queue `singletonKey` + the `User.morningDigestRefreshedOn` - * marker) collapses the many samples of one night, and across all three - * sources, into ONE refresh per user per local morning. + * marker) collapses the many samples of one night, and across all sources, + * into ONE refresh per user per local morning. * * Timezone correctness is load-bearing: "last night" is judged in the user's * PROFILE timezone, never UTC, so a 30-day backfill re-sync never re-triggers @@ -76,24 +76,64 @@ export async function maybeEnqueueMorningRefresh( now: Date = new Date(), ): Promise { try { - if (sleepMeasuredAts.length === 0) return; + // Gate 1 — nothing to refresh on. Every early return annotates a distinct + // `reason` so a real landing shows exactly which gate fired (the gates were + // silent before, so a "insight stuck on the nightly stamp" report could not + // be traced to a gate without a code change). + if (sleepMeasuredAts.length === 0) { + annotate({ + action: { name: "daily.morning_refresh.skipped" }, + meta: { reason: "no_sleep_rows" }, + }); + return; + } const modules = await resolveModuleMap(userId); - if (modules.sleep === false) return; + if (modules.sleep === false) { + annotate({ + action: { name: "daily.morning_refresh.skipped" }, + meta: { reason: "sleep_module_off" }, + }); + return; + } const tz = await resolveUserTimezone(userId); + const todayKey = userDayKey(now, tz); const hasLastNight = sleepMeasuredAts.some((at) => isLastNightLocal(at, now, tz), ); - if (!hasLastNight) return; + if (!hasLastNight) { + // The most diagnostic gate: carry the samples' resolved LOCAL dates (up + // to 5) alongside today's key + tz, so the next real landing tells us + // exactly why `isLastNightLocal` rejected every segment (a tz mismatch, a + // backfill of only-old nights, or a clock-skewed future sample). + annotate({ + action: { name: "daily.morning_refresh.skipped" }, + meta: { + reason: "not_last_night", + today: todayKey, + tz, + sample_local_dates: sleepMeasuredAts + .slice(0, 5) + .map((at) => userDayKey(at, tz)), + }, + }); + return; + } - const localDate = userDayKey(now, tz); + const localDate = todayKey; const row = await prisma.user.findUnique({ where: { id: userId }, select: { morningDigestRefreshedOn: true }, }); - if (row?.morningDigestRefreshedOn === localDate) return; + if (row?.morningDigestRefreshedOn === localDate) { + annotate({ + action: { name: "daily.morning_refresh.skipped" }, + meta: { reason: "already_refreshed", local_date: localDate }, + }); + return; + } await enqueueMorningDigestRefresh({ userId, localDate }); annotate({ diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index 662cfbc46..bdf628061 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -33,6 +33,7 @@ import { } from "./sync"; import { annotate } from "@/lib/logging/context"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; export async function syncUserSleep( userId: string, @@ -78,6 +79,16 @@ export async function syncUserSleep( }) ).imported; // `markSynced` is owned by the orchestrator (`syncUserFitbit`). + + // S4 — trigger the debounced morning refresh on a last-night segment landing + // (mirrors the Withings / WHOOP / Apple seams). + void maybeEnqueueMorningRefresh( + userId, + readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + annotate({ action: { name: "fitbit.sleep.sync", details: { imported } } }); return imported; } diff --git a/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts new file mode 100644 index 000000000..c12b7de98 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts @@ -0,0 +1,112 @@ +/** + * v1.29.1 — pins the S4 morning-digest-refresh wiring on the Google Health + * sleep transport. Google Health is a first-class sleep source, but the + * sleep-arrival trigger (`maybeEnqueueMorningRefresh`) was only hooked into + * Withings / WHOOP / Apple, so a Google-Health user's morning refresh never + * fired and their day stayed stuck at the 04:30 nightly pre-pass. + * + * This asserts a Google-sourced last-night sleep segment landing enqueues the + * debounced refresh EXACTLY once, with the segment's `measuredAt` — mirroring + * the existing transport trigger contract. The debounce itself (one refresh + * per user per local morning) is covered in `morning-refresh-trigger.test.ts`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sleepMeasuredAt = new Date("2026-06-02T05:30:00.000Z"); + +const { + fetchDataPointsMock, + mapSleepSessionDetailedMock, + upsertMock, + maybeEnqueueMorningRefreshMock, +} = vi.hoisted(() => ({ + fetchDataPointsMock: vi.fn(), + mapSleepSessionDetailedMock: vi.fn(), + upsertMock: vi.fn(), + maybeEnqueueMorningRefreshMock: vi.fn(async () => {}), +})); + +vi.mock("../client", () => ({ + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 100, + GOOGLE_HEALTH_DATA_TYPES: { sleep: "sleep" }, + fetchDataPoints: fetchDataPointsMock, + mapSleepSessionDetailed: mapSleepSessionDetailedMock, +})); + +vi.mock("../sync", () => ({ + getValidToken: vi.fn(async () => ({ accessToken: "tok" })), + handleCollectionFetchError: vi.fn(() => 0), + noteHardFailure: vi.fn(), + replaceStaleGoogleHealthSleep: vi.fn(async () => 0), + upsertGoogleHealthMeasurements: upsertMock, +})); + +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ + maybeEnqueueMorningRefresh: maybeEnqueueMorningRefreshMock, +})); + +import { syncUserSleep } from "../sync-sleep"; + +beforeEach(() => { + vi.clearAllMocks(); + fetchDataPointsMock.mockResolvedValue([{ raw: "point" }]); + mapSleepSessionDetailedMock.mockReturnValue({ + rows: [ + { + type: "SLEEP_DURATION", + value: 480, + unit: "min", + measuredAt: sleepMeasuredAt, + fieldTag: "anchor:sleep:a", + sleepStage: null, + }, + ], + windowStart: new Date("2026-06-01T22:00:00.000Z"), + windowEnd: sleepMeasuredAt, + }); + upsertMock.mockResolvedValue({ imported: 1 }); +}); + +describe("google-health syncUserSleep — S4 morning-refresh trigger", () => { + it("enqueues exactly one morning refresh with the sleep segment's measuredAt", async () => { + await syncUserSleep("user-1"); + + expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledTimes(1); + expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", [ + sleepMeasuredAt, + ]); + }); + + it("passes no sleep timestamps when the night carried no SLEEP_DURATION rows", async () => { + mapSleepSessionDetailedMock.mockReturnValue({ + rows: [ + { + type: "HEART_RATE", + value: 55, + unit: "bpm", + measuredAt: sleepMeasuredAt, + fieldTag: "hr:a", + sleepStage: null, + }, + ], + windowStart: new Date("2026-06-01T22:00:00.000Z"), + windowEnd: sleepMeasuredAt, + }); + + await syncUserSleep("user-1"); + + // Still called once (the trigger self-gates on an empty array), but with no + // sleep timestamps — so it no-ops rather than refreshing on non-sleep rows. + expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledTimes(1); + expect(maybeEnqueueMorningRefreshMock).toHaveBeenCalledWith("user-1", []); + }); +}); diff --git a/src/lib/google-health/sync-sleep.ts b/src/lib/google-health/sync-sleep.ts index 69104a4ed..6650b66b6 100644 --- a/src/lib/google-health/sync-sleep.ts +++ b/src/lib/google-health/sync-sleep.ts @@ -38,6 +38,7 @@ import { } from "./sync"; import { annotate, getEvent } from "@/lib/logging/context"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; export async function syncUserSleep( userId: string, @@ -113,6 +114,18 @@ export async function syncUserSleep( }) ).imported; // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). + + // S4 — trigger the debounced morning refresh on a last-night segment landing + // (mirrors the Withings / WHOOP / Apple seams). Google Health is a first- + // class sleep transport, so without this a Google-Health user's morning + // refresh never fired and their day stayed stuck at the 04:30 pre-pass. + void maybeEnqueueMorningRefresh( + userId, + readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + annotate({ action: { name: "googleHealth.sleep.sync", details: { imported } }, }); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 928c3f07b..b91d09197 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -47,6 +47,7 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { sweepStaleSleepSegments, type SleepSegmentSweep, @@ -369,6 +370,16 @@ export async function syncUserOura( // sibling collection failed — one bad collection must not blank the source. const imported = await upsertOuraMeasurements(userId, result.readings); + // S4 — trigger the debounced morning refresh on a last-night segment landing + // (mirrors the Withings / WHOOP / Apple seams). Fires whether or not a + // sibling collection failed, since the sleep readings were still imported. + void maybeEnqueueMorningRefresh( + userId, + result.readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + if (result.failures.length > 0) { // Partial failure: keep the cycle honest. Record the failure and do NOT // stamp success, so the freshness surface reflects that some collections diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts index f89ff5753..8a34b2621 100644 --- a/src/lib/polar/sync.ts +++ b/src/lib/polar/sync.ts @@ -35,6 +35,7 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { fetchActivities, fetchCardioLoads, @@ -161,6 +162,16 @@ export async function syncUserPolar(userId: string): Promise { await sweepStaleSleepSegments(userId, "POLAR", sleepSweeps); const imported = await upsertPolarMeasurements(userId, readings); + + // S4 — trigger the debounced morning refresh on a last-night segment landing + // (mirrors the Withings / WHOOP / Apple seams). + void maybeEnqueueMorningRefresh( + userId, + readings + .filter((r) => r.type === "SLEEP_DURATION") + .map((r) => r.measuredAt), + ).catch(() => {}); + await recordSyncSuccess(userId, "polar"); return imported; } diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index a842b5213..29b98099f 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -158,6 +158,15 @@ export const moodDependentKeys = [ * minutes. The intake routes already hard-evict the server-side * snapshot bucket, so the refetch this triggers returns post-write data * immediately. + * + * v1.29.1 — `dailyDigest` joins the bundle. The Today hero's digest reads + * `medsToday` from the same server snapshot the intake routes hard-evict, + * but nothing invalidated the client query, so the digest's "dose due" + * rail item lingered until a hard reload. Like the snapshot, the digest + * query runs with refetchOnMount/WindowFocus tuned off, so the intake + * seam ALSO forces an inactive refetch (it is unmounted while the user is + * on the medication card); this bundle membership covers the case where + * the dashboard is the active surface. */ export const medicationDependentKeys = [ queryKeys.medications(), @@ -166,6 +175,7 @@ export const medicationDependentKeys = [ queryKeys.insightsTargets(), queryKeys.gamificationAchievements(), queryKeys.dashboardSnapshot(), + queryKeys.dailyDigest(), ["dashboard-medication-compliance"] as const, ["compliance-chart-inline"] as const, ]; From f6e6593bcbe611095cfe6e6811b444016657c699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 10:40:15 +0200 Subject: [PATCH 2/4] fix(coach): name the attached document(s) in the fenced banner; keep the attach menu within its popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fenced-conversation banner said a generic "N documents attached", which gave no hint what a thread was about when returning to it later. It now names the attached document(s): one document shows its title, two show both, three or more show the first two plus an "and N more" tail. Reuses the existing "Chatting about: …" doc-scope phrasing and the attachment DTO titles; the title truncates within the pill so a long name never breaks the banner. Falls back to a plain count only when no title has resolved yet. Also constrain the composer attach menu: a long localised label — e.g. "Neues Dokument hochladen" — could run past the popover edge. The menu items now shrink and truncate within the menu width, mirroring the vault picker items. Adds a "chattingAboutMore" string across all six locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/coach-doc-scope-banner.test.tsx | 6 ++-- .../coach-panel/coach-conversation.tsx | 31 ++++++++++++++----- .../insights/coach-panel/coach-input.tsx | 18 ++++++++--- 9 files changed, 45 insertions(+), 16 deletions(-) diff --git a/messages/de.json b/messages/de.json index 5f73c945f..3dfe3715f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Dokument", "chattingAbout": "Chat über: {title}", + "chattingAboutMore": "Chat über: {titles} und {count} weitere", "notIndexedHint": "Lies dieses Dokument zuerst im Archiv mit KI ein, damit der Coach dazu antworten kann." }, "actionsMenu": "Aktionen", diff --git a/messages/en.json b/messages/en.json index c23a5ead6..d789de5f6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Document", "chattingAbout": "Chatting about: {title}", + "chattingAboutMore": "Chatting about: {titles} and {count} more", "notIndexedHint": "Read this document with AI first in the vault so the Coach can answer about it." }, "actionsMenu": "Actions", diff --git a/messages/es.json b/messages/es.json index 2c0f8d1c6..fdd912d82 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Documento", "chattingAbout": "Hablando sobre: {title}", + "chattingAboutMore": "Hablando sobre: {titles} y {count} más", "notIndexedHint": "Primero lee este documento con IA en el archivo para que el Coach pueda responder sobre él." }, "actionsMenu": "Acciones", diff --git a/messages/fr.json b/messages/fr.json index 6f06aca64..ac8b3e0c8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Document", "chattingAbout": "Discussion sur : {title}", + "chattingAboutMore": "Discussion sur : {titles} et {count} de plus", "notIndexedHint": "Lis d'abord ce document avec l'IA dans le coffre pour que le Coach puisse y répondre." }, "actionsMenu": "Actions", diff --git a/messages/it.json b/messages/it.json index c91cc3c60..32d7464c8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Documento", "chattingAbout": "Stai parlando di: {title}", + "chattingAboutMore": "Stai parlando di: {titles} e altri {count}", "notIndexedHint": "Leggi prima questo documento con l'IA nell'archivio così il Coach può rispondere." }, "actionsMenu": "Azioni", diff --git a/messages/pl.json b/messages/pl.json index a5fe37ce7..c5e8028ff 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2184,6 +2184,7 @@ "docScope": { "badge": "Dokument", "chattingAbout": "Rozmowa o: {title}", + "chattingAboutMore": "Rozmowa o: {titles} i {count} więcej", "notIndexedHint": "Najpierw wczytaj ten dokument z pomocą AI w archiwum, aby Coach mógł na jego temat odpowiadać." }, "actionsMenu": "Działania", diff --git a/src/components/insights/coach-panel/__tests__/coach-doc-scope-banner.test.tsx b/src/components/insights/coach-panel/__tests__/coach-doc-scope-banner.test.tsx index d3e989cf0..b9cff0913 100644 --- a/src/components/insights/coach-panel/__tests__/coach-doc-scope-banner.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-doc-scope-banner.test.tsx @@ -18,7 +18,7 @@ import { CoachConversation } from "../coach-conversation"; * When the Coach is fenced — here seeded via the `/coach?doc=` hand-off, * which stages ONE document as a pending first-turn attachment before any * thread exists — the shared `` surface paints the - * `coach-doc-scope` banner: a paperclip + "N documents attached" count and the + * `coach-doc-scope` banner: a paperclip + the attached document name(s) and the * honest fencing line (the coach runs WITHOUT access to health data and reads * only the attached documents). While a staged attachment is still being * content-indexed the banner adds the "still being indexed" hint. On a normal @@ -73,8 +73,8 @@ describe(" fenced banner (S7)", () => { makeClient(true), ); expect(html).toContain('data-slot="coach-doc-scope"'); - // Exactly one document staged → the singular count. - expect(html).toContain("1 document attached"); + // Exactly one document staged → the banner names it, not a generic count. + expect(html).toContain(`Chatting about: ${TITLE}`); // The honest fencing line is always present on a fenced conversation. expect(html).toContain(FENCING_LINE); // Indexed → no "still being indexed" hint. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 7ff5cb493..4eddfbb7b 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -954,20 +954,35 @@ export function CoachConversation({ // ONLY the attached documents, and counts the attachments. Rendered in BOTH // surfaces (page + drawer) above the thread so the fence is always visible. // Null on a normal health thread. - const attachedCount = attachmentPills.length; + // v1.29.1 — name the attached document(s) in the banner so returning to a + // fenced thread later is clear about what it is about, instead of a generic + // "N documents attached". One doc → its title; two → both, comma-joined + // through the reused `docScope.chattingAbout` phrasing; three or more → the + // first two plus an "and N more" tail. Falls back to the plain count only when + // no title has resolved yet (a sticky-fenced thread whose attachment is still + // in flight), so the banner is never empty. + const attachedTitles = attachmentPills.map((pill) => pill.title); + const attachedCount = attachedTitles.length; + const docScopeText = + attachedCount === 0 + ? t("insights.coach.attach.documentsAttachedOther", { count: 0 }) + : attachedCount <= 2 + ? t("insights.coach.docScope.chattingAbout", { + title: attachedTitles.join(", "), + }) + : t("insights.coach.docScope.chattingAboutMore", { + titles: attachedTitles.slice(0, 2).join(", "), + count: attachedCount - 2, + }); const docScopeBanner = fenced ? (
- -

diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index f62ee7aaa..358810d47 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -595,13 +595,19 @@ export function CoachInput({

+ {t("illness.correlation.settled")} +

+ ); + } + return (
{value.redFlags.length > 0 ? ( diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx index 0334548b3..9b14f8cbd 100644 --- a/src/components/illness/new-episode-sheet.tsx +++ b/src/components/illness/new-episode-sheet.tsx @@ -83,6 +83,10 @@ export function NewEpisodeSheet({ const [type, setType] = useState("INFECTION"); const [lifecycle, setLifecycle] = useState("ACUTE"); const [onset, setOnset] = useState(today); + // Resolved/end date — "" means the episode is still ongoing (open-ended). + // Backdating both onset and this end date lets a whole past episode be + // recorded after the fact. + const [resolved, setResolved] = useState(""); const [parentId, setParentId] = useState(NONE); const [note, setNote] = useState(""); @@ -96,6 +100,9 @@ export function NewEpisodeSheet({ setType(editEpisode?.type ?? "INFECTION"); setLifecycle(editEpisode?.lifecycle ?? "ACUTE"); setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today); + setResolved( + editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "", + ); setParentId(editEpisode?.parentConditionId ?? NONE); setNote(editEpisode?.note ?? ""); } else if (!open && wasOpen) { @@ -104,9 +111,17 @@ export function NewEpisodeSheet({ const showParent = PARENTABLE.includes(lifecycle); + // The end date can never precede the start; the field is min-bounded to the + // onset, and this guard belts-and-braces the same invariant the server holds. + const endBeforeStart = resolved !== "" && resolved < onset; + async function handleSave() { - if (label.trim() === "") return; + if (label.trim() === "" || endBeforeStart) return; const onsetAt = new Date(`${onset}T12:00:00`).toISOString(); + // "" ⇒ ongoing (null clears any stored end). Anchor to local noon so the + // UTC instant lands on the intended calendar day regardless of offset. + const resolvedAt = + resolved === "" ? null : new Date(`${resolved}T12:00:00`).toISOString(); const parentConditionId = showParent && parentId !== NONE ? parentId : null; try { if (isEdit && editEpisode) { @@ -117,6 +132,7 @@ export function NewEpisodeSheet({ type, lifecycle, onsetAt, + resolvedAt, parentConditionId, note: note.trim() === "" ? null : note, }, @@ -126,9 +142,8 @@ export function NewEpisodeSheet({ label: label.trim(), type, lifecycle, - // Anchor to local noon so the UTC instant lands on the intended - // calendar day regardless of the viewer's offset. onsetAt, + resolvedAt, parentConditionId, note: note.trim() === "" ? null : note, }); @@ -154,7 +169,7 @@ export function NewEpisodeSheet({ @@ -240,6 +255,33 @@ export function NewEpisodeSheet({ />
+
+ +
+ + {resolved !== "" ? ( + + ) : null} +
+

+ {t("illness.new.resolvedHelp")} +

+
+