Compare commits

...

14 Commits

Author SHA1 Message Date
marcuspaico
628e14fde4 Merge M2: blood-test labs pipeline — upload, LLM extraction, review gate, normalized biomarkers, dashboard
All checks were successful
CI / check (push) Successful in 1m14s
2026-08-17 16:26:34 -07:00
marcuspaico
a9256a765f fix(web): error handling on discard and history toggle
- LabDraft.tsx discard button: chain .catch to surface a failed discard in
  the existing error banner instead of an unhandled rejection.
- LabDraw.tsx toggleHistory: wrap the marker-history fetch in try/catch so a
  failed request silently no-ops (the row just doesn't expand) rather than
  throwing unhandled from the click handler.
2026-08-17 16:17:03 -07:00
marcuspaico
e81d07975a fix(labs): strict numeric parsing, atomic confirm guard, test integrity
- normalize.ts num(): parseFloat truncated at the first comma, so "1,200"
  silently became 1 (1000x error) and "5,5" became 5. Now strictly matches
  either US thousands-grouping or a plain number spanning the whole string;
  anything else (incl. ambiguous "5,5") returns null instead of a wrong value.
- labs.ts confirm handler: the pending-status check ran before the request
  body was read, so two concurrent confirms could both pass it and
  double-insert. Added a guarded UPDATE ... WHERE status = 'pending' as the
  first statement inside the existing synchronous transaction; zero rows
  affected throws and the route returns 409, with the fast-path check kept
  for the common case.
- Added missing `await` on two rejects.toThrow assertions (llm.test.ts,
  extract.test.ts) that were previously resolving before the assertion
  settled.
- Bumped the 11th-failed-login rate-limit test to a 30s timeout — 10
  sequential argon2id verifies can exceed bun:test's 5s default under load.
2026-08-17 16:17:03 -07:00
marcuspaico
876aa0f181 feat(security): CSP and hardening headers with upload serving
Implement Content-Security-Policy headers and strict content-type handling
for non-API responses, with x-content-type-options applied to all routes.
Adds security-headers test suite to verify header presence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:06:05 -07:00
marcuspaico
1aa600adfc feat(web): labs upload, draft review, draw dashboard with history sparklines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:01:59 -07:00
marcuspaico
7e70bd1940 feat(labs): draw list/detail, marker history, PDF file serving 2026-08-17 15:57:24 -07:00
marcuspaico
96692163f1 fix(labs): synchronous confirm transaction — async callback broke atomicity
bun-sqlite's Database.transaction is synchronous, but async callbacks return
a pending Promise at the first await, causing immediate COMMIT before the
entire callback completes. This allowed partial inserts with no rollback.

Fixed by:
- Remove async from transaction callback
- Add .run() to each insert/update to execute synchronously
- Add regression test proving atomicity: transaction that throws mid-loop
  rolls back all changes (both tables empty after failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:52:59 -07:00
marcuspaico
038c92af45 feat(labs): draft review, confirm with normalization, discard
- Add ConfirmDraftBody schema to shared types
- Implement GET /api/labs/drafts/:id (retrieves draft with extracted data)
- Implement POST /api/labs/drafts/:id/confirm (normalizes markers, inserts lab draw + biomarkers in transaction, marks draft confirmed)
- Implement POST /api/labs/drafts/:id/discard (marks draft discarded)
- Add comprehensive test suite with 3 new tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:46:10 -07:00
marcuspaico
8e9f3d5894 feat(labs): PDF upload, text extraction, LLM draft pipeline 2026-08-17 15:41:02 -07:00
marcuspaico
89394732fd feat(llm): OpenAI-compatible chatJSON helper with injectable fetch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:33:53 -07:00
marcuspaico
e0bb888115 fix(labs): remove BUN aliases from urea, correct DHEA-S molar mass 2026-08-17 15:28:13 -07:00
marcuspaico
ae7ec9d699 docs: fix DHEA-S molar mass in M2 plan (368.49, was 384.5) 2026-08-17 15:27:09 -07:00
marcuspaico
a08544db7d feat(labs): unit conversion, analyte registry, marker normalization 2026-08-17 15:23:34 -07:00
marcuspaico
bd4817cb77 feat(db): lab_drafts, lab_draws, biomarkers tables
Refs PAI-91.
2026-08-17 15:17:37 -07:00
35 changed files with 2064 additions and 10 deletions

View File

@@ -15,6 +15,7 @@
"@helios/shared": "workspace:*", "@helios/shared": "workspace:*",
"drizzle-orm": "^0.44.0", "drizzle-orm": "^0.44.0",
"hono": "^4.6.0", "hono": "^4.6.0",
"unpdf": "^1.8.1",
"zod": "^3.24.0", "zod": "^3.24.0",
}, },
"devDependencies": { "devDependencies": {
@@ -322,6 +323,8 @@
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="],
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],

View File

@@ -260,7 +260,7 @@ export function convert(value: number, fromUnit: string, toUnit: string, molarMa
`server/src/lib/analytes.ts` — registry with `key, display, canonicalUnit, molarMass, panel, aliases`. Canonical units are SI/molar where labs split between conventions. Include at minimum these analytes (panels: iron, hormones, thyroid, lipids, metabolic, inflammation, liver, kidney, cbc, vitamins, electrolytes): `server/src/lib/analytes.ts` — registry with `key, display, canonicalUnit, molarMass, panel, aliases`. Canonical units are SI/molar where labs split between conventions. Include at minimum these analytes (panels: iron, hormones, thyroid, lipids, metabolic, inflammation, liver, kidney, cbc, vitamins, electrolytes):
iron, ferritin, transferrin, tibc, transferrin_saturation; testosterone_total (ng/dL, 288.42), testosterone_free, shbg, dhea_s (µmol/L, 384.5), cortisol (nmol/L, 362.46), estradiol (pmol/L, 272.38), prolactin, fsh, lh, insulin (µIU/mL), igf1; tsh (mIU/L), free_t4 (pmol/L), free_t3 (pmol/L); cholesterol_total (mmol/L, 386.65), hdl (mmol/L, 386.65), ldl (mmol/L, 386.65), triglycerides (mmol/L, 885.4), non_hdl, apob (g/L), lipoprotein_a (nmol/L); glucose (mmol/L, 180.16), hba1c (%), uric_acid (mmol/L, 168.11); hs_crp (mg/L); alt (U/L), ast (U/L), ggt (U/L), alp (U/L), bilirubin_total (µmol/L, 584.66), albumin (g/L), total_protein (g/L); creatinine (µmol/L, 113.12), egfr (mL/min/1.73m2), urea (mmol/L, 60.06); hemoglobin (g/L), hematocrit (%), wbc (x10^9/L), rbc (x10^12/L), platelets (x10^9/L), mcv (fL — kind "other", no conversion), neutrophils (x10^9/L), lymphocytes (x10^9/L); vitamin_d (nmol/L, 400.64), vitamin_b12 (pmol/L, 1355.4), folate (nmol/L, 441.4); sodium (mmol/L), potassium (mmol/L), calcium (mmol/L, 40.08), magnesium (mmol/L, 24.31), zinc (µmol/L, 65.38). iron, ferritin, transferrin, tibc, transferrin_saturation; testosterone_total (ng/dL, 288.42), testosterone_free, shbg, dhea_s (µmol/L, 368.49), cortisol (nmol/L, 362.46), estradiol (pmol/L, 272.38), prolactin, fsh, lh, insulin (µIU/mL), igf1; tsh (mIU/L), free_t4 (pmol/L), free_t3 (pmol/L); cholesterol_total (mmol/L, 386.65), hdl (mmol/L, 386.65), ldl (mmol/L, 386.65), triglycerides (mmol/L, 885.4), non_hdl, apob (g/L), lipoprotein_a (nmol/L); glucose (mmol/L, 180.16), hba1c (%), uric_acid (mmol/L, 168.11); hs_crp (mg/L); alt (U/L), ast (U/L), ggt (U/L), alp (U/L), bilirubin_total (µmol/L, 584.66), albumin (g/L), total_protein (g/L); creatinine (µmol/L, 113.12), egfr (mL/min/1.73m2), urea (mmol/L, 60.06); hemoglobin (g/L), hematocrit (%), wbc (x10^9/L), rbc (x10^12/L), platelets (x10^9/L), mcv (fL — kind "other", no conversion), neutrophils (x10^9/L), lymphocytes (x10^9/L); vitamin_d (nmol/L, 400.64), vitamin_b12 (pmol/L, 1355.4), folate (nmol/L, 441.4); sodium (mmol/L), potassium (mmol/L), calcium (mmol/L, 40.08), magnesium (mmol/L, 24.31), zinc (µmol/L, 65.38).
Aliases: include common lab spellings per analyte (e.g. glucose: "glucose", "glucose fasting", "fasting glucose"; hba1c: "hba1c", "hemoglobin a1c", "haemoglobin a1c"; testosterone_total: "testosterone", "total testosterone", "testosterone total"; vitamin_d: "vitamin d", "25-oh vitamin d", "vitamin d 25-hydroxy", "25-hydroxyvitamin d"; etc. — one sensible alias set per analyte, lowercased). Aliases: include common lab spellings per analyte (e.g. glucose: "glucose", "glucose fasting", "fasting glucose"; hba1c: "hba1c", "hemoglobin a1c", "haemoglobin a1c"; testosterone_total: "testosterone", "total testosterone", "testosterone total"; vitamin_d: "vitamin d", "25-oh vitamin d", "vitamin d 25-hydroxy", "25-hydroxyvitamin d"; etc. — one sensible alias set per analyte, lowercased).

View File

@@ -0,0 +1,33 @@
CREATE TABLE `biomarkers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`draw_id` text NOT NULL,
`panel` text NOT NULL,
`name` text NOT NULL,
`marker` text NOT NULL,
`analyte_key` text,
`value` text NOT NULL,
`value_num` real,
`unit` text,
`reference_range` text,
`flagged` integer DEFAULT 0 NOT NULL,
`value_canonical` real,
`canonical_unit` text
);
--> statement-breakpoint
CREATE TABLE `lab_drafts` (
`id` text PRIMARY KEY NOT NULL,
`filename` text NOT NULL,
`file_path` text NOT NULL,
`status` text NOT NULL,
`extracted` text,
`error` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `lab_draws` (
`id` text PRIMARY KEY NOT NULL,
`collected_at` text NOT NULL,
`lab_name` text,
`draft_id` text,
`created_at` integer NOT NULL
);

View File

@@ -0,0 +1,279 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8d0cf0e5-668d-4526-a0cb-5a264d813be3",
"prevId": "383d7bd7-3ebf-456e-8c5c-f39cebab14c4",
"tables": {
"biomarkers": {
"name": "biomarkers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"draw_id": {
"name": "draw_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"panel": {
"name": "panel",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"marker": {
"name": "marker",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"analyte_key": {
"name": "analyte_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value_num": {
"name": "value_num",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"unit": {
"name": "unit",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reference_range": {
"name": "reference_range",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flagged": {
"name": "flagged",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"value_canonical": {
"name": "value_canonical",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canonical_unit": {
"name": "canonical_unit",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lab_drafts": {
"name": "lab_drafts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"extracted": {
"name": "extracted",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lab_draws": {
"name": "lab_draws",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"collected_at": {
"name": "collected_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lab_name": {
"name": "lab_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"draft_id": {
"name": "draft_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1787000672259, "when": 1787000672259,
"tag": "0000_init", "tag": "0000_init",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1787005028172,
"tag": "0001_labs",
"breakpoints": true
} }
] ]
} }

View File

@@ -5,7 +5,11 @@
"@helios/shared": "workspace:*", "@helios/shared": "workspace:*",
"drizzle-orm": "^0.44.0", "drizzle-orm": "^0.44.0",
"hono": "^4.6.0", "hono": "^4.6.0",
"unpdf": "^1.8.1",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" } "devDependencies": {
"bun-types": "latest",
"drizzle-kit": "^0.31.0"
}
} }

View File

@@ -3,13 +3,23 @@ import { getCookie } from "hono/cookie";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import type { Db } from "./db"; import type { Db } from "./db";
import { authRoutes, isAuthenticated } from "./routes/auth"; import { authRoutes, isAuthenticated } from "./routes/auth";
import { labsRoutes } from "./routes/labs";
import { settingsRoutes } from "./routes/settings"; import { settingsRoutes } from "./routes/settings";
export type Deps = { db: Db; key: Buffer }; export type Deps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch };
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]); const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'";
export function createApp(deps: Deps) { export function createApp(deps: Deps) {
const app = new Hono(); const app = new Hono();
app.use("*", async (c, next) => {
await next();
c.header("x-content-type-options", "nosniff");
if (!c.req.path.startsWith("/api/")) {
c.header("content-security-policy", CSP);
c.header("referrer-policy", "no-referrer");
}
});
app.get("/api/health", (c) => c.json({ ok: true })); app.get("/api/health", (c) => c.json({ ok: true }));
app.use("/api/*", async (c, next) => { app.use("/api/*", async (c, next) => {
if (PUBLIC.has(c.req.path)) return next(); if (PUBLIC.has(c.req.path)) return next();
@@ -20,6 +30,7 @@ export function createApp(deps: Deps) {
}); });
app.route("/api", authRoutes({ db: deps.db })); app.route("/api", authRoutes({ db: deps.db }));
app.route("/api", settingsRoutes(deps)); app.route("/api", settingsRoutes(deps));
app.route("/api", labsRoutes(deps));
// Later route groups (connectors, chat) mount here. // Later route groups (connectors, chat) mount here.
app.all("/api/*", (c) => c.json({ error: "not found" }, 404)); app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
app.use("/*", serveStatic({ root: "./web/dist" })); app.use("/*", serveStatic({ root: "./web/dist" }));

View File

@@ -1,4 +1,4 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {
key: text("key").primaryKey(), key: text("key").primaryKey(),
@@ -10,3 +10,37 @@ export const sessions = sqliteTable("sessions", {
createdAt: integer("created_at").notNull(), createdAt: integer("created_at").notNull(),
expiresAt: integer("expires_at").notNull(), expiresAt: integer("expires_at").notNull(),
}); });
export const labDrafts = sqliteTable("lab_drafts", {
id: text("id").primaryKey(),
filename: text("filename").notNull(),
filePath: text("file_path").notNull(),
status: text("status").notNull(), // pending | confirmed | discarded
extracted: text("extracted"),
error: text("error"),
createdAt: integer("created_at").notNull(),
});
export const labDraws = sqliteTable("lab_draws", {
id: text("id").primaryKey(),
collectedAt: text("collected_at").notNull(), // ISO date YYYY-MM-DD
labName: text("lab_name"),
draftId: text("draft_id"),
createdAt: integer("created_at").notNull(),
});
export const biomarkers = sqliteTable("biomarkers", {
id: integer("id").primaryKey({ autoIncrement: true }),
drawId: text("draw_id").notNull(),
panel: text("panel").notNull(),
name: text("name").notNull(),
marker: text("marker").notNull(),
analyteKey: text("analyte_key"),
value: text("value").notNull(),
valueNum: real("value_num"),
unit: text("unit"),
referenceRange: text("reference_range"),
flagged: integer("flagged").notNull().default(0),
valueCanonical: real("value_canonical"),
canonicalUnit: text("canonical_unit"),
});

View File

@@ -3,6 +3,6 @@ import { loadOrCreateKey } from "./lib/crypto";
import { createApp } from "./app"; import { createApp } from "./app";
const dataDir = process.env.DATA_DIR ?? "./data"; const dataDir = process.env.DATA_DIR ?? "./data";
const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) }); const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir), dataDir });
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };

519
server/src/lib/analytes.ts Normal file
View File

@@ -0,0 +1,519 @@
// Analyte registry: canonical units, molar masses, and panel groupings for
// lab markers. `molarMass` is only set when a mass<->mole bridge is actually
// used in practice for that analyte (see units.ts); it stays null for
// analytes reported purely by mass, by count, as a percentage/ratio, in
// enzyme-activity units (U/L, IU/L), or in units the converter treats as
// "other" (e.g. eGFR's mL/min/1.73m2, MCV's fL) — those never convert.
export interface Analyte {
key: string;
display: string;
canonicalUnit: string;
molarMass: number | null;
panel: string;
aliases: string[];
}
export const ANALYTES: Analyte[] = [
// --- iron ---
{
key: "iron",
display: "Iron",
canonicalUnit: "µmol/L",
molarMass: 55.845,
panel: "iron",
aliases: ["iron", "serum iron", "fe"],
},
{
key: "ferritin",
display: "Ferritin",
canonicalUnit: "µg/L",
molarMass: null,
panel: "iron",
aliases: ["ferritin", "serum ferritin"],
},
{
key: "transferrin",
display: "Transferrin",
canonicalUnit: "g/L",
molarMass: null,
panel: "iron",
aliases: ["transferrin"],
},
{
key: "tibc",
display: "Total Iron Binding Capacity",
canonicalUnit: "µmol/L",
molarMass: 55.845,
panel: "iron",
aliases: ["tibc", "total iron binding capacity", "total iron-binding capacity"],
},
{
key: "transferrin_saturation",
display: "Transferrin Saturation",
canonicalUnit: "%",
molarMass: null,
panel: "iron",
aliases: ["transferrin saturation", "iron saturation", "tsat", "% saturation", "percent saturation"],
},
// --- hormones ---
{
key: "testosterone_total",
display: "Total Testosterone",
canonicalUnit: "ng/dL",
molarMass: 288.42,
panel: "hormones",
aliases: ["testosterone", "total testosterone", "testosterone total", "testosterone, total"],
},
{
key: "testosterone_free",
display: "Free Testosterone",
canonicalUnit: "pg/mL",
molarMass: 288.42,
panel: "hormones",
aliases: ["free testosterone", "testosterone free", "testosterone, free"],
},
{
key: "shbg",
display: "SHBG",
canonicalUnit: "nmol/L",
molarMass: null,
panel: "hormones",
aliases: ["shbg", "sex hormone binding globulin", "sex hormone-binding globulin"],
},
{
key: "dhea_s",
display: "DHEA-S",
canonicalUnit: "µmol/L",
molarMass: 368.49,
panel: "hormones",
aliases: ["dhea-s", "dhea s", "dheas", "dehydroepiandrosterone sulfate"],
},
{
key: "cortisol",
display: "Cortisol",
canonicalUnit: "nmol/L",
molarMass: 362.46,
panel: "hormones",
aliases: ["cortisol", "cortisol am", "cortisol, am", "morning cortisol"],
},
{
key: "estradiol",
display: "Estradiol",
canonicalUnit: "pmol/L",
molarMass: 272.38,
panel: "hormones",
aliases: ["estradiol", "oestradiol", "e2"],
},
{
key: "prolactin",
display: "Prolactin",
canonicalUnit: "µg/L",
molarMass: null,
panel: "hormones",
aliases: ["prolactin", "prl"],
},
{
key: "fsh",
display: "FSH",
canonicalUnit: "IU/L",
molarMass: null,
panel: "hormones",
aliases: ["fsh", "follicle stimulating hormone", "follicle-stimulating hormone"],
},
{
key: "lh",
display: "LH",
canonicalUnit: "IU/L",
molarMass: null,
panel: "hormones",
aliases: ["lh", "luteinizing hormone"],
},
{
key: "insulin",
display: "Insulin",
canonicalUnit: "µIU/mL",
molarMass: null,
panel: "hormones",
aliases: ["insulin", "fasting insulin", "insulin fasting", "insulin, fasting"],
},
{
key: "igf1",
display: "IGF-1",
canonicalUnit: "µg/L",
molarMass: null,
panel: "hormones",
aliases: ["igf-1", "igf1", "igf 1", "insulin-like growth factor 1", "insulin like growth factor 1"],
},
// --- thyroid ---
{
key: "tsh",
display: "TSH",
canonicalUnit: "mIU/L",
molarMass: null,
panel: "thyroid",
aliases: ["tsh", "thyroid stimulating hormone", "thyroid-stimulating hormone"],
},
{
key: "free_t4",
display: "Free T4",
canonicalUnit: "pmol/L",
molarMass: null,
panel: "thyroid",
aliases: ["free t4", "ft4", "free thyroxine", "t4 free", "thyroxine free", "thyroxine, free"],
},
{
key: "free_t3",
display: "Free T3",
canonicalUnit: "pmol/L",
molarMass: null,
panel: "thyroid",
aliases: ["free t3", "ft3", "free triiodothyronine", "t3 free", "triiodothyronine free", "triiodothyronine, free"],
},
// --- lipids ---
{
key: "cholesterol_total",
display: "Total Cholesterol",
canonicalUnit: "mmol/L",
molarMass: 386.65,
panel: "lipids",
aliases: ["cholesterol", "total cholesterol", "cholesterol total", "cholesterol, total"],
},
{
key: "hdl",
display: "HDL Cholesterol",
canonicalUnit: "mmol/L",
molarMass: 386.65,
panel: "lipids",
aliases: ["hdl", "hdl cholesterol", "hdl-c", "cholesterol hdl"],
},
{
key: "ldl",
display: "LDL Cholesterol",
canonicalUnit: "mmol/L",
molarMass: 386.65,
panel: "lipids",
aliases: ["ldl", "ldl cholesterol", "ldl-c", "cholesterol ldl", "ldl calculated", "ldl, calculated"],
},
{
key: "triglycerides",
display: "Triglycerides",
canonicalUnit: "mmol/L",
molarMass: 885.4,
panel: "lipids",
aliases: ["triglycerides", "trig", "trigs", "tg"],
},
{
key: "non_hdl",
display: "Non-HDL Cholesterol",
canonicalUnit: "mmol/L",
molarMass: 386.65,
panel: "lipids",
aliases: ["non-hdl", "non hdl", "non-hdl cholesterol", "non hdl cholesterol"],
},
{
key: "apob",
display: "Apolipoprotein B",
canonicalUnit: "g/L",
molarMass: null,
panel: "lipids",
aliases: ["apob", "apo b", "apolipoprotein b"],
},
{
key: "lipoprotein_a",
display: "Lipoprotein(a)",
canonicalUnit: "nmol/L",
molarMass: null,
panel: "lipids",
aliases: ["lipoprotein a", "lipoprotein(a)", "lp(a)", "lpa"],
},
// --- metabolic ---
{
key: "glucose",
display: "Glucose",
canonicalUnit: "mmol/L",
molarMass: 180.16,
panel: "metabolic",
aliases: ["glucose", "glucose fasting", "fasting glucose", "blood glucose"],
},
{
key: "hba1c",
display: "HbA1c",
canonicalUnit: "%",
molarMass: null,
panel: "metabolic",
aliases: ["hba1c", "hemoglobin a1c", "haemoglobin a1c", "a1c", "glycated hemoglobin", "glycated haemoglobin"],
},
{
key: "uric_acid",
display: "Uric Acid",
canonicalUnit: "mmol/L",
molarMass: 168.11,
panel: "metabolic",
aliases: ["uric acid", "urate"],
},
// --- inflammation ---
{
key: "hs_crp",
display: "hs-CRP",
canonicalUnit: "mg/L",
molarMass: null,
panel: "inflammation",
aliases: ["hs-crp", "hscrp", "hs crp", "high sensitivity crp", "high-sensitivity crp", "c-reactive protein", "crp"],
},
// --- liver ---
{
key: "alt",
display: "ALT",
canonicalUnit: "U/L",
molarMass: null,
panel: "liver",
aliases: ["alt", "sgpt", "alanine aminotransferase", "alanine transaminase"],
},
{
key: "ast",
display: "AST",
canonicalUnit: "U/L",
molarMass: null,
panel: "liver",
aliases: ["ast", "sgot", "aspartate aminotransferase", "aspartate transaminase"],
},
{
key: "ggt",
display: "GGT",
canonicalUnit: "U/L",
molarMass: null,
panel: "liver",
aliases: ["ggt", "gamma gt", "gamma-glutamyl transferase", "gamma glutamyl transferase"],
},
{
key: "alp",
display: "ALP",
canonicalUnit: "U/L",
molarMass: null,
panel: "liver",
aliases: ["alp", "alkaline phosphatase"],
},
{
key: "bilirubin_total",
display: "Total Bilirubin",
canonicalUnit: "µmol/L",
molarMass: 584.66,
panel: "liver",
aliases: ["bilirubin", "total bilirubin", "bilirubin total", "bilirubin, total"],
},
{
key: "albumin",
display: "Albumin",
canonicalUnit: "g/L",
molarMass: null,
panel: "liver",
aliases: ["albumin"],
},
{
key: "total_protein",
display: "Total Protein",
canonicalUnit: "g/L",
molarMass: null,
panel: "liver",
aliases: ["total protein", "protein total", "protein, total"],
},
// --- kidney ---
{
key: "creatinine",
display: "Creatinine",
canonicalUnit: "µmol/L",
molarMass: 113.12,
panel: "kidney",
aliases: ["creatinine", "serum creatinine"],
},
{
key: "egfr",
display: "eGFR",
canonicalUnit: "mL/min/1.73m2",
molarMass: null,
panel: "kidney",
aliases: ["egfr", "estimated gfr", "estimated glomerular filtration rate", "gfr estimated", "gfr, estimated"],
},
{
key: "urea",
display: "Urea",
canonicalUnit: "mmol/L",
molarMass: 60.06,
panel: "kidney",
// Deliberately no "bun"/"blood urea nitrogen" aliases: BUN mg/dL -> urea
// mmol/L needs the nitrogen divisor (2N = 28.01 g/mol), not urea's own
// molar mass (60.06). Mapping BUN here would silently under-convert by
// ~2.14x. BUN rows stay unmapped (raw value preserved) until a proper
// BUN analyte with its own divisor is added.
aliases: ["urea"],
},
// --- cbc ---
{
key: "hemoglobin",
display: "Hemoglobin",
canonicalUnit: "g/L",
molarMass: null,
panel: "cbc",
aliases: ["hemoglobin", "haemoglobin", "hgb", "hb"],
},
{
key: "hematocrit",
display: "Hematocrit",
canonicalUnit: "%",
molarMass: null,
panel: "cbc",
aliases: ["hematocrit", "haematocrit", "hct", "pcv", "packed cell volume"],
},
{
key: "wbc",
display: "White Blood Cells",
canonicalUnit: "x10^9/L",
molarMass: null,
panel: "cbc",
aliases: ["wbc", "white blood cells", "white blood cell count", "white cell count", "leukocytes", "leucocytes"],
},
{
key: "rbc",
display: "Red Blood Cells",
canonicalUnit: "x10^12/L",
molarMass: null,
panel: "cbc",
aliases: ["rbc", "red blood cells", "red blood cell count", "red cell count", "erythrocytes"],
},
{
key: "platelets",
display: "Platelets",
canonicalUnit: "x10^9/L",
molarMass: null,
panel: "cbc",
aliases: ["platelets", "platelet count", "plt"],
},
{
key: "mcv",
display: "MCV",
canonicalUnit: "fL",
molarMass: null,
panel: "cbc",
aliases: ["mcv", "mean corpuscular volume", "mean cell volume"],
},
{
key: "neutrophils",
display: "Neutrophils",
canonicalUnit: "x10^9/L",
molarMass: null,
panel: "cbc",
aliases: ["neutrophils", "neutrophil count", "neutrophils absolute", "absolute neutrophils", "anc"],
},
{
key: "lymphocytes",
display: "Lymphocytes",
canonicalUnit: "x10^9/L",
molarMass: null,
panel: "cbc",
aliases: ["lymphocytes", "lymphocyte count", "lymphocytes absolute", "absolute lymphocytes", "alc"],
},
// --- vitamins ---
{
key: "vitamin_d",
display: "Vitamin D",
canonicalUnit: "nmol/L",
molarMass: 400.64,
panel: "vitamins",
aliases: [
"vitamin d",
"vitamin d3",
"25-oh vitamin d",
"vitamin d 25-hydroxy",
"25-hydroxyvitamin d",
"25-hydroxy vitamin d",
"25 oh vitamin d",
],
},
{
key: "vitamin_b12",
display: "Vitamin B12",
canonicalUnit: "pmol/L",
molarMass: 1355.4,
panel: "vitamins",
aliases: ["vitamin b12", "b12", "cobalamin"],
},
{
key: "folate",
display: "Folate",
canonicalUnit: "nmol/L",
molarMass: 441.4,
panel: "vitamins",
aliases: ["folate", "folic acid", "vitamin b9"],
},
// --- electrolytes ---
{
key: "sodium",
display: "Sodium",
canonicalUnit: "mmol/L",
molarMass: null,
panel: "electrolytes",
aliases: ["sodium", "na"],
},
{
key: "potassium",
display: "Potassium",
canonicalUnit: "mmol/L",
molarMass: null,
panel: "electrolytes",
aliases: ["potassium", "k"],
},
{
key: "calcium",
display: "Calcium",
canonicalUnit: "mmol/L",
molarMass: 40.08,
panel: "electrolytes",
aliases: ["calcium", "calcium total", "total calcium", "ca"],
},
{
key: "magnesium",
display: "Magnesium",
canonicalUnit: "mmol/L",
molarMass: 24.31,
panel: "electrolytes",
aliases: ["magnesium", "mg"],
},
{
key: "zinc",
display: "Zinc",
canonicalUnit: "µmol/L",
molarMass: 65.38,
panel: "electrolytes",
aliases: ["zinc", "zn"],
},
];
// Resolution is alias/display lookup only — normalized to lowercase with
// punctuation collapsed to spaces so "Total Testosterone", "total-testosterone",
// and "TOTAL TESTOSTERONE" all hit the same registry entry.
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim();
const INDEX = new Map<string, Analyte>();
for (const a of ANALYTES) {
INDEX.set(norm(a.display), a);
for (const al of a.aliases) INDEX.set(norm(al), a);
}
const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a]));
export function resolveAnalyte(name: string): Analyte | null {
return INDEX.get(norm(name)) ?? null;
}
export function getAnalyte(key: string): Analyte | null {
return BY_KEY.get(key) ?? null;
}

15
server/src/lib/extract.ts Normal file
View File

@@ -0,0 +1,15 @@
import { ExtractedDraft } from "@helios/shared";
import { chatJSON, type LlmDeps } from "./llm";
const SYSTEM = `You extract structured blood-test results from the raw text of a lab report.
Return ONLY a JSON object: {"collectedDate": "YYYY-MM-DD" or null, "labName": string or null, "markers": [{"panel": string or null, "name": string, "value": string, "unit": string or null, "referenceRange": string or null, "flagged": boolean}]}.
Rules: copy names, values, units, and reference ranges EXACTLY as printed — do not convert units or round values. "value" is always a string (keep comparators like "<0.3"). Set "flagged" true only when the report marks the result abnormal (H, L, *, bold, out-of-range annotation). Use the specimen collection date, not the report date. Skip commentary, footers, and reference-only rows with no result.`;
const MAX_CHARS = 40_000;
export async function extractFromText(deps: LlmDeps, text: string): Promise<ExtractedDraft> {
const out = await chatJSON(deps, { system: SYSTEM, user: text.slice(0, MAX_CHARS) });
const parsed = ExtractedDraft.safeParse(out);
if (!parsed.success) throw new Error("llm_error: draft failed schema validation");
return parsed.data;
}

38
server/src/lib/llm.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { Db } from "../db";
import { DEFAULTS, getSetting } from "../routes/settings";
export type LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch };
export async function chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise<unknown> {
const f = deps.fetchImpl ?? fetch;
const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url;
const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model;
const apiKey = await getSetting(deps.db, "llm_key", deps.key);
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: opts.system },
{ role: "user", content: opts.user },
],
}),
});
if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`);
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("llm_error: empty completion");
const stripped = content.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "");
try {
return JSON.parse(stripped);
} catch {
throw new Error("llm_error: completion was not valid JSON");
}
}

View File

@@ -0,0 +1,80 @@
import { resolveAnalyte } from "./analytes";
import { convert } from "./units";
export interface RawMarker {
panel?: string | null;
name: string;
value: string;
unit?: string | null;
referenceRange?: string | null;
flagged?: boolean;
}
export interface NormMarker {
panel: string;
name: string;
marker: string;
analyteKey: string | null;
value: string;
valueNum: number | null;
unit: string | null;
referenceRange: string | null;
flagged: boolean;
valueCanonical: number | null;
canonicalUnit: string | null;
}
const THOUSANDS_GROUPED = /^\d{1,3}(,\d{3})+(\.\d+)?$/;
const PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
// Strict numeric parsing: parseFloat alone stops at the first non-numeric
// character, so "1,200" silently became 1 (a 1000x error) and "5,5" (a
// European decimal) silently became 5. Instead: strip comparators/whitespace,
// then only accept (a) US thousands-grouping, comma-stripped, or (b) a plain
// number that spans the ENTIRE remaining string. Anything else — including
// ambiguous "5,5" — returns null so no canonical value is computed rather
// than a silently wrong one.
const num = (v: string): number | null => {
const stripped = v.replace(/[<>≤≥\s]/g, "");
if (THOUSANDS_GROUPED.test(stripped)) {
const n = parseFloat(stripped.replace(/,/g, ""));
return Number.isFinite(n) ? n : null;
}
if (PLAIN_NUMBER.test(stripped)) {
const n = parseFloat(stripped);
return Number.isFinite(n) ? n : null;
}
return null;
};
export function normalizeMarker(raw: RawMarker): NormMarker {
const analyte = resolveAnalyte(raw.name);
const valueNum = num(raw.value);
const unit = raw.unit ?? null;
let valueCanonical: number | null = null;
let canonicalUnit: string | null = null;
if (analyte && valueNum !== null && unit) {
canonicalUnit = analyte.canonicalUnit;
valueCanonical = convert(valueNum, unit, analyte.canonicalUnit, analyte.molarMass);
if (valueCanonical === null && unit.toLowerCase().replace(/\s/g, "") === analyte.canonicalUnit.toLowerCase().replace(/\s/g, "")) {
valueCanonical = valueNum;
}
}
return {
// Registry panel wins for mapped analytes so a marker groups the same way
// no matter which section a particular lab filed it under.
panel: analyte?.panel ?? raw.panel ?? "other",
name: raw.name,
marker: analyte?.key ?? raw.name,
analyteKey: analyte?.key ?? null,
value: raw.value,
valueNum,
unit,
referenceRange: raw.referenceRange ?? null,
flagged: raw.flagged === true,
valueCanonical,
canonicalUnit,
};
}

7
server/src/lib/pdf.ts Normal file
View File

@@ -0,0 +1,7 @@
import { extractText, getDocumentProxy } from "unpdf";
export async function pdfToText(data: Uint8Array): Promise<string> {
const doc = await getDocumentProxy(data);
const { text } = await extractText(doc, { mergePages: true });
return text;
}

49
server/src/lib/units.ts Normal file
View File

@@ -0,0 +1,49 @@
// Lab unit conversion. Two transform kinds: dimensional scaling (prefix/volume,
// analyte-independent) and the mass<->mole bridge, which requires the analyte's
// molar mass. %, ratios, IU, and cell counts never bridge to mass or mole.
type Kind = "mass" | "mole" | "count" | "iu" | "percent" | "ratio" | "other";
const AMOUNT: Record<string, [Kind, number]> = {
g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6],
"µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12],
mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6],
nmol: ["mole", 1e-9], pmol: ["mole", 1e-12],
cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3],
million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12],
iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1],
};
const VOLUME: Record<string, number> = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 };
const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ");
interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number }
export function parseUnit(unit: string): Parsed | null {
const u = clean(unit);
if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 };
if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 };
const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/);
if (m) {
const amtEntry = AMOUNT[m[1]];
const volFactor = VOLUME[m[2]];
if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor };
}
return { kind: "other", amountFactor: 1, volumeFactor: 1 };
}
export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null {
const from = parseUnit(fromUnit);
const to = parseUnit(toUnit);
if (!from || !to) return null;
if (clean(fromUnit) === clean(toUnit)) return value;
if (from.kind === "other" || to.kind === "other") return null;
const fromBasePerL = (value * from.amountFactor) / from.volumeFactor;
let toBasePerL = fromBasePerL;
if (from.kind !== to.kind) {
const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass");
if (!bridge || !molarMass) return null;
toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass;
}
return (toBasePerL * to.volumeFactor) / to.amountFactor;
}

189
server/src/routes/labs.ts Normal file
View File

@@ -0,0 +1,189 @@
import type { Changes } from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm";
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "../db";
import { biomarkers, labDrafts, labDraws } from "../db/schema";
import { extractFromText } from "../lib/extract";
import { normalizeMarker } from "../lib/normalize";
import { pdfToText } from "../lib/pdf";
import { getAnalyte } from "../lib/analytes";
import { ConfirmDraftBody } from "@helios/shared";
export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch };
const MAX_UPLOAD = 15 * 1024 * 1024;
export function labsRoutes(deps: LabsDeps) {
const app = new Hono();
app.post("/labs/upload", async (c) => {
const body = await c.req.parseBody();
const file = body.file;
if (!(file instanceof File)) return c.json({ error: "file field required" }, 400);
if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") {
return c.json({ error: "not_a_pdf" }, 400);
}
if (file.size > MAX_UPLOAD) return c.json({ error: "too_large" }, 400);
const id = randomUUID();
const uploadsDir = join(deps.dataDir, "uploads");
mkdirSync(uploadsDir, { recursive: true });
const filePath = join(uploadsDir, `${id}.pdf`);
const bytes = new Uint8Array(await file.arrayBuffer());
await Bun.write(filePath, bytes);
// Extraction failures land on the draft row so the user sees them in the
// review UI instead of the upload 500ing.
let extracted: string | null = null;
let error: string | null = null;
try {
const text = await pdfToText(bytes);
if (text.trim().length < 20) throw new Error("pdf_error: no text layer (scanned PDFs are not supported yet)");
const draft = await extractFromText({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch }, text);
extracted = JSON.stringify(draft);
} catch (e) {
error = e instanceof Error ? e.message : "extraction failed";
}
await deps.db.insert(labDrafts).values({
id, filename: file.name, filePath, status: "pending", extracted, error, createdAt: Date.now(),
});
return c.json({ id }, 201);
});
app.get("/labs/drafts", async (c) => {
const rows = await deps.db.select().from(labDrafts).orderBy(desc(labDrafts.createdAt));
return c.json({
drafts: rows.map((r) => ({
id: r.id,
filename: r.filename,
status: r.status,
error: r.error,
markerCount: r.extracted ? (JSON.parse(r.extracted).markers?.length ?? 0) : 0,
createdAt: r.createdAt,
})),
});
});
app.get("/labs/drafts/:id", async (c) => {
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
if (!row) return c.json({ error: "not found" }, 404);
return c.json({
id: row.id, filename: row.filename, status: row.status, error: row.error,
draft: row.extracted ? JSON.parse(row.extracted) : null,
});
});
app.post("/labs/drafts/:id/confirm", async (c) => {
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
if (!row) return c.json({ error: "not found" }, 404);
if (row.status !== "pending") return c.json({ error: "draft is not pending" }, 409);
const body = ConfirmDraftBody.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400);
const drawId = randomUUID();
const norm = body.data.markers.map((m) => normalizeMarker({
panel: m.panel, name: m.name, value: m.value, unit: m.unit,
referenceRange: m.referenceRange, flagged: m.flagged,
}));
try {
deps.db.transaction((tx) => {
// Guarded status transition (pending -> confirmed) inside the same
// synchronous transaction as the inserts below, so two concurrent
// confirms of the same draft can't both pass the earlier status
// check (before the request body was even read) and double-insert.
// Only the request that actually flips the row gets to write rows.
// drizzle-orm's bun-sqlite types pin TRunResult to `void`, but at
// runtime bun:sqlite's Statement.run() actually returns a
// `{ changes, lastInsertRowid }` Changes object — confirmed in
// node_modules/bun-types/sqlite.d.ts. Cast to the real runtime type.
const updated = tx.update(labDrafts).set({ status: "confirmed" })
.where(and(eq(labDrafts.id, row.id), eq(labDrafts.status, "pending"))).run() as unknown as Changes;
if (updated.changes === 0) throw new Error("draft_not_pending");
tx.insert(labDraws).values({
id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName,
draftId: row.id, createdAt: Date.now(),
}).run();
for (const n of norm) {
tx.insert(biomarkers).values({
drawId, panel: n.panel, name: n.name, marker: n.marker, analyteKey: n.analyteKey,
value: n.value, valueNum: n.valueNum, unit: n.unit, referenceRange: n.referenceRange,
flagged: n.flagged ? 1 : 0, valueCanonical: n.valueCanonical, canonicalUnit: n.canonicalUnit,
}).run();
}
});
} catch (e) {
if (e instanceof Error && e.message === "draft_not_pending") {
return c.json({ error: "draft is not pending" }, 409);
}
throw e;
}
return c.json({ drawId }, 201);
});
app.post("/labs/drafts/:id/discard", async (c) => {
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
if (!row) return c.json({ error: "not found" }, 404);
await deps.db.update(labDrafts).set({ status: "discarded" }).where(eq(labDrafts.id, row.id));
return c.body(null, 204);
});
app.get("/labs/draws", async (c) => {
const draws = await deps.db.select().from(labDraws).orderBy(desc(labDraws.collectedAt));
const rows = await deps.db.select().from(biomarkers);
return c.json({
draws: draws.map((d) => ({
id: d.id, collectedAt: d.collectedAt, labName: d.labName,
markerCount: rows.filter((r) => r.drawId === d.id).length,
flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length,
})),
});
});
app.get("/labs/draws/:id", async (c) => {
const d = (await deps.db.select().from(labDraws).where(eq(labDraws.id, c.req.param("id")))).at(0);
if (!d) return c.json({ error: "not found" }, 404);
const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.drawId, d.id));
const byPanel = new Map<string, typeof rows>();
for (const r of rows) byPanel.set(r.panel, [...(byPanel.get(r.panel) ?? []), r]);
return c.json({
id: d.id, collectedAt: d.collectedAt, labName: d.labName,
panels: [...byPanel.keys()].sort().map((panel) => ({
panel,
markers: byPanel.get(panel)!
.sort((a, b) => a.name.localeCompare(b.name))
.map((r) => ({ ...r, flagged: r.flagged === 1 })),
})),
});
});
app.get("/labs/markers/:key/history", async (c) => {
const analyte = getAnalyte(c.req.param("key"));
if (!analyte) return c.json({ error: "unknown marker" }, 404);
const draws = await deps.db.select().from(labDraws);
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key));
const points = rows
.filter((r) => r.valueCanonical !== null)
.map((r) => ({ drawId: r.drawId, collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! }))
.sort((a, b) => a.collectedAt.localeCompare(b.collectedAt));
return c.json({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points });
});
app.get("/labs/drafts/:id/file", async (c) => {
const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0);
if (!row || !existsSync(row.filePath)) return c.json({ error: "not found" }, 404);
return new Response(Bun.file(row.filePath), {
headers: {
"content-type": "application/pdf",
"content-disposition": `inline; filename="${row.filename.replace(/[^\w.-]/g, "_")}"`,
},
});
});
return app;
}

View File

@@ -5,7 +5,7 @@ import type { Db } from "../db";
import { settings } from "../db/schema"; import { settings } from "../db/schema";
import { decrypt, encrypt, mask } from "../lib/crypto"; import { decrypt, encrypt, mask } from "../lib/crypto";
const DEFAULTS = { export const DEFAULTS = {
llm_base_url: "https://openrouter.ai/api/v1", llm_base_url: "https://openrouter.ai/api/v1",
llm_model: "anthropic/claude-sonnet-4.5", llm_model: "anthropic/claude-sonnet-4.5",
} as const; } as const;

View File

@@ -8,7 +8,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
function makeApp() { function makeApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-")); const dir = mkdtempSync(join(tmpdir(), "helios-"));
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) }); return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir });
} }
const json = (body: unknown) => ({ const json = (body: unknown) => ({
method: "POST", method: "POST",
@@ -54,7 +54,7 @@ describe("auth", () => {
// The global window applies to everyone, including a request with the correct password. // The global window applies to everyone, including a request with the correct password.
const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" })); const blocked = await app.request("/api/login", json({ password: "hunter2hunter2" }));
expect(blocked.status).toBe(429); expect(blocked.status).toBe(429);
}); }, 30000); // 10 sequential argon2id verifies can exceed the 5s default timeout
test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => { test("unknown /api/* returns 404 when authed, 401 when unauthed", async () => {
const app = makeApp(); const app = makeApp();

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { biomarkers, labDraws } from "../src/db/schema";
describe("labs schema", () => {
test("draw + biomarker round-trip", async () => {
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
await db.insert(labDraws).values({ id: "d1", collectedAt: "2026-01-15", labName: "Acme Lab", draftId: null, createdAt: 1 });
await db.insert(biomarkers).values({
drawId: "d1", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl",
value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "< 3.4", flagged: 0,
valueCanonical: 3.1, canonicalUnit: "mmol/L",
});
const rows = await db.select().from(biomarkers);
expect(rows).toHaveLength(1);
expect(rows[0].marker).toBe("ldl");
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
import { extractFromText } from "../src/lib/extract";
const FIXTURE_TEXT = `
Sample Diagnostics — Final Report
Collected: 15 Jan 2026
CHEMISTRY
Glucose (Fasting) 100 mg/dL (70-99) H
LIPIDS
LDL Cholesterol 3.1 mmol/L (<3.4)
`;
describe("extractFromText", () => {
test("passes text to LLM and validates the draft shape", async () => {
let userPrompt = "";
const mock = (async (_: any, init: any) => {
userPrompt = JSON.parse(String(init.body)).messages[1].content;
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
collectedDate: "2026-01-15",
labName: "Sample Diagnostics",
markers: [
{ panel: "chemistry", name: "Glucose (Fasting)", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
{ panel: "lipids", name: "LDL Cholesterol", value: "3.1", unit: "mmol/L", referenceRange: "<3.4", flagged: false },
],
}) } }] }), { status: 200 });
}) as typeof fetch;
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const draft = await extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, FIXTURE_TEXT);
expect(userPrompt).toContain("Glucose (Fasting)");
expect(draft.markers).toHaveLength(2);
expect(draft.collectedDate).toBe("2026-01-15");
expect(draft.markers[0].flagged).toBe(true);
});
test("malformed LLM output → llm_error, not a crash", async () => {
const mock = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"markers": "not an array"}' } }] }), { status: 200 })) as unknown as typeof fetch;
const dir = mkdtempSync(join(tmpdir(), "helios-"));
await expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/);
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { biomarkers, labDrafts, labDraws } from "../src/db/schema";
import { loadOrCreateKey } from "../src/lib/crypto";
async function setup() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
await db.insert(labDrafts).values({
id: "draft1", filename: "r.pdf", filePath: "/tmp/none.pdf", status: "pending",
extracted: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [
{ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
] }),
error: null, createdAt: 1,
});
return { app, db, cookie };
}
describe("draft review", () => {
test("get, confirm with edits → normalized biomarkers, draft confirmed", async () => {
const { app, db, cookie } = await setup();
const h = { cookie, "content-type": "application/json" };
const got = await (await app.request("/api/labs/drafts/draft1", { headers: { cookie } })).json();
expect(got.draft.markers).toHaveLength(1);
const confirm = await app.request("/api/labs/drafts/draft1/confirm", {
method: "POST", headers: h,
body: JSON.stringify({
collectedDate: "2026-01-15",
labName: "Sample Diagnostics",
markers: [
{ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
{ panel: null, name: "Ferritin", value: "30", unit: "ng/mL", referenceRange: null, flagged: false }, // user-added row
],
}),
});
expect(confirm.status).toBe(201);
const rows = await db.select().from(biomarkers);
expect(rows).toHaveLength(2);
const glucose = rows.find((r) => r.analyteKey === "glucose")!;
expect(glucose.valueCanonical).toBeCloseTo(5.551, 2);
expect(glucose.flagged).toBe(1);
const ferritin = rows.find((r) => r.analyteKey === "ferritin")!;
expect(ferritin.valueCanonical).toBeCloseTo(30); // ng/mL → µg/L 1:1
expect(ferritin.canonicalUnit).toBe("µg/L");
const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0];
expect(draft.status).toBe("confirmed");
// second confirm → 409
const again = await app.request("/api/labs/drafts/draft1/confirm", {
method: "POST", headers: h,
body: JSON.stringify({ collectedDate: "2026-01-15", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }),
});
expect(again.status).toBe(409);
// Second (rejected) confirm must not have inserted a second draw or any
// extra biomarker rows — exactly one draw, one set of biomarkers.
const drawsAfter = await db.select().from(labDraws);
expect(drawsAfter).toHaveLength(1);
const rowsAfter = await db.select().from(biomarkers);
expect(rowsAfter).toHaveLength(2);
});
test("discard marks draft discarded", async () => {
const { app, db, cookie } = await setup();
const r = await app.request("/api/labs/drafts/draft1/discard", { method: "POST", headers: { cookie } });
expect(r.status).toBe(204);
const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0];
expect(draft.status).toBe("discarded");
});
test("unknown draft 404; bad date 400", async () => {
const { app, cookie } = await setup();
expect((await app.request("/api/labs/drafts/nope", { headers: { cookie } })).status).toBe(404);
const bad = await app.request("/api/labs/drafts/draft1/confirm", {
method: "POST", headers: { cookie, "content-type": "application/json" },
body: JSON.stringify({ collectedDate: "15/01/2026", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }),
});
expect(bad.status).toBe(400);
});
test("sync transaction rolls back on mid-loop failure", () => {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
expect(() =>
db.transaction((tx) => {
tx.insert(labDraws).values({ id: "dX", collectedAt: "2026-01-01", labName: null, draftId: null, createdAt: 1 }).run();
tx.insert(biomarkers).values({ drawId: "dX", panel: "p", name: "n", marker: "m", analyteKey: null, value: "1", valueNum: 1, unit: null, referenceRange: null, flagged: 0, valueCanonical: null, canonicalUnit: null }).run();
throw new Error("boom");
}),
).toThrow("boom");
// Verify both tables are empty after rollback
const drawRows = db.select().from(labDraws).all();
const bioRows = db.select().from(biomarkers).all();
expect(drawRows).toHaveLength(0);
expect(bioRows).toHaveLength(0);
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { biomarkers, labDraws } from "../src/db/schema";
import { loadOrCreateKey } from "../src/lib/crypto";
async function seeded() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
await db.insert(labDraws).values([
{ id: "d1", collectedAt: "2025-06-01", labName: "A", draftId: null, createdAt: 1 },
{ id: "d2", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 2 },
]);
await db.insert(biomarkers).values([
{ drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "90", valueNum: 90, unit: "mg/dL", referenceRange: null, flagged: 0, valueCanonical: 4.996, canonicalUnit: "mmol/L" },
{ drawId: "d2", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" },
{ drawId: "d2", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl", value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "<3.4", flagged: 0, valueCanonical: 3.1, canonicalUnit: "mmol/L" },
]);
return { app, cookie };
}
describe("labs queries", () => {
test("draw list newest first with counts", async () => {
const { app, cookie } = await seeded();
const { draws } = await (await app.request("/api/labs/draws", { headers: { cookie } })).json();
expect(draws.map((d: any) => d.id)).toEqual(["d2", "d1"]);
expect(draws[0].markerCount).toBe(2);
expect(draws[0].flaggedCount).toBe(1);
});
test("draw detail grouped by panel", async () => {
const { app, cookie } = await seeded();
const detail = await (await app.request("/api/labs/draws/d2", { headers: { cookie } })).json();
expect(detail.panels.map((p: any) => p.panel)).toEqual(["lipids", "metabolic"]);
expect(detail.panels[1].markers[0].flagged).toBe(true);
expect((await app.request("/api/labs/draws/nope", { headers: { cookie } })).status).toBe(404);
});
test("marker history ascending with registry metadata", async () => {
const { app, cookie } = await seeded();
const h = await (await app.request("/api/labs/markers/glucose/history", { headers: { cookie } })).json();
expect(h.display).toBe("Glucose");
expect(h.canonicalUnit).toBe("mmol/L");
expect(h.points.map((p: any) => p.value)).toEqual([4.996, 5.551]);
expect((await app.request("/api/labs/markers/unknown_thing/history", { headers: { cookie } })).status).toBe(404);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
// Minimal one-page PDF with a text object — enough for unpdf to open and read.
const TINY_PDF = `%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
4 0 obj<</Length 60>>stream
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
trailer<</Root 1 0 R>>`;
async function authedApp(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
return { app, cookie };
}
const llmOk = (async () => new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
collectedDate: "2026-01-15", labName: "Sample Diagnostics",
markers: [{ panel: null, name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }],
}) } }] }), { status: 200 })) as unknown as typeof fetch;
describe("labs upload", () => {
test("PDF upload → pending draft with extracted markers", async () => {
const { app, cookie } = await authedApp(llmOk);
const fd = new FormData();
fd.append("file", new File([TINY_PDF], "results.pdf", { type: "application/pdf" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(201);
const { id } = await up.json();
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
expect(list.drafts).toHaveLength(1);
expect(list.drafts[0].id).toBe(id);
expect(list.drafts[0].status).toBe("pending");
expect(list.drafts[0].markerCount).toBe(1);
});
test("non-PDF rejected", async () => {
const { app, cookie } = await authedApp(llmOk);
const fd = new FormData();
fd.append("file", new File(["hi"], "notes.txt", { type: "text/plain" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(400);
});
test("LLM failure → draft stored with error, not a 500", async () => {
const llmDown = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
const { app, cookie } = await authedApp(llmDown);
const fd = new FormData();
fd.append("file", new File([TINY_PDF], "r.pdf", { type: "application/pdf" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(201);
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
expect(list.drafts[0].error).toMatch(/llm_error/);
expect(list.drafts[0].markerCount).toBe(0);
});
});

51
server/test/llm.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { settings } from "../src/db/schema";
import { encrypt, loadOrCreateKey } from "../src/lib/crypto";
import { chatJSON } from "../src/lib/llm";
function deps(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
const key = loadOrCreateKey(dir);
return { db, key, fetchImpl };
}
describe("chatJSON", () => {
test("posts OpenAI-compatible request with bearer key and parses JSON content", async () => {
let captured: { url: string; init: RequestInit } | null = null;
const mock = (async (url: any, init: any) => {
captured = { url: String(url), init };
return new Response(JSON.stringify({ choices: [{ message: { content: '{"ok":1}' } }] }), { status: 200 });
}) as typeof fetch;
const d = deps(mock);
await d.db.insert(settings).values({ key: "llm_key", value: encrypt(d.key, "sk-test-1234") });
const out = await chatJSON(d, { system: "sys", user: "usr" });
expect(out).toEqual({ ok: 1 });
expect(captured!.url).toBe("https://openrouter.ai/api/v1/chat/completions");
const body = JSON.parse(String(captured!.init.body));
expect(body.response_format).toEqual({ type: "json_object" });
expect(body.messages).toEqual([{ role: "system", content: "sys" }, { role: "user", content: "usr" }]);
expect((captured!.init.headers as Record<string, string>).authorization).toBe("Bearer sk-test-1234");
});
test("no key → no auth header (Ollama)", async () => {
let headers: Record<string, string> = {};
const mock = (async (_: any, init: any) => {
headers = init.headers;
return new Response(JSON.stringify({ choices: [{ message: { content: "```json\n{\"a\":2}\n```" } }] }), { status: 200 });
}) as typeof fetch;
const out = await chatJSON(deps(mock), { system: "s", user: "u" });
expect(out).toEqual({ a: 2 }); // fenced JSON stripped
expect(headers.authorization).toBeUndefined();
});
test("non-2xx throws llm_error", async () => {
const mock = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch;
await expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/);
});
});

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { resolveAnalyte } from "../src/lib/analytes";
import { normalizeMarker } from "../src/lib/normalize";
import { convert } from "../src/lib/units";
describe("units", () => {
test("dimensional scaling: ng/mL → µg/L is 1:1", () => {
expect(convert(30, "ng/mL", "µg/L")).toBeCloseTo(30);
});
test("mass→mole bridge needs molar mass", () => {
expect(convert(100, "mg/dL", "mmol/L")).toBeNull();
expect(convert(100, "mg/dL", "mmol/L", 180.16)).toBeCloseTo(5.551, 2); // glucose
});
test("mole→mass: testosterone 20 nmol/L → ng/dL", () => {
expect(convert(20, "nmol/L", "ng/dL", 288.42)).toBeCloseTo(576.8, 0);
});
test("percent only converts to percent", () => {
expect(convert(42, "%", "%")).toBe(42);
expect(convert(42, "%", "mg/dL")).toBeNull();
});
test("count units: x10^9/L → thousand/uL is 1:1", () => {
expect(convert(6.1, "x10^9/L", "thousand/uL")).toBeCloseTo(6.1);
});
});
describe("analytes", () => {
test("alias resolution is case/space-insensitive", () => {
expect(resolveAnalyte("Total Testosterone")?.key).toBe("testosterone_total");
expect(resolveAnalyte("HbA1c")?.key).toBe("hba1c");
expect(resolveAnalyte("definitely not a marker")).toBeNull();
});
});
describe("normalizeMarker", () => {
test("known analyte converts to canonical unit and registry panel", () => {
const n = normalizeMarker({ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true });
expect(n.analyteKey).toBe("glucose");
expect(n.panel).toBe("metabolic"); // registry panel wins for consistent grouping
expect(n.valueCanonical).toBeCloseTo(5.551, 2);
expect(n.canonicalUnit).toBe("mmol/L");
expect(n.value).toBe("100"); // raw preserved
expect(n.flagged).toBe(true);
});
test("unknown marker keeps raw fields, no canonical value", () => {
const n = normalizeMarker({ name: "Exotic Marker X", value: "1.2", unit: "u/L" });
expect(n.analyteKey).toBeNull();
expect(n.marker).toBe("Exotic Marker X");
expect(n.valueCanonical).toBeNull();
});
test("comparator values parse numerically", () => {
const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" });
expect(n.valueNum).toBeCloseTo(0.3);
expect(n.value).toBe("<0.3");
});
test("BUN is not mapped onto urea (needs N-divisor, not molar mass)", () => {
expect(resolveAnalyte("BUN")).toBeNull();
expect(resolveAnalyte("blood urea nitrogen")).toBeNull();
expect(resolveAnalyte("urea")?.key).toBe("urea");
});
test("DHEA-S µg/dL converts with corrected molar mass", () => {
const n = normalizeMarker({ name: "DHEA-S", value: "250", unit: "ug/dL" });
expect(n.valueCanonical).toBeCloseTo(6.78, 1); // 250 * 0.02713
});
test("thousands-grouped value parses as 1200, not 1 (parseFloat truncation bug)", () => {
const n = normalizeMarker({ name: "Vitamin B12", value: "1,200", unit: "pg/mL" });
expect(n.valueNum).toBe(1200);
expect(n.analyteKey).toBe("vitamin_b12");
expect(n.valueCanonical).toBeCloseTo(885.35, 1);
expect(n.canonicalUnit).toBe("pmol/L");
});
test("ambiguous European-decimal-looking value is left unmapped, not silently wrong", () => {
const n = normalizeMarker({ name: "Glucose", value: "5,5", unit: "mg/dL" });
expect(n.valueNum).toBeNull();
expect(n.valueCanonical).toBeNull();
expect(n.value).toBe("5,5"); // raw value preserved
});
test("comparator value still parses after strict-parsing rewrite", () => {
const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" });
expect(n.valueNum).toBeCloseTo(0.3);
});
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
function makeApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir });
}
describe("security headers", () => {
test("API responses: nosniff", async () => {
const res = await makeApp().request("/api/health");
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
});
test("non-API responses: CSP + nosniff + referrer policy", async () => {
const res = await makeApp().request("/anything");
expect(res.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(res.headers.get("content-security-policy")).toContain("frame-ancestors 'none'");
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
});
});

View File

@@ -11,7 +11,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
async function authedApp() { async function authedApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-")); const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir); const db = openDb(dir);
const app = createApp({ db, key: loadOrCreateKey(dir) }); const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" })); await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;

View File

@@ -20,3 +20,27 @@ export const SettingsUpdate = z.object({
llmKey: z.string().min(1).optional(), llmKey: z.string().min(1).optional(),
}); });
export type SettingsUpdate = z.infer<typeof SettingsUpdate>; export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
export const ExtractedMarker = z.object({
panel: z.string().nullable().default(null),
name: z.string().min(1),
value: z.string().min(1),
unit: z.string().nullable().default(null),
referenceRange: z.string().nullable().default(null),
flagged: z.boolean().default(false),
});
export type ExtractedMarker = z.infer<typeof ExtractedMarker>;
export const ExtractedDraft = z.object({
collectedDate: z.string().nullable().default(null),
labName: z.string().nullable().default(null),
markers: z.array(ExtractedMarker).default([]),
});
export type ExtractedDraft = z.infer<typeof ExtractedDraft>;
export const ConfirmDraftBody = z.object({
collectedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
labName: z.string().nullable().default(null),
markers: z.array(ExtractedMarker).min(1),
});
export type ConfirmDraftBody = z.infer<typeof ConfirmDraftBody>;

View File

@@ -5,6 +5,9 @@ import { api } from "./api";
import { Gate } from "./pages/Gate"; import { Gate } from "./pages/Gate";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
import { Today } from "./pages/Today"; import { Today } from "./pages/Today";
import { Labs } from "./pages/Labs";
import { LabDraft } from "./pages/LabDraft";
import { LabDraw } from "./pages/LabDraw";
export function App() { export function App() {
const [me, setMe] = useState<MeResponse | null>(null); const [me, setMe] = useState<MeResponse | null>(null);
@@ -18,11 +21,15 @@ export function App() {
<div className="shell"> <div className="shell">
<nav> <nav>
<Link to="/">Today</Link> <Link to="/">Today</Link>
<Link to="/labs">Labs</Link>
<Link to="/settings">Settings</Link> <Link to="/settings">Settings</Link>
<button onClick={() => api.logout().then(refresh)}>Log out</button> <button onClick={() => api.logout().then(refresh)}>Log out</button>
</nav> </nav>
<Routes> <Routes>
<Route path="/" element={<Today />} /> <Route path="/" element={<Today />} />
<Route path="/labs" element={<Labs />} />
<Route path="/labs/draft/:id" element={<LabDraft />} />
<Route path="/labs/draw/:id" element={<LabDraw />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</div> </div>

View File

@@ -1,4 +1,4 @@
import type { MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared"; import type { ConfirmDraftBody, ExtractedDraft, ExtractedMarker, MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared";
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
@@ -6,6 +6,24 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
return res.status === 204 ? (undefined as T) : res.json(); return res.status === 204 ? (undefined as T) : res.json();
} }
async function uploadFile<T>(path: string, file: File): Promise<T> {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(path, { method: "POST", body: fd });
if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText);
return res.json();
}
export interface LabMarkerRow {
id: number; drawId: string; panel: string; name: string; marker: string; analyteKey: string | null;
value: string; valueNum: number | null; unit: string | null; referenceRange: string | null;
flagged: boolean; valueCanonical: number | null; canonicalUnit: string | null;
}
export interface LabDrawDetail {
id: string; collectedAt: string; labName: string | null; panels: { panel: string; markers: LabMarkerRow[] }[];
}
export const api = { export const api = {
me: () => req<MeResponse>("/api/me"), me: () => req<MeResponse>("/api/me"),
setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }), setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }),
@@ -13,4 +31,12 @@ export const api = {
logout: () => req<void>("/api/logout", { method: "POST" }), logout: () => req<void>("/api/logout", { method: "POST" }),
getSettings: () => req<SettingsResponse>("/api/settings"), getSettings: () => req<SettingsResponse>("/api/settings"),
putSettings: (body: SettingsUpdate) => req<void>("/api/settings", { method: "PUT", body: JSON.stringify(body) }), putSettings: (body: SettingsUpdate) => req<void>("/api/settings", { method: "PUT", body: JSON.stringify(body) }),
uploadLab: (file: File) => uploadFile<{ id: string }>("/api/labs/upload", file),
labDrafts: () => req<{ drafts: { id: string; filename: string; status: string; error: string | null; markerCount: number; createdAt: number }[] }>("/api/labs/drafts"),
labDraft: (id: string) => req<{ id: string; filename: string; status: string; error: string | null; draft: ExtractedDraft | null }>(`/api/labs/drafts/${id}`),
confirmDraft: (id: string, body: ConfirmDraftBody) => req<{ drawId: string }>(`/api/labs/drafts/${id}/confirm`, { method: "POST", body: JSON.stringify(body) }),
discardDraft: (id: string) => req<void>(`/api/labs/drafts/${id}/discard`, { method: "POST" }),
labDraws: () => req<{ draws: { id: string; collectedAt: string; labName: string | null; markerCount: number; flaggedCount: number }[] }>("/api/labs/draws"),
labDraw: (id: string) => req<LabDrawDetail>(`/api/labs/draws/${id}`),
markerHistory: (key: string) => req<{ key: string; display: string; canonicalUnit: string; points: { drawId: string; collectedAt: string; value: number }[] }>(`/api/labs/markers/${key}/history`),
}; };

View File

@@ -0,0 +1,14 @@
export function Sparkline({ points, width = 220, height = 48 }: { points: number[]; width?: number; height?: number }) {
if (points.length < 2) return null;
const min = Math.min(...points);
const max = Math.max(...points);
const span = max - min || 1;
const step = width / (points.length - 1);
const path = points.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - 4 - ((v - min) / span) * (height - 8)).toFixed(1)}`).join(" ");
return (
<svg width={width} height={height} className="sparkline" aria-hidden>
<path d={path} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
<circle cx={(points.length - 1) * step} cy={height - 4 - ((points[points.length - 1] - min) / span) * (height - 8)} r={3} fill="currentColor" />
</svg>
);
}

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import type { ExtractedMarker } from "@helios/shared";
import { api } from "../api";
export function LabDraft() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const [meta, setMeta] = useState<{ filename: string; status: string; error: string | null } | null>(null);
const [collectedDate, setCollectedDate] = useState("");
const [labName, setLabName] = useState("");
const [markers, setMarkers] = useState<ExtractedMarker[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!id) return;
api.labDraft(id).then((r) => {
setMeta({ filename: r.filename, status: r.status, error: r.error });
if (r.draft) {
setCollectedDate(r.draft.collectedDate ?? "");
setLabName(r.draft.labName ?? "");
setMarkers(r.draft.markers);
}
}).catch((e) => setError(e.message));
}, [id]);
const edit = (i: number, patch: Partial<ExtractedMarker>) =>
setMarkers((m) => m.map((row, j) => (j === i ? { ...row, ...patch } : row)));
const remove = (i: number) => setMarkers((m) => m.filter((_, j) => j !== i));
const addRow = () => setMarkers((m) => [...m, { panel: null, name: "", value: "", unit: null, referenceRange: null, flagged: false }]);
const confirm = async () => {
if (!id) return;
setBusy(true);
setError(null);
try {
const { drawId } = await api.confirmDraft(id, { collectedDate, labName: labName || null, markers });
nav(`/labs/draw/${drawId}`);
} catch (e) {
setError(e instanceof Error ? e.message : "confirm failed");
} finally {
setBusy(false);
}
};
if (!meta) return <p>Loading…</p>;
return (
<section>
<h2>Review: {meta.filename}</h2>
{meta.error && <p className="error">Extraction failed: {meta.error}. You can still enter values manually below.</p>}
{meta.status !== "pending" && <p className="muted">This draft is already {meta.status}.</p>}
<p className="hint">Check every value against the PDF (<a href={`/api/labs/drafts/${id}/file`} target="_blank" rel="noreferrer">open original</a>). Nothing is saved until you confirm.</p>
<div className="row">
<label>Collected <input type="date" value={collectedDate} onChange={(e) => setCollectedDate(e.target.value)} /></label>
<label>Lab <input value={labName} onChange={(e) => setLabName(e.target.value)} placeholder="lab name" /></label>
</div>
<table className="review-table">
<thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Range</th><th>Flag</th><th /></tr></thead>
<tbody>
{markers.map((m, i) => (
<tr key={i}>
<td><input value={m.name} onChange={(e) => edit(i, { name: e.target.value })} /></td>
<td><input value={m.value} onChange={(e) => edit(i, { value: e.target.value })} /></td>
<td><input value={m.unit ?? ""} onChange={(e) => edit(i, { unit: e.target.value || null })} /></td>
<td><input value={m.referenceRange ?? ""} onChange={(e) => edit(i, { referenceRange: e.target.value || null })} /></td>
<td><input type="checkbox" checked={m.flagged} onChange={(e) => edit(i, { flagged: e.target.checked })} /></td>
<td><button className="link" onClick={() => remove(i)}>✕</button></td>
</tr>
))}
</tbody>
</table>
<button className="link" onClick={addRow}>+ add marker</button>
{error && <p className="error">{error}</p>}
{meta.status === "pending" && (
<div className="row">
<button disabled={busy || !collectedDate || markers.length === 0} onClick={confirm}>
{busy ? "Saving…" : `Confirm ${markers.length} markers`}
</button>
<button
className="danger"
onClick={() =>
id &&
api
.discardDraft(id)
.then(() => nav("/labs"))
.catch((e) => setError(e instanceof Error ? e.message : "discard failed"))
}
>
Discard
</button>
</div>
)}
</section>
);
}

66
web/src/pages/LabDraw.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { Fragment, useEffect, useState } from "react";
import { useParams } from "react-router";
import { api, type LabDrawDetail } from "../api";
import { Sparkline } from "../components/Sparkline";
export function LabDraw() {
const { id } = useParams<{ id: string }>();
const [detail, setDetail] = useState<LabDrawDetail | null>(null);
const [history, setHistory] = useState<Record<string, { display: string; canonicalUnit: string; points: { collectedAt: string; value: number }[] }>>({});
useEffect(() => {
if (id) api.labDraw(id).then(setDetail).catch(() => setDetail(null));
}, [id]);
const toggleHistory = async (key: string) => {
if (history[key]) {
setHistory((h) => { const { [key]: _, ...rest } = h; return rest; });
return;
}
try {
const h = await api.markerHistory(key);
setHistory((prev) => ({ ...prev, [key]: h }));
} catch {
// Silently no-op: history is a supplemental, click-to-expand feature —
// a failed fetch should never throw unhandled or break the row.
}
};
if (!detail) return <p>Loading…</p>;
return (
<section>
<h2>{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}</h2>
{detail.panels.map((p) => (
<div key={p.panel}>
<h3 className="panel-title">{p.panel}</h3>
<table className="lab-table">
<tbody>
{p.markers.map((m) => (
<Fragment key={m.id}>
<tr className={m.flagged ? "flagged" : undefined}>
<td>
{m.analyteKey
? <button className="link" onClick={() => toggleHistory(m.analyteKey!)}>{m.name}</button>
: m.name}
</td>
<td className="num">{m.value} {m.unit ?? ""}</td>
<td className="muted">{m.referenceRange ?? ""}</td>
<td>{m.flagged ? <span className="flag">●</span> : null}</td>
</tr>
{m.analyteKey && history[m.analyteKey] && (
<tr>
<td colSpan={4}>
<Sparkline points={history[m.analyteKey].points.map((pt) => pt.value)} />
<span className="muted"> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}</span>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
))}
</section>
);
}

72
web/src/pages/Labs.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { api } from "../api";
export function Labs() {
const nav = useNavigate();
const fileInput = useRef<HTMLInputElement>(null);
const [draws, setDraws] = useState<Awaited<ReturnType<typeof api.labDraws>>["draws"]>([]);
const [drafts, setDrafts] = useState<Awaited<ReturnType<typeof api.labDrafts>>["drafts"]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
api.labDraws().then((r) => setDraws(r.draws)).catch((e) => setError(e.message));
api.labDrafts().then((r) => setDrafts(r.drafts.filter((d) => d.status === "pending"))).catch(() => {});
}, []);
useEffect(refresh, [refresh]);
const onFile = async (file: File) => {
setBusy(true);
setError(null);
try {
const { id } = await api.uploadLab(file);
nav(`/labs/draft/${id}`);
} catch (e) {
setError(e instanceof Error ? e.message : "upload failed");
} finally {
setBusy(false);
}
};
return (
<section>
<div className="row-between">
<h2>Labs</h2>
<button disabled={busy} onClick={() => fileInput.current?.click()}>
{busy ? "Extracting…" : "Upload lab PDF"}
</button>
<input ref={fileInput} type="file" accept="application/pdf" hidden
onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
</div>
{error && <p className="error">{error}</p>}
<p className="hint">Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.</p>
{drafts.length > 0 && (
<>
<h3>Awaiting review</h3>
<ul className="list">
{drafts.map((d) => (
<li key={d.id}>
<Link to={`/labs/draft/${d.id}`}>{d.filename}</Link>
{d.error ? <span className="error"> extraction failed</span> : <span className="muted"> {d.markerCount} markers</span>}
</li>
))}
</ul>
</>
)}
<h3>Draws</h3>
{draws.length === 0 && <p className="muted">No confirmed draws yet.</p>}
<ul className="list">
{draws.map((d) => (
<li key={d.id}>
<Link to={`/labs/draw/${d.id}`}>{d.collectedAt}</Link>
<span className="muted"> {d.labName ?? ""} · {d.markerCount} markers</span>
{d.flaggedCount > 0 && <span className="flag"> {d.flaggedCount} flagged</span>}
</li>
))}
</ul>
</section>
);
}

View File

@@ -5,3 +5,19 @@ nav { display: flex; gap: 16px; margin-bottom: 24px; }
form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; } form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; }
input { padding: 8px; } input { padding: 8px; }
.error { color: crimson; } .error { color: crimson; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row-between { display: flex; justify-content: space-between; align-items: center; }
.list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.muted { color: color-mix(in srgb, currentColor 55%, transparent); }
.hint { color: color-mix(in srgb, currentColor 55%, transparent); font-size: 0.9em; }
.flag { color: crimson; }
.flagged td { color: crimson; }
.review-table, .lab-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.review-table td, .review-table th, .lab-table td { padding: 6px 8px; text-align: left; }
.lab-table tr { border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); }
.review-table input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; }
.num { font-variant-numeric: tabular-nums; }
.panel-title { text-transform: capitalize; margin-bottom: 4px; }
button.link { background: none; border: none; color: inherit; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
button.danger { background: none; border: 1px solid crimson; color: crimson; }
.sparkline { color: #4a9eda; display: block; margin: 4px 0; }