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] 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, ];