Merge pull request #471 from MBombeck/release/v1.28.26

release/v1.28.26 — internal restructuring for maintainability
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-11 08:55:16 +02:00
commit da0e509095
34 files changed
+8427 -7711

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [1.28.26] — 2026-07-11 — Internal restructuring for maintainability
No user-facing change. Eight of the largest source files were split along
their natural seams into focused modules — mood analytics calculators, the
insight status-invalidation machinery, feature extraction blocks, the doctor
report's types and helpers, the Google Health mapping layer, the coach chat
bubbles and read-aloud controls, the Telegram webhook handlers (the route now
holds only auth and dispatch), and the coach snapshot's cache, series helpers
and largest per-metric blocks. Every move is verbatim with stable import
paths; behavior is pinned by the full test suite.
## [1.28.25] — 2026-07-11 — Every integration held to the same standard
A platform-wide hardening pass: the failure classes found live this week were
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.25
version: 1.28.26
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.25",
"version": "1.28.26",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.25";
/* @sw-version-fallback */ "v1.28.26";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
File diff suppressed because it is too large. Load diff
@@ -596,7 +596,7 @@ describe("<MessageThread>", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/message-thread.tsx",
"src/components/insights/coach-panel/chat-bubble.tsx",
),
"utf8",
);
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { pickSpeechVoice, scoreSpeechVoice } from "../message-thread";
import { pickSpeechVoice, scoreSpeechVoice } from "../read-aloud";
type V = { name: string; lang: string; localService?: boolean };
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,269 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { Volume2, VolumeX } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
/**
* Read-aloud + capability detection for the Coach message thread, split
* out of `message-thread.tsx` (v1.28.26 file-size split — pure code
* motion, no behaviour change). Owns the SSR-safe Speech-Synthesis /
* Clipboard feature detection, the natural-voice ranking, the
* `useReadAloud` hook, and the per-message read-aloud button, plus the
* shared icon-button styling the sibling action buttons reuse.
*/
/**
* v1.22 (W5) — feature-detect the browser Speech Synthesis API SSR-safe.
* `useSyncExternalStore` returns false on the server + first client paint
* (so the read-aloud button is absent in markup) and resolves to the real
* capability after hydration, mirroring the mic button's detection pattern
* — no hydration mismatch warning, no `connect-src` (it runs in-browser).
*/
function useSpeechSynthesisSupported(): boolean {
return useSyncExternalStore(
() => () => {},
() =>
typeof window !== "undefined" &&
"speechSynthesis" in window &&
"SpeechSynthesisUtterance" in window,
() => false,
);
}
/**
* v1.22.1 — feature-detect the async Clipboard API SSR-safe, mirroring the
* Speech-Synthesis detection above. `navigator.clipboard` is `undefined` on
* plain-HTTP self-hosts (a supported insecure-context config), so the copy
* button must be absent there rather than rendering and error-toasting on tap.
* `useSyncExternalStore` returns false on the server + first client paint and
* resolves to the real capability after hydration — no hydration mismatch.
*/
export function useClipboardSupported(): boolean {
return useSyncExternalStore(
() => () => {},
() =>
typeof navigator !== "undefined" &&
typeof navigator.clipboard?.writeText === "function",
() => false,
);
}
/**
* v1.22 — score a synthesis voice for a target language. Higher is better;
* a negative score marks a legacy/compact voice we would rather skip. Pure
* (no browser globals) so the ranking contract is unit-testable. Exported
* for the read-aloud voice-selection test.
*
* The default OS voice is often a legacy "compact" engine (Anna, Albert,
* Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple
* enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge)
* voice, a "Google …" (Chrome) voice, and a small set of known-good names
* (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare
* language match. `localService` only breaks a tie. Known-bad names are
* penalised so they never win over a neutral remote voice.
*/
export function scoreSpeechVoice(
voice: { name: string; lang: string; localService?: boolean },
lang: string,
): number {
const base = lang.split("-")[0].toLowerCase();
const vlang = voice.lang.toLowerCase().replace("_", "-");
if (!vlang.startsWith(base)) return -Infinity;
const name = voice.name.toLowerCase();
let score = 0;
// Exact lang-REGION match (de-DE, en-US) over a bare language match.
if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40;
// High-quality engines.
if (/\b(enhanced|premium)\b/.test(name)) score += 60;
if (name.includes("siri")) score += 55;
if (name.includes("natural") || name.includes("online")) score += 50;
if (name.startsWith("google")) score += 35;
if (name.includes("samantha")) score += 30;
// Known-bad legacy / compact engines.
if (name.includes("compact")) score -= 80;
if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) {
score -= 100;
}
// Tie-breaker only: a local voice has no network latency.
if (voice.localService) score += 1;
return score;
}
/**
* v1.22 — pick the best-scoring installed voice for `lang`, or null when
* none beats a legacy/compact default (graceful fallback to the browser
* default). Pure helper, exported for the ranking test.
*/
export function pickSpeechVoice(
voices: SpeechSynthesisVoice[],
lang: string,
): SpeechSynthesisVoice | null {
let best: SpeechSynthesisVoice | null = null;
let bestScore = 0; // require a positive score to override the default
for (const v of voices) {
const s = scoreSpeechVoice(v, lang);
if (s > bestScore) {
bestScore = s;
best = v;
}
}
return best;
}
/**
* v1.22 (W5) — read the assistant prose aloud via the browser's
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
* stops while speaking.
*
* v1.22 — natural-voice selection. The browser default is frequently a
* legacy/compact engine that reads robotically; instead of only stamping
* `utterance.lang` we enumerate `getVoices()`, rank the candidates for the
* locale (`pickSpeechVoice`), and assign the winner. The voice list is async
* (empty on the first call, and on iOS Safari it can stay empty until the
* first user-gesture speak), so we subscribe to `voiceschanged` and also
* re-read it lazily on the first toggle. The chosen voice is cached per
* locale in a ref so the ranking runs once.
*/
function useReadAloud(): {
supported: boolean;
speaking: boolean;
toggle: (text: string, lang: string) => void;
} {
const supported = useSpeechSynthesisSupported();
const [speaking, setSpeaking] = useState(false);
// Cache the resolved voice per locale so the ranking runs once.
const voiceByLocaleRef = useRef<Map<string, SpeechSynthesisVoice | null>>(
new Map(),
);
// Pre-warm the voice list + keep it fresh: the list arrives asynchronously
// and `voiceschanged` fires when the OS finishes loading installed voices.
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const refresh = () => {
// A late-arriving list can surface a better voice than an early read;
// clear the cache so the next toggle re-ranks against the full set.
voiceByLocaleRef.current.clear();
};
synth.getVoices();
synth.addEventListener?.("voiceschanged", refresh);
return () => synth.removeEventListener?.("voiceschanged", refresh);
}, [supported]);
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
useEffect(() => {
return () => {
if (typeof window !== "undefined" && "speechSynthesis" in window) {
window.speechSynthesis.cancel();
}
};
}, []);
const resolveVoice = useCallback(
(synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => {
const cached = voiceByLocaleRef.current.get(lang);
if (cached !== undefined) return cached;
const voices = synth.getVoices();
// An empty list (first call / iOS pre-gesture) is not cached so a later
// toggle re-ranks once the OS has populated the voices.
if (voices.length === 0) return null;
const chosen = pickSpeechVoice(voices, lang);
voiceByLocaleRef.current.set(lang, chosen);
return chosen;
},
[],
);
const toggle = useCallback(
(text: string, lang: string) => {
if (!supported) return;
const synth = window.speechSynthesis;
if (speaking) {
synth.cancel();
setSpeaking(false);
return;
}
const trimmed = text.trim();
if (!trimmed) return;
synth.cancel();
const utterance = new SpeechSynthesisUtterance(trimmed);
const voice = resolveVoice(synth, lang);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
} else {
utterance.lang = lang;
}
// A natural cadence: default rate, neutral pitch.
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.onend = () => setSpeaking(false);
utterance.onerror = () => setSpeaking(false);
setSpeaking(true);
synth.speak(utterance);
},
[supported, speaking, resolveVoice],
);
return { supported, speaking, toggle };
}
/**
* v1.22 — shared styling for the icon-only per-message action buttons. One
* tap convention across Copy / Read-aloud / feedback / Try-again: a 44px
* mobile tap floor (WCAG 2.5.5) collapsing to a compact 32px desktop target,
* muted until hover/focus, with the standard focus ring.
*/
export const COACH_ICON_BUTTON = cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex size-11 min-w-11 shrink-0 items-center justify-center rounded",
"outline-none focus-visible:ring-2 disabled:opacity-50 sm:size-8 sm:min-w-8",
);
/**
* v1.22 (W5) — read-aloud toggle for a settled assistant turn. Hidden when
* the browser has no Speech Synthesis support. Speaks `stripChartTokens`
* (the same text the prose shows), so stray tokens are never voiced.
*/
export function ReadAloudButton({ content }: { content: string }) {
const { t, locale } = useTranslations();
const { supported, speaking, toggle } = useReadAloud();
if (!supported) return null;
const label = speaking
? t("insights.coach.readAloudStop")
: t("insights.coach.readAloud");
return (
<button
type="button"
data-slot="coach-read-aloud"
onClick={() => toggle(stripChartTokens(content), locale)}
aria-label={label}
aria-pressed={speaking}
title={label}
className={COACH_ICON_BUTTON}
>
{speaking ? (
<VolumeX className="size-3.5" aria-hidden="true" />
) : (
<Volume2 className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
@@ -0,0 +1,199 @@
/**
* Medication-compliance block for the Coach snapshot.
*
* Medication compliance lives outside the structured features — the
* legacy Coach surface labelled it as "general" provenance only.
* v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from
* the band-engine LEDGER (the same expansion the compliance % and the
* dose-history view consume): a slot counts against the rate only once
* it is genuinely missed, a pending/upcoming slot never reads as "not
* taken", deliberate skips and ad-hoc takes stay out of the
* denominator, and cross-source duplicate rows collapse onto one slot.
*
* Split out of `snapshot.ts`; the builder passes the medication rows it
* already read plus the shared accumulators, so the emitted shape and
* ordering are unchanged.
*/
import {
buildComplianceLedgerRows,
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
type ComplianceSchedule,
type IntakeEvent,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import { annotate } from "@/lib/logging/context";
import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series";
import type {
CoachProvenance,
CoachProvenanceMetric,
CoachScopeSource,
} from "../types";
/**
* The medication row shape the block consumes — exactly what the
* builder's Prisma read selects, expressed through the compliance
* engine's own parameter types so the two can never drift.
*/
type ComplianceBlockMedication = Parameters<
typeof buildComplianceMedicationContext
>[0] & {
schedules: ComplianceSchedule[];
intakeEvents: IntakeEvent[];
};
interface ComplianceBlockContext {
complianceMeds: ComplianceBlockMedication[];
userTz: string;
cutoff: Date;
recentCutoff: Date;
now: Date;
snapshot: Record<string, unknown>;
metrics: Set<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
}
export function buildComplianceBlock(
ctx: Readonly<ComplianceBlockContext>,
): void {
const {
complianceMeds,
userTz,
cutoff,
recentCutoff,
now,
snapshot,
metrics,
counts,
registerBlock,
} = ctx;
const ledgerRows: DoseHistoryRow[] = [];
// v1.17 W1c — the coach's headline adherence figure routes through the
// SAME `calculateCompliance(...).rate` ledger authority the medication
// card shows (the ledger path, `medicationContext` supplied), so the
// coach can never quote a denominator the card doesn't use. Per
// medication we take the ledger numerator (on-time + late takes) and
// denominator (taken + missed) and pool them across the user's
// scheduled medications: for a single medication the headline equals
// that med's card rate exactly; for several it is the dose-weighted
// overall adherence (the same pooling the cross-med timeline below
// already uses), never a per-day / per-week denominator of its own.
let complianceTaken = 0;
let complianceDenominator = 0;
const windowDaysForRate = Math.max(
1,
Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)),
);
for (const med of complianceMeds) {
if (med.schedules.length === 0) continue;
const medCtx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(med.intakeEvents),
userTz,
);
ledgerRows.push(
...buildComplianceLedgerRows(
med.intakeEvents,
med.schedules,
medCtx,
cutoff,
now,
now,
),
);
// The card's rate IS `calculateCompliance(...).rate` over the ledger;
// aggregate the same taken / (taken + missed) counts here so the
// coach's single headline % equals what the card renders.
const result = calculateCompliance(
med.intakeEvents,
med.schedules,
windowDaysForRate,
med.createdAt,
{ now, medicationContext: medCtx },
);
complianceTaken += result.taken;
complianceDenominator += result.taken + result.missed;
}
// Countable rows: taken (on-time or late) or genuinely missed. The
// pending / upcoming / skipped / ad-hoc rows carry no adherence signal.
const countable = ledgerRows.filter(
(r) =>
r.status === "taken_on_time" ||
r.status === "taken_late" ||
r.status === "missed",
);
if (countable.length > 0) {
const recent = countable.filter((r) => r.at >= recentCutoff);
const olderRows = countable.filter((r) => r.at < recentCutoff);
const isTaken = (r: DoseHistoryRow) =>
r.status === "taken_on_time" || r.status === "taken_late";
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = tzDayKey(r.at, userTz);
const e = recentByDay.get(key) ?? {
date: r.at,
total: 0,
taken: 0,
};
e.total += 1;
if (isTaken(r)) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
.map(([date, info]) => ({
date,
weekday: tzWeekday(info.date, userTz),
rate: Math.round((info.taken / info.total) * 100) / 100,
taken: info.taken,
total: info.total,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.at, userTz);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (isTaken(r)) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
.map(([weekISO, v]) => ({
weekISO,
rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0,
taken: v.taken,
total: v.total,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
snapshot.compliance = {
// v1.17 W1c — headline adherence % from the SAME ledger authority
// (`calculateCompliance(...).rate`) the medication card shows: the
// dose-weighted pool of taken / (taken + missed) across the user's
// scheduled medications, so the coach quotes the card's figure
// (single med) or its honest overall adherence (several meds) rather
// than a per-day / per-week rate built off a different denominator.
// Integer 0-100 to match the card's rounding; null when no scheduled
// medication has any countable dose in the window.
rate:
complianceDenominator > 0
? Math.round((complianceTaken / complianceDenominator) * 100)
: null,
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = countable.length;
registerBlock("compliance", "compliance");
} else {
// v1.7.0 — toggled-on cluster with no rows. Annotate so the
// dashboards can distinguish "user has no medication data" from
// "medication cluster was off".
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "medication" },
});
}
}
@@ -0,0 +1,204 @@
/**
* Glucose block for the Coach snapshot (v1.7.0, per-context daily means).
*
* Glucose is summarised per `GlucoseContext` so the Coach can tell
* fasting from postprandial without seeing raw samples. Each context
* gets its own per-day mean timeline; the block omits when no rows.
* The clinical panel summary (TIR / GMI / CV%) is computed over the
* fixed trailing-30-day rows the insights panel + doctor report use,
* never the coach's variable narration window.
*
* Split out of `snapshot.ts`; the builder passes the rows it already
* read plus the shared accumulators, so the emitted shape and ordering
* are unchanged.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
} from "@/lib/analytics/glucose-metrics";
import { annotate } from "@/lib/logging/context";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { bucketWeekly, buildDailyValueRows } from "../snapshot-series";
import type {
CoachProvenance,
CoachProvenanceMetric,
CoachScopeSource,
} from "../types";
/**
* v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact,
* identical across the insights panel, the dashboard snapshot, the doctor
* report, and (here) the coach. Pinned independently of the coach's variable
* narration window so the coach's TIR/GMI/CV% always equals what the panel
* renders.
*/
export const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS;
interface GlucoseBlockContext {
measurementRows: ReadonlyArray<{
type: string;
value: number;
measuredAt: Date;
glucoseContext: unknown;
}>;
/** Effective window cutoff for the glucose source (multi-cluster aware). */
glucoseCutoff: Date;
glucoseClinicalRows: Array<{ value: number; measuredAt: Date }> | null;
glucoseUnit: GlucoseUnit;
recentCutoff: Date;
userTz: string;
now: Date;
snapshot: Record<string, unknown>;
metrics: Set<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
}
export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
const {
measurementRows,
glucoseCutoff,
glucoseClinicalRows,
glucoseUnit,
recentCutoff,
userTz,
now,
snapshot,
metrics,
counts,
registerBlock,
groundingValues,
} = ctx;
const glucoseRows = measurementRows.filter(
(r) => r.type === "BLOOD_GLUCOSE" && r.measuredAt >= glucoseCutoff,
);
if (glucoseRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "glucose", source: "glucose" },
});
} else {
// Group by context (NULL → "unspecified"), then per-day mean.
const byContext = new Map<
string,
Array<{ measuredAt: Date; value: number }>
>();
for (const r of glucoseRows) {
const ctxKey = r.glucoseContext
? String(r.glucoseContext).toLowerCase()
: "unspecified";
const list = byContext.get(ctxKey) ?? [];
list.push({ measuredAt: r.measuredAt, value: r.value });
byContext.set(ctxKey, list);
}
const contexts: Record<string, unknown> = {};
for (const [ctxKey, rows] of byContext) {
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
// user's Coach must read the same number every other surface shows
// (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL,
// then convert each resulting figure ONCE (parity with the series
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
// the conversion is skipped entirely.
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) =>
glucoseUnit === "mmol/L"
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
: d,
);
const weekly = bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
).map((w) =>
glucoseUnit === "mmol/L"
? { ...w, mean: convertGlucose(w.mean, glucoseUnit) }
: w,
);
contexts[ctxKey] = { recent, weekly };
}
// v1.17.0 — clinical panel summary from the ONE literature-locked engine
// the insights panel + doctor report also consume, computed over the SAME
// fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`,
// not the coach-window / cap-trimmed `glucoseRows`), so the coach can never
// quote a TIR / GMI / CV% figure the panel doesn't show — true numeric
// parity, independent of the user's coach scope. Gated by `stillLearning`
// so a thin spot-data window is offered as a calm "still learning" note
// rather than asserted as a clinical AGP. The headline mean is converted
// ONCE to the user's display unit; the unit-agnostic fractions / indices
// travel as-is.
const clinicalRaw = computeGlucoseClinicalMetrics(
(glucoseClinicalRows ?? []).map((r) => ({
measuredAt: r.measuredAt,
mgdl: r.value,
})),
{ windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now },
);
const clinical = clinicalRaw.stillLearning
? {
stillLearning: true as const,
reason: clinicalRaw.stillLearningReason,
readingCount: clinicalRaw.readingCount,
spanDays: Math.round(clinicalRaw.actualSpanDays),
}
: {
stillLearning: false as const,
windowDays: clinicalRaw.windowDays,
spanDays: Math.round(clinicalRaw.actualSpanDays),
readingCount: clinicalRaw.readingCount,
meanInRange:
clinicalRaw.meanMgdl !== null
? Math.round(
convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) *
(glucoseUnit === "mmol/L" ? 10 : 1),
) / (glucoseUnit === "mmol/L" ? 10 : 1)
: null,
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
timeAbovePercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tarLevel1 * 100)
: null,
gmi:
clinicalRaw.gmi !== null
? Math.round(clinicalRaw.gmi * 10) / 10
: null,
estimatedA1c:
clinicalRaw.estimatedA1c !== null
? Math.round(clinicalRaw.estimatedA1c * 10) / 10
: null,
cvPercent: clinicalRaw.variability
? Math.round(clinicalRaw.variability.cv)
: null,
unstable: clinicalRaw.variability?.unstable ?? null,
// Density-derived: a sparse spot series stays a spot-reading
// estimate, a continuous CGM stream (Nightscout) reads false so the
// model can narrate the TIR/GMI as continuous-trace figures.
isSpotEstimate: clinicalRaw.isSpotEstimate,
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical };
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
// W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's
// unit), independent of the user's mmol/L display preference. The
// grounding line's band selection respects the W6 `hasDiabetes` opt-in;
// here we only feed the representative fasting value. Fall back to the
// overall mean when no row is tagged FASTING so the band is still cited.
const fastingRows = glucoseRows.filter(
(r) => String(r.glucoseContext).toUpperCase() === "FASTING",
);
const glucoseScalarRows =
fastingRows.length > 0 ? fastingRows : glucoseRows;
const glucoseMeanMgdl =
glucoseScalarRows.reduce((s, r) => s + r.value, 0) /
glucoseScalarRows.length;
if (Number.isFinite(glucoseMeanMgdl)) {
groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl);
}
}
}
@@ -0,0 +1,213 @@
/**
* Sleep blocks for the Coach snapshot.
*
* Two sections, both gated on the `sleep` source:
* - the v1.7.0 per-night timeline (with optional per-stage enrichment),
* reconstructed through the shared sleep-night helper so the Coach
* narrates the same nightly numbers every other sleep surface shows;
* - the v1.17.0 sleep-rhythm summary (sleep-debt + chronotype), built
* from the SAME assembler the dashboard route uses over the rhythm's
* own fixed trailing-42-day rows.
*
* Split out of `snapshot.ts`; the builder passes the rows it already
* read plus the shared accumulators, so the emitted shape and ordering
* are unchanged.
*/
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import {
reconstructNights,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import {
computeSleepRhythmFromNights,
type SleepRhythmDto,
} from "@/lib/insights/derived/sleep-rhythm";
import { annotate } from "@/lib/logging/context";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { bucketWeekly, tzWeekday } from "../snapshot-series";
import type {
CoachProvenance,
CoachProvenanceMetric,
CoachScopeSource,
} from "../types";
interface SleepTimelineBlockContext {
sleepRows: SleepStageRow[];
sourcePriorityJson: unknown;
userTz: string;
recentCutoff: Date;
snapshot: Record<string, unknown>;
metrics: Set<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
groundingValues: Map<ReferenceMetric, number>;
}
/**
* ── v1.7.0 sleep block (with optional per-stage enrichment) ───────
*
* Sleep needs the `sleepStage` column so the Coach can narrate REM /
* core / deep / awake minutes per night instead of a flat duration.
* The shared `byType("SLEEP_DURATION")` rows already cover the
* duration timeline, but they drop the stage label — so the sleep
* branch builds its own block. Per-night stage minutes come from a
* dedicated read of the SLEEP_DURATION rows that carry a non-null
* stage; the duration timeline is built from the same rows summed per
* night (one night = the sum of its per-stage rows).
*/
export function buildSleepTimelineBlock(
ctx: Readonly<SleepTimelineBlockContext>,
): void {
const {
sleepRows,
sourcePriorityJson,
userTz,
recentCutoff,
snapshot,
metrics,
counts,
registerBlock,
groundingValues,
} = ctx;
// The SLEEP_DURATION rows (with the `sleepStage` column) are read in
// parallel by the builder.
if (sleepRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "sleep", source: "sleep" },
});
} else {
// v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared
// helper so the Coach narrates the same nightly numbers every other
// sleep surface shows: stages clustered into sessions, a dual-source
// night collapsed to one canonical source, and the granular
// CORE/DEEP/REM partition counted WITHOUT double-counting the bare
// ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are
// excluded from the asleep total.
const nights = reconstructSleepNights(
sleepRows,
userTz,
sourcePriorityJson,
).filter((n) => n.asleepMinutes > 0);
// Recent nights: asleep duration + stage breakdown when present.
const recentNights = nights
.filter((n) => n.measuredAt >= recentCutoff)
.map((n) => {
const row: Record<string, unknown> = {
date: n.measuredAt,
weekday: tzWeekday(n.measuredAt, userTz),
minutes: Math.round(n.asleepMinutes),
};
const stageEntries = Object.entries(n.stages).filter(
([stage]) => stage !== "IN_BED" && stage !== "AWAKE",
);
if (stageEntries.length > 0) {
row.stages = Object.fromEntries(
stageEntries.map(([k, v]) => [
k.toLowerCase(),
Math.round(v as number),
]),
);
}
return row;
})
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
const olderNights = nights
.filter((n) => n.measuredAt < recentCutoff)
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
snapshot.sleep = {
timeline: {
recent: recentNights,
weekly: bucketWeekly(olderNights, userTz),
},
};
metrics.add("sleep");
counts.sleep = sleepRows.length;
registerBlock("sleep", "sleep");
// W7 grounding: recent nightly asleep duration in HOURS against the
// AASM 7–9 h band (the reference unit is hours; the snapshot stores
// minutes). Mean over the recent nights the block already reconstructed.
if (recentNights.length > 0) {
const meanMin =
recentNights.reduce(
(s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0),
0,
) / recentNights.length;
if (meanMin > 0) {
groundingValues.set("SLEEP_DURATION", meanMin / 60);
}
}
}
}
interface SleepRhythmBlockContext {
sleepRhythmRows: SleepStageRow[];
sourcePriorityJson: unknown;
userTz: string;
ageYears: number | null;
snapshot: Record<string, unknown>;
metrics: Set<CoachProvenanceMetric>;
registerBlock: (key: string, source: CoachScopeSource) => void;
}
/**
* ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
*
* The two server-authoritative timing signals the Sleep page + the
* dashboard summary render — cumulative sleep debt and the MCTQ
* chronotype band + social jetlag. Built from the SAME assembler the
* dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
* over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
* exact debt + band the page shows regardless of the coach's narration
* window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline —
* the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the
* assembler. The `needMinutes` is the SAME age-resolved need the derived
* block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the
* `sourcePriorityJson` is the one already loaded for the per-stage sleep
* block — no extra read beyond the rhythm rows.
*
* LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning`
* state below their night thresholds. The chronotype `band` is emitted ONLY
* when the state is `ready` — a learning chronotype is surfaced as
* "still calibrating", never asserted as a band the data can't support.
*/
export function buildSleepRhythmBlock(
ctx: Readonly<SleepRhythmBlockContext>,
): void {
const {
sleepRhythmRows,
sourcePriorityJson,
userTz,
ageYears,
snapshot,
metrics,
registerBlock,
} = ctx;
const rhythm: SleepRhythmDto = computeSleepRhythmFromNights(
reconstructNights(sleepRhythmRows, userTz, sourcePriorityJson),
sleepNeedMinutes(ageYears),
);
const chronotypeReady = rhythm.chronotype.state === "ready";
snapshot.sleepRhythm = {
sleepDebt: {
state: rhythm.sleepDebt.state,
debtMinutes: rhythm.sleepDebt.debtMinutes,
needMinutes: rhythm.sleepDebt.needMinutes,
},
chronotype: {
state: rhythm.chronotype.state,
// Only assert a band + social jetlag once the chronotype is `ready`.
// A `learning` chronotype carries no band the data supports — the
// model treats it as "still calibrating", never a typed assertion.
band: chronotypeReady ? rhythm.chronotype.band : null,
socialJetlagMinutes: chronotypeReady
? rhythm.chronotype.socialJetlagMinutes
: null,
},
};
metrics.add("sleep");
registerBlock("sleepRhythm", "sleep");
}
@@ -0,0 +1,98 @@
/**
* Workouts block for the Coach snapshot (v1.7.0).
*
* The Workout model is never dumped row-for-row. The block carries
* the most recent `WORKOUT_RECENT_CAP` sessions (sport, duration,
* energy, distance, avg/max HR) plus a per-sport weekly count + total
* duration/energy rollup for the tail so the prompt stays bounded
* even for a heavy-training account at a long window.
*
* Split out of `snapshot.ts`; the builder passes the rows it already
* read plus the shared accumulators, so the emitted shape and ordering
* are unchanged.
*/
import { annotate } from "@/lib/logging/context";
import { tzDayKey, tzWeekday } from "../snapshot-series";
import type {
CoachProvenance,
CoachProvenanceMetric,
CoachScopeSource,
} from "../types";
/**
* v1.7.0 — the workouts block never dumps every session. It carries
* the most-recent N sessions verbatim plus a per-sport rollup for the
* tail, so a heavy-training account at a long window stays bounded.
*/
const WORKOUT_RECENT_CAP = 15;
interface WorkoutsBlockContext {
workoutRows: Array<{
sportType: string;
startedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
}>;
userTz: string;
snapshot: Record<string, unknown>;
metrics: Set<CoachProvenanceMetric>;
counts: NonNullable<CoachProvenance["counts"]>;
registerBlock: (key: string, source: CoachScopeSource) => void;
}
export function buildWorkoutsBlock(ctx: Readonly<WorkoutsBlockContext>): void {
const { workoutRows, userTz, snapshot, metrics, counts, registerBlock } = ctx;
// The workout sessions are read in parallel by the builder.
if (workoutRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "workouts", source: "workouts" },
});
} else {
const recentList = workoutRows.slice(0, WORKOUT_RECENT_CAP).map((w) => ({
date: tzDayKey(w.startedAt, userTz),
weekday: tzWeekday(w.startedAt, userTz),
sport: w.sportType,
durationMin: Math.round(w.durationSec / 60),
energyKcal: w.totalEnergyKcal ?? null,
distanceM: w.totalDistanceM ?? null,
avgHr: w.avgHeartRate ?? null,
maxHr: w.maxHeartRate ?? null,
}));
// Per-sport rollup over the whole window.
const bySport = new Map<
string,
{ count: number; durationMin: number; energyKcal: number }
>();
for (const w of workoutRows) {
const e = bySport.get(w.sportType) ?? {
count: 0,
durationMin: 0,
energyKcal: 0,
};
e.count += 1;
e.durationMin += Math.round(w.durationSec / 60);
e.energyKcal += w.totalEnergyKcal ?? 0;
bySport.set(w.sportType, e);
}
const sportRollup = Array.from(bySport.entries())
.map(([sport, v]) => ({
sport,
count: v.count,
totalDurationMin: v.durationMin,
totalEnergyKcal: Math.round(v.energyKcal),
}))
.sort((a, b) => b.count - a.count);
snapshot.workouts = {
recent: recentList,
perSport: sportRollup,
totalInWindow: workoutRows.length,
};
metrics.add("workouts");
counts.workouts = workoutRows.length;
registerBlock("workouts", "workouts");
}
}
+88
View File
@@ -0,0 +1,88 @@
/**
* In-memory result cache for the Coach snapshot builder.
*
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
* snapshot reads only persisted data; a single chat conversation sends
* 2-4 turns within a minute and the snapshot would otherwise rebuild
* from the same rows each turn. Caching the result for 60s shaves the
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
* §3.3 estimates at 200-800 ms of server-side tail.
*
* Scope is part of the cache key so a switch from `last30days` to
* `last7days` (or a different `sources` set) computes fresh. The map
* is bounded at 64 entries — a multi-tenant deployment with a few
* active power users sits well inside that ceiling even if each cycles
* through several scopes per minute.
*
* Split out of `snapshot.ts` so the builder file carries only the
* assembly logic; the public surface (`buildCoachSnapshot`,
* `__resetCoachSnapshotCacheForTests`) stays importable from
* `./snapshot` via re-export.
*/
import type { CoachSnapshotResult } from "./snapshot";
import type { CoachScope, CoachScopeWindow } from "./types";
/** Default window when the caller doesn't pass a scope. */
export const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
const SNAPSHOT_TTL_MS = 60_000;
const SNAPSHOT_LRU_MAX = 64;
const snapshotCache = new Map<
string,
{ expiresAt: number; result: CoachSnapshotResult }
>();
export function snapshotCacheKey(
userId: string,
scope: CoachScope | undefined,
): string {
// v1.7.0 — when the request pins an explicit source list, key on it.
// Otherwise the source set is derived from the user's saved
// `dataClusters`, which we don't read here (the cache must stay
// I/O-free on a hit) — key on a stable `clusters` marker instead.
// A cluster change is reflected on the next cache miss (≤60 s), the
// same staleness window every other pref change already tolerates.
const window = scope?.window ?? DEFAULT_WINDOW;
const sourceList =
scope?.sources && scope.sources.length > 0
? Array.from(scope.sources).sort().join(",")
: "clusters";
return `${userId}|${window}|${sourceList}`;
}
export function readSnapshotCache(key: string): CoachSnapshotResult | null {
const entry = snapshotCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
snapshotCache.delete(key);
return null;
}
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
snapshotCache.delete(key);
snapshotCache.set(key, entry);
return entry.result;
}
export function writeSnapshotCache(
key: string,
result: CoachSnapshotResult,
): void {
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
// Evict the oldest entry — JS Map iteration order is insertion order,
// so the first key is the least-recently inserted/touched.
const oldest = snapshotCache.keys().next().value;
if (oldest !== undefined) {
snapshotCache.delete(oldest);
}
}
snapshotCache.set(key, {
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
result,
});
}
/** Clear the snapshot cache. Test-only escape hatch. */
export function __resetCoachSnapshotCacheForTests(): void {
snapshotCache.clear();
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Pure time / series helpers for the Coach snapshot builder.
*
* Everything here is deterministic over its inputs — window arithmetic,
* user-timezone day/week bucketing, daily/weekly fold-downs, the coarse
* rollup tail, and the scope resolver. Split out of `snapshot.ts` so the
* builder file carries only the assembly logic; the dependency direction
* is one-way (snapshot → series), never back.
*/
import { userDayKey } from "@/lib/tz/format";
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { DEFAULT_WINDOW } from "./snapshot-cache";
import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
export function windowToDays(window: CoachScopeWindow): number {
switch (window) {
case "last7days":
return 7;
case "last30days":
return 30;
case "last90days":
return 90;
case "lastYear":
// v1.4.27 B7 / BL-P6-4 — year-in-review window. Same 365-day
// ceiling as the historical `allTime` cap so the prompt budget
// stays bounded, but with explicit semantics — the Coach knows
// the user is asking about a long-horizon view rather than the
// unbounded "all time" backfill.
return 365;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
// pipeline; the timeline stays tight to keep token budget sane.
return 365;
}
}
/**
* YYYY-MM-DD day key anchored to the user's display timezone. The
* prompt asks the Coach "did you read at 23:50 last night?" — that
* "last night" answer has to use the user's clock, not UTC. Up to
* v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading
* landed in the next UTC day's bucket and the Coach couldn't pair it
* with the user's mental model. Delegates to the canonical
* `userDayKey` so every day-bucket surface stays byte-aligned.
*/
export function tzDayKey(date: Date, tz: string): string {
return userDayKey(date, tz);
}
/**
* 0..6 → "Sun".."Sat" using the user's tz so "last Monday" in the
* prompt agrees with the calendar the user is looking at.
*/
export function tzWeekday(date: Date, tz: string): string {
const wk = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
}).format(date);
// Normalise "Mon" / "Mon," — modern engines drop the comma but
// older ones may include it.
const canon = wk.slice(0, 3) as (typeof WEEKDAY_KEYS)[number];
return WEEKDAY_KEYS.includes(canon) ? canon : WEEKDAY_KEYS[date.getUTCDay()];
}
/**
* ISO week key like 2026-W19. We derive the week from the user-tz
* day-key so a Sunday-evening reading in Auckland (which is already
* Monday UTC) labels under the Auckland week.
*/
export function isoWeekKey(date: Date, tz: string): string {
// Resolve the wall-clock date in the user's tz, then compute the ISO
// week from that calendar date. The Thursday-alignment math is the
// standard ISO 8601 algorithm.
const dayKey = tzDayKey(date, tz);
const [y, m, d] = dayKey.split("-").map(Number);
const localMidnight = new Date(Date.UTC(y, m - 1, d));
const dayNum = localMidnight.getUTCDay() || 7;
localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((localMidnight.getTime() - yearStart.getTime()) / 86400000 + 1) / 7,
);
return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
export interface DailyValueRow {
date: string;
weekday: string;
value: number;
}
interface DailyBpRow {
date: string;
weekday: string;
sys: number;
dia: number;
}
interface WeeklyBucket {
weekISO: string;
mean: number;
count: number;
}
/**
* Group raw measurements (one per row, possibly several per day) into
* one entry per user-tz day. Multiple readings on the same day are
* folded into the daily mean — clinically the morning reading is what
* the Coach is usually asked about, but the snapshot stays pre-clinical
* and takes the straight mean to avoid presenting a fabricated number.
*/
function dailyMeans<T extends { measuredAt: Date; value: number }>(
rows: T[],
tz: string,
): Map<string, { date: Date; values: number[] }> {
const grouped = new Map<string, { date: Date; values: number[] }>();
for (const r of rows) {
const key = tzDayKey(r.measuredAt, tz);
const existing = grouped.get(key);
if (existing) {
existing.values.push(r.value);
} else {
grouped.set(key, { date: r.measuredAt, values: [r.value] });
}
}
return grouped;
}
export function bucketWeekly(
rows: Array<{ measuredAt: Date; value: number }>,
tz: string,
): WeeklyBucket[] {
const grouped = new Map<string, number[]>();
for (const r of rows) {
const key = isoWeekKey(r.measuredAt, tz);
const list = grouped.get(key);
if (list) {
list.push(r.value);
} else {
grouped.set(key, [r.value]);
}
}
return Array.from(grouped.entries())
.map(([weekISO, values]) => ({
weekISO,
mean:
Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) /
10,
count: values.length,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
}
export function buildDailyValueRows(
rows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
tz: string,
): DailyValueRow[] {
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
const grouped = dailyMeans(recent, tz);
return Array.from(grouped.entries())
.map(([date, info]) => {
const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
return {
date,
weekday: tzWeekday(info.date, tz),
value: Math.round(mean * 10) / 10,
};
})
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared
* tiered-context builder. The Coach already ships the 0–14d raw + 30–90d
* weekly band (above); this adds only the bands the weekly-fold CANNOT
* produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope —
* so a glucose spike or a BP outlier from months ago survives a coarse
* bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5
* year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is
* the lowest-value, oldest detail). Returns `undefined` when the metric has
* no coarse history at all, so the block is omitted rather than shipped empty.
*/
export interface CoarseTimelineTail {
/** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */
monthly: Array<[string, number, number, number]>;
/** >1y yearly buckets: `[bucketStart, mean, min, max]`. */
yearly: Array<[string, number, number, number]>;
/** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */
anomalies: Array<{
band: string;
date: string;
kind: string;
value: number;
deltaSd: number;
}>;
}
export async function buildCoarseTimelineTail(
userId: string,
type: MeasurementType,
now: Date,
tz: string,
): Promise<CoarseTimelineTail | undefined> {
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
// tail a strictly additive enrichment: any unexpected reader failure simply
// drops the tail rather than failing the whole snapshot (matching the
// "fall back on miss" contract the rest of the snapshot honours).
let series: Awaited<ReturnType<typeof buildTieredSeries>>;
try {
// `coarseOnly` — the snapshot already holds the raw 0–14d rows from its
// own window read and this tail consumes only the MONTH/YEAR bands +
// anomaly envelope, so the builder skips the raw read AND the DAY/WEEK
// band reads it would otherwise discard.
series = await buildTieredSeries(userId, type, {
now: now.getTime(),
tz,
coarseOnly: true,
});
} catch {
return undefined;
}
const monthly = series.monthBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
const yearly = series.yearBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
if (
monthly.length === 0 &&
yearly.length === 0 &&
series.anomalies.length === 0
) {
return undefined;
}
return {
monthly,
yearly,
anomalies: series.anomalies.map((a) => ({
band: a.band,
date: a.date,
kind: a.kind,
value: a.value,
deltaSd: a.deltaSd,
})),
};
}
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
* and a half-measured day would invite a fabricated complement.
*/
export function buildDailyBpRows(
sysRows: Array<{ measuredAt: Date; value: number }>,
diaRows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
tz: string,
): DailyBpRow[] {
const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff);
const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff);
const sysByDay = dailyMeans(sysRecent, tz);
const diaByDay = dailyMeans(diaRecent, tz);
const out: DailyBpRow[] = [];
for (const [day, info] of sysByDay) {
const dia = diaByDay.get(day);
if (!dia) continue;
const sysMean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
const diaMean = dia.values.reduce((s, v) => s + v, 0) / dia.values.length;
out.push({
date: day,
weekday: tzWeekday(info.date, tz),
sys: Math.round(sysMean),
dia: Math.round(diaMean),
});
}
return out.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* v1.22 (W6) — the user's own usual range for one BP component, computed from
* the rows already fetched for the snapshot (no extra DB read) using the SAME
* median ± k·MAD statistic as the baseline engine. Returns null below the
* engine's 7-day history floor so the Coach never quotes a fabricated band.
*/
export function bpBandFromRows(
rows: ReadonlyArray<{ measuredAt: Date; value: number }>,
): { low: number; high: number } | null {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const day = r.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += r.value;
acc.count += 1;
byDay.set(day, acc);
}
const dayMeans = [...byDay.values()].map((a) => a.sum / a.count);
if (dayMeans.length < 7) return null;
const band = buildBaselineBand(dayMeans);
if (!band) return null;
return { low: Math.round(band.low), high: Math.round(band.high) };
}
/**
* Resolve the working scope. The explicit request `scope.sources`
* always wins as the maximum set (an iOS client can pin an exact
* list). When it is absent the builder expands the user's saved
* `dataClusters` instead — `clusterDefault` carries that expansion.
* When neither is present (no prefs row at all, legacy native client)
* the cluster resolver still returns `DEFAULT_COACH_CLUSTERS`, so the
* legacy five domains stay the floor.
*
* v1.7.0 — replaces the constant `DEFAULT_SOURCES` fallback with the
* cluster expansion threaded in by `buildCoachSnapshotImpl`.
*/
export function resolveScope(
scope: CoachScope | undefined,
clusterDefault: ReadonlySet<CoachScopeSource>,
): {
sources: ReadonlySet<CoachScopeSource>;
window: CoachScopeWindow;
} {
const sources =
scope?.sources && scope.sources.length > 0
? new Set(scope.sources)
: clusterDefault;
return {
sources,
window: scope?.window ?? DEFAULT_WINDOW,
};
}
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,9 @@ const ROOT = join(__dirname, "..", "..", "..", "..");
const CONSUMERS = [
"src/app/api/analytics/route.ts",
"src/lib/dashboard/snapshot.ts",
"src/lib/ai/coach/snapshot.ts",
// v1.28.26 — the Coach glucose section moved into its own block module;
// the alias derivation lives there and snapshot.ts imports the alias.
"src/lib/ai/coach/snapshot-blocks/glucose-block.ts",
];
describe("glucose panel window constant", () => {
+25 -779
View File
@@ -6,6 +6,10 @@
* `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
* structurally identical so visual parity between client- and server-rendered
* PDFs is guaranteed by construction, not by drift-prone copy-paste.
*
* The data shapes live in `doctor-report-types.ts` and the pure helpers in
* `doctor-report-helpers.ts`; both are re-exported here so call sites keep a
* single import surface.
*/
import { prisma } from "@/lib/db";
@@ -13,27 +17,14 @@ import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import {
resolveGlucoseUnit,
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { getEvent } from "@/lib/logging/context";
import { readNote } from "@/lib/crypto/note-cipher";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/resolver";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -44,478 +35,27 @@ import {
} from "@/lib/validations/doctor-report-prefs";
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import {
buildComplianceMedicationContext,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
SCHEDULE_COMPLIANCE_SELECT,
type ComplianceSchedule,
type IntakeEvent,
type MedicationPauseEraLike,
} from "@/lib/analytics/compliance";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
WELLNESS_SCORE_REPORT_TYPES,
type CollectDoctorReportOptions,
type DoctorReportData,
type DoctorReportMood,
type DoctorReportRange,
type DoctorReportStats,
} from "./doctor-report-types";
import {
buildLedgerCompliance,
collapseMeasurementsToCanonical,
decryptAllergyReaction,
minMaxOf,
resolveMaxMedicationAdministrations,
sanitisePracticeName,
summariseCanonicalRecovery,
} from "./doctor-report-helpers";
export interface DoctorReportStats {
avg: number;
min: number;
max: number;
count: number;
latest: number;
}
export interface DoctorReportCompliance {
total: number;
taken: number;
skipped: number;
missed: number;
}
/**
* Canonical adherence-rate rounding for the doctor-report export surfaces.
*
* The PDF table, the PDF clinical-summary headline, the FHIR adherence
* Observation, and the GLP-1 block all derive the rate from the SAME ledger
* denominator (`taken / total`, `total = taken + missed`). They MUST round it
* the same way the app does so a clinician comparing the export to the in-app
* card never sees a presentational divergence (87 % on the card, 87.3 % on the
* PDF). The app convention is `round(100 · taken / denominator)` capped at 100
* (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an
* integer percent. This is the one source of truth for every export surface.
*
* Returns `null` when `total <= 0` so callers render the "no expected dose"
* placeholder instead of a misleading `0` or `100`.
*/
export function adherenceRatePercent(
taken: number,
total: number,
): number | null {
if (total <= 0) return null;
return Math.min(100, Math.round((taken / total) * 100));
}
export interface DoctorReportMood {
avg: number;
min: number;
max: number;
count: number;
distribution: Record<number, number>;
}
export interface DoctorReportData {
/**
* Reporting period bounds. `start` and `end` are ISO timestamps; `days` is
* the inclusive day-count between the two (rounded up) so existing callers
* that displayed "last N days" continue to work without a UI rewrite.
*/
period: { days: number; since: string; start: string; end: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
// v1.7.0 — optional patient-identity fields for the export cover +
// FHIR Patient. `fullName` + `insurerName` are plaintext columns; the
// KVNR is NOT carried here (it's encrypted at rest and decrypted by
// the route, then handed to the builders separately). Optional in the
// type so pre-v1.7.0 fixtures that omit them still typecheck — the
// renderer treats absent and null identically.
fullName?: string | null;
insurerName?: string | null;
// v1.8.6 — German insurer institution number (IKNR), plaintext. Carried
// here for the FHIR `Coverage` payor Organization identifier. Optional
// so pre-v1.8.6 fixtures still typecheck.
insurerIkNumber?: string | null;
};
/**
* Free-text practice / clinic name printed on the cover. `null` omits the
* line entirely. Persisted as a user preference (see
* `User.lastReportPracticeName`).
*/
practiceName: string | null;
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the report
* period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI
* tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export
* carries the SAME numbers the insights panel and the coach show. Values are
* canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried
* as a spot-reading estimate (`isSpotEstimate: true`), gated by
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
name: string;
dose: string;
// v1.9.0 — optional user/clinician-asserted drug-classification
// codes. Carried here so the FHIR exporter can emit a coded
// `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL
// when no code was captured — the exporter then emits the pre-v1.9.0
// text-only concept. Optional in the type so pre-v1.9.0 fixtures
// still typecheck.
atcCode?: string | null;
rxNormCode?: string | null;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
/**
* v1.9.0 — acted medication-intake events over the report window, fed
* to the FHIR `MedicationAdministration` builder. One entry per intake
* row the user actually actioned (taken OR explicitly skipped);
* pending / missed / soft-deleted rows are excluded upstream so the
* exporter never asserts an administration that did not happen. The
* `dose` is the medication's structured dose-in-effect at
* `effectiveAt` (from `MedicationDoseChange`) when one exists, else
* NULL. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrations?: Array<{
medicationName: string;
/** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */
effectiveAt: string;
status: "completed" | "not-done";
/** Free-text dose label (the medication's `dose`), mirrors the statement. */
doseText: string | null;
/** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */
dose: { value: number; unit: string } | null;
/** Injection site, when the row recorded one. */
injectionSite: string | null;
/** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */
atcCode: string | null;
rxNormCode: string | null;
/** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */
deliveryForm: string | null;
}>;
/**
* v1.9.0 — set when the administration cap (see
* {@link resolveMaxMedicationAdministrations}) trimmed the administration
* set. `total` is the full acted-intake count over the
* window; `included` is how many (the most-recent) survived the cap.
* Null/absent when nothing was trimmed. The FHIR builder discloses
* this in the document narrative so the export is honest about the
* omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrationsTruncation?: {
total: number;
included: number;
} | null;
mood: DoctorReportMood | null;
/**
* v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at
* least one active GLP-1 medication. Null/absent when the user has no
* GLP-1 prescription (the vast majority of accounts). When set the
* PDF gains a "GLP-1 Therapy" section listing dose history, weight
* change over the therapy window, side-effect counts, and compliance.
* Optional in the type so legacy callers + test fixtures that omit
* the field continue to work; the renderer treats absent and null
* identically.
*/
glp1?: {
medications: Array<{
name: string;
currentDose: { value: number; unit: string; since: string } | null;
doseHistory: Array<{
value: number;
unit: string;
effectiveFrom: string;
note: string | null;
}>;
lastInjection: { date: string; site: string | null } | null;
compliance: { taken: number; total: number };
}>;
weightDeltaKg: number | null;
weightStartKg: number | null;
weightEndKg: number | null;
sideEffects: Array<{ tag: string; count: number }>;
} | null;
/**
* v1.10.0 — the server-derived nightly wellness scores
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the
* report window. These are 0–100 DESCRIPTIVE composites, NOT clinical
* vitals — they are rendered in a clearly-labelled "Wellness summary"
* section separate from the clinical vitals table, each with a
* "descriptive, not a clinical assessment" note. Null/absent when the
* user has no computed scores in the window (the renderer + FHIR builder
* then skip the section). Optional so pre-v1.10 fixtures still typecheck.
*/
wellnessScores?: Array<{
/** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
/** ISO timestamp of the latest score. */
latestAt: string;
}> | null;
/**
* v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg
* length + variability, current phase). Populated ONLY when the `cycle`
* section toggle is explicitly ON (privacy default OFF — a user sharing a
* BP report with a cardiologist must not auto-leak reproductive data) AND
* the user has at least one observed cycle. Statistics only — no free-text
* notes ever reach this surface. Null/absent otherwise; both the PDF and
* FHIR builders then skip the section. Optional so pre-v1.15 fixtures
* still typecheck.
*/
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
/**
* v1.17.1 — structured lab results recorded over the report window.
* Populated when the `labs` section toggle is ON (default) and the user
* recorded at least one result in the period. Statistics + the single
* latest reading per analyte; free-text notes never reach this surface.
* Each entry carries the lab's reference bounds so the PDF can print an
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
* Optional so pre-v1.17.1 fixtures still typecheck.
*/
labResults?: Array<{
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
panel: string | null;
/** Biomarker name as the user recorded it. */
analyte: string;
/**
* Latest numeric value for this analyte over the window; null for a
* qualitative reading (see `valueText`). v1.18.9.
*/
value: number | null;
/**
* v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a
* numeric reading. Exactly one of `value` / `valueText` is set.
*/
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** ISO timestamp of the latest reading. */
takenAt: string;
/** Count of readings for this analyte in the window (≥ 1). */
count: number;
}> | null;
/**
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
* the journal is on and shared deliberately, so the privacy stance matches
* labs (ON when the module is on), not mood / cycle.
* Statistics + labels only — the free-text note is NEVER read here, so no
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
* module is off or no episode overlaps the window; both builders then skip
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
*/
illnessEpisodes?: Array<{
/** User-facing condition label (e.g. "Erkältung"). */
label: string;
/** Broad condition class (INFECTION / ALLERGY / INJURY / …). */
type: string;
/** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */
lifecycle: string;
/** ISO onset instant. */
onsetAt: string;
/** ISO resolution instant, or null while ongoing. */
resolvedAt: string | null;
}> | null;
/**
* v1.27.x — structured allergy / intolerance records. Reference data,
* not time-windowed: populated whenever the `allergies` section toggle
* is ON (default) and the user recorded at least one live row. The
* reaction description is decrypted fail-soft per row (a key-rotation
* gap omits the value, never fails the report); the free-text note is
* NEVER read here — same stance as the illness journal. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
allergies?: Array<{
/** Substance / allergen label as the user recorded it. */
substance: string;
/** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */
category: string;
/** Kind (ALLERGY / INTOLERANCE). */
type: string;
/** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */
severity: string | null;
/** Status (ACTIVE / INACTIVE / RESOLVED). */
status: string;
/** Decrypted reaction description, or null (genuinely unset). */
reaction: string | null;
/**
* True when a reaction description WAS stored but could not be decrypted
* (a key-rotation gap / GCM corruption). Distinct from `reaction: null`
* (nothing recorded) so the clinician-facing report shows an honest
* "unreadable" marker rather than a blank that reads as "no reaction".
*/
reactionUnreadable?: boolean;
}> | null;
/**
* v1.27.x — structured family-history records. Reference data, not
* time-windowed: populated whenever the `familyHistory` section toggle
* is ON (default) and the user recorded at least one live row. Labels +
* onset age only — the free-text note is never read here. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
familyHistory?: Array<{
/** Relationship (MOTHER / FATHER / SISTER / …). */
relationship: string;
/** Condition label as the user recorded it. */
condition: string;
/** The relative's age at onset (years), or null when unknown. */
ageAtOnset: number | null;
}> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
export const WELLNESS_SCORE_REPORT_TYPES = [
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
/** One wellness-summary row (latest + range over the report window). */
interface WellnessScoreSummary {
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
latestAt: string;
}
/** A measurement row as the doctor-report aggregator reads it (subset). */
interface RecoveryMeasurementRow {
type: string;
value: number;
measuredAt: Date;
source: MeasurementSource;
}
/** Minimal Measurement shape the canonical-source collapse consults. */
export interface CanonicalCollapseRow {
type: string;
value: number;
measuredAt: Date;
source: MeasurementSource;
deviceType?: string | null;
}
/**
* Collapse a window's raw Measurement rows to one canonical source per metric
* before the doctor report computes per-type avg/min/max. Without this the PDF
* + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate
* day would average two readings), while the dashboard / insights aggregator
* already collapse to the ladder-canonical source per day. This routes the rows
* through the SAME picker (`pickCanonicalSourceRows`, keyed by
* `metricKeyForType` + the user's day key + source-priority ladder), so the two
* surfaces report identical numbers.
*
* SLEEP_DURATION is passed through untouched: it carries per-stage rows that
* `reconstructSleepNights` resolves with its own source de-dup downstream, so a
* pre-collapse here would drop stages the night reconstruction needs. A type
* with no ladder, or a day with a single source, passes through unchanged via
* the picker's documented fallback. Exported for unit tests.
*/
export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
measurements: readonly T[],
timezone: string,
sourcePriorityJson: unknown,
): T[] {
const dayKey = (d: Date) => userDayKey(d, timezone);
const byType = new Map<string, T[]>();
for (const m of measurements) {
const slot = byType.get(m.type);
if (slot) slot.push(m);
else byType.set(m.type, [m]);
}
const out: T[] = [];
for (const [type, rows] of byType) {
const metricKey =
type === "SLEEP_DURATION"
? null
: metricKeyForType(type as MeasurementType);
if (!metricKey) {
// Loop, not `push(...rows)` — a sample-dense type's window rows run to
// six figures and a spread call overflows the stack (v1.28.22 class).
for (const r of rows) out.push(r);
continue;
}
const { canonicalRows } = pickCanonicalSourceRows(
rows.map((m) => ({
...m,
type: m.type as MeasurementType,
})),
metricKey,
sourcePriorityJson,
dayKey,
);
// `pickCanonicalSourceRows` returns the spread copies; re-key back to the
// original rows by reference identity is unnecessary because the spread
// preserves every field the caller reads (value / measuredAt / type).
for (const r of canonicalRows as unknown as T[]) out.push(r);
}
return out;
}
/**
* Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the
* report window's per-type rows are raw measurements, and a sample-dense type
* (per-sample heart rate, CGM glucose) runs to six figures over a year — a
* spread call overflows the stack exactly on the heaviest accounts (the same
* failure class as the Google Health fullSync tracker, fixed v1.28.22).
*/
function minMaxOf(values: readonly number[]): { min: number; max: number } {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
/**
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
* doctor PDF never blends the proxy and the native value into one min/avg/max.
* Returns null when no recovery row exists. Exported for unit tests.
*/
export function summariseCanonicalRecovery(
measurements: readonly RecoveryMeasurementRow[],
timezone?: string | null,
): WellnessScoreSummary | null {
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
if (recovery.length === 0) return null;
const canonical = resolveCanonicalRecovery(
recovery.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
source: m.source,
})),
timezone,
);
if (canonical.length === 0) return null;
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
// latest value + the window range over the canonical set.
const values = canonical.map((r) => r.value);
const latestRow = canonical[0];
return {
type: "RECOVERY_SCORE",
latest: Math.round(latestRow.value),
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.round(minMaxOf(values).min),
max: Math.round(minMaxOf(values).max),
count: values.length,
latestAt: latestRow.measuredAt.toISOString(),
};
}
export * from "./doctor-report-types";
export * from "./doctor-report-helpers";
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
@@ -524,300 +64,6 @@ const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"BEDTIME",
];
/**
* Validate and normalise the requested reporting window.
* Accepts an unknown value (typically `body.days`); falls back to 90.
*/
export function normaliseDays(value: unknown, fallback = 90): number {
if (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= 365
) {
return value;
}
return fallback;
}
const MIN_RANGE_DAYS = 1;
/**
* Maximum span between `startDate` and `endDate`. Two years balances the
* "give me everything I have" case (chronic condition follow-up) against
* unbounded server work; the existing `days` fallback is capped at 365 so a
* caller asking for a 730-day window must come through the explicit-range
* surface and proves they want it.
*/
const MAX_RANGE_DAYS = 730;
const DEFAULT_RANGE_DAYS = 90;
/**
* Default ceiling on the number of `MedicationAdministration` source rows
* materialised per export, and the bounds an operator override is clamped
* to. The report window is already bounded at {@link MAX_RANGE_DAYS}
* (730 days), but a chronic medication dosed several times a day across
* that window — multiplied by several such medications — can still produce
* thousands of administration resources in a single Bundle. The default of
* 5 000 covers ~3.5 years at four doses a day, so it effectively never bites
* on a realistic report period while still bounding a pathological export.
*/
const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000;
const MIN_MEDICATION_ADMINISTRATIONS = 1;
const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000;
/**
* Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`.
* Accepts a positive integer within `[1, 50000]`; any unset / non-integer /
* out-of-range value falls back to the default. Exported as a pure function so
* the resolution can be tested without import-time env coupling.
*/
export function resolveMaxMedicationAdministrations(
raw: string | undefined,
): number {
const parsed = Number(raw);
return Number.isInteger(parsed) &&
parsed >= MIN_MEDICATION_ADMINISTRATIONS &&
parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING
? parsed
: DEFAULT_MAX_MEDICATION_ADMINISTRATIONS;
}
export interface DoctorReportRange {
/** Start of the reporting window (UTC, inclusive). */
start: Date;
/** End of the reporting window (UTC, inclusive). */
end: Date;
/** Inclusive day-count between `start` and `end` (rounded up). */
days: number;
}
/** Hard cap on the printed cover line — protects PDF layout from runaway input. */
const PRACTICE_NAME_MAX_LENGTH = 120;
/**
* Trim, collapse whitespace, drop control characters, and length-cap a
* caller-provided practice name. Returns `null` if the result is empty so
* the cover line is omitted entirely (rather than rendering a blank label).
*/
export function sanitisePracticeName(value: unknown): string | null {
if (typeof value !== "string") return null;
// Strip ASCII C0 + DEL controls (0x00..0x1F + 0x7F) which jsPDF can't
// render and which break PDF text streams. Then collapse whitespace + trim.
const cleaned = value
.replace(/[\x00-\x1F\x7F]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (cleaned.length === 0) return null;
return cleaned.slice(0, PRACTICE_NAME_MAX_LENGTH);
}
function parseIsoDate(value: unknown): Date | null {
if (typeof value !== "string" || value.length === 0) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d;
}
/**
* Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated
* inclusive reporting window.
*
* Resolution order:
* 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with
* `endDate >= startDate` and span <= 730 days → use them.
* 2. Neither/invalid range, but a valid `days` integer (1..365) is provided
* → fall back to "last `days` days ending now".
* 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default).
*
* The function is intentionally tolerant: invalid input never throws — bad
* shapes silently fall through to the next tier so a malformed request
* still produces a useful report rather than a 422.
*/
export function normaliseDateRange(
value: unknown,
now: Date = new Date(),
): DoctorReportRange {
const body =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const startCandidate = parseIsoDate(body.startDate);
const endCandidate = parseIsoDate(body.endDate);
if (startCandidate && endCandidate) {
const startMs = startCandidate.getTime();
const endMs = endCandidate.getTime();
if (endMs >= startMs) {
const spanMs = endMs - startMs;
const spanDays = Math.ceil(spanMs / 86_400_000) || 1;
if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) {
return {
start: startCandidate,
end: endCandidate,
days: spanDays,
};
}
}
}
const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS);
const end = now;
const start = new Date(end);
start.setDate(start.getDate() - days);
return { start, end, days };
}
export interface CollectDoctorReportOptions {
/** Free-text practice/clinic name to print on the cover. Optional. */
practiceName?: string | null;
/**
* v1.4.25 W6c — per-section visibility toggles. When omitted the
* documented defaults apply (every section ON except mood). When
* `sections.mood = false` the aggregator does NOT query
* `MoodEntry` at all — the mood data never leaves the DB row
* (privacy-by-default). Other sections are stripped from the
* returned payload so downstream renderers (PDF + JSON consumer)
* don't accidentally print a section the user opted out of.
*/
sections?: Partial<DoctorReportPrefs>;
/**
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
* module forces its report section / FHIR resources out of the payload,
* regardless of the user's per-report `sections` toggles: a user who turned
* off the Sleep module exports no sleep series, mood Observations, glucose
* panel, recovery/strain scores, cycle summary, or lab results for the
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
* medications) carry no module key and are always included. Injectable for
* tests so the module ↔ section mapping can be asserted without a DB.
*/
moduleMap?: Record<ModuleKey, boolean>;
}
/**
* v1.17 W1a — the medication row shape the ledger compliance builder needs.
* Carries the cadence-engine context fields (course window + creation +
* archived schedule eras) so the same band minter the detail page uses can
* reconstruct expected slots.
*/
export interface DoctorReportComplianceMedication {
id: string;
name: string;
asNeeded: boolean;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
schedules: ComplianceSchedule[];
scheduleRevisions?: ScheduleRevisionLike[];
/** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */
pauseEras?: MedicationPauseEraLike[];
}
/** A window-bounded intake row keyed to its medication. */
export interface DoctorReportComplianceIntake extends IntakeEvent {
medicationId: string;
}
/**
* v1.17 W1a — doctor-report medication adherence through the dose-ledger
* authority.
*
* The previous implementation tallied RAW intake rows: `taken = takenAt != null`,
* else `skipped`, else `missed`, with the denominator being every row. That
* double-counted cross-source duplicate rows (a REMINDER row + an API row for
* the same slot both counted as taken), ignored band timing (a pre-window or
* ad-hoc take counted as a plain "taken", a pending row counted as "missed"
* regardless of the miss cutoff), and honoured no cadence (rolling / cyclic /
* RRULE off-weeks could enter the tally). A clinician comparing the PDF % to
* the app detail page could see two different adherence numbers for one drug.
*
* Route the report through the SAME ledger the detail page uses: per
* medication, mint the cadence-aware bands over the report window
* `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped`
* from the unified dose-history ledger. `total = taken + missed` so the
* renderers' `taken / total` rate equals the ledger rate the detail page
* shows. PRN / as-needed medications are excluded exactly as before (no
* schedule, no expected dose — a fabricated 100 % on a clinical report).
*
* Pure / synchronous — bands come from pre-fetched schedules + intake
* instants. Keyed by medication name to match the renderer contract.
*/
export function buildLedgerCompliance(
medications: DoctorReportComplianceMedication[],
intakeEvents: DoctorReportComplianceIntake[],
userTz: string,
start: Date,
end: Date,
now: Date,
): Record<string, DoctorReportCompliance> {
const eventsByMedId = new Map<string, DoctorReportComplianceIntake[]>();
for (const event of intakeEvents) {
const list = eventsByMedId.get(event.medicationId);
if (list) list.push(event);
else eventsByMedId.set(event.medicationId, [event]);
}
const compliance: Record<string, DoctorReportCompliance> = {};
for (const med of medications) {
// PRN / as-needed carry no schedule and no expected dose — excluded so
// the report never prints a fabricated 100 %.
if (med.asNeeded) continue;
if (med.schedules.length === 0) continue;
const events = eventsByMedId.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
const tally = tallyComplianceFromLedger(
events,
med.schedules,
ctx,
start,
end,
now,
);
// `total = taken + missed` (the ledger denominator) so the renderer's
// `taken / total` equals `tally.rate` — the exact number the detail page
// shows. Deliberate skips are reported separately but stay out of `total`.
compliance[med.name] = {
total: tally.denominator,
taken: tally.taken,
skipped: tally.skipped,
missed: tally.missed,
};
}
return compliance;
}
/**
* Decrypt an allergy reaction envelope for a clinician-facing export, keeping
* "unreadable" distinct from "unset". A genuinely-empty envelope yields
* `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable
* one (key-rotation gap / GCM corruption) yields
* `{ reaction: null, reactionUnreadable: true }` so the report can render an
* honest marker instead of a blank that reads as "no reaction recorded". Pure
* (no logging) so it is unit-testable; the caller logs on the unreadable flag.
*/
export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): {
reaction: string | null;
reactionUnreadable: boolean;
} {
if (!reactionEncrypted || reactionEncrypted.byteLength === 0) {
return { reaction: null, reactionUnreadable: false };
}
try {
return {
reaction: decryptFromBytes(reactionEncrypted),
reactionUnreadable: false,
};
} catch {
return { reaction: null, reactionUnreadable: true };
}
}
/**
* Aggregate the doctor-report payload for a user over a `[start, end]` range.
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
+421
View File
@@ -0,0 +1,421 @@
/**
* Doctor-report pure helpers.
*
* The synchronous, DB-free functions behind the doctor-report aggregator:
* window normalisation, canonical-source collapse, recovery summarisation,
* ledger-based compliance tallying, and the export-surface sanitisers. Split
* out of `doctor-report-data.ts` as pure code motion; that module re-exports
* every public name here, so existing call sites keep importing from it
* unchanged. The data shapes live in `doctor-report-types.ts`; the aggregator
* itself stays in `doctor-report-data.ts`.
*/
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { userDayKey } from "@/lib/tz/resolver";
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
import {
buildComplianceMedicationContext,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
} from "@/lib/analytics/compliance";
import type {
CanonicalCollapseRow,
DoctorReportCompliance,
DoctorReportComplianceIntake,
DoctorReportComplianceMedication,
DoctorReportRange,
} from "./doctor-report-types";
/**
* Canonical adherence-rate rounding for the doctor-report export surfaces.
*
* The PDF table, the PDF clinical-summary headline, the FHIR adherence
* Observation, and the GLP-1 block all derive the rate from the SAME ledger
* denominator (`taken / total`, `total = taken + missed`). They MUST round it
* the same way the app does so a clinician comparing the export to the in-app
* card never sees a presentational divergence (87 % on the card, 87.3 % on the
* PDF). The app convention is `round(100 · taken / denominator)` capped at 100
* (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an
* integer percent. This is the one source of truth for every export surface.
*
* Returns `null` when `total <= 0` so callers render the "no expected dose"
* placeholder instead of a misleading `0` or `100`.
*/
export function adherenceRatePercent(
taken: number,
total: number,
): number | null {
if (total <= 0) return null;
return Math.min(100, Math.round((taken / total) * 100));
}
/** One wellness-summary row (latest + range over the report window). */
interface WellnessScoreSummary {
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
latestAt: string;
}
/** A measurement row as the doctor-report aggregator reads it (subset). */
interface RecoveryMeasurementRow {
type: string;
value: number;
measuredAt: Date;
source: MeasurementSource;
}
/**
* Collapse a window's raw Measurement rows to one canonical source per metric
* before the doctor report computes per-type avg/min/max. Without this the PDF
* + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate
* day would average two readings), while the dashboard / insights aggregator
* already collapse to the ladder-canonical source per day. This routes the rows
* through the SAME picker (`pickCanonicalSourceRows`, keyed by
* `metricKeyForType` + the user's day key + source-priority ladder), so the two
* surfaces report identical numbers.
*
* SLEEP_DURATION is passed through untouched: it carries per-stage rows that
* `reconstructSleepNights` resolves with its own source de-dup downstream, so a
* pre-collapse here would drop stages the night reconstruction needs. A type
* with no ladder, or a day with a single source, passes through unchanged via
* the picker's documented fallback. Exported for unit tests.
*/
export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
measurements: readonly T[],
timezone: string,
sourcePriorityJson: unknown,
): T[] {
const dayKey = (d: Date) => userDayKey(d, timezone);
const byType = new Map<string, T[]>();
for (const m of measurements) {
const slot = byType.get(m.type);
if (slot) slot.push(m);
else byType.set(m.type, [m]);
}
const out: T[] = [];
for (const [type, rows] of byType) {
const metricKey =
type === "SLEEP_DURATION"
? null
: metricKeyForType(type as MeasurementType);
if (!metricKey) {
// Loop, not `push(...rows)` — a sample-dense type's window rows run to
// six figures and a spread call overflows the stack (v1.28.22 class).
for (const r of rows) out.push(r);
continue;
}
const { canonicalRows } = pickCanonicalSourceRows(
rows.map((m) => ({
...m,
type: m.type as MeasurementType,
})),
metricKey,
sourcePriorityJson,
dayKey,
);
// `pickCanonicalSourceRows` returns the spread copies; re-key back to the
// original rows by reference identity is unnecessary because the spread
// preserves every field the caller reads (value / measuredAt / type).
for (const r of canonicalRows as unknown as T[]) out.push(r);
}
return out;
}
/**
* Loop-based min/max over a value array. NEVER `Math.min(...values)` here: the
* report window's per-type rows are raw measurements, and a sample-dense type
* (per-sample heart rate, CGM glucose) runs to six figures over a year — a
* spread call overflows the stack exactly on the heaviest accounts (the same
* failure class as the Google Health fullSync tracker, fixed v1.28.22).
*/
export function minMaxOf(values: readonly number[]): {
min: number;
max: number;
} {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const v of values) {
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
/**
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
* doctor PDF never blends the proxy and the native value into one min/avg/max.
* Returns null when no recovery row exists. Exported for unit tests.
*/
export function summariseCanonicalRecovery(
measurements: readonly RecoveryMeasurementRow[],
timezone?: string | null,
): WellnessScoreSummary | null {
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
if (recovery.length === 0) return null;
const canonical = resolveCanonicalRecovery(
recovery.map((m) => ({
value: m.value,
measuredAt: m.measuredAt,
source: m.source,
})),
timezone,
);
if (canonical.length === 0) return null;
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
// latest value + the window range over the canonical set.
const values = canonical.map((r) => r.value);
const latestRow = canonical[0];
return {
type: "RECOVERY_SCORE",
latest: Math.round(latestRow.value),
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.round(minMaxOf(values).min),
max: Math.round(minMaxOf(values).max),
count: values.length,
latestAt: latestRow.measuredAt.toISOString(),
};
}
/**
* Validate and normalise the requested reporting window.
* Accepts an unknown value (typically `body.days`); falls back to 90.
*/
export function normaliseDays(value: unknown, fallback = 90): number {
if (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= 365
) {
return value;
}
return fallback;
}
const MIN_RANGE_DAYS = 1;
/**
* Maximum span between `startDate` and `endDate`. Two years balances the
* "give me everything I have" case (chronic condition follow-up) against
* unbounded server work; the existing `days` fallback is capped at 365 so a
* caller asking for a 730-day window must come through the explicit-range
* surface and proves they want it.
*/
const MAX_RANGE_DAYS = 730;
const DEFAULT_RANGE_DAYS = 90;
/**
* Default ceiling on the number of `MedicationAdministration` source rows
* materialised per export, and the bounds an operator override is clamped
* to. The report window is already bounded at {@link MAX_RANGE_DAYS}
* (730 days), but a chronic medication dosed several times a day across
* that window — multiplied by several such medications — can still produce
* thousands of administration resources in a single Bundle. The default of
* 5 000 covers ~3.5 years at four doses a day, so it effectively never bites
* on a realistic report period while still bounding a pathological export.
*/
const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000;
const MIN_MEDICATION_ADMINISTRATIONS = 1;
const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000;
/**
* Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`.
* Accepts a positive integer within `[1, 50000]`; any unset / non-integer /
* out-of-range value falls back to the default. Exported as a pure function so
* the resolution can be tested without import-time env coupling.
*/
export function resolveMaxMedicationAdministrations(
raw: string | undefined,
): number {
const parsed = Number(raw);
return Number.isInteger(parsed) &&
parsed >= MIN_MEDICATION_ADMINISTRATIONS &&
parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING
? parsed
: DEFAULT_MAX_MEDICATION_ADMINISTRATIONS;
}
/** Hard cap on the printed cover line — protects PDF layout from runaway input. */
const PRACTICE_NAME_MAX_LENGTH = 120;
/**
* Trim, collapse whitespace, drop control characters, and length-cap a
* caller-provided practice name. Returns `null` if the result is empty so
* the cover line is omitted entirely (rather than rendering a blank label).
*/
export function sanitisePracticeName(value: unknown): string | null {
if (typeof value !== "string") return null;
// Strip ASCII C0 + DEL controls (0x00..0x1F + 0x7F) which jsPDF can't
// render and which break PDF text streams. Then collapse whitespace + trim.
const cleaned = value
.replace(/[\x00-\x1F\x7F]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (cleaned.length === 0) return null;
return cleaned.slice(0, PRACTICE_NAME_MAX_LENGTH);
}
function parseIsoDate(value: unknown): Date | null {
if (typeof value !== "string" || value.length === 0) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d;
}
/**
* Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated
* inclusive reporting window.
*
* Resolution order:
* 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with
* `endDate >= startDate` and span <= 730 days → use them.
* 2. Neither/invalid range, but a valid `days` integer (1..365) is provided
* → fall back to "last `days` days ending now".
* 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default).
*
* The function is intentionally tolerant: invalid input never throws — bad
* shapes silently fall through to the next tier so a malformed request
* still produces a useful report rather than a 422.
*/
export function normaliseDateRange(
value: unknown,
now: Date = new Date(),
): DoctorReportRange {
const body =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const startCandidate = parseIsoDate(body.startDate);
const endCandidate = parseIsoDate(body.endDate);
if (startCandidate && endCandidate) {
const startMs = startCandidate.getTime();
const endMs = endCandidate.getTime();
if (endMs >= startMs) {
const spanMs = endMs - startMs;
const spanDays = Math.ceil(spanMs / 86_400_000) || 1;
if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) {
return {
start: startCandidate,
end: endCandidate,
days: spanDays,
};
}
}
}
const days = normaliseDays(body.days, DEFAULT_RANGE_DAYS);
const end = now;
const start = new Date(end);
start.setDate(start.getDate() - days);
return { start, end, days };
}
/**
* v1.17 W1a — doctor-report medication adherence through the dose-ledger
* authority.
*
* The previous implementation tallied RAW intake rows: `taken = takenAt != null`,
* else `skipped`, else `missed`, with the denominator being every row. That
* double-counted cross-source duplicate rows (a REMINDER row + an API row for
* the same slot both counted as taken), ignored band timing (a pre-window or
* ad-hoc take counted as a plain "taken", a pending row counted as "missed"
* regardless of the miss cutoff), and honoured no cadence (rolling / cyclic /
* RRULE off-weeks could enter the tally). A clinician comparing the PDF % to
* the app detail page could see two different adherence numbers for one drug.
*
* Route the report through the SAME ledger the detail page uses: per
* medication, mint the cadence-aware bands over the report window
* `[start, end]` and tally `taken` (on-time + late), `missed` and `skipped`
* from the unified dose-history ledger. `total = taken + missed` so the
* renderers' `taken / total` rate equals the ledger rate the detail page
* shows. PRN / as-needed medications are excluded exactly as before (no
* schedule, no expected dose — a fabricated 100 % on a clinical report).
*
* Pure / synchronous — bands come from pre-fetched schedules + intake
* instants. Keyed by medication name to match the renderer contract.
*/
export function buildLedgerCompliance(
medications: DoctorReportComplianceMedication[],
intakeEvents: DoctorReportComplianceIntake[],
userTz: string,
start: Date,
end: Date,
now: Date,
): Record<string, DoctorReportCompliance> {
const eventsByMedId = new Map<string, DoctorReportComplianceIntake[]>();
for (const event of intakeEvents) {
const list = eventsByMedId.get(event.medicationId);
if (list) list.push(event);
else eventsByMedId.set(event.medicationId, [event]);
}
const compliance: Record<string, DoctorReportCompliance> = {};
for (const med of medications) {
// PRN / as-needed carry no schedule and no expected dose — excluded so
// the report never prints a fabricated 100 %.
if (med.asNeeded) continue;
if (med.schedules.length === 0) continue;
const events = eventsByMedId.get(med.id) ?? [];
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(events),
userTz,
);
const tally = tallyComplianceFromLedger(
events,
med.schedules,
ctx,
start,
end,
now,
);
// `total = taken + missed` (the ledger denominator) so the renderer's
// `taken / total` equals `tally.rate` — the exact number the detail page
// shows. Deliberate skips are reported separately but stay out of `total`.
compliance[med.name] = {
total: tally.denominator,
taken: tally.taken,
skipped: tally.skipped,
missed: tally.missed,
};
}
return compliance;
}
/**
* Decrypt an allergy reaction envelope for a clinician-facing export, keeping
* "unreadable" distinct from "unset". A genuinely-empty envelope yields
* `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable
* one (key-rotation gap / GCM corruption) yields
* `{ reaction: null, reactionUnreadable: true }` so the report can render an
* honest marker instead of a blank that reads as "no reaction recorded". Pure
* (no logging) so it is unit-testable; the caller logs on the unreadable flag.
*/
export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): {
reaction: string | null;
reactionUnreadable: boolean;
} {
if (!reactionEncrypted || reactionEncrypted.byteLength === 0) {
return { reaction: null, reactionUnreadable: false };
}
try {
return {
reaction: decryptFromBytes(reactionEncrypted),
reactionUnreadable: false,
};
} catch {
return { reaction: null, reactionUnreadable: true };
}
}
+394
View File
@@ -0,0 +1,394 @@
/**
* Doctor-report data shapes.
*
* The interface / constant inventory for the aggregated doctor-report payload
* (`DoctorReport*` shapes, the wellness-score type list, the canonical-collapse
* row shape, and the collector option contracts). Split out of
* `doctor-report-data.ts` as pure code motion; that module re-exports every
* name here, so existing call sites keep importing from it unchanged. The pure
* helpers live in `doctor-report-helpers.ts`; the aggregator itself stays in
* `doctor-report-data.ts`.
*/
import type { GlucoseUnit } from "@/lib/glucose";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ModuleKey } from "@/lib/modules/gate";
import type {
ComplianceSchedule,
IntakeEvent,
MedicationPauseEraLike,
} from "@/lib/analytics/compliance";
import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras";
export interface DoctorReportStats {
avg: number;
min: number;
max: number;
count: number;
latest: number;
}
export interface DoctorReportCompliance {
total: number;
taken: number;
skipped: number;
missed: number;
}
export interface DoctorReportMood {
avg: number;
min: number;
max: number;
count: number;
distribution: Record<number, number>;
}
export interface DoctorReportData {
/**
* Reporting period bounds. `start` and `end` are ISO timestamps; `days` is
* the inclusive day-count between the two (rounded up) so existing callers
* that displayed "last N days" continue to work without a UI rewrite.
*/
period: { days: number; since: string; start: string; end: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
// v1.7.0 — optional patient-identity fields for the export cover +
// FHIR Patient. `fullName` + `insurerName` are plaintext columns; the
// KVNR is NOT carried here (it's encrypted at rest and decrypted by
// the route, then handed to the builders separately). Optional in the
// type so pre-v1.7.0 fixtures that omit them still typecheck — the
// renderer treats absent and null identically.
fullName?: string | null;
insurerName?: string | null;
// v1.8.6 — German insurer institution number (IKNR), plaintext. Carried
// here for the FHIR `Coverage` payor Organization identifier. Optional
// so pre-v1.8.6 fixtures still typecheck.
insurerIkNumber?: string | null;
};
/**
* Free-text practice / clinic name printed on the cover. `null` omits the
* line entirely. Persisted as a user preference (see
* `User.lastReportPracticeName`).
*/
practiceName: string | null;
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the report
* period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI
* tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export
* carries the SAME numbers the insights panel and the coach show. Values are
* canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried
* as a spot-reading estimate (`isSpotEstimate: true`), gated by
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
name: string;
dose: string;
// v1.9.0 — optional user/clinician-asserted drug-classification
// codes. Carried here so the FHIR exporter can emit a coded
// `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL
// when no code was captured — the exporter then emits the pre-v1.9.0
// text-only concept. Optional in the type so pre-v1.9.0 fixtures
// still typecheck.
atcCode?: string | null;
rxNormCode?: string | null;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
/**
* v1.9.0 — acted medication-intake events over the report window, fed
* to the FHIR `MedicationAdministration` builder. One entry per intake
* row the user actually actioned (taken OR explicitly skipped);
* pending / missed / soft-deleted rows are excluded upstream so the
* exporter never asserts an administration that did not happen. The
* `dose` is the medication's structured dose-in-effect at
* `effectiveAt` (from `MedicationDoseChange`) when one exists, else
* NULL. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrations?: Array<{
medicationName: string;
/** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */
effectiveAt: string;
status: "completed" | "not-done";
/** Free-text dose label (the medication's `dose`), mirrors the statement. */
doseText: string | null;
/** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */
dose: { value: number; unit: string } | null;
/** Injection site, when the row recorded one. */
injectionSite: string | null;
/** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */
atcCode: string | null;
rxNormCode: string | null;
/** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */
deliveryForm: string | null;
}>;
/**
* v1.9.0 — set when the administration cap (see
* {@link resolveMaxMedicationAdministrations}) trimmed the administration
* set. `total` is the full acted-intake count over the
* window; `included` is how many (the most-recent) survived the cap.
* Null/absent when nothing was trimmed. The FHIR builder discloses
* this in the document narrative so the export is honest about the
* omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrationsTruncation?: {
total: number;
included: number;
} | null;
mood: DoctorReportMood | null;
/**
* v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at
* least one active GLP-1 medication. Null/absent when the user has no
* GLP-1 prescription (the vast majority of accounts). When set the
* PDF gains a "GLP-1 Therapy" section listing dose history, weight
* change over the therapy window, side-effect counts, and compliance.
* Optional in the type so legacy callers + test fixtures that omit
* the field continue to work; the renderer treats absent and null
* identically.
*/
glp1?: {
medications: Array<{
name: string;
currentDose: { value: number; unit: string; since: string } | null;
doseHistory: Array<{
value: number;
unit: string;
effectiveFrom: string;
note: string | null;
}>;
lastInjection: { date: string; site: string | null } | null;
compliance: { taken: number; total: number };
}>;
weightDeltaKg: number | null;
weightStartKg: number | null;
weightEndKg: number | null;
sideEffects: Array<{ tag: string; count: number }>;
} | null;
/**
* v1.10.0 — the server-derived nightly wellness scores
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the
* report window. These are 0–100 DESCRIPTIVE composites, NOT clinical
* vitals — they are rendered in a clearly-labelled "Wellness summary"
* section separate from the clinical vitals table, each with a
* "descriptive, not a clinical assessment" note. Null/absent when the
* user has no computed scores in the window (the renderer + FHIR builder
* then skip the section). Optional so pre-v1.10 fixtures still typecheck.
*/
wellnessScores?: Array<{
/** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */
type: string;
latest: number;
avg: number;
min: number;
max: number;
count: number;
/** ISO timestamp of the latest score. */
latestAt: string;
}> | null;
/**
* v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg
* length + variability, current phase). Populated ONLY when the `cycle`
* section toggle is explicitly ON (privacy default OFF — a user sharing a
* BP report with a cardiologist must not auto-leak reproductive data) AND
* the user has at least one observed cycle. Statistics only — no free-text
* notes ever reach this surface. Null/absent otherwise; both the PDF and
* FHIR builders then skip the section. Optional so pre-v1.15 fixtures
* still typecheck.
*/
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
/**
* v1.17.1 — structured lab results recorded over the report window.
* Populated when the `labs` section toggle is ON (default) and the user
* recorded at least one result in the period. Statistics + the single
* latest reading per analyte; free-text notes never reach this surface.
* Each entry carries the lab's reference bounds so the PDF can print an
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
* Optional so pre-v1.17.1 fixtures still typecheck.
*/
labResults?: Array<{
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
panel: string | null;
/** Biomarker name as the user recorded it. */
analyte: string;
/**
* Latest numeric value for this analyte over the window; null for a
* qualitative reading (see `valueText`). v1.18.9.
*/
value: number | null;
/**
* v1.18.9 — qualitative result text ("negativ" / "positiv" / …); null for a
* numeric reading. Exactly one of `value` / `valueText` is set.
*/
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** ISO timestamp of the latest reading. */
takenAt: string;
/** Count of readings for this analyte in the window (≥ 1). */
count: number;
}> | null;
/**
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
* the journal is on and shared deliberately, so the privacy stance matches
* labs (ON when the module is on), not mood / cycle.
* Statistics + labels only — the free-text note is NEVER read here, so no
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
* module is off or no episode overlaps the window; both builders then skip
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
*/
illnessEpisodes?: Array<{
/** User-facing condition label (e.g. "Erkältung"). */
label: string;
/** Broad condition class (INFECTION / ALLERGY / INJURY / …). */
type: string;
/** Lifecycle (ACUTE / CHRONIC_ONGOING / RECURRING / FLARE). */
lifecycle: string;
/** ISO onset instant. */
onsetAt: string;
/** ISO resolution instant, or null while ongoing. */
resolvedAt: string | null;
}> | null;
/**
* v1.27.x — structured allergy / intolerance records. Reference data,
* not time-windowed: populated whenever the `allergies` section toggle
* is ON (default) and the user recorded at least one live row. The
* reaction description is decrypted fail-soft per row (a key-rotation
* gap omits the value, never fails the report); the free-text note is
* NEVER read here — same stance as the illness journal. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
allergies?: Array<{
/** Substance / allergen label as the user recorded it. */
substance: string;
/** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */
category: string;
/** Kind (ALLERGY / INTOLERANCE). */
type: string;
/** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */
severity: string | null;
/** Status (ACTIVE / INACTIVE / RESOLVED). */
status: string;
/** Decrypted reaction description, or null (genuinely unset). */
reaction: string | null;
/**
* True when a reaction description WAS stored but could not be decrypted
* (a key-rotation gap / GCM corruption). Distinct from `reaction: null`
* (nothing recorded) so the clinician-facing report shows an honest
* "unreadable" marker rather than a blank that reads as "no reaction".
*/
reactionUnreadable?: boolean;
}> | null;
/**
* v1.27.x — structured family-history records. Reference data, not
* time-windowed: populated whenever the `familyHistory` section toggle
* is ON (default) and the user recorded at least one live row. Labels +
* onset age only — the free-text note is never read here. Null/absent
* otherwise; the PDF builder then skips the section. Optional so older
* fixtures still typecheck.
*/
familyHistory?: Array<{
/** Relationship (MOTHER / FATHER / SISTER / …). */
relationship: string;
/** Condition label as the user recorded it. */
condition: string;
/** The relative's age at onset (years), or null when unknown. */
ageAtOnset: number | null;
}> | null;
}
/** The three persisted score types surfaced in the wellness summary. */
export const WELLNESS_SCORE_REPORT_TYPES = [
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
/** Minimal Measurement shape the canonical-source collapse consults. */
export interface CanonicalCollapseRow {
type: string;
value: number;
measuredAt: Date;
source: MeasurementSource;
deviceType?: string | null;
}
export interface DoctorReportRange {
/** Start of the reporting window (UTC, inclusive). */
start: Date;
/** End of the reporting window (UTC, inclusive). */
end: Date;
/** Inclusive day-count between `start` and `end` (rounded up). */
days: number;
}
export interface CollectDoctorReportOptions {
/** Free-text practice/clinic name to print on the cover. Optional. */
practiceName?: string | null;
/**
* v1.4.25 W6c — per-section visibility toggles. When omitted the
* documented defaults apply (every section ON except mood). When
* `sections.mood = false` the aggregator does NOT query
* `MoodEntry` at all — the mood data never leaves the DB row
* (privacy-by-default). Other sections are stripped from the
* returned payload so downstream renderers (PDF + JSON consumer)
* don't accidentally print a section the user opted out of.
*/
sections?: Partial<DoctorReportPrefs>;
/**
* v1.18.0 — resolved per-user module enable/disable map. When omitted the
* aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled
* module forces its report section / FHIR resources out of the payload,
* regardless of the user's per-report `sections` toggles: a user who turned
* off the Sleep module exports no sleep series, mood Observations, glucose
* panel, recovery/strain scores, cycle summary, or lab results for the
* modules they no longer keep. Core clinical sections (weight / BP / pulse /
* medications) carry no module key and are always included. Injectable for
* tests so the module ↔ section mapping can be asserted without a DB.
*/
moduleMap?: Record<ModuleKey, boolean>;
}
/**
* v1.17 W1a — the medication row shape the ledger compliance builder needs.
* Carries the cadence-engine context fields (course window + creation +
* archived schedule eras) so the same band minter the detail page uses can
* reconstruct expected slots.
*/
export interface DoctorReportComplianceMedication {
id: string;
name: string;
asNeeded: boolean;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
schedules: ComplianceSchedule[];
scheduleRevisions?: ScheduleRevisionLike[];
/** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */
pauseEras?: MedicationPauseEraLike[];
}
/** A window-bounded intake row keyed to its medication. */
export interface DoctorReportComplianceIntake extends IntakeEvent {
medicationId: string;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+5 -290
View File
@@ -80,58 +80,18 @@ import {
hasActiveConsentForSurface,
} from "@/lib/ai/consent-guard";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale, stripJsonFences } from "@/lib/insights/status-shared";
import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache";
import { stripJsonFences } from "@/lib/insights/status-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout";
import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
// Scope invalidation + refill moved to a sibling module; re-exported so
// every existing call site keeps importing from here.
export * from "@/lib/insights/status-invalidation";
/**
* Minimum gap between a scope's last cached (re)generation and the next
* ingest-driven re-enqueue. A scope whose cached assessment was
* (re)generated within this window is left intact on a fresh measurement
* ingest — not re-enqueued.
*
* A constantly-syncing client (Apple Health drips batches every few
* minutes) used to delete + re-enqueue every dirtied scope on every batch,
* and because each delete dropped the cache row the per-(user,scope,locale)
* enqueue singleton (120 s) could not coalesce across batches that arrive
* minutes apart — so STEPS / general / a metric card regenerated several
* times an hour and the assessment felt "regenerated on every visit". This
* window coalesces the storm: a genuinely stale scope (no fresh assessment)
* still refreshes immediately, but a scope refreshed inside the window is
* skipped, so a fresh assessment survives the day's sync drip.
*
* v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but
* a 6 h wall meant same-day data was not narrated same-day: the nightly
* warm at 04:30 restarted the window, so a notable 08:00 reading never
* re-enqueued its scopes and the card showed the pre-reading text until
* the next nightly tick. The window is now a ONE-HOUR minimum gap, and
* the budget role the 6 h wall carried moves to the worker's content-hash
* gate: every ingest that lands past the gap re-enqueues the dirtied
* scopes, the worker's forced run re-gathers the snapshot, and the gate
* (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a
* timestamp refresh with zero provider cost. So this clock only bounds
* the SQL-gather frequency — at most one worker run per scope per hour
* under a constant sync drip — while provider spend tracks actual data
* change, which is what the gate exists to meter. Comparing the hash
* inline at invalidation time was rejected: it would run the full
* per-scope data gather inside the ingest path, which is exactly the
* work the queue exists to keep off that path.
*/
const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
"anthropometrics",
@@ -446,251 +406,6 @@ export async function buildComparisonSnapshotForUser(
return { baseline, metrics };
}
/**
* The seven per-metric assessment scopes. Each generator persists its
* cached text under `insights.<scope>-status.<locale>`; the nightly warm
* pass refreshes them through each generator's content-hash gate, and the
* targeted invalidator below re-enqueues only the scopes a fresh
* measurement of a given type actually dirties.
*/
export const PER_STATUS_SCOPES = [
"blood-pressure",
"pulse",
"weight",
"bmi",
"mood",
"medication-compliance",
"general",
] as const;
export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
/**
* Map a measurement type to the assessment scopes a fresh reading of it
* dirties. `general` is the catch-all overview, so every measurement
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
* new weight reading invalidates both the weight and the BMI card.
*/
function statusScopesForMeasurementType(
type: MeasurementType,
): PerStatusScope[] {
switch (type) {
case "WEIGHT":
return ["weight", "bmi", "general"];
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return ["blood-pressure", "general"];
case "PULSE":
case "RESTING_HEART_RATE":
return ["pulse", "general"];
default:
// Every other tracked metric (body composition, sleep, steps,
// glucose, …) still feeds the general overview assessment.
return ["general"];
}
}
/**
* Re-warm the cached per-metric assessments that a batch of fresh
* measurements dirties, so the next mount (or the next nightly warm
* pass) reflects the new data instead of serving the pre-measurement
* text for the rest of the day.
*
* Fire-and-forget from the measurement ingest path — idempotent and
* never a blocker on the user's write. v1.16.8 — the invalidator no
* longer DELETES the cache rows: the worker regenerates each enqueued
* scope with `force: true`, and the generator's content-hash gate
* decides whether the data actually changed. Keeping the row intact
* preserves the stale-while-revalidate read AND lets the gate skip the
* LLM entirely when the dirtying batch turned out to be a re-sync of
* known data.
*/
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
// One ordered set of every scope the batch dirties. The seven specialised
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
// cards (v1.8.7.1) carry a `metric:<ID>` prefix. Both share the cache-key
// shape `insights.<scope>-status.<locale>`, so a single set covers the
// eviction + the debounce filter + the enqueue uniformly. Only registered,
// data-bearing types contribute a generic scope; the seven specialised
// metrics and any unregistered type resolve to null and are skipped, so
// the constant sync cannot fan out to unwanted scopes.
const scopes = new Set<InsightStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
const metricId = metricIdForMeasurementType(type);
if (metricId) {
scopes.add(metricStatusScope(metricId));
}
}
if (scopes.size === 0) return;
// v1.8.7 — regenerate only the user's resolved locale, matching the
// read-path (every `*-status` GET serves `normalizeLocale(user.locale)`).
// Warming both locales doubled provider spend on every sync, half of it
// for a language the user never opens. A second locale a client actually
// reads warms lazily through the read-path enqueue on its first miss.
const localeRow = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const locale = normalizeLocale(localeRow?.locale);
// v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing
// client drips batches every few minutes; re-enqueuing every dirtied
// scope on every batch regenerated the same card several times an hour
// (the per-(user,scope,locale) enqueue singleton is only 120 s, so it
// could not coalesce across batches arriving minutes apart). Skip any
// scope whose cached assessment was (re)generated within the minimum
// gap — leave its row intact and do NOT re-enqueue. A genuinely stale
// or missing scope still refreshes immediately, so correctness holds;
// only the redundant churn is removed. Past the gap the enqueue always
// goes through — the worker's content-hash gate decides whether the
// batch actually changed anything (see the constant's doc).
const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
const staleScopes = Array.from(scopes).filter(
(scope) => !freshScopes.has(scope),
);
if (staleScopes.length === 0) {
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: scopes.size, refreshed: 0 },
});
return;
}
// v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for
// each dirtied scope so the cache is re-warmed in the background. The
// enqueue is coalesced per `(user, metric, locale)` via the queue's
// `singletonKey` (120 s window); the debounce above is the second, wider
// coalescing layer that survives across the sync drip. The cache row
// stays in place (stale-while-revalidate keeps the previous assessment
// visible) — the worker forces the generator, whose content-hash gate
// skips the LLM when the batch did not actually change the snapshot.
for (const scope of staleScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: freshScopes.size, refreshed: staleScopes.length },
});
}
/**
* v1.16.8 — enqueue a hash-gated refill of every assessment card one user
* actually has: the seven specialised scopes plus the generic
* `metric:<ID>` scope of every measurement type with live rows. The
* worker regenerates each enqueued scope with `force: true`, so every
* card skips its same-day cache read, re-gathers its snapshot, and lands
* on the content-hash gate — a card whose data changed regenerates, an
* unchanged card gets a free timestamp refresh.
*
* This is the manual-regenerate path's card story: the POST regenerate
* used to blanket-evict every per-status row, which deleted the hash
* baselines and force-paid ~45 regenerations per click. Enqueuing through
* the gate keeps the baseline rows intact, so a user who noticed a stale
* card gets exactly the changed cards re-narrated — and nothing else.
* Deliberately NOT routed through the ingest debounce: an explicit
* regenerate is a user action, already bounded by the route's hourly
* rate limit and the queue's per-(user,scope,locale) singleton.
*
* Returns the number of scopes enqueued (best-effort — the generic-scope
* discovery read failing still refills the seven specialised cards).
*/
export async function enqueueStatusRefillForUser(
userId: string,
locale: "de" | "en",
): Promise<number> {
const scopes = new Set<InsightStatusScope>(PER_STATUS_SCOPES);
try {
// `groupBy` compiles to a server-side `GROUP BY` — Prisma's
// `distinct` dedups in the client AFTER pulling every live row, which
// on a dense multi-year account walks a six-figure row set to answer
// "which types exist?" on the request path.
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null },
});
for (const row of rows) {
const metricId = metricIdForMeasurementType(row.type);
if (metricId) scopes.add(metricStatusScope(metricId));
}
} catch {
// Discovery is best-effort; the specialised scopes still refill.
}
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
return scopes.size;
}
/**
* v1.9.0 — return the subset of `scopes` whose cached assessment for
* `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
* One indexed read per user (a single `findMany` over this user's recent
* status-cache rows, newest-first) answers it for every candidate scope at
* once — cheaper than a per-scope probe and bounded by `take`. A timeout
* stub never counts as fresh (it carries no real assessment), so a scope
* that recently stalled still gets a retry enqueued.
*/
async function findRecentlyWarmedScopes(
userId: string,
locale: "de" | "en",
scopes: ReadonlySet<InsightStatusScope>,
): Promise<Set<InsightStatusScope>> {
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
// Match exactly the cache actions for the candidate scopes in this locale.
const candidateActions = Array.from(scopes, (scope) =>
statusCacheAction(scope, locale),
);
const actionToScope = new Map<string, InsightStatusScope>();
for (const scope of scopes) {
actionToScope.set(statusCacheAction(scope, locale), scope);
}
const rows = await prisma.auditLog.findMany({
where: {
userId,
action: { in: candidateActions },
createdAt: { gte: cutoff },
},
orderBy: { createdAt: "desc" },
select: { action: true, details: true },
});
const fresh = new Set<InsightStatusScope>();
for (const row of rows) {
const scope = actionToScope.get(row.action);
if (!scope || fresh.has(scope)) continue;
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as {
model?: string;
timeout?: boolean;
text?: string;
};
// A stub is not a real assessment — let a stalled scope retry.
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
fresh.add(scope);
} catch {
// Malformed payload — treat as not-fresh so the scope refreshes.
continue;
}
}
return fresh;
}
interface GenerateOptions {
/** Resolved UI locale for the prompt + cache row. */
locale: "de" | "en";
+385
View File
@@ -0,0 +1,385 @@
/**
* Briefing-scoped feature read blocks: full-history extremes, the
* bucketed rollup series, and the v1.22 cross-signal integration blocks
* (flagged labs, preventive-care due/overdue, workout aggregate). Each
* is one bounded DB read projecting to its slice of the
* `AggregatedFeatures` payload.
*
* Extracted verbatim from `features.ts`, which re-exports this module
* so every existing call site keeps importing from there; the assembly
* point (`extractFeatures`) stays in the hub.
*/
import { prisma } from "@/lib/db";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
BucketedSeries,
} from "@/lib/insights/features";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
/** All-time aggregate (full history) for one measurement type. */
interface AllTimeExtremes {
mean: number | null;
min: number | null;
max: number | null;
}
/**
* v1.18.11 P1 — full-history min / max / mean per measurement type via ONE
* grouped SQL aggregation, with NO row materialisation in JS. Used to fill the
* `allTime*` feature fields honestly when the bulk feature read is bounded to a
* recent window: the windowed `summarize()` covers trends + recent windows, and
* this covers the long-horizon extremes the prompt labels "allTime".
*
* Only the four types that expose `allTime*` fields are aggregated (weight,
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
* with no rows is simply absent.
*/
export async function readAllTimeExtremes(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null, type: { in: [...types] } },
_avg: { value: true },
_min: { value: true },
_max: { value: true },
});
const out = new Map<MeasurementType, AllTimeExtremes>();
for (const r of rows) {
out.set(r.type, {
mean: r._avg.value ?? null,
min: r._min.value ?? null,
max: r._max.value ?? null,
});
}
return out;
}
/**
* v1.4.36 W3 T1 — bucket-window definitions for the
* `bucketedMeasurements` payload. Mirrors the rollup populator's
* granularity ladder so the read-side picks up whatever the persistent
* table holds without a recompute round-trip.
*
* The 90 / 365 / 1825-day windows are non-overlapping: each row of
* `measurement_rollups` lives at exactly one granularity, and the
* downstream model reads the union of the three series per type.
* Total volume per type for a heavy power user (5 years of daily
* data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON
* each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the
* 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape.
*/
const BUCKET_WINDOWS: Array<{
granularity: RollupGranularity;
fromDays: number;
toDays: number;
}> = [
{ granularity: "DAY", fromDays: 0, toDays: 90 },
{ granularity: "WEEK", fromDays: 90, toDays: 365 },
{ granularity: "MONTH", fromDays: 365, toDays: 1825 },
];
/**
* Types the bucketed payload covers. Mirrors the aggregate branches
* above so the model never sees a bucket for a metric whose aggregate
* block was suppressed. New `MeasurementType` enum values flow in by
* adding one row; the rollup populator already covers every type.
*/
// Derived from the signal registry: every signal flagged
// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list
// is a membership/iteration set (each type is read independently), so order is
// not significant; the registry-invariant test pins the set byte-for-byte.
const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes();
/** Days a preventive-care item must be due within to surface as "due soon". */
const PREVENTIVE_DUE_HORIZON_DAYS = 21;
/** Cap on items surfaced per preventive-care bucket. */
const PREVENTIVE_MAX_PER_BUCKET = 5;
/** Cap on flagged biomarkers surfaced to the briefing. */
const LABS_MAX_FLAGGED = 8;
/** Only lab readings within this many months are considered "recent". */
const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Strip control chars + collapse whitespace, then bound the length, before a
* user-supplied label can reach the briefing prompt. Mirrors the labs /
* illness snapshot label handling — a self-scoped prompt-injection surface.
*/
function sanitizeLabel(text: string, max = 80): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, max);
}
/**
* v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing.
* Most-recent reading per biomarker over the lookback window; hidden markers
* excluded; qualitative rows neutral. Only abnormal (below/above) OR trending
* markers surface, bounded. Returns `undefined` when nothing is flagged so the
* block is omitted rather than emitting an empty shape.
*/
export async function readLabsBriefingBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["labs"] | undefined> {
const nowDate = new Date(now);
const cutoff = new Date(nowDate);
cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS);
const rows = await prisma.labResult.findMany({
where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } },
orderBy: { takenAt: "desc" },
take: LABS_MAX_FLAGGED * 16,
select: {
analyte: true,
panel: true,
unit: true,
value: true,
valueText: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
biomarkerId: true,
biomarker: {
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
hidden: true,
},
},
},
});
if (rows.length === 0) return undefined;
// Group rows per biomarker identity (linked id, else lower-cased analyte),
// newest-first, so we can read the latest reading + the immediately prior one
// for a trend. Hidden markers are dropped entirely.
const byMarker = new Map<string, typeof rows>();
for (const row of rows) {
if (row.biomarker?.hidden) continue;
const resolved = resolveLabFields(row, row.biomarker);
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
const list = byMarker.get(key) ?? [];
list.push(row);
byMarker.set(key, list);
}
const flagged: NonNullable<AggregatedFeatures["labs"]>["flagged"] = [];
for (const list of byMarker.values()) {
const latest = list[0];
const resolved = resolveLabFields(latest, latest.biomarker);
const rangeStatus =
latest.value === null
? ("unknown" as const)
: classifyReferenceRange(
latest.value,
resolved.referenceLow,
resolved.referenceHigh,
);
// Trend = latest numeric reading vs the immediately prior numeric reading.
let trend: "rising" | "falling" | "flat" | null = null;
if (latest.value !== null) {
const prior = list.find((r, i) => i > 0 && r.value !== null);
if (prior?.value != null) {
const delta = latest.value - prior.value;
const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9);
trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat";
}
}
const isAbnormal = rangeStatus === "below" || rangeStatus === "above";
const isTrending = trend === "rising" || trend === "falling";
if (!isAbnormal && !isTrending) continue;
flagged.push({
analyte: resolved.analyte,
value: latest.value,
valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null,
unit: resolved.unit,
rangeStatus,
trend,
takenAt: latest.takenAt.toISOString(),
daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY),
});
}
if (flagged.length === 0) return undefined;
// Abnormal markers lead, then most-recent first.
flagged.sort((a, b) => {
const abn = (s: typeof a.rangeStatus) =>
s === "below" || s === "above" ? 0 : 1;
const d = abn(a.rangeStatus) - abn(b.rangeStatus);
return d !== 0 ? d : a.daysAgo - b.daysAgo;
});
return {
flagged: flagged.slice(0, LABS_MAX_FLAGGED),
flaggedCount: flagged.length,
};
}
/**
* v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the
* user's enabled, live reminders and buckets by the server-authoritative
* `nextDueAt`. Returns `undefined` when nothing is due or overdue.
*/
export async function readPreventiveCareBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
const rows = await prisma.measurementReminder.findMany({
where: {
userId,
deletedAt: null,
enabled: true,
nextDueAt: { not: null, lte: horizon },
},
orderBy: { nextDueAt: "asc" },
take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4,
select: { label: true, nextDueAt: true },
});
if (rows.length === 0) return undefined;
const overdue: NonNullable<AggregatedFeatures["preventiveCare"]>["overdue"] =
[];
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["due"] = [];
for (const r of rows) {
if (!r.nextDueAt) continue;
const label = sanitizeLabel(r.label);
if (!label) continue;
const diffMs = r.nextDueAt.getTime() - now;
if (diffMs < 0) {
overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) });
} else {
due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) });
}
}
if (overdue.length === 0 && due.length === 0) return undefined;
return {
overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET),
due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET),
};
}
/**
* v1.22 — workout aggregate over the trailing window. Provider-agnostic:
* counts + summed duration + summed distance (km, when any source reported it)
* over 7 / 30 days, plus the latest workout. Returns `undefined` when no
* workouts fall in the window.
*/
export async function readWorkoutsBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["workouts"] | undefined> {
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: { startedAt: "desc" },
take: 2000,
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
});
if (rows.length === 0) return undefined;
const tally = (windowDays: number) => {
const cutoff = now - windowDays * MS_PER_DAY;
let count = 0;
let durationSec = 0;
let distanceM = 0;
let anyDistance = false;
for (const w of rows) {
if (w.startedAt.getTime() < cutoff) continue;
count += 1;
durationSec += w.durationSec;
if (w.totalDistanceM != null) {
distanceM += w.totalDistanceM;
anyDistance = true;
}
}
return {
count,
totalDurationMin: Math.round(durationSec / 60),
totalDistanceKm: anyDistance
? Math.round((distanceM / 1000) * 10) / 10
: null,
};
};
const newest = rows[0];
const latest = {
sportType: sanitizeLabel(newest.sportType, 40),
daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY),
durationMin: Math.round(newest.durationSec / 60),
distanceKm:
newest.totalDistanceM != null
? Math.round((newest.totalDistanceM / 1000) * 10) / 10
: null,
};
return { last7: tally(7), last30: tally(30), latest };
}
/**
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
* persistent rollup table and project to the wire shape. Empty
* (type, granularity) combinations are dropped so the payload never
* carries a labelled-but-empty series.
*/
export async function readBucketedSeries(
userId: string,
now: number,
): Promise<BucketedSeries[]> {
const series: BucketedSeries[] = [];
for (const type of BUCKETED_TYPES) {
for (const window of BUCKET_WINDOWS) {
const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000);
const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000);
const rows = await readRollupBuckets(
userId,
type,
window.granularity,
from,
to,
);
if (rows.length === 0) continue;
series.push({
type,
granularity: window.granularity,
buckets: rows.map((r) => ({
bucketStart: r.bucketStart.toISOString(),
mean: Math.round(r.mean * 100) / 100,
count: r.count,
})),
});
}
}
return series;
}
+23 -599
View File
@@ -4,7 +4,7 @@
* No raw timestamps or exact values are sent in aggregated mode.
*/
import { prisma } from "@/lib/db";
import { summarize, trendSlope } from "@/lib/analytics/trends";
import { summarize } from "@/lib/analytics/trends";
import type { DataPoint } from "@/lib/analytics/trends";
import {
buildComplianceMedicationContext,
@@ -26,18 +26,29 @@ import {
type CorrelationResult,
} from "@/lib/analytics/correlations";
import { getMedicationCategories } from "@/lib/medication-category";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
import {
readAllTimeExtremes,
readBucketedSeries,
readLabsBriefingBlock,
readPreventiveCareBlock,
readWorkoutsBlock,
} from "@/lib/insights/feature-blocks";
import {
computeHistoricalComparison,
computeSignalsOfDay,
type SignalOfDay,
} from "@/lib/insights/signals-of-day";
import type { RollupGranularity } from "@/generated/prisma/client";
// The briefing read blocks and the signals-of-day builder moved to
// sibling modules; re-exported so every existing call site keeps
// importing from here.
export * from "@/lib/insights/feature-blocks";
export * from "@/lib/insights/signals-of-day";
interface DataCoverage {
count: number;
@@ -312,45 +323,6 @@ export interface AggregatedFeatures {
};
}
/**
* v1.18.7 — one present-focused signal feeding the daily briefing. Every
* numeric field is pre-computed so the model states it rather than
* re-deriving the comparison (which small LLMs do unreliably).
*/
export interface SignalOfDay {
/** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */
metric:
"bp" | "weight" | "pulse" | "mood" | "sleep" | "resting_hr" | "glucose";
/** Natural-language label (no enum leak into prose). */
label: string;
/** Unit string when the metric carries one. */
unit?: string;
/** Freshest reading (the "now" value the briefing leads with). */
latest: number;
/** Days since the freshest reading. */
latestDaysAgo: number;
/** Trailing-7d mean. */
avg7: number | null;
/** Trailing-30d mean. */
avg30: number | null;
/** Signed `latest − avg7`, pre-computed. */
deltaVs7: number | null;
/** Signed `latest − avg30`, pre-computed. */
deltaVs30: number | null;
/** Normal-swing SD over the trailing-30d window. */
spread30: number | null;
/** `|latest − avg30| > spread30` — the significance verdict as a boolean. */
outsideNormalSwing: boolean;
/** Emerging direction over the trailing 30 days (slope sign). */
emergingTrend: "rising" | "falling" | "flat" | null;
/** A peak / trough inside the last 14 days, when one stands out. */
recentAnomaly: {
kind: "peak" | "trough";
value: number;
anomalyDaysAgo: number;
} | null;
}
/**
* One bucketed series. `granularity` decides the bucket width (DAY for
* the trailing 90-day window, WEEK for 90→365 days, MONTH for the
@@ -408,46 +380,6 @@ export const FEATURES_MAX_BYTES = 5 * 1024 * 1024;
*/
export const BRIEFING_FEATURE_WINDOW_DAYS = 400;
/** All-time aggregate (full history) for one measurement type. */
interface AllTimeExtremes {
mean: number | null;
min: number | null;
max: number | null;
}
/**
* v1.18.11 P1 — full-history min / max / mean per measurement type via ONE
* grouped SQL aggregation, with NO row materialisation in JS. Used to fill the
* `allTime*` feature fields honestly when the bulk feature read is bounded to a
* recent window: the windowed `summarize()` covers trends + recent windows, and
* this covers the long-horizon extremes the prompt labels "allTime".
*
* Only the four types that expose `allTime*` fields are aggregated (weight,
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
* with no rows is simply absent.
*/
async function readAllTimeExtremes(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null, type: { in: [...types] } },
_avg: { value: true },
_min: { value: true },
_max: { value: true },
});
const out = new Map<MeasurementType, AllTimeExtremes>();
for (const r of rows) {
out.set(r.type, {
mean: r._avg.value ?? null,
min: r._min.value ?? null,
max: r._max.value ?? null,
});
}
return out;
}
/** Round an all-time mean to 1 decimal, matching the windowed `summarize` mean. */
function roundMean(v: number | null): number | null {
return v === null ? null : Math.round(v * 10) / 10;
@@ -468,43 +400,7 @@ export class FeaturesPayloadTooLargeError extends Error {
}
}
/**
* v1.4.36 W3 T1 — bucket-window definitions for the
* `bucketedMeasurements` payload. Mirrors the rollup populator's
* granularity ladder so the read-side picks up whatever the persistent
* table holds without a recompute round-trip.
*
* The 90 / 365 / 1825-day windows are non-overlapping: each row of
* `measurement_rollups` lives at exactly one granularity, and the
* downstream model reads the union of the three series per type.
* Total volume per type for a heavy power user (5 years of daily
* data): 90 DAY + 39 WEEK + 50 MONTH = 179 buckets × ~40 bytes JSON
* each ≈ 7 KB. Eight metric types lands at ~56 KB — well under the
* 5 MB cap above, vs 25.9 MB for the v1.4.35 rawMeasurements shape.
*/
const BUCKET_WINDOWS: Array<{
granularity: RollupGranularity;
fromDays: number;
toDays: number;
}> = [
{ granularity: "DAY", fromDays: 0, toDays: 90 },
{ granularity: "WEEK", fromDays: 90, toDays: 365 },
{ granularity: "MONTH", fromDays: 365, toDays: 1825 },
];
/**
* Types the bucketed payload covers. Mirrors the aggregate branches
* above so the model never sees a bucket for a metric whose aggregate
* block was suppressed. New `MeasurementType` enum values flow in by
* adding one row; the rollup populator already covers every type.
*/
// Derived from the signal registry: every signal flagged
// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list
// is a membership/iteration set (each type is read independently), so order is
// not significant; the registry-invariant test pins the set byte-for-byte.
const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes();
function stdDev(values: number[]): number | null {
export function stdDev(values: number[]): number | null {
if (values.length < 2) return null;
const mean = values.reduce((s, v) => s + v, 0) / values.length;
const variance =
@@ -512,7 +408,7 @@ function stdDev(values: number[]): number | null {
return Math.round(Math.sqrt(variance) * 10) / 10;
}
function toDataPoints(
export function toDataPoints(
records: Array<{ value: number; measuredAt: Date }>,
): DataPoint[] {
return records.map((r) => ({ date: r.measuredAt, value: r.value }));
@@ -548,7 +444,7 @@ function computeCoverage(
}
/** Compute average of values within a time window (days ago from now). */
function avgInWindow(
export function avgInWindow(
records: Array<{ value: number; measuredAt: Date }>,
now: number,
fromDaysAgo: number,
@@ -565,193 +461,6 @@ function avgInWindow(
return Math.round((sum / filtered.length) * 100) / 100;
}
/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */
function computeHistoricalComparison(
records: Array<{ value: number; measuredAt: Date }>,
now: number,
): {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
} {
const current7dAvg = avgInWindow(records, now, 7, 0);
const previous30dAvg = avgInWindow(records, now, 37, 7);
const change =
current7dAvg !== null && previous30dAvg !== null
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
: null;
return { current7dAvg, previous30dAvg, change };
}
/**
* v1.18.7 — clinical priority order for the signals block. BP / glucose
* lead, then resting HR / pulse, then weight, then mood.
* Lower index = higher priority; the briefing surfaces the top ≤3.
*/
const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [
"bp",
"glucose",
"resting_hr",
"pulse",
"weight",
"mood",
];
/** Round helper local to the signals builder. */
function r2(v: number): number {
return Math.round(v * 100) / 100;
}
/**
* Build one signal from a single metric's measurement records. Returns null
* when there is no fresh reading or fewer than three points in 30 days — a
* sparse metric cannot carry an honest "today vs your normal" read.
*/
function buildSignal(
metric: SignalOfDay["metric"],
label: string,
records: Array<{ value: number; measuredAt: Date }>,
now: number,
unit?: string,
): SignalOfDay | null {
if (records.length === 0) return null;
const newest = records[records.length - 1];
const latestDaysAgo = Math.round(
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
);
// A reading older than two weeks is not a "signal of the day".
if (latestDaysAgo > 14) return null;
const win30 = records.filter(
(rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000,
);
if (win30.length < 3) return null;
const avg7 = avgInWindow(records, now, 7);
const avg30 = avgInWindow(records, now, 30);
const spread30 = stdDev(win30.map((rec) => rec.value));
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
const outsideNormalSwing =
avg30 !== null && spread30 !== null && spread30 > 0
? Math.abs(newest.value - avg30) > spread30
: false;
const slope = trendSlope(toDataPoints(records), 30, now);
const emergingTrend: SignalOfDay["emergingTrend"] = slope
? slope.direction === "up"
? "rising"
: slope.direction === "down"
? "falling"
: "flat"
: null;
// Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD.
let recentAnomaly: SignalOfDay["recentAnomaly"] = null;
// Track the RAW extreme magnitude — comparing against the already-r2()
// rounded stored value can drop a genuinely larger anomaly.
let bestAbs = 0;
if (avg30 !== null && spread30 !== null && spread30 > 0) {
const recent = records.filter(
(rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000,
);
for (const rec of recent) {
const sd = (rec.value - avg30) / spread30;
if (Math.abs(sd) >= 2) {
const abs = Math.abs(rec.value - avg30);
if (recentAnomaly === null || abs > bestAbs) {
bestAbs = abs;
recentAnomaly = {
kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
value: r2(rec.value),
anomalyDaysAgo: Math.round(
(now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
),
};
}
}
}
}
return {
metric,
label,
...(unit ? { unit } : {}),
latest: r2(newest.value),
latestDaysAgo,
avg7,
avg30,
deltaVs7,
deltaVs30,
spread30,
outsideNormalSwing,
emergingTrend,
recentAnomaly,
};
}
/**
* v1.18.7 — assemble the present-focused "Signals of the day" block from the
* in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d
* deltas, an emerging slope, and a recent anomaly per salient metric, then
* returns the top ≤3 ranked by clinical priority. Salient signals
* (outside-normal-swing or a recent anomaly) bubble above quiet ones inside
* each priority tier so the briefing leads with what actually moved.
*/
function computeSignalsOfDay(
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
now: number,
): SignalOfDay[] {
const candidates: SignalOfDay[] = [];
const push = (s: SignalOfDay | null) => {
if (s) candidates.push(s);
};
// Systolic carries the BP signal (the headline number clinicians read first).
push(
buildSignal(
"bp",
"blood pressure (systolic)",
byType("BLOOD_PRESSURE_SYS"),
now,
"mmHg",
),
);
// Glucose uses the canonical stored value (the model reads the snapshot,
// not display units); absent data simply produces no signal.
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
push(
buildSignal(
"resting_hr",
"resting heart rate",
byType("RESTING_HEART_RATE"),
now,
"bpm",
),
);
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
// Sleep is stored one row per stage per night, so a raw "latest" point
// would mis-sum; the sleep aggregates carry that signal already. Steps
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row
// is a partial-day fragment, not a daily total — excluded like sleep.
const priorityIndex = (m: SignalOfDay["metric"]) => {
const idx = SIGNAL_PRIORITY.indexOf(m);
return idx === -1 ? SIGNAL_PRIORITY.length : idx;
};
const salience = (s: SignalOfDay) =>
(s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0);
return candidates
.sort((a, b) => {
const sal = salience(b) - salience(a);
if (sal !== 0) return sal;
return priorityIndex(a.metric) - priorityIndex(b.metric);
})
.slice(0, 3);
}
// ─── v1.22 — cross-signal integration blocks (briefing-scoped) ────────────
/**
@@ -765,23 +474,6 @@ function computeSignalsOfDay(
*/
const INTEGRATION_BLOCK_MIN_WINDOW_DAYS = 180;
/** Days a preventive-care item must be due within to surface as "due soon". */
const PREVENTIVE_DUE_HORIZON_DAYS = 21;
/** Cap on items surfaced per preventive-care bucket. */
const PREVENTIVE_MAX_PER_BUCKET = 5;
/** Cap on flagged biomarkers surfaced to the briefing. */
const LABS_MAX_FLAGGED = 8;
/** Only lab readings within this many months are considered "recent". */
const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* v1.25 — newest-first row cap on the bulk measurement read in
* `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP`
@@ -792,237 +484,6 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
*/
const FEATURE_MEASUREMENT_ROW_CAP = 6000;
/**
* Strip control chars + collapse whitespace, then bound the length, before a
* user-supplied label can reach the briefing prompt. Mirrors the labs /
* illness snapshot label handling — a self-scoped prompt-injection surface.
*/
function sanitizeLabel(text: string, max = 80): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, max);
}
/**
* v1.22 — recent FLAGGED biomarkers (abnormal or trending) for the briefing.
* Most-recent reading per biomarker over the lookback window; hidden markers
* excluded; qualitative rows neutral. Only abnormal (below/above) OR trending
* markers surface, bounded. Returns `undefined` when nothing is flagged so the
* block is omitted rather than emitting an empty shape.
*/
async function readLabsBriefingBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["labs"] | undefined> {
const nowDate = new Date(now);
const cutoff = new Date(nowDate);
cutoff.setMonth(cutoff.getMonth() - LABS_LOOKBACK_MONTHS);
const rows = await prisma.labResult.findMany({
where: { userId, deletedAt: null, takenAt: { gte: cutoff, lte: nowDate } },
orderBy: { takenAt: "desc" },
take: LABS_MAX_FLAGGED * 16,
select: {
analyte: true,
panel: true,
unit: true,
value: true,
valueText: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
biomarkerId: true,
biomarker: {
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
hidden: true,
},
},
},
});
if (rows.length === 0) return undefined;
// Group rows per biomarker identity (linked id, else lower-cased analyte),
// newest-first, so we can read the latest reading + the immediately prior one
// for a trend. Hidden markers are dropped entirely.
const byMarker = new Map<string, typeof rows>();
for (const row of rows) {
if (row.biomarker?.hidden) continue;
const resolved = resolveLabFields(row, row.biomarker);
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
const list = byMarker.get(key) ?? [];
list.push(row);
byMarker.set(key, list);
}
const flagged: NonNullable<AggregatedFeatures["labs"]>["flagged"] = [];
for (const list of byMarker.values()) {
const latest = list[0];
const resolved = resolveLabFields(latest, latest.biomarker);
const rangeStatus =
latest.value === null
? ("unknown" as const)
: classifyReferenceRange(
latest.value,
resolved.referenceLow,
resolved.referenceHigh,
);
// Trend = latest numeric reading vs the immediately prior numeric reading.
let trend: "rising" | "falling" | "flat" | null = null;
if (latest.value !== null) {
const prior = list.find((r, i) => i > 0 && r.value !== null);
if (prior?.value != null) {
const delta = latest.value - prior.value;
const eps = Math.max(Math.abs(prior.value) * 0.02, 1e-9);
trend = delta > eps ? "rising" : delta < -eps ? "falling" : "flat";
}
}
const isAbnormal = rangeStatus === "below" || rangeStatus === "above";
const isTrending = trend === "rising" || trend === "falling";
if (!isAbnormal && !isTrending) continue;
flagged.push({
analyte: resolved.analyte,
value: latest.value,
valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null,
unit: resolved.unit,
rangeStatus,
trend,
takenAt: latest.takenAt.toISOString(),
daysAgo: Math.round((now - latest.takenAt.getTime()) / MS_PER_DAY),
});
}
if (flagged.length === 0) return undefined;
// Abnormal markers lead, then most-recent first.
flagged.sort((a, b) => {
const abn = (s: typeof a.rangeStatus) =>
s === "below" || s === "above" ? 0 : 1;
const d = abn(a.rangeStatus) - abn(b.rangeStatus);
return d !== 0 ? d : a.daysAgo - b.daysAgo;
});
return {
flagged: flagged.slice(0, LABS_MAX_FLAGGED),
flaggedCount: flagged.length,
};
}
/**
* v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the
* user's enabled, live reminders and buckets by the server-authoritative
* `nextDueAt`. Returns `undefined` when nothing is due or overdue.
*/
async function readPreventiveCareBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["preventiveCare"] | undefined> {
const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY);
const rows = await prisma.measurementReminder.findMany({
where: {
userId,
deletedAt: null,
enabled: true,
nextDueAt: { not: null, lte: horizon },
},
orderBy: { nextDueAt: "asc" },
take: (PREVENTIVE_MAX_PER_BUCKET + 1) * 4,
select: { label: true, nextDueAt: true },
});
if (rows.length === 0) return undefined;
const overdue: NonNullable<AggregatedFeatures["preventiveCare"]>["overdue"] =
[];
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["due"] = [];
for (const r of rows) {
if (!r.nextDueAt) continue;
const label = sanitizeLabel(r.label);
if (!label) continue;
const diffMs = r.nextDueAt.getTime() - now;
if (diffMs < 0) {
overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) });
} else {
due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) });
}
}
if (overdue.length === 0 && due.length === 0) return undefined;
return {
overdue: overdue.slice(0, PREVENTIVE_MAX_PER_BUCKET),
due: due.slice(0, PREVENTIVE_MAX_PER_BUCKET),
};
}
/**
* v1.22 — workout aggregate over the trailing window. Provider-agnostic:
* counts + summed duration + summed distance (km, when any source reported it)
* over 7 / 30 days, plus the latest workout. Returns `undefined` when no
* workouts fall in the window.
*/
async function readWorkoutsBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["workouts"] | undefined> {
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: { startedAt: "desc" },
take: 2000,
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
});
if (rows.length === 0) return undefined;
const tally = (windowDays: number) => {
const cutoff = now - windowDays * MS_PER_DAY;
let count = 0;
let durationSec = 0;
let distanceM = 0;
let anyDistance = false;
for (const w of rows) {
if (w.startedAt.getTime() < cutoff) continue;
count += 1;
durationSec += w.durationSec;
if (w.totalDistanceM != null) {
distanceM += w.totalDistanceM;
anyDistance = true;
}
}
return {
count,
totalDurationMin: Math.round(durationSec / 60),
totalDistanceKm: anyDistance
? Math.round((distanceM / 1000) * 10) / 10
: null,
};
};
const newest = rows[0];
const latest = {
sportType: sanitizeLabel(newest.sportType, 40),
daysAgo: Math.round((now - newest.startedAt.getTime()) / MS_PER_DAY),
durationMin: Math.round(newest.durationSec / 60),
distanceKm:
newest.totalDistanceM != null
? Math.round((newest.totalDistanceM / 1000) * 10) / 10
: null,
};
return { last7: tally(7), last30: tally(30), latest };
}
export async function extractFeatures(
userId: string,
includeRaw: boolean,
@@ -1886,43 +1347,6 @@ export async function extractFeatures(
return features;
}
/**
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
* persistent rollup table and project to the wire shape. Empty
* (type, granularity) combinations are dropped so the payload never
* carries a labelled-but-empty series.
*/
async function readBucketedSeries(
userId: string,
now: number,
): Promise<BucketedSeries[]> {
const series: BucketedSeries[] = [];
for (const type of BUCKETED_TYPES) {
for (const window of BUCKET_WINDOWS) {
const from = new Date(now - window.toDays * 24 * 60 * 60 * 1000);
const to = new Date(now - window.fromDays * 24 * 60 * 60 * 1000);
const rows = await readRollupBuckets(
userId,
type,
window.granularity,
from,
to,
);
if (rows.length === 0) continue;
series.push({
type,
granularity: window.granularity,
buckets: rows.map((r) => ({
bucketStart: r.bucketStart.toISOString(),
mean: Math.round(r.mean * 100) / 100,
count: r.count,
})),
});
}
}
return series;
}
/**
* Hard ceiling on the serialised payload. Throws
* `FeaturesPayloadTooLargeError` (tagged with `code` so the route
File diff suppressed because it is too large. Load diff
+160
View File
@@ -0,0 +1,160 @@
/**
* The unified "what's associated with your better days" board (F2):
* folds the F1 tag-influence rows and the mood × health-metric
* correlations into one effect-size-ranked, confidence-gated,
* observational list.
*
* Extracted verbatim from `mood-aggregates.ts`, which re-exports this
* module so every existing call site keeps importing from the hub.
* Pure functions over already-computed aggregate shapes.
*/
import type { CorrelationResult } from "@/lib/analytics/correlations";
import type {
CorrelationKey,
MoodMetricCorrelation,
} from "@/lib/insights/mood-aggregates";
import type {
InfluenceConfidence,
TagInfluence,
} from "@/lib/insights/mood-tag-influence";
// --- "What's associated with your better days" board (F2) ---
/**
* One ranked factor on the unified board. Either a tag (`source: "tag"`,
* effect = the mood-point delta) or a health-metric correlation
* (`source: "metric"`, effect = Pearson r). Direction tells the UI whether
* the factor goes with higher or lower mood; the standing "association, not
* cause" caption is rendered once for the whole board.
*/
export interface BetterDayFactor {
source: "tag" | "metric";
/** Tag key OR correlation channel key (sleep/steps/pulse/weight/bp). */
key: string;
/** Flat tags / metrics: null. Structured tags: the i18n label key. */
labelKey: string | null;
/** Decrypted custom-tag label; null for catalogue / flat / metric rows. */
label?: string | null;
categoryKey: string | null;
icon: string | null;
/** "up" = associated with higher mood; "down" = with lower mood. */
direction: "up" | "down";
/** Sample count behind the factor (tag: smaller group; metric: paired n). */
n: number;
/** Discrete confidence band. */
confidence: InfluenceConfidence;
/**
* Unified ranking strength in [0,1]. For metrics this is |r|; for tags it
* is min(1, |delta| / 2) — a two-point mood swing reads as full strength.
* Ranking only; the UI surfaces the raw delta / r, not this number.
*/
effectSize: number;
/** Raw mood-point delta for a tag factor; null for a metric factor. */
delta: number | null;
/** Raw Pearson r for a metric factor; null for a tag factor. */
r: number | null;
}
/** Max factors on the board so the headline surface stays scannable. */
export const BETTER_DAYS_MAX_FACTORS = 8;
/** Map a metric correlation strength label to the shared confidence band. */
function metricConfidence(
strength: CorrelationResult["strength"],
): InfluenceConfidence {
if (strength === "stark") return "high";
if (strength === "moderat") return "medium";
return "low";
}
/**
* Merge the F1 tag influence rows and the mood × health-metric
* correlations into one effect-size-ranked, confidence-gated board.
*
* Inclusion gates (multiple-comparison aware):
* - Tag rows: already gated by `computeTagInfluence` (sample floors +
* Welch); included as-is.
* - Metric rows: only correlations that reached `n ≥ 5` AND carry a
* non-"keine" strength (|r| ≥ 0.2) are folded in — a near-zero r is not
* an association.
*
* Ranking is by `effectSize` desc, then confidence, then key for stability.
* The two sources are put on one comparable scale: metric rows use |r|
* (already unitless in [0,1]); tag rows use a standardized effect — the raw
* mood-point `delta` divided by the pooled with/without mood SD (Cohen's d),
* clamped to [0,1]. A |d| ≥ 1 (a mean shift of one full SD) saturates the
* scale, mirroring an |r| near its ceiling, so neither source dominates the
* board for scale reasons alone. When a tag has no pooled SD (both groups
* perfectly constant), it falls back to the legacy |delta|/2 heuristic — a
* rare degenerate case that can't be standardized. The raw delta / r the UI
* shows is unchanged; this only governs the sort order. Observational only.
*/
export function computeBetterDays(
tagInfluence: TagInfluence,
correlations: Record<CorrelationKey, MoodMetricCorrelation>,
): BetterDayFactor[] {
const factors: BetterDayFactor[] = [];
// Tag factors (both axes). Structured tags carry their label meta.
for (const row of [...tagInfluence.structured, ...tagInfluence.flat]) {
factors.push({
source: "tag",
key: row.tag,
labelKey: row.labelKey,
label: row.label ?? null,
categoryKey: row.categoryKey,
icon: row.icon,
direction: row.delta >= 0 ? "up" : "down",
n: Math.min(row.withDays, row.withoutDays),
confidence: row.confidence,
// Cohen's-d standardization so the tag effect is commensurable with a
// metric |r|; fall back to the legacy |delta|/2 only when the pooled
// SD is unavailable (both groups perfectly constant).
effectSize:
row.pooledSd && row.pooledSd > 0
? Math.min(1, Math.abs(row.delta) / row.pooledSd)
: Math.min(1, Math.abs(row.delta) / 2),
delta: row.delta,
r: null,
});
}
// Metric factors — only meaningful, sufficiently-sampled correlations.
for (const [key, corr] of Object.entries(correlations)) {
const result = corr.result;
if (!result || corr.n < 5) continue;
if (result.strength === "keine") continue;
factors.push({
source: "metric",
key,
labelKey: null,
label: null,
categoryKey: null,
icon: null,
// Mood is the x-axis: positive r = higher metric on higher-mood days,
// i.e. the metric is associated with higher mood.
direction: result.r >= 0 ? "up" : "down",
n: corr.n,
confidence: metricConfidence(result.strength),
effectSize: Math.min(1, Math.abs(result.r)),
delta: null,
r: result.r,
});
}
const confidenceRank: Record<InfluenceConfidence, number> = {
high: 3,
medium: 2,
low: 1,
};
return factors
.sort(
(a, b) =>
b.effectSize - a.effectSize ||
confidenceRank[b.confidence] - confidenceRank[a.confidence] ||
a.key.localeCompare(b.key),
)
.slice(0, BETTER_DAYS_MAX_FACTORS);
}
+689
View File
@@ -0,0 +1,689 @@
/**
* Tag × health-metric and RATED-factor × health-metric crosstabs for
* the mood surface: with-vs-without (or low-vs-high median-split) metric
* deltas per structured tag / rated factor, Welch-tested and BH-FDR
* corrected as one family per board, with same-day and D → D+1 pairing
* modes.
*
* Extracted verbatim from `mood-aggregates.ts`, which re-exports this
* module so every existing call site keeps importing from the hub.
* Everything here is a pure function over already-fetched rows; the DB
* read + orchestration stay in the hub's `fetchMoodAggregates`.
*/
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery";
import { welchTTest } from "@/lib/insights/correlations";
import { round } from "@/lib/insights/status-shared";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { MS_PER_DAY } from "@/lib/time-constants";
import { toBerlinYmd } from "@/lib/tz/resolver";
import type {
CrossMetricMeasurement,
MoodAggregateEntry,
RatedFactorScore,
StructuredTagRef,
} from "@/lib/insights/mood-aggregates";
import {
collapseToTaggedDays,
influenceConfidence,
type InfluenceConfidence,
} from "@/lib/insights/mood-tag-influence";
import type { MeasurementType } from "@/generated/prisma/enums";
import type { MeasurementSource } from "@/generated/prisma/client";
// --- Tag × health-metric crosstab (v1.12.0) ------------------------------
/**
* v1.12.0 — per-tag × health-metric crosstab.
*
* Daylio's "Activities & Mood" board answers "how does my mood differ on
* days I tagged X?". This extends the same with-vs-without comparison to a
* health METRIC: for each structured mood tag present on enough days, the
* metric's mean on tag-present days vs tag-absent days, the delta, the
* Welch two-sided p, and a confidence band — reusing the EXACT statistics
* engine the F1 tag-influence board already runs (`welchTTest` +
* `influenceConfidence` + the per-group day floors). FDR-corrected across
* every tested (tag × metric) pair via the shared `benjaminiHochberg`
* step-up so the surface stays honest as the matrix grows.
*
* Pairing modes:
* - "sameDay" — the metric on the SAME day the tag was logged. Used for
* activity (a workout/active tag × ACTIVE_ENERGY_BURNED) and sleep (a
* sleep tag × SLEEP_DURATION).
* - "nextDay" — the metric on the day AFTER the tag (D → D+1 lag join, the
* same lag the correlation-discovery engine uses). Used for an
* alcohol/food tag × next-day recovery/readiness (RECOVERY_SCORE),
* where the plausible direction is "tonight's choice → tomorrow's
* recovery".
*
* Observational only — the UI renders the standing "association, not
* cause" caption once. The metric value is shown in its display unit
* (SLEEP_DURATION minutes → hours; energy kcal; recovery score 0..100).
*/
/** Default Benjamini-Hochberg target FDR for the crosstab family. */
export const CROSSTAB_FDR_Q = 0.1;
/**
* Per-side day floors for the crosstab — kept in step with the influence
* floors (`INFLUENCE_MIN_PRESENT_DAYS` / `INFLUENCE_MIN_ABSENT_DAYS`) so a tag
* needs the same defensible present/absent support before a metric delta
* surfaces. Declared independently so the crosstab surface owns its own floor.
*/
export const CROSSTAB_MIN_PRESENT_DAYS = 5;
export const CROSSTAB_MIN_ABSENT_DAYS = 5;
/** Max rows surfaced across the whole crosstab so the surface stays scannable. */
export const CROSSTAB_MAX_ROWS = 8;
type CrosstabMode = "sameDay" | "nextDay";
/**
* The metric channels the crosstab pairs each tag against. The `display`
* key drives unit formatting on the client; `mode` fixes the pairing lag.
* Single-sourced so the fetch filter (`CROSSTAB_METRIC_TYPES`) and the
* compute can never drift.
*/
export const CROSSTAB_METRICS: Record<
string,
{
type: MeasurementType;
mode: CrosstabMode;
display: "hours" | "kcal" | "score";
}
> = {
// A workout / active tag × same-day active energy.
activeEnergy: {
type: "ACTIVE_ENERGY_BURNED",
mode: "sameDay",
display: "kcal",
},
// A sleep tag × same-night sleep duration (stored minutes → hours on UI).
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
// An alcohol / food tag × next-day recovery/readiness (D → D+1 lag).
nextDayRecovery: {
type: "RECOVERY_SCORE",
mode: "nextDay",
display: "score",
},
} as const;
export type CrosstabMetricKey = keyof typeof CROSSTAB_METRICS;
/** Distinct measurement types the crosstab reads — single-sourced. */
export const CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
new Set(Object.values(CROSSTAB_METRICS).map((m) => m.type)),
);
export interface TagMetricCrosstabRow {
/** Stable structured-tag key. */
tag: string;
/** i18n label key for the tag (structured tags only — never flat). */
labelKey: string;
/** Decrypted custom-tag label; null for catalogue tags. */
label?: string | null;
/** Parent category key, for grouping/icon. */
categoryKey: string;
/** Lucide icon name, or null. */
icon: string | null;
/** Which metric channel this row compares against. */
metricKey: CrosstabMetricKey;
/** Display unit hint for the client formatter. */
display: "hours" | "kcal" | "score";
/** Pairing mode used (echoed so the UI can caption "next-day"). */
mode: CrosstabMode;
/** Days the tag was present that had a paired metric value. */
withDays: number;
/** Days the tag was absent that had a paired metric value. */
withoutDays: number;
/** Mean metric on tag-present days (display unit). */
withAvg: number;
/** Mean metric on tag-absent days (display unit). */
withoutAvg: number;
/** withAvg − withoutAvg (display unit). */
delta: number;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Benjamini-Hochberg adjusted q-value across the tested family. */
qValue: number;
/** Discrete confidence band (p + min per-group day count). */
confidence: InfluenceConfidence;
}
const CROSSTAB_SUM_TYPES = new Set<string>([
"ACTIVE_ENERGY_BURNED",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
]);
/** Berlin-calendar day key (`YYYY-MM-DD`) for a row's `measuredAt`. */
function berlinDayKey(measuredAt: Date): string {
const { year, month, day } = toBerlinYmd(measuredAt);
return `${year}-${month}-${day}`;
}
/**
* Build a Berlin-day-keyed metric map with the right aggregation. Energy
* and step totals are SUMMED per day (HealthKit `stats:` rows are already
* one daily-total row, so the sum is the total either way); sleep duration
* is SUMMED across per-stage rows to get the night total; everything else
* (a once-daily score) is MEANED. Returns minutes/kcal/raw — the display
* conversion happens at row-build time.
*
* Cross-source de-dup: before bucketing, the rows for this metric run
* through the SAME canonical-source picker the analytics steps/sleep path
* uses (`pickCanonicalSourceRows`, keyed by `metricKeyForType` + the
* Berlin day key). Without it, the moment two sources report the same day
* (Fitbit + Apple steps, Fitbit + WHOOP sleep) the SUM channels would
* double-count and bias the Welch delta. The picker collapses each day to
* one source (and one device-type within it), so the sum reflects one
* stream. MEAN channels (a once-daily score like recovery) gain the same
* single-source guarantee for free. Rows without a `source` (the legacy
* test fixtures, or a metric whose source isn't in the ladder) fall
* through the picker's pass-through branch unchanged.
*/
export function metricDayMap(
measurements: CrossMetricMeasurement[],
type: string,
userPriorityJson: unknown,
): Map<string, number> {
const summed = CROSSTAB_SUM_TYPES.has(type);
const typeRows = measurements.filter((m) => m.type === type);
if (typeRows.length === 0) return new Map();
// Resolve the canonical source per day. `metricKeyForType` maps the
// crosstab's MeasurementType to its priority ladder; a metric with no
// ladder (or rows without a source) keeps every row via the picker's
// documented pass-through fallback, so behaviour is identical to the
// pre-fix sum for single-source data.
const metricKey = metricKeyForType(type as MeasurementType);
const canonicalRows = metricKey
? pickCanonicalSourceRows(
typeRows.map((m) => ({
measuredAt: m.measuredAt,
source: (m.source ?? "MANUAL") as MeasurementSource,
deviceType: m.deviceType ?? null,
type: type as MeasurementType,
value: m.value,
})),
metricKey,
userPriorityJson,
berlinDayKey,
).canonicalRows
: typeRows;
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of canonicalRows) {
const key = berlinDayKey(m.measuredAt);
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += m.value;
cur.count += 1;
byDay.set(key, cur);
}
const out = new Map<string, number>();
for (const [key, agg] of byDay) {
out.set(key, summed ? agg.sum : agg.sum / agg.count);
}
return out;
}
/** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */
export function shiftDayKey(day: string, lagDays: number): string {
const [y, m, d] = day.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + lagDays);
return dt.toISOString().slice(0, 10);
}
/** Convert a raw metric value to the row's display unit. */
function toDisplayUnit(
value: number,
display: "hours" | "kcal" | "score",
): number {
return display === "hours" ? value / 60 : value;
}
interface CrosstabCandidate {
row: Omit<TagMetricCrosstabRow, "qValue">;
}
/**
* Compute the tag × metric crosstab. Pure over already-fetched rows.
*
* For every structured tag and every configured metric channel, collapse
* the window to one observation per tz-anchored day (the tag's daily
* membership) joined to the metric's per-day value (same-day or D+1). The
* tag-present and tag-absent metric samples feed `welchTTest`; rows that
* clear both day floors are tested, FDR-corrected as one family, and the
* survivors (p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`) surface ranked by q then
* |delta|.
*
* Structured tags only — flat free-text tags are excluded because the
* crosstab needs a stable, localized label and a curated-catalog tag is
* the right granularity for a metric comparison.
*/
export function computeTagMetricCrosstab(args: {
entries: MoodAggregateEntry[];
measurements: CrossMetricMeasurement[];
now: Date;
windowDays?: number;
/**
* The user's source-priority blob. Threaded into `metricDayMap` so the
* per-day canonical-source pick honours the user's ladder. `null` (the
* test default) resolves to the default ladders.
*/
userPriorityJson?: unknown;
}): TagMetricCrosstabRow[] {
const { entries, measurements, now } = args;
const windowDays = args.windowDays ?? 365;
const userPriorityJson = args.userPriorityJson ?? null;
const dayMap = collapseToTaggedDays(entries, now, windowDays);
if (dayMap.size === 0) return [];
// The set of structured tags seen in the window, with their meta.
const structuredMeta = new Map<string, StructuredTagRef>();
for (const day of dayMap.values()) {
for (const [key, ref] of day.structuredTags) {
if (!structuredMeta.has(key)) structuredMeta.set(key, ref);
}
}
if (structuredMeta.size === 0) return [];
const candidates: CrosstabCandidate[] = [];
for (const [metricKey, cfg] of Object.entries(CROSSTAB_METRICS) as Array<
[CrosstabMetricKey, (typeof CROSSTAB_METRICS)[CrosstabMetricKey]]
>) {
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
if (metricByDay.size === 0) continue;
for (const [tagKey, ref] of structuredMeta) {
const withVals: number[] = [];
const withoutVals: number[] = [];
for (const [dayKey, day] of dayMap) {
const metricKeyDay =
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
const metricValue = metricByDay.get(metricKeyDay);
if (metricValue == null || !Number.isFinite(metricValue)) continue;
const display = toDisplayUnit(metricValue, cfg.display);
if (day.structuredTags.has(tagKey)) withVals.push(display);
else withoutVals.push(display);
}
if (
withVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
withoutVals.length < CROSSTAB_MIN_ABSENT_DAYS
) {
continue;
}
const welch = welchTTest(withVals, withoutVals);
const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length;
const withoutAvg =
withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length;
const delta = withAvg - withoutAvg;
if (delta === 0) continue;
const pValue = welch.status === "ok" ? welch.pValue : 1;
const minGroupDays = Math.min(withVals.length, withoutVals.length);
candidates.push({
row: {
tag: tagKey,
labelKey: ref.labelKey,
label: ref.label ?? null,
categoryKey: ref.categoryKey,
icon: ref.icon,
metricKey,
display: cfg.display,
mode: cfg.mode,
withDays: withVals.length,
withoutDays: withoutVals.length,
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
},
});
}
}
if (candidates.length === 0) return [];
// FDR-correct across the whole tested family (every tag × metric pair) so
// the multiple-comparison surface stays honest as the matrix grows. Reuses
// the same Benjamini-Hochberg step-up the correlation-discovery engine runs.
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
return candidates
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.tag.localeCompare(b.tag),
)
.slice(0, CROSSTAB_MAX_ROWS);
}
// --- RATED factor × health-metric crosstab (low- vs high-factor days) ---
/**
* v1.14.0 — the flagship cross-domain insight: "on days you rated <factor>
* low, your <vital> ran X below baseline".
*
* A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS
* per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab`
* handles. To reuse the EXACT same Welch + FDR engine, this thresholds the
* factor's daily mean into a binary membership — a day is "low" when its
* factor mean sits BELOW the factor's own median over the window, "high" at
* or above. The median split is robust + self-calibrating (always two
* non-empty groups when the factor has spread), and invents no fixed cutoff.
*
* `inverse` is applied at the SERIES boundary (the factor's per-day mean is
* flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like
* stress), so the median split — and therefore the "low day" label — always
* means "a worse day for this factor", honestly, without per-call casing.
*
* Same honesty discipline as the tag crosstab: both sides need
* `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric
* days, the family is BH-FDR corrected as one (across every factor × metric
* × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and
* the surface is capped + ranked. Observational only — the card renders the
* standing "association, not cause" caption.
*/
/**
* The metric channels the factor crosstab pairs each RATED factor against.
* Broader than the tag crosstab (which is activity-focused) because the
* value of a RATED factor is its bridge from a SUBJECTIVE score to an
* OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep,
* steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV),
* matching the plausible "today's factor → tomorrow's body" direction.
*/
export const FACTOR_CROSSTAB_METRICS: Record<
string,
{
type: MeasurementType;
mode: CrosstabMode;
display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg";
}
> = {
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" },
restingHeartRate: {
type: "RESTING_HEART_RATE",
mode: "nextDay",
display: "bpm",
},
heartRateVariability: {
type: "HEART_RATE_VARIABILITY",
mode: "nextDay",
display: "ms",
},
weight: { type: "WEIGHT", mode: "sameDay", display: "kg" },
bloodPressureSystolic: {
type: "BLOOD_PRESSURE_SYS",
mode: "sameDay",
display: "mmHg",
},
} as const;
export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS;
/** Distinct measurement types the factor crosstab reads — single-sourced. */
export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)),
);
export interface FactorMetricCrosstabRow {
/** Stable RATED-factor key. */
factor: string;
/** i18n label key for the factor. */
labelKey: string;
/** Parent category key, for grouping/icon. */
categoryKey: string;
/** Lucide icon name, or null. */
icon: string | null;
/**
* `true` when the factor is inverse-scaled (stress / conflict). The
* UI flips the phrasing — "your worse <factor> days" — but the split
* itself already runs on the flipped series, so a "low" row always
* means a worse day regardless.
*/
inverse: boolean;
/** Which metric channel this row compares against. */
metricKey: FactorCrosstabMetricKey;
/** Display unit hint for the client formatter. */
display: FactorMetricDisplay;
/** Pairing mode used (echoed so the UI can caption "next-day"). */
mode: CrosstabMode;
/** Days the factor was rated LOW (below its median) with a paired metric. */
lowDays: number;
/** Days the factor was rated HIGH (at/above its median) with a paired metric. */
highDays: number;
/** Mean metric on low-factor days (display unit). */
lowAvg: number;
/** Mean metric on high-factor days (display unit). */
highAvg: number;
/** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */
delta: number;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Benjamini-Hochberg adjusted q-value across the tested family. */
qValue: number;
/** Discrete confidence band (p + min per-group day count). */
confidence: InfluenceConfidence;
}
type FactorMetricDisplay =
(typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"];
/** Convert a raw metric value to its factor-crosstab display unit. */
function toFactorDisplayUnit(
value: number,
display: FactorMetricDisplay,
): number {
return display === "hours" ? value / 60 : value;
}
/** Median of a numeric array (sorted-copy, mean-of-two for even length). */
function median(values: number[]): number {
if (values.length === 0) return Number.NaN;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/** Reference data for one RATED factor's daily series + its meta. */
interface FactorDailySeries {
ref: Pick<
RatedFactorScore,
"key" | "labelKey" | "categoryKey" | "icon" | "inverse"
>;
/** Day key → factor's daily-mean score, inverse-flipped if needed. */
byDay: Map<string, number>;
}
/**
* Build a per-factor daily-mean series, applying the `inverse` sign-flip
* once. For each tz-anchored day, the mean of the factor's ratings across
* the day's entries; an inverse factor's rating `r` maps to
* `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a
* better day. Pure. Exported for the discovery-channel path (period
* narrative) so the two consumers can never diverge on the flip.
*/
export function buildFactorDailySeries(
entries: MoodAggregateEntry[],
now: Date,
windowDays: number,
): Map<string, FactorDailySeries> {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const acc = new Map<
string,
{
ref: FactorDailySeries["ref"];
byDay: Map<string, { sum: number; count: number }>;
}
>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
if (!entry.ratedFactors) continue;
for (const f of entry.ratedFactors) {
if (!Number.isFinite(f.rating)) continue;
// The documented sign-flip: an inverse factor's rating is mirrored
// across its scale midpoint so a higher series value always means a
// better day. Done here, once, at the series boundary.
const value = f.inverse ? f.scaleMin + f.scaleMax - f.rating : f.rating;
const slot = acc.get(f.key) ?? {
ref: {
key: f.key,
labelKey: f.labelKey,
categoryKey: f.categoryKey,
icon: f.icon,
inverse: f.inverse,
},
byDay: new Map<string, { sum: number; count: number }>(),
};
const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 };
cur.sum += value;
cur.count += 1;
slot.byDay.set(entry.date, cur);
acc.set(f.key, slot);
}
}
const out = new Map<string, FactorDailySeries>();
for (const [key, slot] of acc) {
const byDay = new Map<string, number>();
for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count);
out.set(key, { ref: slot.ref, byDay });
}
return out;
}
interface FactorCrosstabCandidate {
row: Omit<FactorMetricCrosstabRow, "qValue">;
}
/**
* Compute the RATED-factor × metric crosstab. Pure over already-fetched
* rows. Mirrors `computeTagMetricCrosstab` but splits each factor's
* continuous daily score into low/high by its own median, then runs the
* same Welch + FDR engine.
*/
export function computeFactorMetricCrosstab(args: {
entries: MoodAggregateEntry[];
measurements: CrossMetricMeasurement[];
now: Date;
windowDays?: number;
userPriorityJson?: unknown;
}): FactorMetricCrosstabRow[] {
const { entries, measurements, now } = args;
const windowDays = args.windowDays ?? 365;
const userPriorityJson = args.userPriorityJson ?? null;
const factorSeries = buildFactorDailySeries(entries, now, windowDays);
if (factorSeries.size === 0) return [];
const candidates: FactorCrosstabCandidate[] = [];
for (const [metricKey, cfg] of Object.entries(
FACTOR_CROSSTAB_METRICS,
) as Array<
[
FactorCrosstabMetricKey,
(typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey],
]
>) {
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
if (metricByDay.size === 0) continue;
for (const [factorKey, series] of factorSeries) {
// The median split is over the factor's own rated days only, so a
// sparse factor never borrows another's threshold.
const ratedDays = [...series.byDay.values()];
if (
ratedDays.length <
CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS
) {
continue;
}
const split = median(ratedDays);
const lowVals: number[] = [];
const highVals: number[] = [];
for (const [dayKey, score] of series.byDay) {
const metricDayKey =
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
const metricValue = metricByDay.get(metricDayKey);
if (metricValue == null || !Number.isFinite(metricValue)) continue;
const display = toFactorDisplayUnit(metricValue, cfg.display);
// Below the median is a "low" (worse, after the inverse flip) day;
// at or above is "high". A perfectly bimodal factor with all days
// exactly on the median falls entirely into "high" and fails the
// low-side floor below — honest: no contrast, no row.
if (score < split) lowVals.push(display);
else highVals.push(display);
}
if (
lowVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
highVals.length < CROSSTAB_MIN_ABSENT_DAYS
) {
continue;
}
const welch = welchTTest(lowVals, highVals);
const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length;
const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length;
const delta = lowAvg - highAvg;
if (delta === 0) continue;
const pValue = welch.status === "ok" ? welch.pValue : 1;
const minGroupDays = Math.min(lowVals.length, highVals.length);
candidates.push({
row: {
factor: factorKey,
labelKey: series.ref.labelKey,
categoryKey: series.ref.categoryKey,
icon: series.ref.icon,
inverse: series.ref.inverse,
metricKey,
display: cfg.display,
mode: cfg.mode,
lowDays: lowVals.length,
highDays: highVals.length,
lowAvg: round(lowAvg, 2),
highAvg: round(highAvg, 2),
delta: round(delta, 2),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
},
});
}
}
if (candidates.length === 0) return [];
// One BH family across every factor × metric pair tested — the same
// step-up the tag crosstab + discovery engine run.
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
return candidates
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.factor.localeCompare(b.factor),
)
.slice(0, CROSSTAB_MAX_ROWS);
}
+308
View File
@@ -0,0 +1,308 @@
/**
* Distribution / rhythm calculators for the mood surface: in-target
* share, discrete-level distribution, weekday averages, the tz-aware
* time-of-day pattern, and the day-to-day stability score.
*
* Extracted verbatim from `mood-aggregates.ts`, which re-exports this
* module so every existing call site keeps importing from the hub.
* Everything here is a pure function over already-bucketed daily points
* or raw mood rows; the DB read + orchestration stay in the hub's
* `fetchMoodAggregates`.
*/
import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series";
import {
MOOD_GREEN_MAX,
MOOD_GREEN_MIN,
type DailyPoint,
type MoodAggregateEntry,
} from "@/lib/insights/mood-aggregates";
import { round } from "@/lib/insights/status-shared";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { wallClockInTz } from "@/lib/tz/wall-clock";
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
/**
* Share (0..100) of the newest 30 daily mood buckets that land in the
* green band (>= 3.5). Returns null when there is no recent data.
*/
export function computeInTargetPct(daily: DailyPoint[]): number | null {
const recent = daily.filter((bucket) => bucket.dayOffset < 30);
if (recent.length === 0) return null;
const inTarget = recent.filter(
(entry) => entry.value >= MOOD_GREEN_MIN && entry.value <= MOOD_GREEN_MAX,
).length;
return round((inTarget / recent.length) * 100, 1);
}
// --- Mood distribution (share per discrete level) ---
export interface DistributionRow {
/** Rounded mood level 1..5. */
score: number;
count: number;
}
/**
* Distribution of daily-mean mood across the five discrete levels.
*
* Open product question #2 (design §7) resolves to the daily-mean
* convention used everywhere else on the surface: each day contributes
* one observation, its mean rounded to the nearest level. This keeps
* multi-entry days from over-weighting the histogram and matches the
* heatmap (one cell per day).
*/
export function computeDistribution(daily: DailyPoint[]): DistributionRow[] {
const counts = new Map<number, number>();
for (const bucket of daily) {
const level = Math.min(5, Math.max(1, Math.round(bucket.value)));
counts.set(level, (counts.get(level) ?? 0) + 1);
}
const rows: DistributionRow[] = [];
for (let score = 1; score <= 5; score++) {
rows.push({ score, count: counts.get(score) ?? 0 });
}
return rows;
}
// --- Average mood by weekday ---
export interface WeekdayRow {
/** 0 = Monday … 6 = Sunday. */
weekday: number;
avgScore: number | null;
count: number;
}
/**
* Average daily-mean mood grouped by weekday (Monday = 0). The weekday
* is read off the day-key in UTC so it matches the heatmap's UTC-anchored
* Monday alignment (`compliance-heatmap` reads `getUTCDay`).
*/
export function computeWeekdayAverages(
daily: DailyPoint[],
now: Date,
tz: string = DEFAULT_TIMEZONE,
): WeekdayRow[] {
const sums = new Map<number, { sum: number; count: number }>();
for (const bucket of daily) {
const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz);
const d = new Date(dayKey + "T00:00:00Z");
const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0
const cur = sums.get(weekday) ?? { sum: 0, count: 0 };
cur.sum += bucket.value;
cur.count += 1;
sums.set(weekday, cur);
}
const rows: WeekdayRow[] = [];
for (let weekday = 0; weekday < 7; weekday++) {
const agg = sums.get(weekday);
rows.push({
weekday,
avgScore: agg ? round(agg.sum / agg.count, 2) : null,
count: agg?.count ?? 0,
});
}
return rows;
}
// --- Time-of-day pattern (tz-aware part-of-day buckets) ---
/** Part-of-day bucket key. Order is morning → night for stable rendering. */
export type TimeOfDayBucket = "morning" | "afternoon" | "evening" | "night";
/** Ordered bucket list — drives both the chart x-axis and the iteration. */
export const TIME_OF_DAY_BUCKETS: readonly TimeOfDayBucket[] = [
"morning",
"afternoon",
"evening",
"night",
] as const;
/**
* Minimum entries a single bucket must hold before it counts toward the
* pattern. A lone log in a bucket is noise, not a daypart preference.
*/
export const TIME_OF_DAY_MIN_BUCKET_SAMPLES = 3;
/**
* Minimum distinct populated buckets (each at or above the per-bucket
* sample floor) before the pattern surfaces at all. The guard against the
* once-a-day logger: a nightly Telegram check-in clusters in a single
* bucket, which can never clear a two-bucket spread, so the "you feel
* best in the morning" takeaway never fires misleadingly.
*/
export const TIME_OF_DAY_MIN_SPREAD = 2;
/**
* Map a local hour-of-day (0..23) to its part-of-day bucket.
*
* morning 05:00–11:59, afternoon 12:00–16:59, evening 17:00–20:59,
* night 21:00–04:59. The boundaries follow the common Daylio / consumer
* convention; night wraps midnight so a 02:00 log lands in `night`.
*/
export function bucketForHour(hour: number): TimeOfDayBucket {
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 17) return "afternoon";
if (hour >= 17 && hour < 21) return "evening";
return "night";
}
export interface TimeOfDayRow {
bucket: TimeOfDayBucket;
avgScore: number | null;
count: number;
}
export interface TimeOfDayPattern {
/** All four buckets, in canonical order; unpopulated buckets carry null. */
buckets: TimeOfDayRow[];
/**
* Whether the spread + sample floors are cleared — i.e. the pattern is
* trustworthy enough to surface a chart and a takeaway. False for the
* once-a-day logger (everything in one bucket) or a sparse history.
*/
reliable: boolean;
/** Best / worst bucket keys when `reliable`; null otherwise. */
best: TimeOfDayBucket | null;
worst: TimeOfDayBucket | null;
}
/**
* Average mood per part of day, bucketed in each entry's own timezone.
*
* `moodLoggedAt` carries the exact instant; `tz` (per-row IANA) anchors
* the local hour. Legacy rows without a `tz` fall back to UTC — the same
* convention the rollup tier uses for `tz IS NULL` mood rows. Every entry
* (not the daily mean) feeds its bucket, because the question is "what
* time of day do I feel best", which a daily collapse would erase.
*
* The `reliable` flag is the once-a-day-logger guard: it only trips when
* at least `TIME_OF_DAY_MIN_SPREAD` buckets each carry
* `TIME_OF_DAY_MIN_BUCKET_SAMPLES` entries. `best`/`worst` are computed
* over the populated-and-sufficient buckets only.
*/
export function computeTimeOfDayAverages(
entries: MoodAggregateEntry[],
): TimeOfDayPattern {
const sums = new Map<TimeOfDayBucket, { sum: number; count: number }>();
for (const entry of entries) {
const tz = entry.tz ?? "UTC";
const { hour } = wallClockInTz(entry.moodLoggedAt, tz);
const bucket = bucketForHour(hour);
const cur = sums.get(bucket) ?? { sum: 0, count: 0 };
cur.sum += entry.score;
cur.count += 1;
sums.set(bucket, cur);
}
const buckets: TimeOfDayRow[] = TIME_OF_DAY_BUCKETS.map((bucket) => {
const agg = sums.get(bucket);
return {
bucket,
avgScore: agg ? round(agg.sum / agg.count, 2) : null,
count: agg?.count ?? 0,
};
});
const sufficient = buckets.filter(
(row): row is TimeOfDayRow & { avgScore: number } =>
row.avgScore != null && row.count >= TIME_OF_DAY_MIN_BUCKET_SAMPLES,
);
const reliable = sufficient.length >= TIME_OF_DAY_MIN_SPREAD;
let best: TimeOfDayBucket | null = null;
let worst: TimeOfDayBucket | null = null;
if (reliable) {
let bestRow = sufficient[0];
let worstRow = sufficient[0];
for (const row of sufficient) {
if (row.avgScore > bestRow.avgScore) bestRow = row;
if (row.avgScore < worstRow.avgScore) worstRow = row;
}
best = bestRow.bucket;
worst = worstRow.bucket;
}
return { buckets, reliable, best, worst };
}
// --- Mood stability score (variance of daily means → 0..100) ---
/**
* Minimum distinct daily points before a stability score is computed. A
* handful of days has no meaningful variance signal, so a sparse logger
* gets `null` (no tile, no sentence) rather than a noisy number.
*/
export const STABILITY_MIN_DAYS = 7;
/**
* The full-scale standard deviation that maps to a 0 stability score. The
* mood scale spans 1..5, so the widest day-to-day swing is 4 points; a
* population SD at or above this is treated as maximally unstable. Below
* it, the score scales linearly toward 100 (perfectly steady).
*/
export const STABILITY_SD_FULL_SCALE = 1.5;
export type StabilityBand =
"verySteady" | "steady" | "variable" | "veryVariable";
export interface MoodStability {
/** 0..100; higher = steadier (lower day-to-day variance). */
score: number;
/** Population standard deviation of the daily means (raw, for tests). */
stdDev: number;
/** Descriptive, non-judgemental band. */
band: StabilityBand;
/** Daily points the score was computed over. */
days: number;
}
/**
* Map a 0..100 stability score to a four-band descriptive label.
* Descriptive, not judgemental — some variation is healthy (Oura framing),
* so the bands read "steady" / "variable", never "good" / "bad".
*/
function stabilityBand(score: number): StabilityBand {
if (score >= 80) return "verySteady";
if (score >= 60) return "steady";
if (score >= 40) return "variable";
return "veryVariable";
}
/**
* Mood-stability score from the population standard deviation of the
* daily means.
*
* Formula:
* sd = sqrt( mean( (x_i - mean(x))^2 ) ) // population SD
* score = round( 100 * (1 - min(sd, FULL) / FULL) ) // clamped 0..100
*
* A flat mood (sd = 0) scores 100; an sd at or beyond
* `STABILITY_SD_FULL_SCALE` scores 0. Returns `null` below
* `STABILITY_MIN_DAYS` distinct daily points so a sparse logger never
* gets a meaningless score.
*/
export function computeMoodStability(
daily: DailyPoint[],
): MoodStability | null {
if (daily.length < STABILITY_MIN_DAYS) return null;
const values = daily.map((bucket) => bucket.value);
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
const stdDev = Math.sqrt(variance);
const normalised =
Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE;
const score = Math.round(100 * (1 - normalised));
return {
score,
stdDev: round(stdDev, 3),
band: stabilityBand(score),
days: daily.length,
};
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Tag analytics for the mood surface: frequency + per-tag average mood
* on both axes (flat free-text and the structured taxonomy) plus the
* tag "Influence on Mood" board — with-vs-without daily-mean deltas,
* Welch-tested, confidence-banded.
*
* Extracted verbatim from `mood-aggregates.ts`, which re-exports this
* module so every existing call site keeps importing from the hub.
* Everything here is a pure function over already-fetched rows; the DB
* read + orchestration stay in the hub's `fetchMoodAggregates`.
*/
import { welchTTest } from "@/lib/insights/correlations";
import { round } from "@/lib/insights/status-shared";
import { MS_PER_DAY } from "@/lib/time-constants";
import type {
MoodAggregateEntry,
StructuredTagRef,
} from "@/lib/insights/mood-aggregates";
// --- Tag frequency + per-tag avg score (lifted from mood-status) ---
export interface TagSummaryRow {
tag: string;
count: number;
avgScore: number;
}
/**
* Tag frequency + per-tag average mood over a recency window.
*
* Keeps the v1.4.5 ~90-day window so the model still gets a recency-
* weighted view of tag patterns; the caller slices by date, not by
* record count. Only tags with count >= 2 survive (a single mention is
* noise), top 10 by frequency.
*/
export function computeTagSummary(
entries: MoodAggregateEntry[],
now: Date,
windowDays = 90,
): TagSummaryRow[] {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const tagCounts = new Map<string, { count: number; scoreSum: number }>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
if (entry.tags && Array.isArray(entry.tags)) {
for (const tag of entry.tags as string[]) {
const current = tagCounts.get(tag) ?? { count: 0, scoreSum: 0 };
current.count += 1;
current.scoreSum += entry.score;
tagCounts.set(tag, current);
}
}
}
return Array.from(tagCounts.entries())
.filter(([, stats]) => stats.count >= 2)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 10)
.map(([tag, stats]) => ({
tag,
count: stats.count,
avgScore: round(stats.scoreSum / stats.count, 2),
}));
}
// --- Structured-tag frequency + per-tag avg score (v1.8.5 taxonomy) ---
export interface StructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
/** Decrypted custom-tag label; null for catalogue tags (see `StructuredTagRef`). */
label?: string | null;
icon: string | null;
count: number;
avgScore: number;
}
/**
* Frequency + per-tag average mood for the structured taxonomy tags,
* over the same recency window as the flat-tag summary.
*
* Unlike `computeTagSummary` (flat free text), singletons are KEPT — a
* structured tag comes from a curated catalog the user deliberately
* picked, so a single occurrence is signal, not noise. Ranked by
* frequency desc, then label key for a stable order. The category key
* rides along so the UI can group the bars by category with icons.
*/
export function computeStructuredTagSummary(
entries: MoodAggregateEntry[],
now: Date,
windowDays = 90,
): StructuredTagRow[] {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const byKey = new Map<
string,
{
categoryKey: string;
labelKey: string;
label: string | null;
icon: string | null;
count: number;
scoreSum: number;
}
>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
if (!entry.structuredTags) continue;
for (const tag of entry.structuredTags) {
const current = byKey.get(tag.key) ?? {
categoryKey: tag.categoryKey,
labelKey: tag.labelKey,
label: tag.label ?? null,
icon: tag.icon,
count: 0,
scoreSum: 0,
};
current.count += 1;
current.scoreSum += entry.score;
byKey.set(tag.key, current);
}
}
return Array.from(byKey.entries())
.map(([key, stats]) => ({
key,
categoryKey: stats.categoryKey,
labelKey: stats.labelKey,
label: stats.label,
icon: stats.icon,
count: stats.count,
avgScore: round(stats.scoreSum / stats.count, 2),
}))
.sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey));
}
// --- Tag "Influence on Mood" — with-vs-without daily-mean delta (F1) ---
/**
* Minimum days a tag must be PRESENT on, and minimum days it must be
* ABSENT on, before its with/without influence surfaces. Both floors
* apply: a tag seen on 4 days has no defensible "with" average, and a
* tag present on all-but-2 days has no defensible "without" baseline.
* Five days per side is the same order as the stability floor (7 daily
* points) but per-group — enough for a mean of daily means to mean
* something without surfacing a two-day fluke as a "factor".
*/
export const INFLUENCE_MIN_PRESENT_DAYS = 5;
export const INFLUENCE_MIN_ABSENT_DAYS = 5;
/**
* Maximum influence rows surfaced per axis (flat / structured). Ranked by
* absolute delta so the strongest associations lead; the cap keeps the
* multiple-comparison surface bounded (the confidence band downgrades the
* rest honestly).
*/
export const INFLUENCE_MAX_ROWS = 8;
/** Discrete confidence the UI chip renders for an influence row. */
export type InfluenceConfidence = "low" | "medium" | "high";
export interface TagInfluenceRow {
/** Stable tag key. For flat tags this is the free-text string. */
tag: string;
/**
* For structured tags, the i18n label key + parent category so the UI
* can render the localized label with its icon. `null` for flat tags
* (rendered verbatim).
*/
labelKey: string | null;
/** Decrypted custom-tag label; null for catalogue / flat tags. */
label?: string | null;
categoryKey: string | null;
icon: string | null;
/** Days the tag was present (daily-mean convention). */
withDays: number;
/** Days the tag was absent over the same window. */
withoutDays: number;
/** Mean of daily means on days the tag was present. */
withAvg: number;
/** Mean of daily means on days the tag was absent (the counterfactual). */
withoutAvg: number;
/** withAvg − withoutAvg, rounded. Positive = higher mood with the tag. */
delta: number;
/**
* Pooled mood SD across the with/without groups (Cohen's-d denominator).
* `null` when neither group has testable spread (both constant). Lets the
* board standardize the raw delta into a unitless effect comparable to a
* Pearson |r|. Not surfaced in the UI — ranking only.
*/
pooledSd: number | null;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Discrete confidence band derived from p-value + per-group sample size. */
confidence: InfluenceConfidence;
}
/**
* Map a Welch p-value + the smaller per-group day count to a discrete
* confidence band. Both inputs matter: a small p on tiny groups is still
* fragile, and a comfortable sample with a borderline p is only suggestive.
*
* - high : p < 0.01 AND both groups ≥ 12 days
* - medium : p < 0.05 AND both groups ≥ 8 days
* - low : everything else that cleared the surfacing floors
*
* The deterministic "no_variance" case (both groups perfectly constant but
* with a non-zero delta) is treated as `low` — a real but un-tested
* difference should never read as confident.
*/
export function influenceConfidence(
pValue: number,
minGroupDays: number,
): InfluenceConfidence {
if (pValue < 0.01 && minGroupDays >= 12) return "high";
if (pValue < 0.05 && minGroupDays >= 8) return "medium";
return "low";
}
/**
* Collapse raw mood rows to one observation per tz-anchored day: the
* day's mean score plus the union of every flat + structured tag key
* present on any entry that day. Daily-mean convention so a multi-entry
* day never over-weights an influence comparison (it matches the heatmap,
* distribution, and weekday axes).
*/
export interface TaggedDay {
/** Day's mean mood (mean of the day's entry scores). */
mean: number;
/** Flat free-text tag keys present that day. */
flatTags: Set<string>;
/** Structured tag refs present that day, keyed for de-dup. */
structuredTags: Map<string, StructuredTagRef>;
}
export function collapseToTaggedDays(
entries: MoodAggregateEntry[],
now: Date,
windowDays: number,
): Map<string, TaggedDay> {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const byDay = new Map<
string,
{
sum: number;
count: number;
flat: Set<string>;
structured: Map<string, StructuredTagRef>;
}
>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
const day = byDay.get(entry.date) ?? {
sum: 0,
count: 0,
flat: new Set<string>(),
structured: new Map<string, StructuredTagRef>(),
};
day.sum += entry.score;
day.count += 1;
if (entry.tags && Array.isArray(entry.tags)) {
for (const tag of entry.tags as string[]) day.flat.add(tag);
}
if (entry.structuredTags) {
for (const ref of entry.structuredTags) day.structured.set(ref.key, ref);
}
byDay.set(entry.date, day);
}
const out = new Map<string, TaggedDay>();
for (const [date, agg] of byDay) {
out.set(date, {
mean: agg.sum / agg.count,
flatTags: agg.flat,
structuredTags: agg.structured,
});
}
return out;
}
/**
* Compute the with-vs-without influence row for ONE candidate tag over a
* pre-collapsed day map. `presentOn(day)` decides membership. Returns null
* when either group is below its day floor (so the caller drops the tag)
* or when Welch reports no testable spread AND the means are equal.
*/
function influenceForTag(
days: TaggedDay[],
presentOn: (day: TaggedDay) => boolean,
meta: {
tag: string;
labelKey: string | null;
label: string | null;
categoryKey: string | null;
icon: string | null;
},
): TagInfluenceRow | null {
const withVals: number[] = [];
const withoutVals: number[] = [];
for (const day of days) {
if (presentOn(day)) withVals.push(day.mean);
else withoutVals.push(day.mean);
}
if (
withVals.length < INFLUENCE_MIN_PRESENT_DAYS ||
withoutVals.length < INFLUENCE_MIN_ABSENT_DAYS
) {
return null;
}
const welch = welchTTest(withVals, withoutVals);
// Deterministic means even when Welch declines (no_variance) — the
// delta is still meaningful, we just cannot attach a p-value, so the
// band falls to `low`.
const withAvg = withVals.reduce((s, v) => s + v, 0) / withVals.length;
const withoutAvg =
withoutVals.reduce((s, v) => s + v, 0) / withoutVals.length;
const delta = withAvg - withoutAvg;
const minGroupDays = Math.min(withVals.length, withoutVals.length);
// Pooled SD (Cohen's-d denominator) so the board can standardize this raw
// mood-point delta into a unitless effect comparable to a Pearson |r|.
// Unbiased (n−1) per-group variances pooled on (nWith + nWithout − 2) df;
// null when there is no testable spread (both groups perfectly constant).
const nWith = withVals.length;
const nWithout = withoutVals.length;
const varWith =
withVals.reduce((s, v) => s + (v - withAvg) ** 2, 0) / (nWith - 1);
const varWithout =
withoutVals.reduce((s, v) => s + (v - withoutAvg) ** 2, 0) / (nWithout - 1);
const pooledVar =
((nWith - 1) * varWith + (nWithout - 1) * varWithout) /
(nWith + nWithout - 2);
const pooledSd = pooledVar > 0 ? Math.sqrt(pooledVar) : null;
const pValue = welch.status === "ok" ? welch.pValue : 1;
// A zero delta with no spread is not an "influence" — drop it so the
// board never shows a 0.0 row.
if (delta === 0) return null;
return {
tag: meta.tag,
labelKey: meta.labelKey,
label: meta.label,
categoryKey: meta.categoryKey,
icon: meta.icon,
withDays: withVals.length,
withoutDays: withoutVals.length,
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pooledSd: pooledSd === null ? null : round(pooledSd, 3),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
};
}
export interface TagInfluence {
/** Flat free-text tag influence rows, ranked by |delta| desc. */
flat: TagInfluenceRow[];
/** Structured taxonomy tag influence rows, ranked by |delta| desc. */
structured: TagInfluenceRow[];
}
/**
* Tag "Influence on Mood" (Daylio's flagship): for each frequent flat and
* structured tag, the average daily mood on days the tag is PRESENT vs the
* counterfactual baseline of days it is ABSENT, the delta, and a confidence
* band from a Welch two-sample t-test.
*
* Gating: both groups must clear `INFLUENCE_MIN_PRESENT_DAYS` /
* `INFLUENCE_MIN_ABSENT_DAYS`; rows are ranked by absolute delta and capped
* at `INFLUENCE_MAX_ROWS` per axis. Observational only — the UI applies the
* standing "association, not cause" caption. Sparse tags, single-day tags,
* all-same-value days, and divide-by-zero are all handled cleanly (the row
* is dropped, never NaN).
*/
export function computeTagInfluence(
entries: MoodAggregateEntry[],
now: Date,
windowDays = 90,
): TagInfluence {
const dayMap = collapseToTaggedDays(entries, now, windowDays);
const days = Array.from(dayMap.values());
// Need at least one day per side to even attempt — the per-tag floor
// catches the real gate, this just short-circuits an empty history.
if (days.length < INFLUENCE_MIN_PRESENT_DAYS + INFLUENCE_MIN_ABSENT_DAYS) {
return { flat: [], structured: [] };
}
// Candidate flat tags: every distinct flat key seen in the window.
const flatKeys = new Set<string>();
for (const day of days) for (const k of day.flatTags) flatKeys.add(k);
const flat: TagInfluenceRow[] = [];
for (const key of flatKeys) {
const row = influenceForTag(days, (d) => d.flatTags.has(key), {
tag: key,
labelKey: null,
label: null,
categoryKey: null,
icon: null,
});
if (row) flat.push(row);
}
// Candidate structured tags: every distinct structured key + its meta.
const structuredMeta = new Map<string, StructuredTagRef>();
for (const day of days) {
for (const [key, ref] of day.structuredTags) {
if (!structuredMeta.has(key)) structuredMeta.set(key, ref);
}
}
const structured: TagInfluenceRow[] = [];
for (const [key, ref] of structuredMeta) {
const row = influenceForTag(days, (d) => d.structuredTags.has(key), {
tag: key,
labelKey: ref.labelKey,
label: ref.label ?? null,
categoryKey: ref.categoryKey,
icon: ref.icon,
});
if (row) structured.push(row);
}
const rank = (a: TagInfluenceRow, b: TagInfluenceRow) =>
Math.abs(b.delta) - Math.abs(a.delta) || a.tag.localeCompare(b.tag);
return {
flat: flat.sort(rank).slice(0, INFLUENCE_MAX_ROWS),
structured: structured.sort(rank).slice(0, INFLUENCE_MAX_ROWS),
};
}
+238
View File
@@ -0,0 +1,238 @@
/**
* v1.18.7 — the present-focused "Signals of the day" block for the
* daily briefing: per-metric today-vs-7d/30d deltas, an emerging slope,
* and a recent anomaly, ranked by clinical priority with salient
* signals bubbling first. Every numeric field is pre-computed so the
* model states it rather than re-deriving the comparison.
*
* Extracted verbatim from `features.ts`, which re-exports this module
* so every existing call site keeps importing from there.
*/
import { trendSlope } from "@/lib/analytics/trends";
import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features";
/**
* v1.18.7 — one present-focused signal feeding the daily briefing. Every
* numeric field is pre-computed so the model states it rather than
* re-deriving the comparison (which small LLMs do unreliably).
*/
export interface SignalOfDay {
/** Briefing `sourceMetric` discriminator the UI pins an icon + route on. */
metric:
"bp" | "weight" | "pulse" | "mood" | "sleep" | "resting_hr" | "glucose";
/** Natural-language label (no enum leak into prose). */
label: string;
/** Unit string when the metric carries one. */
unit?: string;
/** Freshest reading (the "now" value the briefing leads with). */
latest: number;
/** Days since the freshest reading. */
latestDaysAgo: number;
/** Trailing-7d mean. */
avg7: number | null;
/** Trailing-30d mean. */
avg30: number | null;
/** Signed `latest − avg7`, pre-computed. */
deltaVs7: number | null;
/** Signed `latest − avg30`, pre-computed. */
deltaVs30: number | null;
/** Normal-swing SD over the trailing-30d window. */
spread30: number | null;
/** `|latest − avg30| > spread30` — the significance verdict as a boolean. */
outsideNormalSwing: boolean;
/** Emerging direction over the trailing 30 days (slope sign). */
emergingTrend: "rising" | "falling" | "flat" | null;
/** A peak / trough inside the last 14 days, when one stands out. */
recentAnomaly: {
kind: "peak" | "trough";
value: number;
anomalyDaysAgo: number;
} | null;
}
/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */
export function computeHistoricalComparison(
records: Array<{ value: number; measuredAt: Date }>,
now: number,
): {
current7dAvg: number | null;
previous30dAvg: number | null;
change: number | null;
} {
const current7dAvg = avgInWindow(records, now, 7, 0);
const previous30dAvg = avgInWindow(records, now, 37, 7);
const change =
current7dAvg !== null && previous30dAvg !== null
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
: null;
return { current7dAvg, previous30dAvg, change };
}
/**
* v1.18.7 — clinical priority order for the signals block. BP / glucose
* lead, then resting HR / pulse, then weight, then mood.
* Lower index = higher priority; the briefing surfaces the top ≤3.
*/
const SIGNAL_PRIORITY: SignalOfDay["metric"][] = [
"bp",
"glucose",
"resting_hr",
"pulse",
"weight",
"mood",
];
/** Round helper local to the signals builder. */
function r2(v: number): number {
return Math.round(v * 100) / 100;
}
/**
* Build one signal from a single metric's measurement records. Returns null
* when there is no fresh reading or fewer than three points in 30 days — a
* sparse metric cannot carry an honest "today vs your normal" read.
*/
function buildSignal(
metric: SignalOfDay["metric"],
label: string,
records: Array<{ value: number; measuredAt: Date }>,
now: number,
unit?: string,
): SignalOfDay | null {
if (records.length === 0) return null;
const newest = records[records.length - 1];
const latestDaysAgo = Math.round(
(now - newest.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
);
// A reading older than two weeks is not a "signal of the day".
if (latestDaysAgo > 14) return null;
const win30 = records.filter(
(rec) => rec.measuredAt.getTime() >= now - 30 * 24 * 60 * 60 * 1000,
);
if (win30.length < 3) return null;
const avg7 = avgInWindow(records, now, 7);
const avg30 = avgInWindow(records, now, 30);
const spread30 = stdDev(win30.map((rec) => rec.value));
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
const outsideNormalSwing =
avg30 !== null && spread30 !== null && spread30 > 0
? Math.abs(newest.value - avg30) > spread30
: false;
const slope = trendSlope(toDataPoints(records), 30, now);
const emergingTrend: SignalOfDay["emergingTrend"] = slope
? slope.direction === "up"
? "rising"
: slope.direction === "down"
? "falling"
: "flat"
: null;
// Recent anomaly: an extreme inside the last 14 days vs the 30d mean ± 2 SD.
let recentAnomaly: SignalOfDay["recentAnomaly"] = null;
// Track the RAW extreme magnitude — comparing against the already-r2()
// rounded stored value can drop a genuinely larger anomaly.
let bestAbs = 0;
if (avg30 !== null && spread30 !== null && spread30 > 0) {
const recent = records.filter(
(rec) => rec.measuredAt.getTime() >= now - 14 * 24 * 60 * 60 * 1000,
);
for (const rec of recent) {
const sd = (rec.value - avg30) / spread30;
if (Math.abs(sd) >= 2) {
const abs = Math.abs(rec.value - avg30);
if (recentAnomaly === null || abs > bestAbs) {
bestAbs = abs;
recentAnomaly = {
kind: (sd > 0 ? "peak" : "trough") as "peak" | "trough",
value: r2(rec.value),
anomalyDaysAgo: Math.round(
(now - rec.measuredAt.getTime()) / (24 * 60 * 60 * 1000),
),
};
}
}
}
}
return {
metric,
label,
...(unit ? { unit } : {}),
latest: r2(newest.value),
latestDaysAgo,
avg7,
avg30,
deltaVs7,
deltaVs30,
spread30,
outsideNormalSwing,
emergingTrend,
recentAnomaly,
};
}
/**
* v1.18.7 — assemble the present-focused "Signals of the day" block from the
* in-memory measurement set (no extra DB round-trip). Computes today-vs-7d/30d
* deltas, an emerging slope, and a recent anomaly per salient metric, then
* returns the top ≤3 ranked by clinical priority. Salient signals
* (outside-normal-swing or a recent anomaly) bubble above quiet ones inside
* each priority tier so the briefing leads with what actually moved.
*/
export function computeSignalsOfDay(
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
now: number,
): SignalOfDay[] {
const candidates: SignalOfDay[] = [];
const push = (s: SignalOfDay | null) => {
if (s) candidates.push(s);
};
// Systolic carries the BP signal (the headline number clinicians read first).
push(
buildSignal(
"bp",
"blood pressure (systolic)",
byType("BLOOD_PRESSURE_SYS"),
now,
"mmHg",
),
);
// Glucose uses the canonical stored value (the model reads the snapshot,
// not display units); absent data simply produces no signal.
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
push(
buildSignal(
"resting_hr",
"resting heart rate",
byType("RESTING_HEART_RATE"),
now,
"bpm",
),
);
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
// Sleep is stored one row per stage per night, so a raw "latest" point
// would mis-sum; the sleep aggregates carry that signal already. Steps
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row
// is a partial-day fragment, not a daily total — excluded like sleep.
const priorityIndex = (m: SignalOfDay["metric"]) => {
const idx = SIGNAL_PRIORITY.indexOf(m);
return idx === -1 ? SIGNAL_PRIORITY.length : idx;
};
const salience = (s: SignalOfDay) =>
(s.outsideNormalSwing ? 2 : 0) + (s.recentAnomaly ? 1 : 0);
return candidates
.sort((a, b) => {
const sal = salience(b) - salience(a);
if (sal !== 0) return sal;
return priorityIndex(a.metric) - priorityIndex(b.metric);
})
.slice(0, 3);
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Per-metric assessment-scope invalidation + refill.
*
* Maps fresh measurement types to the assessment scopes they dirty,
* debounces the ingest-invalidation storm against recently warmed cache
* rows, and enqueues the hash-gated background regenerations — for both
* the ingest path (`invalidateStatusInsightsForTypes`) and the manual
* regenerate path (`enqueueStatusRefillForUser`).
*
* Extracted verbatim from `comprehensive-generate.ts`, which re-exports
* this module so every existing call site (the measurement ingest routes
* and every provider sync) keeps importing from there.
*/
import { prisma } from "@/lib/db";
import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Minimum gap between a scope's last cached (re)generation and the next
* ingest-driven re-enqueue. A scope whose cached assessment was
* (re)generated within this window is left intact on a fresh measurement
* ingest — not re-enqueued.
*
* A constantly-syncing client (Apple Health drips batches every few
* minutes) used to delete + re-enqueue every dirtied scope on every batch,
* and because each delete dropped the cache row the per-(user,scope,locale)
* enqueue singleton (120 s) could not coalesce across batches that arrive
* minutes apart — so STEPS / general / a metric card regenerated several
* times an hour and the assessment felt "regenerated on every visit". This
* window coalesces the storm: a genuinely stale scope (no fresh assessment)
* still refreshes immediately, but a scope refreshed inside the window is
* skipped, so a fresh assessment survives the day's sync drip.
*
* v1.16.8 — first widened from 30 min to 6 h to bound provider spend, but
* a 6 h wall meant same-day data was not narrated same-day: the nightly
* warm at 04:30 restarted the window, so a notable 08:00 reading never
* re-enqueued its scopes and the card showed the pre-reading text until
* the next nightly tick. The window is now a ONE-HOUR minimum gap, and
* the budget role the 6 h wall carried moves to the worker's content-hash
* gate: every ingest that lands past the gap re-enqueues the dirtied
* scopes, the worker's forced run re-gathers the snapshot, and the gate
* (`refreshUnchangedStatusInsight`) turns an unchanged snapshot into a
* timestamp refresh with zero provider cost. So this clock only bounds
* the SQL-gather frequency — at most one worker run per scope per hour
* under a constant sync drip — while provider spend tracks actual data
* change, which is what the gate exists to meter. Comparing the hash
* inline at invalidation time was rejected: it would run the full
* per-scope data gather inside the ingest path, which is exactly the
* work the queue exists to keep off that path.
*/
const INGEST_INVALIDATE_MIN_GAP_MS = 60 * 60 * 1000;
/**
* The seven per-metric assessment scopes. Each generator persists its
* cached text under `insights.<scope>-status.<locale>`; the nightly warm
* pass refreshes them through each generator's content-hash gate, and the
* targeted invalidator below re-enqueues only the scopes a fresh
* measurement of a given type actually dirties.
*/
export const PER_STATUS_SCOPES = [
"blood-pressure",
"pulse",
"weight",
"bmi",
"mood",
"medication-compliance",
"general",
] as const;
export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number];
/**
* Map a measurement type to the assessment scopes a fresh reading of it
* dirties. `general` is the catch-all overview, so every measurement
* type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a
* new weight reading invalidates both the weight and the BMI card.
*/
function statusScopesForMeasurementType(
type: MeasurementType,
): PerStatusScope[] {
switch (type) {
case "WEIGHT":
return ["weight", "bmi", "general"];
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
return ["blood-pressure", "general"];
case "PULSE":
case "RESTING_HEART_RATE":
return ["pulse", "general"];
default:
// Every other tracked metric (body composition, sleep, steps,
// glucose, …) still feeds the general overview assessment.
return ["general"];
}
}
/**
* Re-warm the cached per-metric assessments that a batch of fresh
* measurements dirties, so the next mount (or the next nightly warm
* pass) reflects the new data instead of serving the pre-measurement
* text for the rest of the day.
*
* Fire-and-forget from the measurement ingest path — idempotent and
* never a blocker on the user's write. v1.16.8 — the invalidator no
* longer DELETES the cache rows: the worker regenerates each enqueued
* scope with `force: true`, and the generator's content-hash gate
* decides whether the data actually changed. Keeping the row intact
* preserves the stale-while-revalidate read AND lets the gate skip the
* LLM entirely when the dirtying batch turned out to be a re-sync of
* known data.
*/
export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
// One ordered set of every scope the batch dirties. The seven specialised
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
// cards (v1.8.7.1) carry a `metric:<ID>` prefix. Both share the cache-key
// shape `insights.<scope>-status.<locale>`, so a single set covers the
// eviction + the debounce filter + the enqueue uniformly. Only registered,
// data-bearing types contribute a generic scope; the seven specialised
// metrics and any unregistered type resolve to null and are skipped, so
// the constant sync cannot fan out to unwanted scopes.
const scopes = new Set<InsightStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
const metricId = metricIdForMeasurementType(type);
if (metricId) {
scopes.add(metricStatusScope(metricId));
}
}
if (scopes.size === 0) return;
// v1.8.7 — regenerate only the user's resolved locale, matching the
// read-path (every `*-status` GET serves `normalizeLocale(user.locale)`).
// Warming both locales doubled provider spend on every sync, half of it
// for a language the user never opens. A second locale a client actually
// reads warms lazily through the read-path enqueue on its first miss.
const localeRow = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const locale = normalizeLocale(localeRow?.locale);
// v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing
// client drips batches every few minutes; re-enqueuing every dirtied
// scope on every batch regenerated the same card several times an hour
// (the per-(user,scope,locale) enqueue singleton is only 120 s, so it
// could not coalesce across batches arriving minutes apart). Skip any
// scope whose cached assessment was (re)generated within the minimum
// gap — leave its row intact and do NOT re-enqueue. A genuinely stale
// or missing scope still refreshes immediately, so correctness holds;
// only the redundant churn is removed. Past the gap the enqueue always
// goes through — the worker's content-hash gate decides whether the
// batch actually changed anything (see the constant's doc).
const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
const staleScopes = Array.from(scopes).filter(
(scope) => !freshScopes.has(scope),
);
if (staleScopes.length === 0) {
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: scopes.size, refreshed: 0 },
});
return;
}
// v1.8.7 — regenerate-on-invalidate: enqueue a debounced regenerate for
// each dirtied scope so the cache is re-warmed in the background. The
// enqueue is coalesced per `(user, metric, locale)` via the queue's
// `singletonKey` (120 s window); the debounce above is the second, wider
// coalescing layer that survives across the sync drip. The cache row
// stays in place (stale-while-revalidate keeps the previous assessment
// visible) — the worker forces the generator, whose content-hash gate
// skips the LLM when the batch did not actually change the snapshot.
for (const scope of staleScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: freshScopes.size, refreshed: staleScopes.length },
});
}
/**
* v1.16.8 — enqueue a hash-gated refill of every assessment card one user
* actually has: the seven specialised scopes plus the generic
* `metric:<ID>` scope of every measurement type with live rows. The
* worker regenerates each enqueued scope with `force: true`, so every
* card skips its same-day cache read, re-gathers its snapshot, and lands
* on the content-hash gate — a card whose data changed regenerates, an
* unchanged card gets a free timestamp refresh.
*
* This is the manual-regenerate path's card story: the POST regenerate
* used to blanket-evict every per-status row, which deleted the hash
* baselines and force-paid ~45 regenerations per click. Enqueuing through
* the gate keeps the baseline rows intact, so a user who noticed a stale
* card gets exactly the changed cards re-narrated — and nothing else.
* Deliberately NOT routed through the ingest debounce: an explicit
* regenerate is a user action, already bounded by the route's hourly
* rate limit and the queue's per-(user,scope,locale) singleton.
*
* Returns the number of scopes enqueued (best-effort — the generic-scope
* discovery read failing still refills the seven specialised cards).
*/
export async function enqueueStatusRefillForUser(
userId: string,
locale: "de" | "en",
): Promise<number> {
const scopes = new Set<InsightStatusScope>(PER_STATUS_SCOPES);
try {
// `groupBy` compiles to a server-side `GROUP BY` — Prisma's
// `distinct` dedups in the client AFTER pulling every live row, which
// on a dense multi-year account walks a six-figure row set to answer
// "which types exist?" on the request path.
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null },
});
for (const row of rows) {
const metricId = metricIdForMeasurementType(row.type);
if (metricId) scopes.add(metricStatusScope(metricId));
}
} catch {
// Discovery is best-effort; the specialised scopes still refill.
}
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
return scopes.size;
}
/**
* v1.9.0 — return the subset of `scopes` whose cached assessment for
* `locale` was generated within `INGEST_INVALIDATE_MIN_GAP_MS` and is a
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
* One indexed read per user (a single `findMany` over this user's recent
* status-cache rows, newest-first) answers it for every candidate scope at
* once — cheaper than a per-scope probe and bounded by `take`. A timeout
* stub never counts as fresh (it carries no real assessment), so a scope
* that recently stalled still gets a retry enqueued.
*/
async function findRecentlyWarmedScopes(
userId: string,
locale: "de" | "en",
scopes: ReadonlySet<InsightStatusScope>,
): Promise<Set<InsightStatusScope>> {
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_MIN_GAP_MS);
// Match exactly the cache actions for the candidate scopes in this locale.
const candidateActions = Array.from(scopes, (scope) =>
statusCacheAction(scope, locale),
);
const actionToScope = new Map<string, InsightStatusScope>();
for (const scope of scopes) {
actionToScope.set(statusCacheAction(scope, locale), scope);
}
const rows = await prisma.auditLog.findMany({
where: {
userId,
action: { in: candidateActions },
createdAt: { gte: cutoff },
},
orderBy: { createdAt: "desc" },
select: { action: true, details: true },
});
const fresh = new Set<InsightStatusScope>();
for (const row of rows) {
const scope = actionToScope.get(row.action);
if (!scope || fresh.has(scope)) continue;
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as {
model?: string;
timeout?: boolean;
text?: string;
};
// A stub is not a real assessment — let a stalled scope retry.
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
fresh.add(scope);
} catch {
// Malformed payload — treat as not-fresh so the scope refreshes.
continue;
}
}
return fresh;
}
File diff suppressed because it is too large. Load diff