mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user