diff --git a/CHANGELOG.md b/CHANGELOG.md index 33373c2c6..079773f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d94e3dc64..1b0b02a91 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/messages/de.json b/messages/de.json index 43373eedb..aa4bcd595 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 937805e02..af5e01480 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index e56efdbf9..09e524701 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9f16448a4..a37d14cea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index b3a7cc930..6dbf5208e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index d1d1ae6ea..beb3947b1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/package.json b/package.json index 4963af024..aecdc5b15 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 778a756b2..50c7c0d12 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index e05e2a2dd..73125d354 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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(() => { - 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 ? ( ) : digestQuery.data ? ( - + ) : null)} {/* v1.18.6 — the spotlight tour launcher moved to the app-shell diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 3c2770c8e..393b954c3 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -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("", () => { 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(); - 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(); 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"'); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index c52c5bcf1..a3deed51a 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -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> = { - READINESS: "readiness", - RECOVERY_SCORE: "recovery", - SLEEP_SCORE: "sleep", - MED_COMPLIANCE: "meds", -}; - -const RING_LABEL_KEY: Record = { - 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 = { - 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", )} > -
+ {/* 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. */} +
{/* The day's read — the numeric face on the trailing edge, the - narrative lead leading. */} -
+ narrative lead leading. On md+ the lead sits flush-top with the + score ring (items-start) rather than floating centred beside it. */} +
{/* 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({
- {/* 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 ? ( -
- {rings.map((ring) => ( - - {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. - - ) : ( - - )} - - ))} -
- ) : 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. */} diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 4edec9d0b..852b7d9d4 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -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 ( +

+ {t("illness.correlation.settled")} +

+ ); + } + return (
{value.redFlags.length > 0 ? ( diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx index 0334548b3..9b14f8cbd 100644 --- a/src/components/illness/new-episode-sheet.tsx +++ b/src/components/illness/new-episode-sheet.tsx @@ -83,6 +83,10 @@ export function NewEpisodeSheet({ const [type, setType] = useState("INFECTION"); const [lifecycle, setLifecycle] = useState("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(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({ @@ -240,6 +255,33 @@ export function NewEpisodeSheet({ />
+
+ +
+ + {resolved !== "" ? ( + + ) : null} +
+

+ {t("illness.new.resolvedHelp")} +

+
+