mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
33 files changed
+598
-309
No files matched your search
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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. */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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