Files
HealthLog/src/app/api/gamification/achievements/route.ts
T
Marc-André Bombeck c59cf0b652 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.
2026-06-16 22:25:50 +02:00

1248 lines
39 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import type { ModuleKey } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
ACHIEVEMENT_DEFINITIONS,
GAMIFICATION_ROLLOUT_AT,
applyDiscoveryFilter,
bridgeFrozenStreakGaps,
calculateLongestStreak,
evaluateAchievementsWithCompletionDates,
moduleForMetric,
toBerlinDayKey,
type AchievementMetrics,
type AchievementProgress,
} from "@/lib/gamification/achievements";
import {
buildExpansionMetricValues,
getEarnabilityFlags,
} from "@/lib/gamification/expansion-metrics";
import {
getMissFreeDayKeys,
getWeeklyConsistency,
} from "@/lib/gamification/care-metrics";
import {
classifyBMI,
classifyBP,
classifyPulse,
} from "@/lib/analytics/classifications";
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { NextRequest } from "next/server";
interface IosAchievement {
id: string;
key: string;
title: string;
description: string;
iconName: string;
unlocked: boolean;
unlockedAt: string | null;
progress: number;
// v1.18.0 B5 — parity fields the web payload already carries; the iOS
// client needs them to group badges by category, render the points
// tally, show absolute progress (current / target) and the opaque
// hidden-card placeholder in lock-step with the web surface.
category: string;
points: number;
target: number;
current: number;
isHidden: boolean;
}
export const dynamic = "force-dynamic";
const DAY_MS = 24 * 60 * 60 * 1000;
const ROLLING_WINDOW_DAYS = 30;
// v1.16.1 — weekly measurement-consistency badge: 4 consecutive weeks
// with at least 5 distinct active vitals days each. Mirrors the
// `measurement-weeks-4` definition's target.
const MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK = 5;
const MEASUREMENT_CONSISTENCY_TARGET_WEEKS = 4;
type IntakeEventRecord = {
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
/** v1.16.1 — feeds the miss-free day streak. */
autoMissed: boolean;
};
type MedicationScheduleRecord = {
windowStart: string;
windowEnd: string;
daysOfWeek: string | null;
};
type MeasurementRecord = {
type: "WEIGHT" | "BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA" | "PULSE";
value: number;
measuredAt: Date;
};
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;
}
function serialToDayKey(serial: number): string {
const date = new Date(serial * DAY_MS);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function dayKeyToDate(dayKey: string): Date {
const [year, month, day] = dayKey.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
function mean(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((total, value) => total + value, 0) / values.length;
}
function findCountCompletionDate(dates: Date[], target: number): Date | null {
if (dates.length < target) return null;
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
return sorted[target - 1] ?? null;
}
function findStreakCompletionDate(
dayKeys: string[],
target: number,
firstDateByDay: Map<string, Date>,
): Date | null {
if (dayKeys.length === 0) return null;
let streak = 1;
if (target === 1) {
return firstDateByDay.get(dayKeys[0]) ?? null;
}
for (let i = 1; i < dayKeys.length; i++) {
const previous = dayKeyToSerial(dayKeys[i - 1]);
const current = dayKeyToSerial(dayKeys[i]);
streak = current - previous === 1 ? streak + 1 : 1;
if (streak >= target) {
return firstDateByDay.get(dayKeys[i]) ?? null;
}
}
return null;
}
function toDaySeries(dayKeys: string[]): {
dayKeys: string[];
firstDateByDay: Map<string, Date>;
} {
const sortedKeys = [...new Set(dayKeys)].sort();
const firstDateByDay = new Map<string, Date>(
sortedKeys.map((key) => [key, dayKeyToDate(key)]),
);
return {
dayKeys: sortedKeys,
firstDateByDay,
};
}
function getEventDaySeries(dates: Date[]): {
dayKeys: string[];
firstDateByDay: Map<string, Date>;
} {
const firstDateByDay = new Map<string, Date>();
for (const date of dates) {
const dayKey = toBerlinDayKey(date);
const existing = firstDateByDay.get(dayKey);
if (!existing || date < existing) {
firstDateByDay.set(dayKey, date);
}
}
return {
dayKeys: Array.from(firstDateByDay.keys()).sort(),
firstDateByDay,
};
}
function getHealthGreenDaySeries(
measurements: MeasurementRecord[],
heightCm: number | null,
) {
const dayStats = new Map<
string,
{
weight: number[];
bpSys: number[];
bpDia: number[];
pulse: number[];
}
>();
for (const measurement of measurements) {
const dayKey = toBerlinDayKey(measurement.measuredAt);
const bucket = dayStats.get(dayKey) ?? {
weight: [],
bpSys: [],
bpDia: [],
pulse: [],
};
if (measurement.type === "WEIGHT") bucket.weight.push(measurement.value);
if (measurement.type === "BLOOD_PRESSURE_SYS")
bucket.bpSys.push(measurement.value);
if (measurement.type === "BLOOD_PRESSURE_DIA")
bucket.bpDia.push(measurement.value);
if (measurement.type === "PULSE") bucket.pulse.push(measurement.value);
dayStats.set(dayKey, bucket);
}
const bmiGreenDays: string[] = [];
const bpGreenDays: string[] = [];
const pulseGreenDays: string[] = [];
for (const [dayKey, bucket] of dayStats.entries()) {
if (heightCm && bucket.weight.length > 0) {
const bmi = mean(bucket.weight) / (heightCm / 100) ** 2;
if (classifyBMI(bmi).severity === "normal") {
bmiGreenDays.push(dayKey);
}
}
if (bucket.bpSys.length > 0 && bucket.bpDia.length > 0) {
const bpClass = classifyBP(mean(bucket.bpSys), mean(bucket.bpDia));
if (bpClass.severity === "normal") {
bpGreenDays.push(dayKey);
}
}
if (bucket.pulse.length > 0) {
const pulseClass = classifyPulse(mean(bucket.pulse));
if (pulseClass.severity === "normal") {
pulseGreenDays.push(dayKey);
}
}
}
return {
bmi: toDaySeries(bmiGreenDays),
bp: toDaySeries(bpGreenDays),
pulse: toDaySeries(pulseGreenDays),
};
}
function getOnTimePerfectDaySeries(
intakeEvents: IntakeEventRecord[],
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
) {
const eventsByDay = new Map<string, IntakeEventRecord[]>();
for (const event of intakeEvents) {
const dayKey = toBerlinDayKey(event.scheduledFor);
const list = eventsByDay.get(dayKey) ?? [];
list.push(event);
eventsByDay.set(dayKey, list);
}
const perfectDays: string[] = [];
for (const [dayKey, events] of eventsByDay.entries()) {
if (events.length === 0) continue;
const scheduledDate = dayKeyToDate(dayKey);
let isPerfectDay = true;
for (const event of events) {
if (event.skipped || event.takenAt === null) {
isPerfectDay = false;
break;
}
const schedules = schedulesByMedicationId.get(event.medicationId) ?? [];
if (schedules.length === 0) {
continue;
}
const eventMinutes =
event.scheduledFor.getUTCHours() * 60 +
event.scheduledFor.getUTCMinutes();
let bestSchedule = schedules[0];
let bestDistance = Infinity;
for (const schedule of schedules) {
const [h, m] = schedule.windowStart.split(":").map(Number);
const scheduleMinutes = h * 60 + m;
const distance = Math.abs(eventMinutes - scheduleMinutes);
if (distance < bestDistance) {
bestDistance = distance;
bestSchedule = schedule;
}
}
const timing = classifyIntakeTiming(
event.takenAt,
bestSchedule.windowStart,
bestSchedule.windowEnd,
scheduledDate,
);
// v1.4.34 IW-C — `early` is also a compliant bucket (dose taken
// within the 3h pre-window grace), so it preserves a perfect day.
if (timing !== "on_time" && timing !== "early") {
isPerfectDay = false;
break;
}
}
if (isPerfectDay) {
perfectDays.push(dayKey);
}
}
return toDaySeries(perfectDays);
}
function parseDaysOfWeek(daysOfWeek: string | null): Set<number> | null {
if (!daysOfWeek) return null;
const values = daysOfWeek
.split(",")
.map((value) => Number(value.trim()))
.filter((value) => Number.isInteger(value) && value >= 0 && value <= 6);
return values.length > 0 ? new Set(values) : null;
}
function getExpectedIntakesForDay(
schedules: MedicationScheduleRecord[],
dayKey: string,
): number {
if (schedules.length === 0) return 0;
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKey)) return 0;
const day = dayKeyToDate(dayKey).getUTCDay();
let expected = 0;
for (const schedule of schedules) {
const allowedDays = parseDaysOfWeek(schedule.daysOfWeek);
if (!allowedDays || allowedDays.has(day)) {
expected += 1;
}
}
return expected;
}
function getIntakeIssueMetrics(
intakeEvents: IntakeEventRecord[],
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
) {
const skippedIntakeDates = intakeEvents
.filter((event) => event.skipped)
.map((event) => event.scheduledFor)
.sort((a, b) => a.getTime() - b.getTime());
const eventsByMedicationDay = new Map<
string,
Map<string, IntakeEventRecord[]>
>();
for (const event of intakeEvents) {
if (!event.medicationId || Number.isNaN(event.scheduledFor.getTime())) {
continue;
}
const dayKey = toBerlinDayKey(event.scheduledFor);
const byDay = eventsByMedicationDay.get(event.medicationId) ?? new Map();
const list = byDay.get(dayKey) ?? [];
list.push(event);
byDay.set(dayKey, list);
eventsByMedicationDay.set(event.medicationId, byDay);
}
let overIntakeCount = 0;
const overIntakeDates: Date[] = [];
for (const [medicationId, dayMap] of eventsByMedicationDay.entries()) {
const schedules = schedulesByMedicationId.get(medicationId) ?? [];
for (const [dayKey, events] of dayMap.entries()) {
const expectedCount = getExpectedIntakesForDay(schedules, dayKey);
if (expectedCount <= 0) {
continue;
}
const takenEvents = events
.filter((event) => !event.skipped && event.takenAt !== null)
.sort((a, b) => {
const aTime = (a.takenAt ?? a.scheduledFor).getTime();
const bTime = (b.takenAt ?? b.scheduledFor).getTime();
return aTime - bTime;
});
const excessCount = Math.max(0, takenEvents.length - expectedCount);
if (excessCount === 0) {
continue;
}
overIntakeCount += excessCount;
for (
let index = takenEvents.length - excessCount;
index < takenEvents.length;
index++
) {
const event = takenEvents[index];
if (!event) continue;
overIntakeDates.push(event.takenAt ?? event.scheduledFor);
}
}
}
overIntakeDates.sort((a, b) => a.getTime() - b.getTime());
return {
skippedIntakeCount: skippedIntakeDates.length,
skippedIntakeDates,
overIntakeCount,
overIntakeDates,
};
}
function getCompliance80DaySeries(
intakeEvents: IntakeEventRecord[],
startDate: Date,
endDate: Date,
) {
const perDayCounts = new Map<number, { logged: number; taken: number }>();
for (const event of intakeEvents) {
const serial = dayKeyToSerial(toBerlinDayKey(event.scheduledFor));
const current = perDayCounts.get(serial) ?? { logged: 0, taken: 0 };
const logged = event.skipped || event.takenAt !== null;
const taken = !event.skipped && event.takenAt !== null;
if (logged) current.logged += 1;
if (taken) current.taken += 1;
perDayCounts.set(serial, current);
}
const startSerial = dayKeyToSerial(toBerlinDayKey(startDate));
const endSerial = dayKeyToSerial(toBerlinDayKey(endDate));
let rollingLogged = 0;
let rollingTaken = 0;
const greenDays: string[] = [];
for (let serial = startSerial; serial <= endSerial; serial++) {
const todayCounts = perDayCounts.get(serial);
if (todayCounts) {
rollingLogged += todayCounts.logged;
rollingTaken += todayCounts.taken;
}
const outOfWindowCounts = perDayCounts.get(serial - ROLLING_WINDOW_DAYS);
if (outOfWindowCounts) {
rollingLogged -= outOfWindowCounts.logged;
rollingTaken -= outOfWindowCounts.taken;
}
const hasFullWindow = serial >= startSerial + (ROLLING_WINDOW_DAYS - 1);
if (!hasFullWindow || rollingLogged <= 0) continue;
const compliance = Math.round((rollingTaken / rollingLogged) * 100);
if (compliance >= 80) {
greenDays.push(serialToDayKey(serial));
}
}
return toDaySeries(greenDays);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// v1.18.0 — when the account has the achievements module turned off the
// whole gamification surface disappears: no badge evaluation, no unlock
// persistence, no payload. Returns the 403 `module.disabled` envelope
// verbatim so the client (web + iOS) hides the page / dashboard tile /
// unlock toast in lock-step with this refusal.
const gate = await requireModuleEnabled(user.id, "achievements");
if (!gate.enabled) {
annotate({
action: { name: "gamification.achievements" },
meta: { moduleDisabled: true },
});
return gate.response;
}
const formatParam = request.nextUrl.searchParams.get("format");
const isIosFormat = formatParam === "ios";
annotate({
action: { name: "gamification.achievements" },
meta: { format: isIosFormat ? "ios" : "default" },
});
// v1.4.34 IW-G — cache the web-shape result keyed on userId. The
// iOS-format branch runs the locale-aware transform after the cache
// read so the cache stays format-agnostic and the achievement-progress
// dashboard duplicate (seen twice per dashboard mount in the v1.4.33
// HAR) coalesces into one builder call. v1.18.0 B5 — unlock persistence
// now happens in the handler after the cache read (idempotent), not as
// a side effect inside the cached factory.
// v1.18.0 B5 — resolve the per-user module map once and pass it into the
// builder so badge categories whose owning module is disabled (sleep
// badges when sleep is off, mood badges when mood is off) are skipped
// from evaluation AND unlock-persistence. Resolved outside the cache so
// a toggle change is reflected on the next read.
const moduleMap = await resolveModuleMap(user.id);
const result = await cached(
caches.achievements as ServerCache<AchievementsResult>,
user.id,
() => buildAchievementsResult(user, moduleMap),
annotate,
);
// v1.18.0 B5 — persist newly unlocked achievements OUTSIDE the cached
// factory. `createMany({ skipDuplicates: true })` is idempotent on the
// `(userId, achievementId)` unique, so re-running it on a cache hit is
// a no-op rather than a duplicate, and the write is never skipped just
// because the read was served from cache.
if (result.pendingUnlocks.length > 0) {
await prisma.userAchievement.createMany({
data: result.pendingUnlocks.map((u) => ({
userId: user.id,
achievementId: u.achievementId,
unlockedAt: new Date(u.unlockedAt),
})),
skipDuplicates: true,
});
annotate({
action: { name: "gamification.achievements" },
meta: { newUnlocks: result.pendingUnlocks.length },
});
}
// Strip the internal `pendingUnlocks` carrier; it never goes on the wire.
const payload = {
summary: result.summary,
achievements: result.achievements,
metrics: result.metrics,
};
if (isIosFormat) {
const locale = await resolveServerLocale({
request,
userLocale: user.locale,
});
const t = getServerTranslator(locale);
const ios: IosAchievement[] = payload.achievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
description: t.t(a.descriptionKey),
iconName: a.icon,
unlocked: a.unlocked,
unlockedAt: a.completedAt,
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
category: a.category,
points: a.points,
target: a.target,
current: a.current,
isHidden: a.isHidden,
}));
return apiSuccess(ios);
}
return apiSuccess(payload);
});
type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
type AchievementsResult = Awaited<ReturnType<typeof buildAchievementsResult>>;
/**
* v1.4.34 IW-G — pulled out of the GET handler so `cached()` can wrap
* the heavy aggregation. Returns the locale-agnostic web payload; the
* iOS-format transform runs in the handler after the cache read.
*
* v1.18.0 B5 — this builder NO LONGER writes. It computes the not-yet-
* persisted unlocks and returns them as `pendingUnlocks`; the handler
* performs the idempotent `createMany({ skipDuplicates: true })` on every
* request after the cache read. That keeps the persistence off the cached
* path (a cache hit can't silently skip it, a concurrent miss can't
* duplicate it) while the unique `(userId, achievementId)` makes a repeat
* write a no-op.
*/
async function buildAchievementsResult(
user: AuthedUser,
moduleMap: Record<ModuleKey, boolean>,
) {
const now = new Date();
const userId = user.id;
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
const [
measurements,
intakeEvents,
medications,
passkeys,
auditEvents,
moodEntries,
sleepMeasurements,
healthProfile,
illnessEpisodes,
] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
measuredAt: { gte: startDate, lte: now },
source: { not: "IMPORT" },
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
// v1.4.41 W-DELETED-2 — soft-deleted measurements are excluded
// from achievement progress so deleting a row immediately rolls
// back any streak / count badge it earned.
deletedAt: null,
},
select: {
type: true,
value: true,
measuredAt: true,
},
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from streak counting.
deletedAt: null,
source: { not: "IMPORT" },
scheduledFor: { gte: startDate, lte: now },
},
select: {
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
// v1.16.1 — the miss-free streak disqualifies a day on any
// auto-missed slot.
autoMissed: true,
},
}),
prisma.medication.findMany({
where: { userId },
select: {
id: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
daysOfWeek: true,
},
},
},
}),
prisma.passkey.findMany({
where: {
userId,
createdAt: { gte: startDate, lte: now },
},
select: {
createdAt: true,
},
}),
prisma.auditLog.findMany({
where: {
userId,
createdAt: { gte: startDate, lte: now },
action: {
in: [
"auth.login.passkey",
"auth.login.password",
"bugreport.submit",
// v1.4.18 — hidden Easter-egg triggers
"doctor-report.generate",
"doctor-report.pdf.generate",
"settings.locale.update",
],
},
},
select: {
action: true,
createdAt: true,
},
}),
// v1.4.18 — mood entries feed the new mood badges + the
// entry-streak/consistent-month engagement metrics. Synced from
// moodLog.app or entered directly; we intentionally include all
// sources because consistency-of-tracking is what the badge
// rewards.
prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows from streak counting.
deletedAt: null,
moodLoggedAt: { gte: startDate, lte: now },
},
select: {
date: true,
score: true,
moodLoggedAt: true,
},
}),
// v1.16.1 — sleep samples feed the sleep-logging streak. Read
// separately from the vitals query above so passive sleep syncs
// never inflate the entry-day / consistent-month engagement
// metrics, which reward ACTIVE tracking.
prisma.measurement.findMany({
where: {
userId,
measuredAt: { gte: startDate, lte: now },
source: { not: "IMPORT" },
type: "SLEEP_DURATION",
deletedAt: null,
},
select: { measuredAt: true },
}),
// v1.16.1 — presence-only read of the self-context questionnaire
// (nothing is decrypted) for the maintained-self-report badge.
prisma.userHealthProfile.findUnique({
where: { userId },
select: {
aboutMeEncrypted: true,
conditionsEncrypted: true,
allergiesEncrypted: true,
coachFocusEncrypted: true,
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]),
);
const intakeIssueMetrics = getIntakeIssueMetrics(
intakeEvents,
schedulesByMedicationId,
);
const takenIntakeDates = intakeEvents
.filter((event) => !event.skipped && event.takenAt !== null)
.map((event) => event.takenAt as Date);
const passkeyCreatedDates = passkeys.map((passkey) => passkey.createdAt);
const passkeyLoginDates = auditEvents
.filter((event) => event.action === "auth.login.passkey")
.map((event) => event.createdAt);
const passwordLoginDates = auditEvents
.filter((event) => event.action === "auth.login.password")
.map((event) => event.createdAt);
const bugReportDates = auditEvents
.filter((event) => event.action === "bugreport.submit")
.map((event) => event.createdAt);
const loginDaySeries = getEventDaySeries([
...passkeyLoginDates,
...passwordLoginDates,
]);
const healthSeries = getHealthGreenDaySeries(
measurements as MeasurementRecord[],
user.heightCm,
);
const onTimeSeries = getOnTimePerfectDaySeries(
intakeEvents,
schedulesByMedicationId,
);
const complianceSeries = getCompliance80DaySeries(
intakeEvents,
startDate,
now,
);
// v1.16.1 — care-routine series. Miss-free days and sleep-logging
// days reuse the standard day-series plumbing; the weekly measurement
// consistency folds distinct active vitals days into Monday-anchored
// weeks (≥5 active days each, 4 consecutive weeks for the badge).
const missFreeSeries = toDaySeries(getMissFreeDayKeys(intakeEvents));
const sleepSeries = getEventDaySeries(
sleepMeasurements.map((m) => m.measuredAt),
);
const weeklyConsistency = getWeeklyConsistency(
measurements.map((m) => toBerlinDayKey(m.measuredAt)),
MEASUREMENT_CONSISTENCY_MIN_DAYS_PER_WEEK,
MEASUREMENT_CONSISTENCY_TARGET_WEEKS,
);
const selfContextComplete =
healthProfile !== null &&
healthProfile.aboutMeEncrypted !== null &&
healthProfile.conditionsEncrypted !== null &&
healthProfile.allergiesEncrypted !== null &&
healthProfile.coachFocusEncrypted !== null
? 1
: 0;
const expansionValues = buildExpansionMetricValues({
measurements,
moodEntries,
intakeEvents,
auditEvents,
});
const earnability = getEarnabilityFlags({
hasMedication: medications.length > 0,
moodEntryCount: expansionValues.moodEntryCount,
measurementCounts: {
weightCount: expansionValues.weightMeasurementCount,
bpCount: expansionValues.bpMeasurementCount,
pulseCount: expansionValues.pulseMeasurementCount,
},
sleepSampleCount: sleepMeasurements.length,
});
const metrics = {
totalTakenIntakes: takenIntakeDates.length,
overIntakeCount: intakeIssueMetrics.overIntakeCount,
skippedIntakeCount: intakeIssueMetrics.skippedIntakeCount,
// 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(freeze(loginDaySeries.dayKeys)),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)),
measurementConsistencyWeeks: weeklyConsistency.longestRunWeeks,
selfContextCompleteCount: selfContextComplete,
sleepLogDayStreak: calculateLongestStreak(freeze(sleepSeries.dayKeys)),
};
const completionDates: Partial<Record<string, Date>> = {};
for (const definition of ACHIEVEMENT_DEFINITIONS) {
if (definition.metric === "totalTakenIntakes") {
const completedAt = findCountCompletionDate(
takenIntakeDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "passkeyCreatedCount") {
const completedAt = findCountCompletionDate(
passkeyCreatedDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "passkeyLoginCount") {
const completedAt = findCountCompletionDate(
passkeyLoginDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "passwordLoginCount") {
const completedAt = findCountCompletionDate(
passwordLoginDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "bugReportCount") {
const completedAt = findCountCompletionDate(
bugReportDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "overIntakeCount") {
const completedAt = findCountCompletionDate(
intakeIssueMetrics.overIntakeDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "skippedIntakeCount") {
const completedAt = findCountCompletionDate(
intakeIssueMetrics.skippedIntakeDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "loginDayStreak") {
const completedAt = findStreakCompletionDate(
loginDaySeries.dayKeys,
definition.target,
loginDaySeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "bmiGreenStreak") {
const completedAt = findStreakCompletionDate(
healthSeries.bmi.dayKeys,
definition.target,
healthSeries.bmi.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "bpGreenStreak") {
const completedAt = findStreakCompletionDate(
healthSeries.bp.dayKeys,
definition.target,
healthSeries.bp.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "pulseGreenStreak") {
const completedAt = findStreakCompletionDate(
healthSeries.pulse.dayKeys,
definition.target,
healthSeries.pulse.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "onTimePerfectDayStreak") {
const completedAt = findStreakCompletionDate(
onTimeSeries.dayKeys,
definition.target,
onTimeSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "compliance80DayStreak") {
const completedAt = findStreakCompletionDate(
complianceSeries.dayKeys,
definition.target,
complianceSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
// ── v1.16.1 ─ care-routine completion dates ─────────────────────
if (definition.metric === "missFreeDayStreak") {
const completedAt = findStreakCompletionDate(
missFreeSeries.dayKeys,
definition.target,
missFreeSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "sleepLogDayStreak") {
const completedAt = findStreakCompletionDate(
sleepSeries.dayKeys,
definition.target,
sleepSeries.firstDateByDay,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "measurementConsistencyWeeks") {
if (weeklyConsistency.completionDayKey) {
completionDates[definition.id] = dayKeyToDate(
weeklyConsistency.completionDayKey,
);
}
continue;
}
if (definition.metric === "selfContextCompleteCount") {
if (selfContextComplete === 1 && healthProfile) {
completionDates[definition.id] = healthProfile.updatedAt;
}
}
}
// Load persisted achievements
const persisted = await prisma.userAchievement.findMany({
where: { userId },
select: { achievementId: true, unlockedAt: true },
});
const persistedMap = new Map(
persisted.map((p) => [p.achievementId, p.unlockedAt]),
);
// Merge: persisted dates take priority, then calculated dates
for (const [id, date] of Object.entries(completionDates)) {
if (!persistedMap.has(id) && date) {
persistedMap.set(id, date);
}
}
// Use persisted dates as the source of truth
const mergedDates: Partial<Record<string, Date>> = {};
for (const [id, date] of persistedMap) {
mergedDates[id] = date;
}
const fullResult = evaluateAchievementsWithCompletionDates(
metrics,
mergedDates,
);
// v1.18.0 B5 — drop badges whose owning module is disabled BEFORE both
// unlock-persistence and serialisation. A sleep badge must never unlock
// (or render) while the sleep module is off; same for mood badges when
// mood is off, etc. Core-domain and account-wide badges return a null
// owner from `moduleForMetric` and always pass through.
const moduleEnabledAchievements = fullResult.achievements.filter((a) => {
const owner = moduleForMetric(a.metric);
if (owner === null) return true;
return moduleMap[owner as ModuleKey] !== false;
});
// v1.18.0 B5 — compute the newly unlocked rows here but DON'T write them
// inside the cached factory: a write must not be a side effect of a
// cached GET (a cache hit would silently skip it, a concurrent miss
// could duplicate it). The handler performs the idempotent
// `createMany({ skipDuplicates: true })` on every request after the
// cache read, so the persistence runs regardless of cache outcome and
// is safe to repeat.
const pendingUnlocks: PendingUnlock[] = moduleEnabledAchievements
.filter(
(a) =>
a.unlocked &&
a.completedAt &&
!persisted.some((p) => p.achievementId === a.id),
)
.map((a) => ({ achievementId: a.id, unlockedAt: a.completedAt! }));
// v1.4.18 — apply the discovery filter so locked badges that the
// user has *no path* to earn (e.g. mood badges for someone who has
// never logged a mood entry) don't clutter the page. Hidden Easter-
// eggs always pass through. Already-unlocked badges always pass
// through (regression guard).
const visibleAchievements = applyDiscoveryFilter(
moduleEnabledAchievements,
earnability,
);
// Recompute the summary so the headline counters reflect the
// discovered set. Hidden achievements that are still locked are
// counted toward `totalCount` so the user sees they exist.
const visibleUnlocked = visibleAchievements.filter((a) => a.unlocked);
const visibleEarned = visibleUnlocked.reduce((acc, a) => acc + a.points, 0);
const visibleTotalPoints = visibleAchievements.reduce(
(acc, a) => acc + a.points,
0,
);
const nextAchievement =
visibleAchievements
.filter((a) => !a.unlocked && !a.isHidden)
.sort((a, b) => b.progressPercent - a.progressPercent)[0] ?? null;
// v1.4.18 reconcile — redact hidden+locked entries before serialising
// so a curious user opening DevTools/Network can't read the trigger
// semantics from `metric`, `titleKey`, `descriptionKey`, `icon`,
// `target`, `current`, or `progressPercent`. The DOM render path
// already short-circuits on `isHidden && !unlocked`; this closes the
// wire-shape leak. Once an Easter-egg unlocks, the real fields ship
// (the unlock IS the moment to reveal the surprise).
const redactedAchievements = visibleAchievements.map((a) =>
redactIfHiddenLocked(a),
);
const redactedNextAchievement = nextAchievement
? redactIfHiddenLocked(nextAchievement)
: null;
const redactedMetrics = redactHiddenMetrics(
fullResult.metrics,
visibleAchievements,
);
const result = {
summary: {
unlockedCount: visibleUnlocked.length,
totalCount: visibleAchievements.length,
earnedPoints: visibleEarned,
totalPoints: visibleTotalPoints,
completionPercent:
visibleAchievements.length === 0
? 100
: Math.round(
(visibleUnlocked.length / visibleAchievements.length) * 100,
),
nextAchievement: redactedNextAchievement,
},
achievements: redactedAchievements,
metrics: redactedMetrics,
// v1.18.0 B5 — carried out of the cached factory so the handler can
// persist them on every read (idempotent), not as a write side effect
// that a cache hit would skip. NOT part of the serialised payload.
pendingUnlocks,
};
return result;
}
/** v1.18.0 B5 — a not-yet-persisted unlock the handler writes after the cache read. */
interface PendingUnlock {
achievementId: string;
unlockedAt: string;
}
/**
* Hidden Easter-eggs ship to the client *only* as opaque cards while
* locked. Returning the full `AchievementProgress` shape would let a
* snooper read the trigger semantics from `metric`, `titleKey`, etc.
* This helper projects locked+hidden entries down to the safe shape
* the UI's opaque-placeholder branch needs.
*
* Unlocked hidden achievements pass through unchanged — the unlock IS
* the reveal moment, so the toast and the unlocked card show the real
* strings.
*/
function redactIfHiddenLocked(
achievement: AchievementProgress,
): AchievementProgress {
if (!achievement.isHidden || achievement.unlocked) return achievement;
return {
id: achievement.id,
metric: "totalTakenIntakes",
category: "hidden",
titleKey: "achievements.hiddenCard.title",
descriptionKey: "achievements.hiddenCard.description",
icon: "HelpCircle",
format: "count",
target: 0,
current: 0,
points: achievement.points,
unlocked: false,
progressPercent: 0,
completedAt: null,
isHidden: true,
};
}
/**
* Drop hidden-only metric counters from the response `metrics` block
* unless any hidden achievement they back is unlocked. The DOM never
* reads these counters today — the only consumer is the iOS client's
* progress bar — so removing the hidden ones from the wire is a pure
* spoiler shield with zero UI impact.
*/
function redactHiddenMetrics(
metrics: AchievementMetrics,
achievements: AchievementProgress[],
): Partial<AchievementMetrics> {
const HIDDEN_METRIC_KEYS = [
"nightOwlCount",
"earlyBirdCount",
"leapDayCount",
"doctorPdfCount",
"localeFlipCount",
] as const;
const safe: Partial<AchievementMetrics> = { ...metrics };
for (const key of HIDDEN_METRIC_KEYS) {
const backing = achievements.find(
(a) => a.isHidden && a.metric === key && a.unlocked,
);
if (!backing) {
delete safe[key];
}
}
return safe;
}