mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #471 from MBombeck/release/v1.28.26
release/v1.28.26 — internal restructuring for maintainability
This commit is contained in:
34 files changed
+8427
-7711
No files matched your search
@@ -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,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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+96
-894
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
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+15
-1146
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+46
-1505
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user