feat(polish): default tracking modules on; illness backdate + course fixes

A clean install now defaults every tracking module on (weight, blood
pressure, glucose, sleep, mood, medications, labs, documents, insights,
mental wellbeing, cycle, illness, environment, workouts) so the app is
usable without enabling each surface by hand. Only the two channels that
open an egress or attack surface stay opt-in — the remote MCP endpoint and
the micronutrient sync; the document AI-read and the daily-briefing push
remain separately gated and off. The change touches defaults only:
accounts with a saved preference resolve exactly as before, the new default
applies solely where no preference exists.

Drop the "typical range = median ± MAD" method caption from the vitals
baseline tiles — it repeated across every tile and read as out of place at
the top of the vitals surface. The dedicated bands and composite scores
keep their method copy.

Illness episodes: the capture sheet now carries an editable end date
alongside the start, so a whole past episode can be recorded after the
fact and an open-ended course can be resolved (or re-opened) later. The
end is bounded to on-or-after the start.

Fix the per-episode "how it progressed" card rendering an empty body when
the engine returned a valid result with no notable findings — it now shows
a calm, honest line instead of looking as though nothing was recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-07-17 11:02:57 +02:00
1 parent f6e6593bcb
commit 2da6a192a7
12 files changed
+172 -80

No files matched your search

+6 -2
View File
@@ -7693,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",
@@ -7745,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",
+6 -2
View File
@@ -7693,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}",
@@ -7745,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",
+6 -2
View File
@@ -7693,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}",
@@ -7745,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",
+6 -2
View File
@@ -7693,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}",
@@ -7745,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",
+6 -2
View File
@@ -7693,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}",
@@ -7745,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",
+6 -2
View File
@@ -7693,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}",
@@ -7745,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",
@@ -57,6 +57,25 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
// the engine on thin data — never a recovery claim, never a second gap.
const sleep = value.sleepContext;
// The engine can return `ok` with NO findings — a mild episode where nothing
// strayed notably from the personal band, still active with no recovery gap,
// and no red flag. Without this the card body rendered empty under its "how
// it progressed" heading, reading as if nothing was recorded at all. Show a
// calm, honest line instead (retrospective, never a fabricated finding).
const hasContent =
value.redFlags.length > 0 ||
gap !== null ||
value.preOnset.length > 0 ||
value.nadir.length > 0;
if (!hasContent) {
return (
<p className="text-muted-foreground text-sm">
{t("illness.correlation.settled")}
</p>
);
}
return (
<div className="space-y-5">
{value.redFlags.length > 0 ? (
+46 -4
View File
@@ -83,6 +83,10 @@ export function NewEpisodeSheet({
const [type, setType] = useState<IllnessType>("INFECTION");
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
const [onset, setOnset] = useState(today);
// Resolved/end date — "" means the episode is still ongoing (open-ended).
// Backdating both onset and this end date lets a whole past episode be
// recorded after the fact.
const [resolved, setResolved] = useState("");
const [parentId, setParentId] = useState<string>(NONE);
const [note, setNote] = useState("");
@@ -96,6 +100,9 @@ export function NewEpisodeSheet({
setType(editEpisode?.type ?? "INFECTION");
setLifecycle(editEpisode?.lifecycle ?? "ACUTE");
setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today);
setResolved(
editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "",
);
setParentId(editEpisode?.parentConditionId ?? NONE);
setNote(editEpisode?.note ?? "");
} else if (!open && wasOpen) {
@@ -104,9 +111,17 @@ export function NewEpisodeSheet({
const showParent = PARENTABLE.includes(lifecycle);
// The end date can never precede the start; the field is min-bounded to the
// onset, and this guard belts-and-braces the same invariant the server holds.
const endBeforeStart = resolved !== "" && resolved < onset;
async function handleSave() {
if (label.trim() === "") return;
if (label.trim() === "" || endBeforeStart) return;
const onsetAt = new Date(`${onset}T12:00:00`).toISOString();
// "" ⇒ ongoing (null clears any stored end). Anchor to local noon so the
// UTC instant lands on the intended calendar day regardless of offset.
const resolvedAt =
resolved === "" ? null : new Date(`${resolved}T12:00:00`).toISOString();
const parentConditionId = showParent && parentId !== NONE ? parentId : null;
try {
if (isEdit && editEpisode) {
@@ -117,6 +132,7 @@ export function NewEpisodeSheet({
type,
lifecycle,
onsetAt,
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
},
@@ -126,9 +142,8 @@ export function NewEpisodeSheet({
label: label.trim(),
type,
lifecycle,
// Anchor to local noon so the UTC instant lands on the intended
// calendar day regardless of the viewer's offset.
onsetAt,
resolvedAt,
parentConditionId,
note: note.trim() === "" ? null : note,
});
@@ -154,7 +169,7 @@ export function NewEpisodeSheet({
</Button>
<Button
onClick={handleSave}
disabled={pending || label.trim() === ""}
disabled={pending || label.trim() === "" || endBeforeStart}
>
{t("common.save")}
</Button>
@@ -240,6 +255,33 @@ export function NewEpisodeSheet({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-resolved">{t("illness.new.resolved")}</Label>
<div className="flex items-center gap-2">
<DateField
id="illness-resolved"
value={resolved}
min={onset}
max={today}
onChange={setResolved}
className="max-w-44"
/>
{resolved !== "" ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResolved("")}
>
{t("illness.new.markOngoing")}
</Button>
) : null}
</div>
<p className="text-muted-foreground text-xs">
{t("illness.new.resolvedHelp")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
<Textarea
+3
View File
@@ -59,6 +59,9 @@ export interface IllnessEpisodeCreateInput {
type: IllnessType;
lifecycle?: IllnessLifecycle;
onsetAt?: string;
/** Optional resolved/end instant — set to backdate an already-recovered
* episode captured after the fact; omit/null for an ongoing episode. */
resolvedAt?: string | null;
parentConditionId?: string | null;
note?: string | null;
}
@@ -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} />}
/>
+28 -32
View File
@@ -353,23 +353,18 @@ describe("resolveModuleEnabled — mcp module (opt-in, default-OFF)", () => {
});
describe("registry — opt-in marker", () => {
// The opt-in modules ship dark because they open an egress/attack surface or
// are highly sensitive: `mcp` (remote external-assistant endpoint),
// `environment` (outbound weather fetch tied to a coarse location),
// `inboundDocuments` (sends an uploaded clinical document to the OCR/vision
// provider), `mentalHealth` (a depression / anxiety self-assessment — at
// least as sensitive as mood, surfaced only on explicit opt-in), and
// v1.29.1 — a fresh install defaults every TRACKING module on so the app is
// usable without enabling each one. Only two modules stay opt-in (ship dark),
// because each opens a channel that must be turned on deliberately:
// `mcp` (the remote external-assistant endpoint — a new attack surface) and
// `nutrients` (supplement-pattern intake synced from the phone's health
// store — the device-side HealthKit permission is not visible consent to
// server/AI-side storage, so ingest refuses while off). Every other
// module is the default-on disabled-allowlist.
const OPT_IN_KEYS = new Set([
"mcp",
"environment",
"inboundDocuments",
"mentalHealth",
"nutrients",
]);
// server/AI-side storage, so ingest refuses while off). Everything else —
// including `environment`, `inboundDocuments`, and `mentalHealth` — is the
// default-on disabled-allowlist now; their egress channels (the weather
// fetch, the document AI-read, the AI Coach exposure) are separately gated
// and stay off until explicitly configured.
const OPT_IN_KEYS = new Set(["mcp", "nutrients"]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
for (const key of MODULE_KEYS) {
@@ -379,35 +374,36 @@ describe("registry — opt-in marker", () => {
});
describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => {
// Parked in code from v1.25.3; the document vault re-enabled it. Normal
// two-layer opt-in resolution applies again: dark by default, ON only on
// an explicit per-user opt-in, and an operator `false` still wins.
// v1.29.1 — default-on now (a fresh install can store + organise documents
// out of the box). The AI READ of a document is a separate per-user opt-in
// (`documentsAutoAiRead`), so default-on carries no silent egress. Normal
// disabled-allowlist resolution: ON unless an explicit `false`, operator wins.
it("is no longer flagged as code-disabled in the registry", () => {
expect(isCodeDisabledModule("inboundDocuments")).toBe(false);
});
it("is OFF when no preference is recorded (opt-in ships dark)", () => {
it("is ON when no preference is recorded (default-on like its siblings)", () => {
expect(
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
).toBe(false);
});
it("is ON when the user opted in", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
false,
ALL_AVAILABLE,
),
).toBe(true);
});
it("stays OFF when the operator disabled it, even with a user opt-in", () => {
it("is OFF when the user opted out", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs({ modulePreferences: { inboundDocuments: true } }),
inputs({ modulePreferences: { inboundDocuments: false } }),
false,
ALL_AVAILABLE,
),
).toBe(false);
});
it("stays OFF when the operator disabled it, even without a user opt-out", () => {
expect(
resolveModuleEnabled(
"inboundDocuments",
inputs(),
false,
operator({ inboundDocuments: false }),
),
+32 -31
View File
@@ -118,11 +118,11 @@ export const MODULE_KEYS = [
// re-enabling finds the rows intact.
"medications",
"doctorReport",
// v1.25.0 (W-ENV) — environmental-context module. Like `mcp`, it is OPT-IN
// (off by default): it performs an outbound weather fetch tied to where the
// user physically is (a coarse home / travel location), so opt-in is the
// right privacy default. With it off no job runs, no row is written, and no
// egress happens. The `optIn` marker inverts the per-user default.
// v1.25.0 (W-ENV) — environmental-context module. Default-on since v1.29.1
// (a fresh install is usable without a per-module opt-in); the outbound
// weather fetch only runs once the user configures a home / travel location
// on the Environment settings surface, so default-on carries no silent
// egress. No `optIn` marker → the ordinary default-on disabled-allowlist.
"environment",
// v1.22.0 — the remote Model Context Protocol endpoint (`/mcp`). Unlike
// every other module this is OPT-IN (off by default): it exposes a new
@@ -133,19 +133,17 @@ export const MODULE_KEYS = [
// reuses the existing module machinery unchanged.
"mcp",
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (`/documents/inbound`).
// Like `mcp` this is OPT-IN (off by default): ingesting a doctor report /
// discharge letter sends the document to the configured OCR/vision provider,
// so the surface ships dark and the user turns it on deliberately. The
// `optIn` marker on its registry entry inverts the per-user default;
// everything else reuses the existing module machinery unchanged.
// Default-on since v1.29.1: a fresh install can store + organise documents
// in the vault out of the box. The AI READ of a document (the egress to the
// OCR/vision provider) stays a SEPARATE per-user opt-in
// (`documentsAutoAiRead`), so the module being on never egresses on its own.
// No `optIn` marker → the ordinary default-on disabled-allowlist.
"inboundDocuments",
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7), beside mood
// tracking. OPT-IN (off by default): a depression / anxiety self-assessment
// is at least as sensitive as mood, so it ships dark and the user turns it on
// deliberately. The `optIn` marker inverts the per-user default; with it off
// the `/mental-wellbeing` surface is hidden from nav and the
// assessment routes refuse server-side. Item answers are always encrypted and
// always excluded from the AI Coach / MCP regardless of this toggle.
// v1.25.0 — mental-health screeners (WHO-5 / PHQ-9 / GAD-7), beside mood
// tracking. Default-on since v1.29.1: the `/mental-wellbeing` check-in is
// available on a fresh install alongside mood. Item answers are always
// encrypted and always excluded from the AI Coach / MCP regardless of this
// toggle. No `optIn` marker → the ordinary default-on disabled-allowlist.
"mentalHealth",
// v1.28 — micronutrient-intake sync (vitamins, minerals, water,
// caffeine as day totals from Apple Health). OPT-IN (off by default):
@@ -296,11 +294,13 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
labelKey: "modules.environment.label",
descriptionKey: "modules.environment.description",
category: "device",
// Off by default: performs an outbound weather fetch tied to a coarse
// location, so it ships dark and the user turns it on deliberately. The
// home location + travel overrides + backfill live on the dedicated
// Environment settings surface (rendered when the module is on).
optIn: true,
// Default-on tracking module (v1.29.1): a fresh install ships the
// environmental-context surface usable without a separate opt-in. No
// egress happens until the user configures a home location on the
// dedicated Environment settings surface, so default-on carries no
// silent outbound fetch. The MCP endpoint + the auto-AI-read of
// documents stay opt-in — those are the surfaces that open an egress /
// attack channel the moment they are on.
},
mcp: {
key: "mcp",
@@ -316,21 +316,22 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
labelKey: "modules.inboundDocuments.label",
descriptionKey: "modules.inboundDocuments.description",
category: "tracking",
// Off by default: ingesting a clinical document egresses it to the
// configured OCR/vision provider, so the user turns it on deliberately.
optIn: true,
// Default-on tracking module (v1.29.1): a fresh install can store and
// organise clinical documents in the vault out of the box. The AI READ
// of a document (the egress to the OCR/vision provider) is a SEPARATE
// per-user opt-in (`documentsAutoAiRead`), so the module being on never
// sends a document anywhere on its own.
},
mentalHealth: {
key: "mentalHealth",
labelKey: "modules.mentalHealth.label",
descriptionKey: "modules.mentalHealth.description",
category: "tracking",
// Off by default: a depression / anxiety screener is highly sensitive, so
// the surface ships dark and the user opts in deliberately. Turning it on
// reveals the `/mental-wellbeing` check-in and lets the screener
// total ride the doctor-report export; item answers stay encrypted and
// off the AI Coach / MCP regardless.
optIn: true,
// Default-on tracking module (v1.29.1): the `/mental-wellbeing` check-in
// (WHO-5 / PHQ-9 / GAD-7) is available on a fresh install alongside mood.
// Item answers stay encrypted at rest and remain excluded from the AI
// Coach / MCP regardless of this toggle — the screener total may ride the
// doctor-report export the user assembles.
},
nutrients: {
key: "nutrients",