mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta.
608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
/**
|
|
* Server-side aggregator for doctor-report data.
|
|
*
|
|
* Single source of truth for the aggregated payload consumed by both
|
|
* `/api/doctor-report` (JSON, client renders PDF) and
|
|
* `/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.
|
|
*/
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
getEffectiveRange,
|
|
type ThresholdOverridesJson,
|
|
} from "@/lib/analytics/effective-range";
|
|
import {
|
|
resolveGlucoseUnit,
|
|
thresholdMetricForContext,
|
|
type GlucoseUnit,
|
|
} from "@/lib/glucose";
|
|
import type { GlucoseContext } from "@/generated/prisma/client";
|
|
import {
|
|
DEFAULT_DOCTOR_REPORT_PREFS,
|
|
type DoctorReportPrefs,
|
|
} from "@/lib/validations/doctor-report-prefs";
|
|
|
|
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;
|
|
};
|
|
/**
|
|
* 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;
|
|
bmi: number | null;
|
|
compliance: Record<string, DoctorReportCompliance>;
|
|
medications: Array<{
|
|
name: string;
|
|
dose: string;
|
|
schedules: Array<{
|
|
windowStart: string;
|
|
windowEnd: string;
|
|
label: string | 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;
|
|
}
|
|
|
|
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
|
"FASTING",
|
|
"POSTPRANDIAL",
|
|
"RANDOM",
|
|
"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;
|
|
|
|
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(/[ |