mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
chore: remove production files that only tests reached
Four files under src had no production importer: - the keyset measurement reader for backups: both backup writers moved to the shared account copy and the streamed full backup, and its header still claimed both used it; - the HOMA-IR calculator and the mood stability label, never wired to a surface; - the backup verdict helper, which only the backup classification guard called. It now lives in that guard. The queue expiry table is what the job queue guards check the registry against and nothing in the worker reads it, so it moves to the test helpers. The version-stamp baseline drops the deleted files.
This commit is contained in:
1 parent
655a1d65a5
commit
fc1fadcdab
15 files changed
+25
-537
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
File renamed without changes.
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<string, unknown>> };
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Row[]>;
|
||||
project: (row: Row) => Out;
|
||||
pageSize?: number;
|
||||
}): Promise<Out[]> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user