Merge pull request #516 from MBombeck/release/v1.29.1

release/v1.29.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-17 11:25:00 +02:00
commit 63cdd709af
33 files changed
+598 -309

No files matched your search

+28
View File
@@ -2,6 +2,34 @@
## [Unreleased]
## [1.29.1] — 2026-07-17
A round of fixes and small refinements from living with the daily view.
- **Today refreshes right after a dose.** Logging or undoing a medication now
updates the day's read immediately — the medication line no longer lingers on
a stale count until the next reload.
- **A calmer Today hero.** The extra score rings are gone; the hero keeps the
one health-score ring and a tighter layout.
- **The Coach names the document you attached.** Asking about a document now
shows its title in the chat instead of a plain "1 document" count, and the
attach menu stays within its popover on long labels.
- **A fresh install starts with tracking on.** New installs now enable the
tracking modules by default — you can still switch any of them off. The
external-AI options and the daily push stay off until you turn them on.
- **Insights vitals lose the explainer line.** The "your personal normal range
is the median…" caption is removed from the baseline tiles.
- **Illness journal: set and edit the end.** You can mark an episode's end date,
clear it back to ongoing, and backdate the start. When nothing strayed from
your personal range, the course section now reads a short settled line instead
of appearing empty.
- **The morning refresh covers every sleep source.** The refresh that folds last
night's sleep into the day now triggers from Google Health, Fitbit, Oura, and
Polar as well, not only Withings, WHOOP, and Apple Health. Each skipped refresh
is now recorded so a stale morning read can be traced to its cause.
No new data is collected and there is no migration.
## [1.29.0] — 2026-07-17 — A daily health companion
This release draws a line under a run of work that turns HealthLog from a place
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.29.0
version: 1.29.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Dokument",
"chattingAbout": "Chat über: {title}",
"chattingAboutMore": "Chat über: {titles} und {count} weitere",
"notIndexedHint": "Lies dieses Dokument zuerst im Archiv mit KI ein, damit der Coach dazu antworten kann."
},
"actionsMenu": "Aktionen",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
"saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen.",
"parent": "Teil von",
"parentNone": "Nicht verknüpft"
"parentNone": "Nicht verknüpft",
"resolved": "Beendet am",
"resolvedHelp": "Leer lassen, solange die Episode noch andauert.",
"markOngoing": "Noch andauernd"
},
"sheet": {
"title": "{date} erfassen",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "über deinem Üblichen",
"below": "unter deinem Üblichen"
}
},
"settled": "Während dieser Episode wich nichts Auffälliges von deinem üblichen Bereich ab."
},
"insights": {
"title": "Dein Krankheitsverlauf",
@@ -8608,8 +8613,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": {
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Document",
"chattingAbout": "Chatting about: {title}",
"chattingAboutMore": "Chatting about: {titles} and {count} more",
"notIndexedHint": "Read this document with AI first in the vault so the Coach can answer about it."
},
"actionsMenu": "Actions",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Anything worth remembering (encrypted).",
"saveError": "Could not save the episode. Please try again.",
"parent": "Part of",
"parentNone": "Not linked"
"parentNone": "Not linked",
"resolved": "Ended on",
"resolvedHelp": "Leave empty while the episode is still ongoing.",
"markOngoing": "Still ongoing"
},
"sheet": {
"title": "Log {date}",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "above your usual",
"below": "below your usual"
}
},
"settled": "Nothing notable stood out from your usual range during this episode."
},
"insights": {
"title": "Your illness history",
@@ -8608,8 +8613,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": {
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Documento",
"chattingAbout": "Hablando sobre: {title}",
"chattingAboutMore": "Hablando sobre: {titles} y {count} más",
"notIndexedHint": "Primero lee este documento con IA en el archivo para que el Coach pueda responder sobre él."
},
"actionsMenu": "Acciones",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
"saveError": "No se pudo guardar el episodio. Inténtalo de nuevo.",
"parent": "Parte de",
"parentNone": "Sin vincular"
"parentNone": "Sin vincular",
"resolved": "Finalizó el",
"resolvedHelp": "Déjalo vacío mientras el episodio siga en curso.",
"markOngoing": "Aún en curso"
},
"sheet": {
"title": "Registrar {date}",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "por encima de lo habitual",
"below": "por debajo de lo habitual"
}
},
"settled": "Durante este episodio nada destacó respecto a tu rango habitual."
},
"insights": {
"title": "Tu historial de enfermedades",
@@ -8608,8 +8613,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": {
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Document",
"chattingAbout": "Discussion sur : {title}",
"chattingAboutMore": "Discussion sur : {titles} et {count} de plus",
"notIndexedHint": "Lis d'abord ce document avec l'IA dans le coffre pour que le Coach puisse y répondre."
},
"actionsMenu": "Actions",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
"saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer.",
"parent": "Fait partie de",
"parentNone": "Non lié"
"parentNone": "Non lié",
"resolved": "Terminée le",
"resolvedHelp": "Laissez vide tant que l'épisode est en cours.",
"markOngoing": "Toujours en cours"
},
"sheet": {
"title": "Enregistrer {date}",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "au-dessus de ton habituel",
"below": "en dessous de ton habituel"
}
},
"settled": "Rien de notable ne s'est écarté de ta plage habituelle pendant cet épisode."
},
"insights": {
"title": "Ton historique de maladies",
@@ -8608,8 +8613,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": {
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Documento",
"chattingAbout": "Stai parlando di: {title}",
"chattingAboutMore": "Stai parlando di: {titles} e altri {count}",
"notIndexedHint": "Leggi prima questo documento con l'IA nell'archivio così il Coach può rispondere."
},
"actionsMenu": "Azioni",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
"saveError": "Impossibile salvare l'episodio. Riprova.",
"parent": "Parte di",
"parentNone": "Non collegato"
"parentNone": "Non collegato",
"resolved": "Conclusa il",
"resolvedHelp": "Lascia vuoto finché l'episodio è ancora in corso.",
"markOngoing": "Ancora in corso"
},
"sheet": {
"title": "Registra {date}",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "sopra il tuo solito",
"below": "sotto il tuo solito"
}
},
"settled": "Durante questo episodio nulla si è discostato in modo evidente dal tuo intervallo abituale."
},
"insights": {
"title": "La tua storia di malattie",
@@ -8608,8 +8613,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": {
+8 -4
View File
@@ -2184,6 +2184,7 @@
"docScope": {
"badge": "Dokument",
"chattingAbout": "Rozmowa o: {title}",
"chattingAboutMore": "Rozmowa o: {titles} i {count} więcej",
"notIndexedHint": "Najpierw wczytaj ten dokument z pomocą AI w archiwum, aby Coach mógł na jego temat odpowiadać."
},
"actionsMenu": "Działania",
@@ -7692,7 +7693,10 @@
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
"saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie.",
"parent": "Część",
"parentNone": "Niepowiązane"
"parentNone": "Niepowiązane",
"resolved": "Zakończono",
"resolvedHelp": "Pozostaw puste, dopóki epizod nadal trwa.",
"markOngoing": "Nadal trwa"
},
"sheet": {
"title": "Zapisz {date}",
@@ -7744,7 +7748,8 @@
"direction": {
"above": "powyżej Twojej normy",
"below": "poniżej Twojej normy"
}
},
"settled": "Podczas tego epizodu nic istotnego nie odbiegało od twojego zwykłego zakresu."
},
"insights": {
"title": "Twoja historia chorób",
@@ -8608,8 +8613,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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.29.0",
"version": "1.29.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.29.0";
/* @sw-version-fallback */ "v1.29.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+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. */}
@@ -57,6 +57,25 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
// the engine on thin data — never a recovery claim, never a second gap.
const sleep = value.sleepContext;
// The engine can return `ok` with NO findings — a mild episode where nothing
// strayed notably from the personal band, still active with no recovery gap,
// and no red flag. Without this the card body rendered empty under its "how
// it progressed" heading, reading as if nothing was recorded at all. Show a
// calm, honest line instead (retrospective, never a fabricated finding).
const hasContent =
value.redFlags.length > 0 ||
gap !== null ||
value.preOnset.length > 0 ||
value.nadir.length > 0;
if (!hasContent) {
return (
<p className="text-muted-foreground text-sm">
{t("illness.correlation.settled")}
</p>
);
}
return (
<div className="space-y-5">
{value.redFlags.length > 0 ? (
+46 -4
View File
@@ -83,6 +83,10 @@ export function NewEpisodeSheet({
const [type, setType] = useState<IllnessType>("INFECTION");
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
const [onset, setOnset] = useState(today);
// Resolved/end date — "" means the episode is still ongoing (open-ended).
// Backdating both onset and this end date lets a whole past episode be
// recorded after the fact.
const [resolved, setResolved] = useState("");
const [parentId, setParentId] = useState<string>(NONE);
const [note, setNote] = useState("");
@@ -96,6 +100,9 @@ export function NewEpisodeSheet({
setType(editEpisode?.type ?? "INFECTION");
setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
setResolved(
editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "",
);
setParentId(editEpisode?.parentConditionId ?? NONE);
setNote(editEpisode?.note ?? "");
} else if (!open && wasOpen) {
@@ -104,9 +111,17 @@ export function NewEpisodeSheet({
const showParent = PARENTABLE.includes(lifecycle);
// The end date can never precede the start; the field is min-bounded to the
// onset, and this guard belts-and-braces the same invariant the server holds.
const endBeforeStart = resolved !== "" && resolved < onset;
async function handleSave() {
if (label.trim() === "") return;
if (label.trim() === "" || endBeforeStart) return;
const onsetAt = new Date(`${onset}T12:00:00`).toISOString();
// "" ⇒ ongoing (null clears any stored end). Anchor to local noon so the
// UTC instant lands on the intended calendar day regardless of offset.
const resolvedAt =
resolved === "" ? null : new Date(`${resolved}T12:00:00`).toISOString();
const parentConditionId = showParent && parentId !== NONE ? parentId : null;
try {
if (isEdit && editEpisode) {
@@ -117,6 +132,7 @@ export function NewEpisodeSheet({
type,
lifecycle,
onsetAt,
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
},
@@ -126,9 +142,8 @@ export function NewEpisodeSheet({
label: label.trim(),
type,
lifecycle,
// Anchor to local noon so the UTC instant lands on the intended
// calendar day regardless of the viewer's offset.
onsetAt,
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
});
@@ -154,7 +169,7 @@ export function NewEpisodeSheet({
</Button>
<Button
onClick={handleSave}
disabled={pending || label.trim() === ""}
disabled={pending || label.trim() === "" || endBeforeStart}
>
{t("common.save")}
</Button>
@@ -240,6 +255,33 @@ export function NewEpisodeSheet({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-resolved">{t("illness.new.resolved")}</Label>
<div className="flex items-center gap-2">
<DateField
id="illness-resolved"
value={resolved}
min={onset}
max={today}
onChange={setResolved}
className="max-w-44"
/>
{resolved !== "" ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResolved("")}
>
{t("illness.new.markOngoing")}
</Button>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t("illness.new.resolvedHelp")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
<Textarea
+3
View File
@@ -59,6 +59,9 @@ export interface IllnessEpisodeCreateInput {
type: IllnessType;
lifecycle?: IllnessLifecycle;
onsetAt?: string;
/** Optional resolved/end instant — set to backdate an already-recovered
* episode captured after the fact; omit/null for an ongoing episode. */
resolvedAt?: string | null;
parentConditionId?: string | null;
note?: string | null;
}
@@ -18,7 +18,7 @@ import { CoachConversation } from "../coach-conversation";
* When the Coach is fenced — here seeded via the `/coach?doc=<id>` hand-off,
* which stages ONE document as a pending first-turn attachment before any
* thread exists — the shared `<CoachConversation>` surface paints the
* `coach-doc-scope` banner: a paperclip + "N documents attached" count and the
* `coach-doc-scope` banner: a paperclip + the attached document name(s) and the
* honest fencing line (the coach runs WITHOUT access to health data and reads
* only the attached documents). While a staged attachment is still being
* content-indexed the banner adds the "still being indexed" hint. On a normal
@@ -73,8 +73,8 @@ describe("<CoachConversation> fenced banner (S7)", () => {
makeClient(true),
);
expect(html).toContain('data-slot="coach-doc-scope"');
// Exactly one document staged → the singular count.
expect(html).toContain("1 document attached");
// Exactly one document staged → the banner names it, not a generic count.
expect(html).toContain(`Chatting about: ${TITLE}`);
// The honest fencing line is always present on a fenced conversation.
expect(html).toContain(FENCING_LINE);
// Indexed → no "still being indexed" hint.
@@ -954,20 +954,35 @@ export function CoachConversation({
// ONLY the attached documents, and counts the attachments. Rendered in BOTH
// surfaces (page + drawer) above the thread so the fence is always visible.
// Null on a normal health thread.
const attachedCount = attachmentPills.length;
// v1.29.1 — name the attached document(s) in the banner so returning to a
// fenced thread later is clear about what it is about, instead of a generic
// "N documents attached". One doc → its title; two → both, comma-joined
// through the reused `docScope.chattingAbout` phrasing; three or more → the
// first two plus an "and N more" tail. Falls back to the plain count only when
// no title has resolved yet (a sticky-fenced thread whose attachment is still
// in flight), so the banner is never empty.
const attachedTitles = attachmentPills.map((pill) => pill.title);
const attachedCount = attachedTitles.length;
const docScopeText =
attachedCount === 0
? t("insights.coach.attach.documentsAttachedOther", { count: 0 })
: attachedCount <= 2
? t("insights.coach.docScope.chattingAbout", {
title: attachedTitles.join(", "),
})
: t("insights.coach.docScope.chattingAboutMore", {
titles: attachedTitles.slice(0, 2).join(", "),
count: attachedCount - 2,
});
const docScopeBanner = fenced ? (
<div
data-slot="coach-doc-scope"
className="border-border/70 bg-muted/20 flex shrink-0 flex-col gap-1 border-b px-4 py-2 sm:px-6"
>
<div className="flex min-w-0 items-center gap-2 text-xs">
<span className="bg-primary/15 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium">
<Paperclip className="size-3" aria-hidden="true" />
{attachedCount === 1
? t("insights.coach.attach.documentsAttachedOne")
: t("insights.coach.attach.documentsAttachedOther", {
count: attachedCount,
})}
<span className="bg-primary/15 text-primary inline-flex min-w-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium">
<Paperclip className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate">{docScopeText}</span>
</span>
</div>
<p className="text-muted-foreground text-[11px] leading-snug">
@@ -595,13 +595,19 @@ export function CoachInput({
<Paperclip className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-56">
{/* v1.29.1 — the item labels shrink + truncate within the popover width
(`min-w-0` + a `truncate` span) so a long localised label — e.g.
"Neues Dokument hochladen" — can never overflow past the menu edge.
Mirrors the vault picker items' width handling. */}
<DropdownMenuContent align="start" side="top" className="w-60">
<DropdownMenuItem
data-slot="coach-input-attach-vault"
onSelect={() => onPickFromVault?.()}
>
<FolderOpen className="size-4" aria-hidden="true" />
{t("insights.coach.attach.menuChooseFromDocuments")}
<FolderOpen className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
{t("insights.coach.attach.menuChooseFromDocuments")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-attach-upload"
@@ -611,8 +617,10 @@ export function CoachInput({
setTimeout(() => attachFileInputRef.current?.click(), 0)
}
>
<Upload className="size-4" aria-hidden="true" />
{t("insights.coach.attach.menuUploadNew")}
<Upload className="size-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">
{t("insights.coach.attach.menuUploadNew")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -288,7 +288,14 @@ function BaselineTile({
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={
<MetricProvenance metric={metric} provenance={data.provenance} />
// v1.29.1 — the VITALS_BASELINE "typical range = median ± MAD" method
// caption is suppressed on its own tiles: it repeated across every
// vital baseline tile and read as out-of-place clutter at the top of
// the vitals surface. The dedicated bands (wrist-temp, stair speed,
// 6-min walk) keep their method copy — only VITALS_BASELINE is muted.
metric === "VITALS_BASELINE" ? undefined : (
<MetricProvenance metric={metric} provenance={data.provenance} />
)
}
footer={<LearnMoreLink concept={tileId} />}
/>
@@ -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 } },
});
+28 -32
View File
@@ -353,23 +353,18 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
});
describe("registry — opt-in marker", () => {
// The opt-in modules ship dark because they open an egress/attack surface or
// are highly sensitive: `mcp` (remote external-assistant endpoint),
// `environment` (outbound weather fetch tied to a coarse location),
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
// provider), `mentalHealth` (a depression / anxiety self-assessment — at
// least as sensitive as mood, surfaced only on explicit opt-in), and
// v1.29.1 — a fresh install defaults every TRACKING module on so the app is
// usable without enabling each one. Only two modules stay opt-in (ship dark),
// because each opens a channel that must be turned on deliberately:
// `mcp` (the remote external-assistant endpoint — a new attack surface) and
// `nutrients` (supplement-pattern intake synced from the phone's health
// store — the device-side HealthKit permission is not visible consent to
// server/AI-side storage, so ingest refuses while off). Every other
// module is the default-on disabled-allowlist.
const OPT_IN_KEYS = new Set([
"mcp",
"environment",
"inboundDocuments",
"mentalHealth",
"nutrients",
]);
// server/AI-side storage, so ingest refuses while off). Everything else —
// including `environment`, `inboundDocuments`, and `mentalHealth` — is the
// default-on disabled-allowlist now; their egress channels (the weather
// fetch, the document AI-read, the AI Coach exposure) are separately gated
// and stay off until explicitly configured.
const OPT_IN_KEYS = new Set(["mcp", "nutrients"]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
for (const key of MODULE_KEYS) {
@@ -379,35 +374,36 @@ describe("registry — opt-in marker", () => {
});
describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => {
// Parked in code from v1.25.3; the document vault re-enabled it. Normal
// two-layer opt-in resolution applies again: dark by default, ON only on
// an explicit per-user opt-in, and an operator `false` still wins.
// v1.29.1 — default-on now (a fresh install can store + organise documents
// out of the box). The AI READ of a document is a separate per-user opt-in
// (`documentsAutoAiRead`), so default-on carries no silent egress. Normal
// disabled-allowlist resolution: ON unless an explicit `false`, operator wins.
it("is no longer flagged as code-disabled in the registry", () => {
expect(isCodeDisabledModule("inboundDocuments")).toBe(false);
});
it("is OFF when no preference is recorded (opt-in ships dark)", () => {
it("is ON when no preference is recorded (default-on like its siblings)", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
it("is ON when the user opted in", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
ALL_AVAILABLE,
),
).toBe(true);
});
it("stays OFF when the operator disabled it, even with a user opt-in", () => {
it("is OFF when the user opted out", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
inputs({ modulePreferences: { inboundDocuments: false } }),
false,
ALL_AVAILABLE,
),
).toBe(false);
});
it("stays OFF when the operator disabled it, even without a user opt-out", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs(),
false,
operator({ inboundDocuments: false }),
),
+32 -31
View File
@@ -118,11 +118,11 @@ export const MODULE_KEYS = [
// re-enabling finds the rows intact.
"medications",
"doctorReport",
// v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
// (off by default): it performs an outbound weather fetch tied to where the
// user physically is (a coarse home / travel location), so opt-in is the
// right privacy default. With it off no job runs, no row is written, and no
// egress happens. The `optIn` marker inverts the per-user default.
// v1.25.0 (W-ENV) — environmental-context module. Default-on since v1.29.1
// (a fresh install is usable without a per-module opt-in); the outbound
// weather fetch only runs once the user configures a home / travel location
// on the Environment settings surface, so default-on carries no silent
// egress. No `optIn` marker → the ordinary default-on disabled-allowlist.
"environment",
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
// every other module this is OPT-IN (off by default): it exposes a new
@@ -133,19 +133,17 @@ export const MODULE_KEYS = [
// reuses the existing module machinery unchanged.
"mcp",
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (`/documents/inbound`).
// Like `mcp` this is OPT-IN (off by default): ingesting a doctor report /
// discharge letter sends the document to the configured OCR/vision provider,
// so the surface ships dark and the user turns it on deliberately. The
// `optIn` marker on its registry entry inverts the per-user default;
// everything else reuses the existing module machinery unchanged.
// Default-on since v1.29.1: a fresh install can store + organise documents
// in the vault out of the box. The AI READ of a document (the egress to the
// OCR/vision provider) stays a SEPARATE per-user opt-in
// (`documentsAutoAiRead`), so the module being on never egresses on its own.
// No `optIn` marker → the ordinary default-on disabled-allowlist.
"inboundDocuments",
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
// is at least as sensitive as mood, so it ships dark and the user turns it on
// deliberately. The `optIn` marker inverts the per-user default; with it off
// the `/mental-wellbeing` surface is hidden from nav and the
// assessment routes refuse server-side. Item answers are always encrypted and
// always excluded from the AI Coach / MCP regardless of this toggle.
// v1.25.0 — mental-health screeners (WHO-5 / PHQ-9 / GAD-7), beside mood
// tracking. Default-on since v1.29.1: the `/mental-wellbeing` check-in is
// available on a fresh install alongside mood. Item answers are always
// encrypted and always excluded from the AI Coach / MCP regardless of this
// toggle. No `optIn` marker → the ordinary default-on disabled-allowlist.
"mentalHealth",
// v1.28 — micronutrient-intake sync (vitamins, minerals, water,
// caffeine as day totals from Apple Health). OPT-IN (off by default):
@@ -296,11 +294,13 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
labelKey: "modules.environment.label",
descriptionKey: "modules.environment.description",
category: "device",
// Off by default: performs an outbound weather fetch tied to a coarse
// location, so it ships dark and the user turns it on deliberately. The
// home location + travel overrides + backfill live on the dedicated
// Environment settings surface (rendered when the module is on).
optIn: true,
// Default-on tracking module (v1.29.1): a fresh install ships the
// environmental-context surface usable without a separate opt-in. No
// egress happens until the user configures a home location on the
// dedicated Environment settings surface, so default-on carries no
// silent outbound fetch. The MCP endpoint + the auto-AI-read of
// documents stay opt-in — those are the surfaces that open an egress /
// attack channel the moment they are on.
},
mcp: {
key: "mcp",
@@ -316,21 +316,22 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
labelKey: "modules.inboundDocuments.label",
descriptionKey: "modules.inboundDocuments.description",
category: "tracking",
// Off by default: ingesting a clinical document egresses it to the
// configured OCR/vision provider, so the user turns it on deliberately.
optIn: true,
// Default-on tracking module (v1.29.1): a fresh install can store and
// organise clinical documents in the vault out of the box. The AI READ
// of a document (the egress to the OCR/vision provider) is a SEPARATE
// per-user opt-in (`documentsAutoAiRead`), so the module being on never
// sends a document anywhere on its own.
},
mentalHealth: {
key: "mentalHealth",
labelKey: "modules.mentalHealth.label",
descriptionKey: "modules.mentalHealth.description",
category: "tracking",
// Off by default: a depression / anxiety screener is highly sensitive, so
// the surface ships dark and the user opts in deliberately. Turning it on
// reveals the `/mental-wellbeing` check-in and lets the screener
// total ride the doctor-report export; item answers stay encrypted and
// off the AI Coach / MCP regardless.
optIn: true,
// Default-on tracking module (v1.29.1): the `/mental-wellbeing` check-in
// (WHO-5 / PHQ-9 / GAD-7) is available on a fresh install alongside mood.
// Item answers stay encrypted at rest and remain excluded from the AI
// Coach / MCP regardless of this toggle — the screener total may ride the
// doctor-report export the user assembles.
},
nutrients: {
key: "nutrients",
+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,
];