feat(illness): Rest Mode — annotate score/recovery/streaks during an active episode + FHIR/doctor-report

An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.

- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
  for "is the account in Rest Mode + which episodes back it". iOS mirrors the
  resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
  computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
  scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
  skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
  day-streak across illness days; never invents a streak, a non-illness gap
  still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
  numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
  the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
  code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
  (retrospective, neutral palette); FHIR Condition section in the bundle.

i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 22:25:50 +02:00
1 parent 8447ad90cc
commit c59cf0b652
37 files changed
+1352 -24

No files matched your search

@@ -3,6 +3,7 @@ import {
ACHIEVEMENT_DEFINITIONS,
ACHIEVEMENT_CATEGORY_ORDER,
applyDiscoveryFilter,
bridgeFrozenStreakGaps,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
getUniqueBerlinDays,
@@ -198,3 +199,43 @@ describe("applyDiscoveryFilter", () => {
expect(result.summary.unlockedCount).toBe(0);
});
});
// v1.18.1 P4 — Rest Mode streak-freeze. A streak that lapses ONLY across
// illness days bridges into one continuous run rather than breaking; it is
// never invented, and a non-illness gap still breaks the streak.
describe("bridgeFrozenStreakGaps (Rest Mode streak-freeze)", () => {
it("is a no-op with no frozen days (returns the sorted input)", () => {
const days = ["2026-06-03", "2026-06-01", "2026-06-02"];
expect(bridgeFrozenStreakGaps(days, new Set())).toEqual([
"2026-06-01",
"2026-06-02",
"2026-06-03",
]);
});
it("bridges a gap whose every interior day is frozen, preserving the streak", () => {
// Tracked 06-01, then ill 06-02..06-04, tracked again 06-05. Without the
// freeze the streak would break to 1; with it, the run is continuous.
const tracked = ["2026-06-01", "2026-06-05"];
const frozen = new Set(["2026-06-02", "2026-06-03", "2026-06-04"]);
const bridged = bridgeFrozenStreakGaps(tracked, frozen);
expect(calculateLongestStreak(bridged)).toBe(5);
});
it("does NOT bridge a gap that contains a non-frozen missed day (genuine break)", () => {
const tracked = ["2026-06-01", "2026-06-05"];
// 06-03 is NOT a frozen illness day → the gap is a real lapse.
const frozen = new Set(["2026-06-02", "2026-06-04"]);
const bridged = bridgeFrozenStreakGaps(tracked, frozen);
expect(calculateLongestStreak(bridged)).toBe(1);
});
it("never invents a streak from a lone frozen day with no surrounding run", () => {
const tracked = ["2026-06-10"];
const frozen = new Set(["2026-06-02", "2026-06-03"]);
const bridged = bridgeFrozenStreakGaps(tracked, frozen);
// A single tracked day, untouched — the frozen days bridge nothing.
expect(bridged).toEqual(["2026-06-10"]);
expect(calculateLongestStreak(bridged)).toBe(1);
});
});
+62
View File
@@ -791,6 +791,68 @@ export function calculateLongestStreak(dayKeys: string[]): number {
return maxStreak;
}
/**
* v1.18.1 P4 — freeze (don't break) a day-streak across illness days.
*
* Rest Mode principle: a streak that lapses ONLY because the user was unwell
* should not be broken — being ill is not a failure to track. This pure
* helper bridges any calendar gap between two qualifying days when EVERY
* missing day in the gap is a frozen (active-illness) day. The bridged days
* are folded into the series so `calculateLongestStreak` sees one continuous
* run across the episode.
*
* It never INVENTS a streak: a frozen day with no surrounding qualifying days
* on either side adds nothing (there is no run to preserve), and a gap that
* contains even one non-frozen missed day stays a genuine break. The user is
* neither penalised for being ill nor rewarded for it — the streak simply
* pauses and resumes.
*
* Both inputs are `YYYY-MM-DD` keys. Returns a fresh ascending, de-duplicated
* day-key array; the original is not mutated. A no-op (returns the sorted
* input) when `frozenDayKeys` is empty.
*/
export function bridgeFrozenStreakGaps(
dayKeys: string[],
frozenDayKeys: ReadonlySet<string>,
): string[] {
const unique = Array.from(new Set(dayKeys)).sort();
if (frozenDayKeys.size === 0 || unique.length < 2) return unique;
const frozenSerials = new Set<number>();
for (const key of frozenDayKeys) frozenSerials.add(dayKeyToNumber(key));
const bridged = new Set(unique);
for (let i = 1; i < unique.length; i++) {
const prev = dayKeyToNumber(unique[i - 1]);
const current = dayKeyToNumber(unique[i]);
if (current - prev <= 1) continue;
// A gap. Bridge it only when every interior day is a frozen illness day.
let allFrozen = true;
for (let serial = prev + 1; serial < current; serial++) {
if (!frozenSerials.has(serial)) {
allFrozen = false;
break;
}
}
if (allFrozen) {
for (let serial = prev + 1; serial < current; serial++) {
bridged.add(serialToDayKey(serial));
}
}
}
return Array.from(bridged).sort();
}
/** Inverse of `dayKeyToNumber` — a UTC day serial back to a `YYYY-MM-DD` key. */
function serialToDayKey(serial: number): string {
const d = new Date(serial * 86_400_000);
const year = d.getUTCFullYear();
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function calculateProgress(current: number, target: number): number {
if (target <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / target) * 100)));