diff --git a/messages/de.json b/messages/de.json
index 3dfe3715f..aa4bcd595 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index d789de5f6..af5e01480 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index fdd912d82..09e524701 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index ac8b3e0c8..a37d14cea 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 32d7464c8..6dbf5208e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index c5e8028ff..beb3947b1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
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")}
+
+
+
+ // 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 : (
+
+ )
}
footer={}
/>
diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts
index 54a800165..be671ea46 100644
--- a/src/lib/modules/__tests__/gate.test.ts
+++ b/src/lib/modules/__tests__/gate.test.ts
@@ -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 }),
),
diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts
index 1d5b356b2..16546d4c3 100644
--- a/src/lib/modules/registry.ts
+++ b/src/lib/modules/registry.ts
@@ -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> =
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> =
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",