feat(web): labs upload, draft review, draw dashboard with history sparklines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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`),
|
||||||
};
|
};
|
||||||
|
|||||||
14
web/src/components/Sparkline.tsx
Normal file
14
web/src/components/Sparkline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
web/src/pages/LabDraft.tsx
Normal file
88
web/src/pages/LabDraft.tsx
Normal file
@@ -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<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"))}>Discard</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/pages/LabDraw.tsx
Normal file
61
web/src/pages/LabDraw.tsx
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
|
const h = await api.markerHistory(key);
|
||||||
|
setHistory((prev) => ({ ...prev, [key]: h }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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
72
web/src/pages/Labs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user