mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
f8c0ea5120
commit
65f90160c7
20 files changed
+350
-210
No files matched your search
+1
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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 = (
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", []);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user