mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge(reconcile): r4-logic audit findings
This commit is contained in:
commit
2425832f47
11 files changed
+481
-66
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user