fix(daily): refresh the Today digest after a dose, drop the hero ring cluster

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-07-17 11:00:22 +02:00
1 parent f8c0ea5120
commit 65f90160c7
20 files changed
+350 -210

No files matched your search

+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+1 -2
View File
@@ -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": {
+5 -29
View File
@@ -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<DashboardScoreRing[]>(() => {
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 ? (
<TodayHeroSkeleton />
) : digestQuery.data ? (
<TodayHero digest={digestQuery.data} rings={heroScoreRings} />
<TodayHero digest={digestQuery.data} />
) : null)}
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
@@ -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("<TodayHero>", () => {
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(<TodayHero digest={digest()} rings={rings} />);
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(<TodayHero digest={digest()} />);
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"');
});
+14 -117
View File
@@ -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<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "meds",
};
const RING_LABEL_KEY: Record<ScoreRingId, string> = {
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<ScoreRingId, string> = {
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",
)}
>
<div className="flex flex-col gap-4 md:gap-5">
{/* 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. */}
<div className="flex flex-col gap-3 md:gap-4">
{/* The day's read — the numeric face on the trailing edge, the
narrative lead leading. */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
narrative lead leading. On md+ the lead sits flush-top with the
score ring (items-start) rather than floating centred beside it. */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
<div className="min-w-0 flex-1 space-y-2">
{/* 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({
</div>
</div>
{/* 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 ? (
<div
data-slot="today-hero-ring-cluster"
className="flex flex-wrap items-start justify-center gap-x-4 gap-y-3 md:justify-end"
>
{rings.map((ring) => (
<Link
key={ring.id}
href={RING_HREF[ring.id]}
data-slot="today-hero-ring"
data-ring={ring.id}
aria-label={t("daily.today.ringLink", {
metric: t(RING_LABEL_KEY[ring.id]),
})}
className="focus-visible:ring-ring/50 rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
{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.
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("daily.today.ringDosesAria", {
taken: ring.doses.taken,
scheduled: ring.doses.scheduled,
})}
size="sm"
flat
label={t(RING_LABEL_KEY[ring.id])}
/>
) : (
<ScoreRing
score={ring.score}
band={ring.band}
size="sm"
flat
hue={RING_HUE_BY_ID[ring.id]}
label={t(RING_LABEL_KEY[ring.id])}
/>
)}
</Link>
))}
</div>
) : 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. */}
@@ -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,
@@ -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<void> {
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 = (
+8
View File
@@ -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"]));
});
});
/**
@@ -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<string, unknown> {
const call = annotateMock.mock.calls.at(-1);
return (call?.[0] as { meta?: Record<string, unknown> })?.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 () => {
+52 -12
View File
@@ -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<void> {
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({
+11
View File
@@ -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;
}
@@ -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", []);
});
});
+13
View File
@@ -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 } },
});
+11
View File
@@ -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
+11
View File
@@ -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<number> {
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;
}
+10
View File
@@ -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,
];