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

@@ -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 = (