mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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>> = {};
|
||||
|
||||
Reference in new issue
Block a user