diff --git a/src/__tests__/ai-job-expiry-guard.test.ts b/src/__tests__/ai-job-expiry-guard.test.ts index 65197af2d..e7b7cc5a5 100644 --- a/src/__tests__/ai-job-expiry-guard.test.ts +++ b/src/__tests__/ai-job-expiry-guard.test.ts @@ -51,7 +51,7 @@ import { stripComments, walkSourceFiles, } from "./helpers/source-files"; -import { QUEUE_RUNTIME, type QueueRuntime } from "@/lib/jobs/queue-runtime"; +import { QUEUE_RUNTIME, type QueueRuntime } from "./helpers/queue-runtime"; import { COMPLETION_CEILING_MS, COMPREHENSIVE_WARM_CEILING_MS, diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index c62089f7d..9f93c188a 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -10,7 +10,23 @@ import { NOT_IN_BACKUP_MODELS, RAW_SQL_TABLES, } from "@/lib/export/backup-plan"; -import { backupVerdict } from "@/lib/export/backup-verdict"; + +type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; + +/** + * The verdict for a model, or `null` when the schema carries a model the plan + * has never ruled on. The guard below turns that `null` into a failing test: + * a new model gets a decision, not a default. + */ +function backupVerdict(model: string): BackupVerdict | null { + if ((BACKED_UP_MODELS as readonly string[]).includes(model)) { + return "BACKED_UP"; + } + if (model in DERIVED_MODELS) return "DERIVED"; + if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP"; + if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH"; + return null; +} /** * Every model in the schema has a written verdict about the backup. diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 642303b6b..0afbd80b7 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2884, - "total": 9191, + "count": 2881, + "total": 9188, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2368,7 +2368,6 @@ "lib/labs/__tests__/note-update.test.ts": 1, "lib/labs/biomarker-catalog.ts": 10, "lib/labs/biomarker-store.ts": 2, - "lib/labs/homa-ir.ts": 1, "lib/labs/lab-trend.ts": 1, "lib/labs/local-ocr.ts": 1, "lib/labs/note-update.ts": 1, @@ -2769,11 +2768,9 @@ "lib/sync/cursor.ts": 1, "lib/targets/__tests__/builders.test.ts": 1, "lib/targets/__tests__/glucose-targets.test.ts": 1, - "lib/targets/__tests__/mood-stability-label.test.ts": 1, "lib/targets/__tests__/source-link.test.ts": 1, "lib/targets/__tests__/target-unit-display.test.ts": 1, "lib/targets/build-response.ts": 3, - "lib/targets/mood-stability-label.ts": 1, "lib/targets/target-unit-display.ts": 2, "lib/targets/types.ts": 2, "lib/targets/vitals-builder.ts": 2, diff --git a/src/lib/jobs/queue-runtime.ts b/src/__tests__/helpers/queue-runtime.ts similarity index 100% rename from src/lib/jobs/queue-runtime.ts rename to src/__tests__/helpers/queue-runtime.ts diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts index cfc4de732..854c980e0 100644 --- a/src/__tests__/job-queue-runtime-guard.test.ts +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -8,7 +8,7 @@ * so could every other job that legitimately needs longer: the nightly status * passes, the pre-generation, the record scan, the key rotation, the backups. * - * `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or + * `helpers/queue-runtime.ts` declares every queue `short`, `budgeted` or * `long`. This guard holds the tree to it: * * - every queue the worker registry declares (`jobs/registry/`) is in the @@ -41,7 +41,7 @@ import { DEFAULT_JOB_EXPIRE_SECONDS, QUEUE_RUNTIME, type QueueRuntime, -} from "@/lib/jobs/queue-runtime"; +} from "./helpers/queue-runtime"; import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared"; import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane"; import type { JobDefinition } from "@/lib/jobs/registry/define-job"; diff --git a/src/lib/export/backup-verdict.ts b/src/lib/export/backup-verdict.ts deleted file mode 100644 index 6fbaf8ea6..000000000 --- a/src/lib/export/backup-verdict.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The backup verdict for one schema model, read off the lists in - * `./backup-plan.ts`. Kept beside it rather than in it so the plan stays the - * lists and their reasons, and the one question asked of them lives here. - */ -import { - AUTH_MODELS_OUT_OF_SCOPE, - BACKED_UP_MODELS, - DERIVED_MODELS, - NOT_IN_BACKUP_MODELS, -} from "./backup-plan"; - -export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH"; - -/** - * The verdict for a model, or `null` when the schema carries a model this file - * has never ruled on. The guard turns that `null` into a failing test, which - * is the whole point: a new model gets a decision, not a default. - */ -export function backupVerdict(model: string): BackupVerdict | null { - if ((BACKED_UP_MODELS as readonly string[]).includes(model)) { - return "BACKED_UP"; - } - if (model in DERIVED_MODELS) return "DERIVED"; - if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP"; - if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH"; - return null; -} diff --git a/src/lib/jobs/__tests__/morning-digest-refresh.test.ts b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts index 64dd37bf6..6c365eb93 100644 --- a/src/lib/jobs/__tests__/morning-digest-refresh.test.ts +++ b/src/lib/jobs/__tests__/morning-digest-refresh.test.ts @@ -6,7 +6,7 @@ import { COMPREHENSIVE_WARM_CEILING_MS, comprehensiveWarmBudgetMs, } from "../model-time"; -import { QUEUE_RUNTIME } from "../queue-runtime"; +import { QUEUE_RUNTIME } from "@/__tests__/helpers/queue-runtime"; import { COMPREHENSIVE_MODEL_CALLS } from "@/lib/ai/ai-budgets"; import { AI_RESPONSE_TIMEOUT_MAX_SECONDS } from "@/lib/ai/effective-timeout"; import type { GenerateOutcome } from "@/lib/insights/comprehensive-generate"; diff --git a/src/lib/jobs/__tests__/restore-drill.test.ts b/src/lib/jobs/__tests__/restore-drill.test.ts index f72469026..fe6a05f0c 100644 --- a/src/lib/jobs/__tests__/restore-drill.test.ts +++ b/src/lib/jobs/__tests__/restore-drill.test.ts @@ -8,7 +8,7 @@ import { RESTORE_DRILL_QUEUE, RESTORE_DRILL_SEND_OPTIONS, } from "@/lib/jobs/restore-drill-queue"; -import { QUEUE_RUNTIME } from "../queue-runtime"; +import { QUEUE_RUNTIME } from "@/__tests__/helpers/queue-runtime"; vi.mock("../report-worker-error", () => ({ reportWorkerError: vi.fn().mockResolvedValue(undefined), diff --git a/src/lib/jobs/backup/__tests__/measurement-page.test.ts b/src/lib/jobs/backup/__tests__/measurement-page.test.ts deleted file mode 100644 index fa668c0ac..000000000 --- a/src/lib/jobs/backup/__tests__/measurement-page.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Round-trip + paging contract for the backup measurement reader. - * - * Runs without a database: the keyset pager takes a `fetchPage` callback, - * so an in-memory fixture stands in for Prisma. This pins the two things - * the heap-bounding refactor must not break: - * 1. the keyset scan visits every row exactly once, in id order, - * across page boundaries and the exact-multiple edge case; - * 2. a measurement survives the full serialize → encrypt-blob shape → - * `parseBackupPayload` → restore-mapping round-trip unchanged, - * including a soft-deleted (tombstoned) row, which both writers keep. - */ -import { describe, it, expect } from "vitest"; -import { - collectPagedMeasurements, - toWeeklyBackupMeasurement, - sortWeeklyMeasurementsDesc, - type WeeklyMeasurementRow, -} from "@/lib/jobs/backup/measurement-page"; -import { parseBackupPayload } from "@/lib/validations/backup"; - -/** Build an id-ascending fixture; `id` is zero-padded so string order == insert order. */ -function makeRows(n: number): WeeklyMeasurementRow[] { - return Array.from({ length: n }, (_, i) => ({ - id: `m${String(i).padStart(4, "0")}`, - type: "WEIGHT", - value: 70 + i, - unit: "kg", - source: "MANUAL", - measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 8, 0, 0)), - notes: i % 2 === 0 ? `note ${i}` : null, - // notesEncrypted left null so readNote returns the plaintext fallback - // and the pure test needs no encryption key. - notesEncrypted: null, - })); -} - -/** In-memory keyset fetcher over an id-sorted fixture. */ -function fakeFetcher(rows: WeeklyMeasurementRow[]) { - const sorted = [...rows].sort((a, b) => - a.id < b.id ? -1 : a.id > b.id ? 1 : 0, - ); - return (afterId: string | null, take: number) => { - const start = afterId ? sorted.findIndex((r) => r.id === afterId) + 1 : 0; - return Promise.resolve(sorted.slice(start, start + take)); - }; -} - -describe("collectPagedMeasurements", () => { - it("visits every row exactly once, in id order, across pages", async () => { - const rows = makeRows(25); - const out = await collectPagedMeasurements({ - fetchPage: fakeFetcher(rows), - project: (r) => r.id, - pageSize: 10, - }); - expect(out).toEqual(rows.map((r) => r.id)); - expect(new Set(out).size).toBe(out.length); - }); - - it("terminates on the exact-multiple boundary without dropping rows", async () => { - const rows = makeRows(20); - const out = await collectPagedMeasurements({ - fetchPage: fakeFetcher(rows), - project: (r) => r.id, - pageSize: 10, - }); - expect(out).toHaveLength(20); - expect(out[out.length - 1]).toBe("m0019"); - }); - - it("handles an empty set", async () => { - const out = await collectPagedMeasurements({ - fetchPage: fakeFetcher([]), - project: (r) => r.id, - pageSize: 10, - }); - expect(out).toEqual([]); - }); -}); - -describe("weekly backup measurement round-trip", () => { - it("serialize → parseBackupPayload → restore-map equals originals", async () => { - const rows: WeeklyMeasurementRow[] = [ - { - id: "m0001", - type: "WEIGHT", - value: 82.4, - unit: "kg", - source: "MANUAL", - measuredAt: new Date("2026-02-10T07:30:00.000Z"), - notes: "morning", - notesEncrypted: null, - }, - { - id: "m0002", - type: "PULSE", - value: 58, - unit: "bpm", - source: "WITHINGS", - measuredAt: new Date("2026-02-11T06:00:00.000Z"), - notes: null, - notesEncrypted: null, - }, - { - // A tombstoned row: the weekly writer applies no deletedAt filter, - // so a soft-deleted measurement is still backed up. The narrow - // weekly projection does not carry deletedAt (the restore does not - // recreate it), but the row must still appear in the payload. - id: "m0003", - type: "BLOOD_GLUCOSE", - value: 95, - unit: "mg/dL", - source: "IMPORT", - measuredAt: new Date("2026-02-12T12:00:00.000Z"), - notes: "fasting", - notesEncrypted: null, - }, - ]; - - const measurements = sortWeeklyMeasurementsDesc( - await collectPagedMeasurements({ - fetchPage: fakeFetcher(rows), - project: toWeeklyBackupMeasurement, - pageSize: 2, - }), - ); - - // Same envelope the writer produces, then parse it back exactly as the - // admin restore route does. - const backupJson = JSON.stringify({ - schemaVersion: "1", - exportedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(), - userId: "user-1", - measurements, - medications: [], - intakeEvents: [], - moodEntries: [], - }); - const payload = parseBackupPayload(backupJson); - - expect(payload.measurements).toHaveLength(rows.length); - - // measuredAt-desc ordering preserved (newest first). - expect(payload.measurements.map((m) => m.measuredAt)).toEqual([ - "2026-02-12T12:00:00.000Z", - "2026-02-11T06:00:00.000Z", - "2026-02-10T07:30:00.000Z", - ]); - - // Every original row survives the round-trip by value (the tombstoned - // m0003 included). - for (const original of rows) { - const restored = payload.measurements.find( - (m) => m.measuredAt === original.measuredAt.toISOString(), - ); - expect(restored, `row ${original.id} missing from payload`).toBeDefined(); - expect(restored!.type).toBe(original.type); - expect(restored!.value).toBe(original.value); - expect(restored!.unit).toBe(original.unit); - expect(restored!.source).toBe(original.source); - expect(restored!.notes ?? null).toBe(original.notes ?? null); - } - }); -}); - -describe("offhost backup measurement round-trip", () => { - it("full-row JSON dump → parse preserves all columns incl. soft-deleted", async () => { - // The off-host DR dump keeps the full row shape (identity projection). - // Mirror the writer's select output for a live row and a tombstoned row. - const liveRow = { - id: "m0001", - userId: "user-1", - type: "WEIGHT", - value: 80.1, - valueMin: null, - valueMax: null, - unit: "kg", - source: "MANUAL", - measuredAt: new Date("2026-03-01T07:00:00.000Z"), - notes: null, - notesEncrypted: null, - externalId: null, - externalSourceVersion: null, - glucoseContext: null, - sleepStage: null, - rhythmClassification: null, - deviceType: "scale", - syncVersion: 1, - deletedAt: null, - createdAt: new Date("2026-03-01T07:00:01.000Z"), - updatedAt: new Date("2026-03-01T07:00:01.000Z"), - }; - const tombstonedRow = { - ...liveRow, - id: "m0002", - value: 81.2, - measuredAt: new Date("2026-03-02T07:00:00.000Z"), - deletedAt: new Date("2026-03-03T09:00:00.000Z"), - }; - - const measurements = await collectPagedMeasurements({ - fetchPage: fakeFetcher([ - liveRow, - tombstonedRow, - ] as unknown as WeeklyMeasurementRow[]), - project: (row) => row, - pageSize: 1, - }); - - const payload = JSON.parse( - JSON.stringify({ - exportedAt: new Date("2026-03-04T00:00:00.000Z").toISOString(), - userId: "user-1", - measurements, - medications: [], - intakeEvents: [], - moodEntries: [], - cycleProfile: null, - cycles: [], - cycleDayLogs: [], - }), - ) as { measurements: Array> }; - - expect(payload.measurements).toHaveLength(2); - - // The soft-deleted row survives with its tombstone instant intact — - // the DR snapshot must include it. - const restoredTombstone = payload.measurements.find( - (m) => m.id === "m0002", - )!; - expect(restoredTombstone.deletedAt).toBe("2026-03-03T09:00:00.000Z"); - - // Every scalar column the writer selected is present on the dump. - const expectedKeys = Object.keys(liveRow).sort(); - expect(Object.keys(payload.measurements[0]).sort()).toEqual(expectedKeys); - }); -}); diff --git a/src/lib/jobs/backup/measurement-page.ts b/src/lib/jobs/backup/measurement-page.ts deleted file mode 100644 index abbf1ab95..000000000 --- a/src/lib/jobs/backup/measurement-page.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Keyset-paged measurement reader shared by the two backup writers. - * - * Both writers — the weekly on-host `DataBackup` snapshot - * (`reminder/backup-handlers.ts`) and the nightly off-host DR dump - * (`offhost-backup.ts`) — used to load a user's ENTIRE measurement set - * as full ORM rows into one array and then run a single - * `JSON.stringify` over the whole payload. On a heavy multi-year tenant - * that holds every measurement row plus the giant serialized string in - * memory at the same instant, which is the dominant heap spike of either - * job and scales linearly with history depth. - * - * This reader walks the set in id-ascending keyset pages: at most one - * page of rows is resident at a time, and each page is projected to its - * compact backup shape before the next page is fetched. Transient peak - * heap is bounded by the page size, not by the size of the user's - * history. The accumulated output is the compact projection the payload - * needs anyway — not the wide ORM rows. - * - * Keyset (cursor on `id`) rather than `skip`/`take` offset paging so the - * scan stays O(rows) instead of O(rows^2) as the offset grows, and so a - * concurrent insert can't shift rows across a page boundary. - */ -import { readNote } from "@/lib/crypto/note-cipher"; - -/** - * Page size for the backup keyset scan. Large enough that the per-page - * round-trip overhead stays negligible, small enough that a single page - * of rows is a bounded slice of any tenant's history. - */ -export const MEASUREMENT_BACKUP_PAGE_SIZE = 2000; - -/** - * Walk a keyset-paged result set, projecting each row to its compact - * backup shape as it arrives. `fetchPage` returns the next page of rows - * after `afterId` (exclusive, id-ascending), bounded to `take` rows; - * returning fewer than `take` rows signals the final page. The generic - * stays free of any Prisma dependency so the paging contract is unit - * testable without a database. - */ -export async function collectPagedMeasurements< - Row extends { id: string }, - Out, ->(args: { - fetchPage: (afterId: string | null, take: number) => Promise; - project: (row: Row) => Out; - pageSize?: number; -}): Promise { - const { fetchPage, project } = args; - const pageSize = args.pageSize ?? MEASUREMENT_BACKUP_PAGE_SIZE; - const out: Out[] = []; - let afterId: string | null = null; - for (;;) { - const page = await fetchPage(afterId, pageSize); - if (page.length === 0) break; - for (const row of page) out.push(project(row)); - if (page.length < pageSize) break; - afterId = page[page.length - 1].id; - } - return out; -} - -/** - * Narrow row shape the weekly snapshot reads — exactly the columns the - * admin restore path recreates a measurement from - * (`api/admin/backups/[id]/restore`): `type`, `value`, `unit`, `source`, - * `measuredAt`, plus the note pair so the decrypted note rides into the - * (whole-blob-encrypted) payload. `id` rides along only as the keyset - * cursor. - */ -export interface WeeklyMeasurementRow { - id: string; - type: string; - value: number; - unit: string; - source: string; - measuredAt: Date; - notes: string | null; - notesEncrypted: Uint8Array | null; -} - -/** Compact measurement shape the weekly `DataBackup` JSON carries. */ -export interface WeeklyBackupMeasurement { - type: string; - value: number; - unit: string; - measuredAt: string; - source: string; - notes: string | null; -} - -/** - * Project a narrow measurement row into the weekly backup shape. The - * note is decrypted here so the restore re-encrypts on re-insert — the - * same contract the previous inline writer used. Shared with the writer - * so the round-trip test exercises the real projection. - */ -export function toWeeklyBackupMeasurement( - row: WeeklyMeasurementRow, -): WeeklyBackupMeasurement { - return { - type: row.type, - value: row.value, - unit: row.unit, - measuredAt: row.measuredAt.toISOString(), - source: row.source, - notes: readNote(row.notesEncrypted, row.notes), - }; -} - -/** - * Order the collected weekly measurements newest-first to match the - * pre-paging `orderBy: { measuredAt: "desc" }` output. The keyset scan - * reads id-ascending for a stable cursor; this restores the historical - * array order. Operates on the compact projections, so it does not - * reintroduce the wide-row heap spike. ISO-8601 strings sort - * lexicographically in chronological order, so a string compare is the - * chronological compare. - */ -export function sortWeeklyMeasurementsDesc( - measurements: WeeklyBackupMeasurement[], -): WeeklyBackupMeasurement[] { - return measurements.sort((a, b) => - a.measuredAt < b.measuredAt ? 1 : a.measuredAt > b.measuredAt ? -1 : 0, - ); -} diff --git a/src/lib/labs/__tests__/homa-ir.test.ts b/src/lib/labs/__tests__/homa-ir.test.ts deleted file mode 100644 index 205827936..000000000 --- a/src/lib/labs/__tests__/homa-ir.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { computeHomaIr, classifyHomaIr } from "@/lib/labs/homa-ir"; - -describe("computeHomaIr", () => { - it("computes the conventional-unit formula (glucose × insulin ÷ 405)", () => { - // 90 mg/dL × 5 µIU/mL ÷ 405 = 1.111… - expect(computeHomaIr(90, 5)).toBeCloseTo(1.1111, 3); - }); - - it("matches the SI form within rounding (mmol/L × insulin ÷ 22.5)", () => { - // 100 mg/dL = 5.556 mmol/L; SI: 5.556 × 8 ÷ 22.5 ≈ 1.975; conv: 100×8÷405 ≈ 1.975 - const conv = computeHomaIr(100, 8)!; - const si = (5.5556 * 8) / 22.5; - expect(conv).toBeCloseTo(si, 2); - }); - - it("returns null for missing or non-positive inputs", () => { - expect(computeHomaIr(null, 5)).toBeNull(); - expect(computeHomaIr(90, undefined)).toBeNull(); - expect(computeHomaIr(0, 5)).toBeNull(); - expect(computeHomaIr(90, -1)).toBeNull(); - expect(computeHomaIr(Number.NaN, 5)).toBeNull(); - }); -}); - -describe("classifyHomaIr", () => { - it("places values in the documented bands", () => { - expect(classifyHomaIr(0.8)).toBe("optimal"); - expect(classifyHomaIr(1.5)).toBe("intermediate"); - expect(classifyHomaIr(2.5)).toBe("elevated"); - expect(classifyHomaIr(3.5)).toBe("high"); - }); -}); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 3cb8058c1..40255cf41 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -104,8 +104,7 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [ upperBound: 5.7, }, // v1.25 — longevity panel: fasting insulin (metabolically-optimal often - // cited < 8 µIU/mL; typical reference ~2.5–13). HOMA-IR is derived from - // fasting glucose × insulin (see `homa-ir.ts`), not a stored catalog marker. + // cited < 8 µIU/mL; typical reference ~2.5–13). { slug: "fasting-insulin", panel: "glucose", diff --git a/src/lib/labs/homa-ir.ts b/src/lib/labs/homa-ir.ts deleted file mode 100644 index 289f15e9d..000000000 --- a/src/lib/labs/homa-ir.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * v1.25 — HOMA-IR (Homeostatic Model Assessment of Insulin Resistance). - * - * A computed longevity marker derived from fasting glucose + fasting insulin — - * NOT a stored biomarker catalog entry (BMI's derived-value precedent). Server- - * authoritative per the parity rule: compute it here, never recompute on the - * client. Pure + side-effect-free so it is trivially testable. - * - * Conventional-unit formula (fasting glucose mg/dL × fasting insulin µIU/mL ÷ - * 405) and the SI form (mmol/L × µIU/mL ÷ 22.5) are equivalent - * (mg/dL ÷ 18 = mmol/L; 405 = 22.5 × 18). Population-dependent interpretive - * bands (optimal < 1.0, early IR > 1.9, significant IR > 2.9) are applied at the - * display edge, never asserted here. [HOMA-IR, PMC5033570]. - */ - -/** Interpretive band for a HOMA-IR index value (population-dependent guidance). */ -export type HomaIrBand = "optimal" | "intermediate" | "elevated" | "high"; - -/** - * Compute HOMA-IR from fasting glucose (mg/dL) and fasting insulin (µIU/mL). - * Returns `null` when either input is missing or non-positive (the index is - * undefined for a zero/negative reading). - */ -export function computeHomaIr( - fastingGlucoseMgDl: number | null | undefined, - fastingInsulinUiuMl: number | null | undefined, -): number | null { - if ( - fastingGlucoseMgDl == null || - fastingInsulinUiuMl == null || - !Number.isFinite(fastingGlucoseMgDl) || - !Number.isFinite(fastingInsulinUiuMl) || - fastingGlucoseMgDl <= 0 || - fastingInsulinUiuMl <= 0 - ) { - return null; - } - return (fastingGlucoseMgDl * fastingInsulinUiuMl) / 405; -} - -/** Place a HOMA-IR value in its descriptive band (general guidance, not a diagnosis). */ -export function classifyHomaIr(value: number): HomaIrBand { - if (value < 1.0) return "optimal"; - if (value <= 1.9) return "intermediate"; - if (value <= 2.9) return "elevated"; - return "high"; -} diff --git a/src/lib/targets/__tests__/mood-stability-label.test.ts b/src/lib/targets/__tests__/mood-stability-label.test.ts deleted file mode 100644 index 9434a7fbc..000000000 --- a/src/lib/targets/__tests__/mood-stability-label.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { moodStabilityLabel } from "../mood-stability-label"; - -/** - * v1.4.25 — threshold contract for the verbal MOOD_STABILITY label. - * The card switches from raw σ to one of three buckets; the rule lives - * here so the boundaries can be unit-tested without React. - */ - -describe("moodStabilityLabel", () => { - it("returns 'stable' for σ below 1.0", () => { - expect(moodStabilityLabel(0)).toBe("stable"); - expect(moodStabilityLabel(0.42)).toBe("stable"); - expect(moodStabilityLabel(0.999)).toBe("stable"); - }); - - it("returns 'variable' for σ in [1.0, 2.0)", () => { - expect(moodStabilityLabel(1.0)).toBe("variable"); - expect(moodStabilityLabel(1.5)).toBe("variable"); - expect(moodStabilityLabel(1.999)).toBe("variable"); - }); - - it("returns 'highlyVariable' for σ ≥ 2.0", () => { - expect(moodStabilityLabel(2.0)).toBe("highlyVariable"); - expect(moodStabilityLabel(3.5)).toBe("highlyVariable"); - }); -}); diff --git a/src/lib/targets/mood-stability-label.ts b/src/lib/targets/mood-stability-label.ts deleted file mode 100644 index b9b026855..000000000 --- a/src/lib/targets/mood-stability-label.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * v1.4.25 — verbal label for the MOOD_STABILITY target headline. - * - * The card used to render the raw σ value ("0.42 σ"), which most users - * cannot interpret. Replace with a three-state verbal label keyed - * against translation entries `targets.mood.stability.{stable, - * variable,highlyVariable}`. - * - * Thresholds (maintainer-set, documented in the W3e implementation plan): - * σ < 1.0 → "stable" (stabil / stable) - * σ < 2.0 → "variable" (schwankend / variable) - * σ ≥ 2.0 → "highly-variable" (sehr schwankend / highly variable) - * - * Pure helper so the threshold logic can be unit-tested in isolation - * without React. Returns the stable English key — callers (the card, - * the Coach prompt builder) own the locale resolution. - */ -export type MoodStabilityLabel = "stable" | "variable" | "highlyVariable"; - -export function moodStabilityLabel(sigma: number): MoodStabilityLabel { - if (sigma < 1.0) return "stable"; - if (sigma < 2.0) return "variable"; - return "highlyVariable"; -}