merge(reconcile): r4-logic audit findings

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:59:31 +02:00
commit 2425832f47
11 files changed
+481 -66

No files matched your search

+8
View File
@@ -17,6 +17,7 @@ import {
type BpInTargetEnvelope,
} from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { isModuleEnabled } from "@/lib/modules/gate";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -420,12 +421,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// same prior-week delta values). The hand-rolled per-surface assembly that
// let the two diverge is gone.
const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek);
// v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood
// account drops the mood-stability pillar from the ring instead of
// being silently scored on data hidden everywhere else. The dashboard
// already blanks the mood tile + the coach snapshot unions mood out;
// this closes the matching gap on the Health Score.
const moodEnabled = await isModuleEnabled(user.id, "mood");
const healthScore = await computeUserHealthScoreFastPath({
userId: user.id,
...bpInputs,
heightCm: user.heightCm ?? null,
now: new Date(),
coverage,
moodEnabled,
});
return {
+19 -3
View File
@@ -78,6 +78,15 @@ export interface HealthScoreCardProps {
* starts collapsed.
*/
initiallyExpanded?: boolean;
/**
* v1.18.0 R4 — when the `mood` module is disabled for the account the
* server already drops the mood-stability pillar from the score (the
* pillar arrives null-weighted). This hides the Mood row from the
* component + provenance lists too so the card never advertises a
* pillar the user turned off. Default-on so omitting it keeps the
* four-row layout for the common (mood-enabled) case.
*/
moodEnabled?: boolean;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -164,6 +173,7 @@ export function HealthScoreCard({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onAskCoach: _onAskCoach,
initiallyExpanded = false,
moodEnabled = true,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -198,9 +208,14 @@ export function HealthScoreCard({
// without relying on visual proximity alone.
const deltaExplainerId = useId();
const componentEntries = (
Object.keys(components) as Array<ComponentKey>
).map((key) => ({
// v1.18.0 R4 — drop the Mood pillar from the card's row lists when the
// module is disabled. The server already null-weights it in the score;
// hiding the row keeps the card from naming a pillar the account turned
// off (it would otherwise render as a permanently empty "Mood" line).
const visibleKeys = (Object.keys(components) as Array<ComponentKey>).filter(
(key) => moodEnabled || key !== "mood",
);
const componentEntries = visibleKeys.map((key) => ({
key,
label: t(COMPONENT_LABEL_KEY[key]),
value: components[key].value,
@@ -211,6 +226,7 @@ export function HealthScoreCard({
// null values sink to the bottom (`weight * 0 === 0`); the tie-break
// is the alphabetical key order so determinism holds across renders.
const provenanceRows = COMPONENT_ORDER
.filter((key) => moodEnabled || key !== "mood")
.map((key) => {
const c = components[key];
const inferredSource: HealthScoreComponentSource =
+7
View File
@@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { cn } from "@/lib/utils";
import { SuggestedPrompts } from "./suggested-prompts";
import {
@@ -149,6 +150,11 @@ export function HeroStrip({
// tests render the strip without a QueryClient.
const disableCoach = useDisableCoach();
const coachEnabled = flags.coach && !disableCoach;
// v1.18.0 R4 — mood-module state so the score card hides its Mood row
// when the account turned the module off (the server already drops the
// pillar from the number itself). SSR-safe + default-on, matching the
// disabled-allowlist gate.
const moodEnabled = useModuleEnabled("mood");
const greetingKey = resolveGreetingKey(now ?? new Date());
const greetingBase = t(greetingKey);
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
@@ -339,6 +345,7 @@ export function HeroStrip({
band={healthScore.band}
components={healthScore.components}
delta={healthScore.delta}
moodEnabled={moodEnabled}
onAskCoach={
// v1.4.37 W5 — short-circuit the prop drilling even
// though `HealthScoreCard` retired its inline button in
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.18.0 R4 — SSR-safe accessor for a per-account module flag.
*
* Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and
* crashes when a legacy presentational component (`<HeroStrip>`, the
* isolated score-card snapshots, …) renders without a
* `<QueryClientProvider>`. We gate the inner hook on
* `useQueryClientMounted()` and short-circuit to the default-on result
* when no client is in the tree — the branch is stable across the
* component's lifetime, so the conditional call is safe per the Rules of
* Hooks.
*
* Default-on (disabled allowlist): an absent map, a missing key, or any
* non-`false` value reads as enabled — matching the server gate in
* `src/lib/modules/gate.ts`.
*/
export function useModuleEnabled(moduleKey: ModuleKey): boolean {
const hasClient = useQueryClientMounted();
if (!hasClient) return true;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useModuleEnabledInner(moduleKey);
}
function useModuleEnabledInner(moduleKey: ModuleKey): boolean {
const { user } = useAuth();
return user?.modules?.[moduleKey] !== false;
}
@@ -17,6 +17,7 @@ import {
buildMedicationComplianceBundle,
buildComplianceDisplay,
calculateCompliance,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
tallyLedgerRows,
@@ -44,6 +45,21 @@ function dailySchedule(): ComplianceSchedule {
};
}
function weeklyMondaySchedule(): ComplianceSchedule {
return {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
function rollingSchedule(intervalDays: number): ComplianceSchedule {
return {
windowStart: "10:00",
@@ -205,6 +221,104 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
});
});
describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () => {
it("weekly Monday-only med: per-day series agrees with compliance30 and never marks non-due days", () => {
// NOW (2026-06-01) is a Monday; build five trailing Mondays and take
// every one on time. A weekly-cadence med expects a dose ONLY on Mondays.
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
null,
TZ,
);
const events: FixtureEvent[] = [];
for (let week = 0; week < 5; week++) {
const daysAgo = week * 7; // every Monday back from NOW
events.push({
takenAt: slotAt(daysAgo, "08:05"),
skipped: false,
scheduledFor: slotAt(daysAgo, "08:00"),
});
}
ctx.lastIntakeAt = lastNonSkippedTakenAt(events);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
// Took every expected Monday → 100% over 30 days.
expect(bundle.compliance30.rate).toBe(100);
// The per-day series carves the same 30-day window out of the shared
// ledger and must agree with compliance30 — no off-cadence weekday is
// counted as a miss.
const from = new Date(NOW.getTime() - 30 * DAY_MS);
const windowRows = bundle.ledgerRows.filter(
(r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
);
const series = dailyComplianceRatesFromLedger(windowRows);
// Only the Mondays in the window produce a point — Tuesday…Sunday emit
// nothing, so a 7×-denser daily denominator can never drag the rate down.
// NOW is itself a Monday, so the 30-day window holds five Mondays.
expect(series.length).toBe(5);
for (const day of series) {
expect(day.rate).toBe(100);
expect(day.missed).toBe(0);
}
// The series' mean rate equals the canonical compliance30 rate: the two
// are computed from the SAME ledger and cannot contradict each other.
const meanRate =
series.reduce((sum, d) => sum + d.rate, 0) / series.length;
expect(Math.round(meanRate)).toBe(bundle.compliance30.rate);
});
it("weekly med with one missed Monday: the missed day reads 0%, due days only", () => {
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt },
null,
TZ,
);
// Four Mondays in the 30-day window; skip the take on the 2-weeks-ago one.
const events: FixtureEvent[] = [];
for (let week = 0; week < 5; week++) {
const daysAgo = week * 7;
if (week === 2) continue; // forgotten dose → an expected slot reads missed
events.push({
takenAt: slotAt(daysAgo, "08:05"),
skipped: false,
scheduledFor: slotAt(daysAgo, "08:00"),
});
}
ctx.lastIntakeAt = lastNonSkippedTakenAt(events);
const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW);
const from = new Date(NOW.getTime() - 30 * DAY_MS);
const windowRows = bundle.ledgerRows.filter(
(r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(),
);
const series = dailyComplianceRatesFromLedger(windowRows);
// Still one point per due Monday — never a point on a non-due weekday.
// Five Mondays in the window; the skipped take leaves one as a miss.
expect(series.length).toBe(5);
const zeroDays = series.filter((d) => d.rate === 0);
expect(zeroDays.length).toBe(1);
expect(zeroDays[0].missed).toBe(1);
// Aggregate taken / (taken + missed) across due days equals compliance30.
const taken = series.reduce((sum, d) => sum + d.taken, 0);
const missed = series.reduce((sum, d) => sum + d.missed, 0);
const aggregateRate = Math.round((taken / (taken + missed)) * 100);
expect(aggregateRate).toBe(bundle.compliance30.rate);
});
});
describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => {
const createdAt = new Date(NOW.getTime() - 40 * DAY_MS);
@@ -667,6 +667,127 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.18.0 R4 — module-aware mood pillar. With the mood module disabled
* the helper must NOT read mood, must null-weight the mood pillar, and
* must re-normalise the surviving pillars so the score isn't penalised
* (or inflated) by a pillar the user can no longer see. Golden fixture:
* an identical profile with strong, stable mood scored mood-on vs
* mood-off must produce a different, correctly re-weighted score.
*/
describe("module-aware mood pillar (R4)", () => {
// A profile with BP + steady high mood (low variance → high
// stability) + nothing else, so the mood pillar's contribution is
// unambiguous. Six entries clears the 5-entry stability floor.
function steadyMood(now: Date) {
return Array.from({ length: 6 }, (_v, i) => ({
score: 4,
moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000),
}));
}
it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const now = new Date("2026-06-07T12:00:00.000Z");
MOOD_FIND_MANY.mockResolvedValue(steadyMood(now));
const result = await computeUserHealthScoreFastPath({
userId: "user-mood-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
});
expect(result).not.toBeNull();
// Mood read fired and the pillar carries a non-zero weight.
expect(MOOD_FIND_MANY).toHaveBeenCalled();
expect(result?.components.mood.value).not.toBeNull();
expect(result?.components.mood.weight).toBeGreaterThan(0);
});
it("drops the mood pillar and re-normalises the score when moodEnabled is false", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const now = new Date("2026-06-07T12:00:00.000Z");
// Even if mood rows existed they must be ignored — the helper
// should never issue the read when the module is off.
MOOD_FIND_MANY.mockResolvedValue(steadyMood(now));
const result = await computeUserHealthScoreFastPath({
userId: "user-mood-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
moodEnabled: false,
});
expect(result).not.toBeNull();
// The disabled module is off the round-trip budget entirely.
expect(MOOD_FIND_MANY).not.toHaveBeenCalled();
// Mood pillar null-weighted; surviving pillar (BP) absorbs the
// freed weight via proportional redistribution.
expect(result?.components.mood.value).toBeNull();
expect(result?.components.mood.weight).toBe(0);
expect(result?.components.bp.weight).toBe(1);
// BP is the only present pillar → score is the BP graded value.
expect(result?.score).toBe(60);
});
it("golden: mood-on vs mood-off yield different, correctly re-weighted scores", async () => {
const coverage = new Map<string, boolean>();
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-06-07T12:00:00.000Z");
// Mood-ON: BP=60 (0.30) + perfect-stability mood=100 (0.20).
// present base weights = 0.50 → bp 0.6, mood 0.4 →
// 60*0.6 + 100*0.4 = 36 + 40 = 76.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
MOOD_FIND_MANY.mockResolvedValue(
// Constant score → coefficient of variation 0 → stability 100.
Array.from({ length: 6 }, (_v, i) => ({
score: 5,
moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000),
})),
);
const onResult = await computeUserHealthScoreFastPath({
userId: "user-golden-on",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
});
// Mood-OFF: only BP=60 survives → score 60.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
const offResult = await computeUserHealthScoreFastPath({
userId: "user-golden-off",
bpInTargetPct: 60,
bpGradedScore: 60,
heightCm: null,
now,
coverage,
moodEnabled: false,
});
expect(onResult?.score).toBe(76);
expect(offResult?.score).toBe(60);
// The mood-off score is NOT silently dragged toward 0 by a phantom
// pillar — it equals the honest single-pillar BP score.
expect(offResult?.score).not.toBe(onResult?.score);
});
});
/**
* v1.15.12 A1 — the graded clinical-proximity score, when supplied,
* becomes the BP pillar VALUE; the binary in-target rate only gates
+80
View File
@@ -52,6 +52,7 @@ import {
type RecurrenceContext,
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
import { toBerlinDayKey } from "@/lib/tz/resolver";
export interface IntakeEvent {
takenAt: Date | null;
@@ -1836,6 +1837,85 @@ export function buildMedicationComplianceBundle(
return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom };
}
/**
* One day of the cadence-aware compliance series: the day's compliance %
* over the doses the schedule actually expected that day, plus the raw
* taken / missed counts behind it.
*/
export interface DailyComplianceRate {
/** The day's anchor instant (the first expected slot of the day). */
date: Date;
/** round(100 · taken / (taken + missed)), capped at 100. */
rate: number;
/** Doses taken (on-time + late) on the day. */
taken: number;
/** Expected doses never acted on past their miss cutoff, on the day. */
missed: number;
}
/**
* v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware
* per-day compliance series.
*
* Only days the schedule's cadence actually expected a dose produce a point:
* the series is grouped over the ledger's scheduled `slot` rows, so an
* off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week
* (the off-week of a bi-weekly schedule) emits no point at all and therefore
* can never be read as a 0% "miss". Each day's rate uses the SAME
* taken / (taken + missed) tally — and the SAME on-time-plus-late numerator,
* skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies
* to `compliance7` / `compliance30`, so the per-day series and the window
* rates are computed from one ledger and cannot contradict each other.
*
* `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming`
* rows (the window hasn't opened) are dropped before grouping, exactly as the
* window tally excludes them. A day whose only ledger rows are skips yields no
* point (zero denominator) rather than a misleading 0%.
*/
export function dailyComplianceRatesFromLedger(
ledgerRows: DoseHistoryRow[],
): DailyComplianceRate[] {
const byDay = new Map<
string,
{ taken: number; missed: number; date: Date }
>();
for (const row of ledgerRows) {
// Only scheduled slots have a defensible denominator; ad-hoc top-ups are
// excluded from the rate exactly as `tallyLedgerRows` excludes them.
if (row.kind !== "slot") continue;
const isTaken =
row.status === "taken_on_time" || row.status === "taken_late";
const isMissed = row.status === "missed";
// `skipped` (deliberate) and `upcoming` (window not open) advance neither
// counter — they never enter the day's denominator.
if (!isTaken && !isMissed) continue;
const dayKey = toBerlinDayKey(row.at);
const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at };
if (isTaken) bucket.taken += 1;
else bucket.missed += 1;
// Keep the earliest slot instant of the day as the point's anchor.
if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at;
byDay.set(dayKey, bucket);
}
return Array.from(byDay.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((day) => {
const denom = day.taken + day.missed;
return {
date: day.date,
rate:
denom > 0
? Math.min(100, Math.round((day.taken / denom) * 100))
: 100,
taken: day.taken,
missed: day.missed,
};
});
}
/**
* The cadence-timeline construction from {@link calculateCompliance},
* extracted so {@link buildMedicationComplianceBundle} can build it once
+36 -10
View File
@@ -136,6 +136,19 @@ export interface HealthScoreFastPathInput {
* the probe cost stays flat in the fan-out.
*/
coverage?: RollupCoverageMap;
/**
* v1.18.0 R4 — module-aware mood pillar. When the caller resolves the
* `mood` module as disabled it threads `moodEnabled: false` and the
* helper drops the 0.20 mood-stability pillar entirely: the mood read
* is skipped and the series stays empty, so `moodStability` returns
* `null` and the existing proportional null-redistribution re-scales
* the surviving pillars to sum to 100. Without this a disabled-mood
* account is silently penalised (or rewarded) by a pillar it can no
* longer see anywhere else in the product. Omit (or pass `true`) to
* keep the pre-v1.18.0 always-weighted behaviour — the field is
* additive so legacy callers are unchanged.
*/
moodEnabled?: boolean;
}
/**
@@ -150,6 +163,10 @@ export async function computeUserHealthScoreFastPath(
): Promise<HealthScoreResult | null> {
const DAY_MS = 24 * 60 * 60 * 1000;
const { userId, bpInTargetPct, heightCm, now } = input;
// v1.18.0 R4 — default-on so omitting the flag preserves the
// pre-v1.18.0 always-weighted mood pillar. Only an explicit `false`
// (the caller resolved `mood` as a disabled module) drops the pillar.
const moodEnabled = input.moodEnabled !== false;
// v1.4.38 — prior-week BP fallback. When the caller omits the field
// we pin to the current value (pre-v1.4.38 behaviour) so the helper
// stays a drop-in replacement for legacy callers; when the caller
@@ -294,16 +311,25 @@ export async function computeUserHealthScoreFastPath(
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
// v1.18.0 R4 — skip the read entirely when the mood module is off.
// An empty series feeds `moodStability` → null → the 0.20 pillar
// weight redistributes across the surviving pillars. Skipping the
// query (rather than reading-then-discarding) keeps the disabled
// module off the round-trip budget too.
moodEnabled
? prisma.moodEntry.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
})
: Promise.resolve(
[] as Array<{ score: number; moodLoggedAt: Date }>,
),
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications carry no expected doses
// and are excluded from every compliance rate: they must
@@ -5,7 +5,8 @@ import {
} from "@/lib/ai/prompts/medication-compliance";
import {
buildComplianceMedicationContext,
calculateCompliance,
buildMedicationComplianceBundle,
dailyComplianceRatesFromLedger,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
@@ -224,50 +225,34 @@ export async function generateMedicationComplianceStatusForUser(
lastNonSkippedTakenAt(events),
userTz,
);
const compliance7 = calculateCompliance(
// v1.18.0 — one shared cadence-aware expansion pass yields BOTH the
// canonical compliance7 / compliance30 (the values shown in this same
// snapshot) AND the unified dose-history ledger the per-day series is
// built from, so the series can never disagree with compliance30.
const bundle = buildMedicationComplianceBundle(
events,
medication.schedules,
7,
medication.createdAt,
{ medicationContext },
medicationContext,
now,
);
const compliance30 = calculateCompliance(
events,
medication.schedules,
30,
medication.createdAt,
{ medicationContext },
const compliance7 = bundle.compliance7;
const compliance30 = bundle.compliance30;
// v1.18.0 — the per-day series honours the medication's cadence
// (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the
// schedule actually expected a dose produce a point, and each day's rate
// uses the SAME taken / (taken + missed) tally as compliance30. The old
// path divided every event-day by `schedules.length`, treating every
// calendar day as fully dose-due — which made a weekly-cadence med's
// per-day series contradict its own compliance30. Reusing the canonical
// ledger fixes that by construction.
const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map(
(day) => ({
measuredAt: day.date,
value: round(day.rate, 1),
}),
);
// Collapse the events into one rate-per-day record, then run them
// through the shared bucketing helper so the model receives the
// canonical 360 daily + 24 monthly view per medication.
const byDay = new Map<
string,
{ expected: number; taken: number; skipped: number; date: Date }
>();
const expectedPerDay = Math.max(1, medication.schedules.length);
for (const event of events) {
const dayKey = toBerlinDayKey(event.scheduledFor);
const bucket = byDay.get(dayKey) ?? {
expected: medication.schedules.length,
taken: 0,
skipped: 0,
date: event.scheduledFor,
};
if (event.takenAt && !event.skipped) bucket.taken += 1;
if (event.skipped) bucket.skipped += 1;
byDay.set(dayKey, bucket);
}
const perDayRecords = Array.from(byDay.values())
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map((stats) => ({
measuredAt: stats.date,
value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1),
}));
// `applyPayloadBudget` gives the latest-day focus; the compact
// graded series is what reaches the prompt.
const dailyBudgeted = applyPayloadBudget(perDayRecords, { now });
@@ -45,6 +45,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn(() => "morning blood pressure"),
encryptToBytes: vi.fn(),
}));
// The sleep-debt branch resolves the user's source priority through the
// real `@/lib/db` prisma; stub it so the unit suite stays DB-free. A
// null priority is the no-custom-priority default — `reconstructSleepNights`
// (pure) then runs normally on the stubbed measurement rows.
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
function dose(taken: boolean, skipped = false, autoMissed?: boolean) {
return {
+31 -13
View File
@@ -78,6 +78,9 @@ import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
// Trend thresholds shared with the dashboard hero's verdict resolver
// live in a client-safe leaf module; re-exported below so server-side
// imports keep their path.
@@ -551,9 +554,19 @@ export async function findTriggerForUser(
}
}
// 5) Sleep-deficit series: per-night hours over the last 7 days,
// longest record per calendar night (multi-source rows collapse
// to the fullest one).
// 5) Sleep-deficit series: per-night asleep HOURS over the last 7
// days, reconstructed through the canonical sleep engine.
//
// `SLEEP_DURATION` is stored one row per STAGE per night, in
// MINUTES. The trigger compares against
// `effectiveRange("SLEEP_DURATION").greenMin`, which is in HOURS
// (7). The prior inline model kept each night's MAX single stage
// row (a fragment, not the night) AND fed minutes straight into
// the hours comparison — so a real ~360-480 minute night never
// cleared `< 6.5` and the nudge could never fire. Reusing
// `reconstructSleepNights` gives the proper asleep total
// (CORE+DEEP+REM, multi-source de-duped, user-tz wake-day keyed);
// dividing by 60 converts to the hours the trigger expects.
const sleepRows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -561,20 +574,25 @@ export async function findTriggerForUser(
deletedAt: null,
measuredAt: { gte: sevenDaysAgo, lte: now },
},
select: { value: true, measuredAt: true },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
});
if (sleepRows.length > 0) {
const byNight = new Map<string, number>();
for (const row of sleepRows) {
const night = row.measuredAt.toISOString().slice(0, 10);
const prev = byNight.get(night);
if (prev === undefined || row.value > prev) {
byNight.set(night, row.value);
}
}
const sourcePriority = await loadUserSourcePriority(user.id);
const nights = reconstructSleepNights(
sleepRows,
user.timezone ?? DEFAULT_TIMEZONE,
sourcePriority,
);
const nightlyHours = nights.map((n) => n.asleepMinutes / 60);
if (
evaluateSleepDebtTrigger(
[...byNight.values()],
nightlyHours,
effectiveRange("SLEEP_DURATION")?.greenMin ?? null,
)
) {