From 1aa600adfca7508bfd59e881ae7a674ec7db9d99 Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 16:01:59 -0700 Subject: [PATCH] feat(web): labs upload, draft review, draw dashboard with history sparklines Co-Authored-By: Claude Fable 5 --- web/src/App.tsx | 7 +++ web/src/api.ts | 28 +++++++++- web/src/components/Sparkline.tsx | 14 +++++ web/src/pages/LabDraft.tsx | 88 ++++++++++++++++++++++++++++++++ web/src/pages/LabDraw.tsx | 61 ++++++++++++++++++++++ web/src/pages/Labs.tsx | 72 ++++++++++++++++++++++++++ web/src/styles.css | 16 ++++++ 7 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 web/src/components/Sparkline.tsx create mode 100644 web/src/pages/LabDraft.tsx create mode 100644 web/src/pages/LabDraw.tsx create mode 100644 web/src/pages/Labs.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ff7936..210f885 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,9 @@ import { api } from "./api"; import { Gate } from "./pages/Gate"; import { Settings } from "./pages/Settings"; import { Today } from "./pages/Today"; +import { Labs } from "./pages/Labs"; +import { LabDraft } from "./pages/LabDraft"; +import { LabDraw } from "./pages/LabDraw"; export function App() { const [me, setMe] = useState(null); @@ -18,11 +21,15 @@ export function App() {
} /> + } /> + } /> + } /> } />
diff --git a/web/src/api.ts b/web/src/api.ts index 82acad3..81f99a5 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); @@ -6,6 +6,24 @@ async function req(path: string, init?: RequestInit): Promise { return res.status === 204 ? (undefined as T) : res.json(); } +async function uploadFile(path: string, file: File): Promise { + 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 = { me: () => req("/api/me"), setup: (password: string) => req("/api/setup", { method: "POST", body: JSON.stringify({ password }) }), @@ -13,4 +31,12 @@ export const api = { logout: () => req("/api/logout", { method: "POST" }), getSettings: () => req("/api/settings"), putSettings: (body: SettingsUpdate) => req("/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(`/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(`/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`), }; diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx new file mode 100644 index 0000000..604f4f1 --- /dev/null +++ b/web/src/components/Sparkline.tsx @@ -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 ( + + + + + ); +} diff --git a/web/src/pages/LabDraft.tsx b/web/src/pages/LabDraft.tsx new file mode 100644 index 0000000..adfcdaf --- /dev/null +++ b/web/src/pages/LabDraft.tsx @@ -0,0 +1,88 @@ +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([]); + const [error, setError] = useState(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) => + 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

Loading…

; + return ( +
+

Review: {meta.filename}

+ {meta.error &&

Extraction failed: {meta.error}. You can still enter values manually below.

} + {meta.status !== "pending" &&

This draft is already {meta.status}.

} +

Check every value against the PDF (open original). Nothing is saved until you confirm.

+ +
+ + +
+ + + + + {markers.map((m, i) => ( + + + + + + + + + ))} + +
NameValueUnitRangeFlag
edit(i, { name: e.target.value })} /> edit(i, { value: e.target.value })} /> edit(i, { unit: e.target.value || null })} /> edit(i, { referenceRange: e.target.value || null })} /> edit(i, { flagged: e.target.checked })} />
+ + + {error &&

{error}

} + {meta.status === "pending" && ( +
+ + +
+ )} +
+ ); +} diff --git a/web/src/pages/LabDraw.tsx b/web/src/pages/LabDraw.tsx new file mode 100644 index 0000000..a9acc90 --- /dev/null +++ b/web/src/pages/LabDraw.tsx @@ -0,0 +1,61 @@ +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(null); + const [history, setHistory] = useState>({}); + + 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; + } + const h = await api.markerHistory(key); + setHistory((prev) => ({ ...prev, [key]: h })); + }; + + if (!detail) return

Loading…

; + return ( +
+

{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}

+ {detail.panels.map((p) => ( +
+

{p.panel}

+ + + {p.markers.map((m) => ( + + + + + + + + {m.analyteKey && history[m.analyteKey] && ( + + + + )} + + ))} + +
+ {m.analyteKey + ? + : m.name} + {m.value} {m.unit ?? ""}{m.referenceRange ?? ""}{m.flagged ? ● : null}
+ pt.value)} /> + {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit} +
+
+ ))} +
+ ); +} diff --git a/web/src/pages/Labs.tsx b/web/src/pages/Labs.tsx new file mode 100644 index 0000000..1216325 --- /dev/null +++ b/web/src/pages/Labs.tsx @@ -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(null); + const [draws, setDraws] = useState>["draws"]>([]); + const [drafts, setDrafts] = useState>["drafts"]>([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+
+

Labs

+ + e.target.files?.[0] && onFile(e.target.files[0])} /> +
+ {error &&

{error}

} +

Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.

+ + {drafts.length > 0 && ( + <> +

Awaiting review

+
    + {drafts.map((d) => ( +
  • + {d.filename} + {d.error ? extraction failed : {d.markerCount} markers} +
  • + ))} +
+ + )} + +

Draws

+ {draws.length === 0 &&

No confirmed draws yet.

} +
    + {draws.map((d) => ( +
  • + {d.collectedAt} + {d.labName ?? ""} · {d.markerCount} markers + {d.flaggedCount > 0 && {d.flaggedCount} flagged} +
  • + ))} +
+
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index a261856..f4702e8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5,3 +5,19 @@ nav { display: flex; gap: 16px; margin-bottom: 24px; } form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; } input { padding: 8px; } .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; }