mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
ACHIEVEMENT_DEFINITIONS,
|
|
ACHIEVEMENT_CATEGORY_ORDER,
|
|
applyDiscoveryFilter,
|
|
bridgeFrozenStreakGaps,
|
|
calculateLongestStreak,
|
|
evaluateAchievementsWithCompletionDates,
|
|
getUniqueBerlinDays,
|
|
type AchievementMetrics,
|
|
type AchievementProgress,
|
|
type EarnabilityFlags,
|
|
} from "@/lib/gamification/achievements";
|
|
|
|
const FULL_METRICS: AchievementMetrics = {
|
|
totalTakenIntakes: 1000,
|
|
overIntakeCount: 1000,
|
|
skippedIntakeCount: 1000,
|
|
bmiGreenStreak: 1000,
|
|
bpGreenStreak: 1000,
|
|
pulseGreenStreak: 1000,
|
|
onTimePerfectDayStreak: 1000,
|
|
compliance80DayStreak: 1000,
|
|
passkeyCreatedCount: 1000,
|
|
passkeyLoginCount: 1000,
|
|
passwordLoginCount: 1000,
|
|
loginDayStreak: 1000,
|
|
bugReportCount: 1000,
|
|
moodEntryCount: 1000,
|
|
moodDayStreak: 1000,
|
|
moodImprovementHit: 1000,
|
|
weightMeasurementCount: 1000,
|
|
bpMeasurementCount: 1000,
|
|
pulseMeasurementCount: 1000,
|
|
consistentMonthCount: 1000,
|
|
entryDayStreak: 1000,
|
|
weekendStreakCount: 1000,
|
|
nightOwlCount: 1000,
|
|
earlyBirdCount: 1000,
|
|
leapDayCount: 1000,
|
|
doctorPdfCount: 1000,
|
|
localeFlipCount: 1000,
|
|
missFreeDayStreak: 1000,
|
|
measurementConsistencyWeeks: 1000,
|
|
selfContextCompleteCount: 1000,
|
|
sleepLogDayStreak: 1000,
|
|
};
|
|
|
|
const ZERO_METRICS: AchievementMetrics = Object.fromEntries(
|
|
Object.keys(FULL_METRICS).map((key) => [key, 0]),
|
|
) as unknown as AchievementMetrics;
|
|
|
|
const ALL_EARNABLE: EarnabilityFlags = {
|
|
hasMedication: true,
|
|
hasMood: true,
|
|
hasWeight: true,
|
|
hasBp: true,
|
|
hasPulse: true,
|
|
hasSleep: true,
|
|
};
|
|
|
|
const NONE_EARNABLE: EarnabilityFlags = {
|
|
hasMedication: false,
|
|
hasMood: false,
|
|
hasWeight: false,
|
|
hasBp: false,
|
|
hasPulse: false,
|
|
hasSleep: false,
|
|
};
|
|
|
|
describe("gamification achievements", () => {
|
|
it("deduplicates and sorts day keys", () => {
|
|
const days = getUniqueBerlinDays([
|
|
new Date("2026-02-11T12:00:00Z"),
|
|
new Date("2026-02-10T12:00:00Z"),
|
|
new Date("2026-02-11T16:00:00Z"),
|
|
]);
|
|
|
|
expect(days).toEqual(["2026-02-10", "2026-02-11"]);
|
|
});
|
|
|
|
it("calculates the longest streak", () => {
|
|
expect(
|
|
calculateLongestStreak([
|
|
"2026-02-01",
|
|
"2026-02-02",
|
|
"2026-02-04",
|
|
"2026-02-05",
|
|
"2026-02-06",
|
|
]),
|
|
).toBe(3);
|
|
});
|
|
|
|
it("ships the expanded definition list (65 total — v1.4.18 + v1.16.1 care routine)", () => {
|
|
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(65);
|
|
});
|
|
|
|
it("includes mood and hidden categories in the render order", () => {
|
|
expect(ACHIEVEMENT_CATEGORY_ORDER).toContain("mood");
|
|
expect(ACHIEVEMENT_CATEGORY_ORDER).toContain("hidden");
|
|
});
|
|
|
|
it("evaluates unlocked achievements and points when all metrics maxed", () => {
|
|
const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {});
|
|
|
|
expect(result.summary.unlockedCount).toBe(65);
|
|
expect(result.summary.totalCount).toBe(65);
|
|
expect(result.summary.nextAchievement).toBeNull();
|
|
expect(result.summary.earnedPoints).toBe(result.summary.totalPoints);
|
|
});
|
|
|
|
it("flags hidden achievements with isHidden", () => {
|
|
const hidden = ACHIEVEMENT_DEFINITIONS.filter((def) => def.isHidden);
|
|
expect(hidden.length).toBeGreaterThanOrEqual(5);
|
|
expect(hidden.length).toBeLessThanOrEqual(8);
|
|
for (const def of hidden) {
|
|
expect(def.category).toBe("hidden");
|
|
}
|
|
});
|
|
|
|
it("flags non-hidden achievements with category != hidden", () => {
|
|
const visible = ACHIEVEMENT_DEFINITIONS.filter((def) => !def.isHidden);
|
|
for (const def of visible) {
|
|
expect(def.category).not.toBe("hidden");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("applyDiscoveryFilter", () => {
|
|
function progressFor(id: string): AchievementProgress {
|
|
const def = ACHIEVEMENT_DEFINITIONS.find((d) => d.id === id);
|
|
if (!def) throw new Error(`unknown achievement: ${id}`);
|
|
return {
|
|
id: def.id,
|
|
metric: def.metric,
|
|
category: def.category,
|
|
titleKey: def.tTitle,
|
|
descriptionKey: def.tDescription,
|
|
icon: def.icon,
|
|
format: def.format,
|
|
target: def.target,
|
|
current: 0,
|
|
points: def.points,
|
|
unlocked: false,
|
|
progressPercent: 0,
|
|
completedAt: null,
|
|
isHidden: def.isHidden,
|
|
};
|
|
}
|
|
|
|
it("hides public mood achievements when the user has no mood data", () => {
|
|
const items = [progressFor("mood-first")];
|
|
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
|
|
expect(filtered).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps mood achievements when the user has at least one mood entry", () => {
|
|
const items = [progressFor("mood-first")];
|
|
const filtered = applyDiscoveryFilter(items, {
|
|
...NONE_EARNABLE,
|
|
hasMood: true,
|
|
});
|
|
expect(filtered).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps unlocked achievements even if precondition no longer holds (anti-regression)", () => {
|
|
const items = [{ ...progressFor("mood-first"), unlocked: true }];
|
|
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
|
|
expect(filtered).toHaveLength(1);
|
|
});
|
|
|
|
it("always keeps hidden achievements regardless of earnability", () => {
|
|
const items = [progressFor("hidden-night-owl")];
|
|
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
|
|
expect(filtered).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps engagement / security achievements unconditionally", () => {
|
|
const items = [
|
|
progressFor("login-streak-7"),
|
|
progressFor("passkey-created-1"),
|
|
];
|
|
const filtered = applyDiscoveryFilter(items, NONE_EARNABLE);
|
|
expect(filtered).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps medication achievements only when the user has at least one medication", () => {
|
|
const items = [progressFor("intake-total-1")];
|
|
expect(applyDiscoveryFilter(items, NONE_EARNABLE)).toHaveLength(0);
|
|
expect(
|
|
applyDiscoveryFilter(items, { ...NONE_EARNABLE, hasMedication: true }),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("zero-progress full set with all-earnable still yields full count", () => {
|
|
const result = evaluateAchievementsWithCompletionDates(ZERO_METRICS, {});
|
|
const filtered = applyDiscoveryFilter(result.achievements, ALL_EARNABLE);
|
|
expect(filtered.length).toBe(result.achievements.length);
|
|
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);
|
|
});
|
|
});
|