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

+16
View File
@@ -18,6 +18,7 @@ import {
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { isModuleEnabled } from "@/lib/modules/gate";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -444,6 +445,21 @@ async function buildAnalyticsResponse(user: AuthedUser) {
moodEnabled,
});
// v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is
// active we frame the score (not penalise it): the number above is left
// exactly as computed and we attach a value-free "you were unwell" context.
// Resolved server-side so iOS mirrors the same fact, never recomputing it.
if (healthScore) {
const restMode = await resolveRestMode(user.id);
healthScore.restMode = restMode.active
? {
active: true,
since: restMode.since,
episodeCount: restMode.episodeCount,
}
: null;
}
return {
summaries: results,
bmi,
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -98,6 +99,7 @@ beforeEach(() => {
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -95,6 +96,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -81,6 +82,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
@@ -27,6 +27,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
userHealthProfile: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn() },
},
}));
@@ -87,6 +88,7 @@ beforeEach(() => {
count: 0,
} as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue(
null as never,
);
+61 -8
View File
@@ -9,6 +9,7 @@ import {
ACHIEVEMENT_DEFINITIONS,
GAMIFICATION_ROLLOUT_AT,
applyDiscoveryFilter,
bridgeFrozenStreakGaps,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
moduleForMetric,
@@ -90,6 +91,29 @@ function maxDate(a: Date, b: Date): Date {
return a > b ? a : b;
}
/**
* v1.18.1 P4 — every Berlin-local day key covered by an illness episode
* (onset day through the resolved day, or through `now` while ongoing),
* inclusive on both ends. These are the days a streak is allowed to lapse
* across without breaking (the Rest Mode freeze). Uses the same
* `toBerlinDayKey` day anchoring every other streak series uses, so the
* frozen days line up with the qualifying days exactly.
*/
function collectIllnessDayKeys(
episodes: Array<{ onsetAt: Date; resolvedAt: Date | null }>,
now: Date,
): Set<string> {
const frozen = new Set<string>();
for (const ep of episodes) {
const startSerial = dayKeyToSerial(toBerlinDayKey(ep.onsetAt));
const endSerial = dayKeyToSerial(toBerlinDayKey(ep.resolvedAt ?? now));
for (let serial = startSerial; serial <= endSerial; serial++) {
frozen.add(serialToDayKey(serial));
}
}
return frozen;
}
function dayKeyToSerial(dayKey: string): number {
const [year, month, day] = dayKey.split("-").map(Number);
return Date.UTC(year, month - 1, day) / DAY_MS;
@@ -612,6 +636,7 @@ async function buildAchievementsResult(
moodEntries,
sleepMeasurements,
healthProfile,
illnessEpisodes,
] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -737,8 +762,33 @@ async function buildAchievementsResult(
updatedAt: true,
},
}),
// v1.18.1 P4 — illness episodes over the window, for the streak-freeze.
// Gated on the illness module so a non-illness account does no read and
// never freezes anything. Both ongoing and already-resolved episodes are
// read: a past illness should not retroactively break a historical
// streak, just as an active one should not break the current run.
moduleMap.illness !== false
? prisma.illnessEpisode.findMany({
where: {
userId,
deletedAt: null,
onsetAt: { lte: now },
OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }],
},
select: { onsetAt: true, resolvedAt: true },
})
: Promise.resolve([]),
]);
// v1.18.1 P4 — Rest Mode streak-freeze. Build the set of Berlin-local day
// keys covered by an illness episode (onset → resolved, or → now while
// ongoing). A day-streak that lapses only across these days is bridged
// rather than broken (see `bridgeFrozenStreakGaps`): being unwell pauses a
// streak, it does not fail it.
const frozenIllnessDayKeys = collectIllnessDayKeys(illnessEpisodes, now);
const freeze = (dayKeys: string[]): string[] =>
bridgeFrozenStreakGaps(dayKeys, frozenIllnessDayKeys);
const schedulesByMedicationId = new Map(
medications.map((med) => [med.id, med.schedules]),
);
@@ -822,22 +872,25 @@ async function buildAchievementsResult(
totalTakenIntakes: takenIntakeDates.length,
overIntakeCount: intakeIssueMetrics.overIntakeCount,
skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount,
bmiGreenStreak: calculateLongestStreak(healthSeries.bmi.dayKeys),
bpGreenStreak: calculateLongestStreak(healthSeries.bp.dayKeys),
pulseGreenStreak: calculateLongestStreak(healthSeries.pulse.dayKeys),
onTimePerfectDayStreak: calculateLongestStreak(onTimeSeries.dayKeys),
compliance80DayStreak: calculateLongestStreak(complianceSeries.dayKeys),
// v1.18.1 P4 — every day-streak runs through `freeze(...)` so an active
// or past illness episode bridges (does not break) the run across the
// days the user was unwell.
bmiGreenStreak: calculateLongestStreak(freeze(healthSeries.bmi.dayKeys)),
bpGreenStreak: calculateLongestStreak(freeze(healthSeries.bp.dayKeys)),
pulseGreenStreak: calculateLongestStreak(freeze(healthSeries.pulse.dayKeys)),
onTimePerfectDayStreak: calculateLongestStreak(freeze(onTimeSeries.dayKeys)),
compliance80DayStreak: calculateLongestStreak(freeze(complianceSeries.dayKeys)),
passkeyCreatedCount: passkeyCreatedDates.length,
passkeyLoginCount: passkeyLoginDates.length,
passwordLoginCount: passwordLoginDates.length,
loginDayStreak: calculateLongestStreak(loginDaySeries.dayKeys),
loginDayStreak: calculateLongestStreak(freeze(loginDaySeries.dayKeys)),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
missFreeDayStreak: calculateLongestStreak(missFreeSeries.dayKeys),
missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)),
measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
selfContextCompleteCount: selfContextComplete,
sleepLogDayStreak: calculateLongestStreak(sleepSeries.dayKeys),
sleepLogDayStreak: calculateLongestStreak(freeze(sleepSeries.dayKeys)),
};
const completionDates: Partial<Record<string, Date>> = {};