From e8a4fcfc603e0c20d6ebed3ee38f18086a758e32 Mon Sep 17 00:00:00 2001 From: Yoshio Date: Wed, 19 Aug 2026 13:51:26 +0700 Subject: [PATCH 01/11] Stream the ward live, and fit the board on one screen Adds a demo-only telemetry driver and reworks the board so a clinician does not scroll to see it. Both were verified against the running stack. THE WARD CLOCK A tick now continues each stay's own hourly grid instead of stamping the wall clock. `/ward/seed` already walked that grid, so only the live tick diverged, and it diverged in ways that mattered: the latch clock saw seconds between readings while the physiology advanced an hour, so `demote_dwell_min = 120` was unreachable and no band could ever step back down. Every parameter also aged in seconds, and the stay's start slid an hour further into the past on each reading. 44 ticks of seed and the same span driven through tick now produce identical trajectories -- 17 band changes, 3 completed demotions. CONCURRENCY AND RECORDS - `/api/ward/tick` and `/api/ward/seed` share one guard. Both read then write the same StayState with no version predicate, so concurrently a tick's write landed after the seed's delete -- a lost update with no error anywhere. - The stream driver is epoch-guarded and waits out any tick already in flight. Pause then Play inside the cadence window used to issue a second tick, take a 409, and kill the stream. - `reviewed_at` stays the real instant and `ward_time_at_review` is recorded beside it. Stamping the ward's clock into `reviewed_at` fixed an ordering problem by writing a time at which nothing happened, in the only human-authored record the system holds. - A failed regeneration no longer overwrites a good stored explanation with the permanent "unavailable" string. - An explanation is discarded when the reading it describes is superseded, so prose cannot sit under a score it does not belong to. THE SCALE LABELS Labels overlapped because the row counter saturated and wrote surplus labels on top of each other unchecked, and because the collision threshold was a fraction of the score axis while a label is a fixed 78px -- 116px of clearance at one viewport and 48px at another. Placement is now measured in pixels and labels move sideways, with the leader lines tying each back to its mark. One global pass keeps position monotonic in score, so the lines cannot cross. Verified at seven track widths and ten degenerate inputs. THE LAYOUT 2107px against an 876px viewport became one screen that does not scroll. The simulation panel moved out of the clinician's column into the chrome -- 307px of prototype affordance was pushing the ward off the bottom. Score and bed identifiers sit on one baseline rather than stacked, taking rows from 64px to 47px. All seven scored beds are visible at once; the data-limited bed keeps its separate section and needs a short scroll within the list pane. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LZbJV3yRcxhRHBzEBannhP --- back-end/README.md | 11 +- back-end/controllers/assessmentController.js | 152 ++++++++++++--- back-end/model/Prompt.js | 8 + back-end/pythonService/app.py | 117 +++++++++++- back-end/pythonService/synthetic_ward.py | 2 +- back-end/routes/api/assessment.js | 1 + checks/README.md | 2 +- checks/check_llm.py | 2 +- contract/clinical.ts | 8 + front-end/src/App.tsx | 63 ++++-- .../src/components/board/DataLimitedRow.tsx | 8 +- .../src/components/board/InputStatusPanel.tsx | 27 ++- front-end/src/components/board/PatientRow.tsx | 16 +- .../components/board/SelectedPatientPanel.tsx | 6 +- front-end/src/components/board/WardScale.tsx | 179 ++++++++++++------ front-end/src/components/chrome/AppHeader.tsx | 23 +++ .../src/components/chrome/SimulationBar.tsx | 168 ++++++++++++++++ .../components/detail/ExplanationPanel.tsx | 71 +++++-- front-end/src/data/WardProvider.tsx | 23 ++- front-end/src/data/feed.ts | 70 ++++++- front-end/src/hooks/useApi.ts | 40 +++- front-end/src/hooks/useMeasuredWidth.ts | 41 ++++ front-end/src/hooks/useWardClock.ts | 38 ++++ front-end/src/hooks/useWardStream.ts | 159 ++++++++++++++++ front-end/src/lib/labelPlacement.ts | 149 +++++++++++++++ front-end/src/screens/PatientDetail.tsx | 9 +- .../src/screens/PatientOverviewBoard.tsx | 35 ++-- 27 files changed, 1259 insertions(+), 169 deletions(-) create mode 100644 front-end/src/components/chrome/SimulationBar.tsx create mode 100644 front-end/src/hooks/useMeasuredWidth.ts create mode 100644 front-end/src/hooks/useWardClock.ts create mode 100644 front-end/src/hooks/useWardStream.ts create mode 100644 front-end/src/lib/labelPlacement.ts diff --git a/back-end/README.md b/back-end/README.md index 1e13f71..45468dd 100644 --- a/back-end/README.md +++ b/back-end/README.md @@ -11,8 +11,8 @@ front-end (Vite) -> this API :3500 -> MongoDB Atlas **This layer stores; the model service scores.** Reads never reach the model service — everything the board shows comes out of Mongo, which is what makes the history durable and -a restart harmless. Only `/api/ward/seed`, `/api/ward/tick` and `/api/patient/:id/explain` -cross the boundary. +a restart harmless. Only `/api/ward/seed`, `/api/ward/tick`, `/api/ward/warmup` and +`/api/patient/:id/explain` cross the boundary. Mongo is not a cache. Each stored reading carries the band the hysteresis machine *published* at the time, which is not a function of that reading's score and cannot be @@ -39,13 +39,14 @@ The board is empty until `POST /api/ward/seed`. | Method | Path | | |---|---|---| | GET | `/api/ward` | every bed's latest assessment | -| POST | `/api/ward/seed` | build the ward, backfill 24 hours of scored history | -| POST | `/api/ward/tick` | advance every bed by one reading | +| POST | `/api/ward/seed` | build the ward and backfill `backfill_ticks` hourly readings (default 24) | +| POST | `/api/ward/tick` | advance every bed by one reading, an hour on the ward's clock | +| POST | `/api/ward/warmup` | load the 7B ahead of the first explanation (~40 s, stores nothing) | | GET | `/api/patient/:id` | one patient's current assessment | | GET | `/api/patient/:id/history` | recent assessments, oldest first | | GET | `/api/patient/:id/context` | borrowed demographics and comorbidities | | GET | `/api/patient/:id/parameter/:name` | one parameter's charting history | -| POST | `/api/patient/:id/explain` | generate the explanation (slow; `use_llm: false` for the template) | +| POST | `/api/patient/:id/explain` | generate the explanation (slow; `assessed_at` names the reading, `use_llm: false` picks the template) | | POST | `/api/patient/:id/device` | switch an input source off or on | | POST | `/api/prompt/:id/review` | record a clinician's disposition | diff --git a/back-end/controllers/assessmentController.js b/back-end/controllers/assessmentController.js index 70023c4..c0fa002 100644 --- a/back-end/controllers/assessmentController.js +++ b/back-end/controllers/assessmentController.js @@ -83,7 +83,22 @@ const persist = async (assessment) => { // the next HIGH reading is not a promotion and never raises a replacement. }; -/** Build the ward and backfill 24 hours of scored history. +// ONE WARD OPERATION AT A TIME. `tickWard` and `seedWard` both read, then write, +// the same `StayState`, and `seedWard` additionally deletes all three +// collections. Run concurrently, a tick's write lands after the seed's delete and +// leaves a StayState one generation behind its own assessments -- a lost update +// with no error anywhere, because neither path carries a version predicate. +// This runs as a single local process, so a module-level flag is the whole fix; +// a second instance against the same database would need that predicate. +let wardBusy = false; + +const wardBusyResponse = (res) => res.status(409).type('application/problem+json').json({ + type: 'about:blank', title: 'Conflict', status: 409, + detail: 'another ward operation is already in progress', + instance: req_id(res), +}); + +/** Build the ward and backfill its scored history, hourly. * Destructive by design: seeding twice gives the same ward, not two. */ const seedWard = async (req, res) => { if (!ALLOW_DESTRUCTIVE) { @@ -94,6 +109,16 @@ const seedWard = async (req, res) => { instance: req_id(res), }); } + if (wardBusy) return wardBusyResponse(res); + wardBusy = true; + try { + return await runSeed(req, res); + } finally { + wardBusy = false; + } +}; + +const runSeed = async (req, res) => { const seed = Number(req.body?.seed ?? DEFAULT_SEED); const ticks = Number(req.body?.backfill_ticks ?? BACKFILL_TICKS); @@ -152,9 +177,23 @@ const seedWard = async (req, res) => { /** Advance every bed by one reading, from the state held in Mongo. */ const tickWard = async (req, res) => { + if (wardBusy) return wardBusyResponse(res); + wardBusy = true; + try { + return await runTick(res); + } finally { + wardBusy = false; + } +}; + +const runTick = async (res) => { const states = await StayState.find().lean(); if (!states.length) { - return res.status(409).json({ message: 'ward not seeded -- POST /api/ward/seed first' }); + return res.status(409).type('application/problem+json').json({ + type: 'about:blank', title: 'Conflict', status: 409, + detail: 'the ward is not seeded -- POST /api/ward/seed first', + instance: req_id(res), + }); } const beds = states.map((s) => ({ @@ -256,26 +295,47 @@ const getParameterHistory = async (req, res) => { res.json(points); }; -/** Generate the explanation for a patient's latest assessment. 18-23 s on a +/** Generate the explanation for one stored reading. 18-23 s on a * local 7B. Every string is grounded against the record before it is stored; * one that fails is replaced by the template, not shown with a warning. */ const explainPatient = async (req, res) => { const { patientId } = req.params; + + // Which reading to explain. Without `assessed_at` this is whichever row is + // newest when the request arrives; with it, the row the caller has on screen. + // On a ward that is advancing those are not the same, and the client's choice + // is the right one -- it is the reading a clinician was actually reading. + const hasTarget = req.body?.assessed_at !== undefined && req.body.assessed_at !== null; + const at = hasTarget ? new Date(req.body.assessed_at) : null; + if (at && Number.isNaN(at.getTime())) { + return res.status(400).json({ message: 'assessed_at is not a date' }); + } + // `+record` because the schema hides it by default. It is what makes the // explanation describe the STORED reading -- sending a tick instead had the // service re-score at its own `now` and narrate a dwell no row ever had. - const latest = await Assessment.findOne({ patient_id: patientId }) - .sort({ assessed_at: -1 }).select('+record').lean(); - if (!latest) { - return res.status(404).json({ message: `no assessment for ${patientId}` }); + // No sort on the targeted branch: `{patient_id, assessed_at}` is unique. + const query = Assessment.findOne( + at ? { patient_id: patientId, assessed_at: at } : { patient_id: patientId } + ); + const target = await (at ? query : query.sort({ assessed_at: -1 })) + .select('+record').lean(); + if (!target) { + // Two different facts, and an operator reading "no assessment for PM-204" + // when the bed has thirty of them goes looking for an unseeded ward. + return res.status(404).json({ + message: at + ? `no reading for ${patientId} at ${at.toISOString()}` + : `no assessment for ${patientId}` + }); } - if (latest.assessment_status !== 'assessed') { + if (target.assessment_status !== 'assessed') { return res.status(409).json({ message: 'this reading is below the data-sufficiency floor and is not explained', - insufficiency_reason: latest.insufficiency_reason + insufficiency_reason: target.insufficiency_reason }); } - if (!latest.record) { + if (!target.record) { return res.status(409).json({ message: 'this assessment predates record storage -- re-seed the ward' }); @@ -285,7 +345,7 @@ const explainPatient = async (req, res) => { try { const { data } = await explaining.post('/explain/patient', { patient_id: patientId, - record: latest.record, + record: target.record, // The deterministic template floor instead of the 7B. Off by default; the // only way to exercise this path without 6.9 GB of VRAM. use_llm: req.body?.use_llm !== false @@ -295,17 +355,41 @@ const explainPatient = async (req, res) => { return fromUpstream(res, err, 'the explanation generator'); } - await Assessment.updateOne( - { _id: latest._id }, - { - explanation: { - status: result.status, - explanation_text: result.explanation_text, - grounding_status: result.grounding_status + // A FAILED REGENERATION MUST NOT DESTROY A GOOD ONE. + // + // This wrote back unconditionally, so a 7B that OOM'd while re-explaining a + // row that already had grounded prose replaced it with the fixed "unavailable" + // string -- permanently, and the panel offers no way back from that state. An + // explanation that could not be produced is not a reason to discard one that + // was. The caller still gets the real result and can show the failure. + const wouldDestroy = result.status === 'unavailable' + && target.explanation?.status === 'generated'; + if (!wouldDestroy) { + await Assessment.updateOne( + { _id: target._id }, + { + explanation: { + status: result.status, + explanation_text: result.explanation_text, + grounding_status: result.grounding_status + } } - } - ); - res.json(result); + ); + } + res.json({ ...result, stored: !wouldDestroy }); +}; + +/** Load the 7B before it is first needed. Writes nothing. + * Uses the `explaining` client because the work runs on the model thread under + * EXPLAIN_TIMEOUT_S, and the caller must sit above the callee (PM-TIME-001) -- + * not because of how long the load takes. Figures: `.claude/rules/demo.md`. */ +const warmExplainer = async (req, res) => { + try { + const { data } = await explaining.post('/warmup', {}); + return res.json(data); + } catch (err) { + return fromUpstream(res, err, 'the explanation generator'); + } }; /** Borrowed patient context: recorded, never computed by the model. */ @@ -326,6 +410,26 @@ const reviewPrompt = async (req, res) => { return res.status(400).json({ message: `disposition must be one of ${allowed.join(', ')}` }); } + // TWO TIMES, BOTH TRUE. NOT ONE INVENTED ONE. + // + // `raised_at` comes from the reading that raised the prompt, and a simulated + // tick moves the ward an hour ahead of real time -- so a disposition recorded + // now can sit hours "before" the prompt it answers. The fix is NOT to stamp + // `reviewed_at` from the ward's clock: that writes an instant at which nothing + // happened, silently, into the only human-authored record the system holds, + // and it destroys ordering (two reviews inside one tick become identical) and + // fires on ordinary clock skew. It is the same error as a defaulted clinician + // name, and `attributed` below is the pattern -- declare the second fact. + const existing = await Prompt.findById(req.params.promptId).select('patient_id').lean(); + // Scoped to the patient so `{patient_id, assessed_at}` serves it; ward-wide + // this is a full collection scan that grows with every tick. + const newest = existing + ? await Assessment.findOne({ patient_id: existing.patient_id }) + .sort({ assessed_at: -1 }).select('assessed_at').lean() + : null; + const reviewedAt = new Date(); + const wardTime = newest ? new Date(newest.assessed_at) : null; + // ATTRIBUTION COMES FROM AN AUTHENTICATED PRINCIPAL, OR IT IS DECLARED ABSENT. // // This used to read `clinician: clinician || 'ICU Clinician'` -- a free-text @@ -346,7 +450,10 @@ const reviewPrompt = async (req, res) => { review: { disposition, note: note || null, - reviewed_at: new Date(), + reviewed_at: reviewedAt, + // Null when it adds nothing -- only recorded where the two genuinely differ. + ward_time_at_review: + wardTime && wardTime.getTime() > reviewedAt.getTime() ? wardTime : null, clinician: actor, attributed: actor !== null } @@ -402,6 +509,7 @@ module.exports = { getParameterHistory, getPatientContext, explainPatient, + warmExplainer, reviewPrompt, setDeviceState }; diff --git a/back-end/model/Prompt.js b/back-end/model/Prompt.js index 07f5d17..0ab2e79 100644 --- a/back-end/model/Prompt.js +++ b/back-end/model/Prompt.js @@ -30,7 +30,15 @@ const promptSchema = new Schema({ }, // A short tracking note. Clinical documentation stays in the EHR. note: { type: String, default: null }, + // The real instant the clinician acted. Always wall clock, always true. reviewed_at: Date, + // What the ward's own clock read at that moment. A simulated tick moves the + // ward an hour, so `raised_at` can sit hours ahead of `reviewed_at` and the + // record reads as a disposition answering a prompt that did not exist yet. + // Declared as a second fact rather than folded into the first: writing the + // ward's time INTO `reviewed_at` would record an instant at which nothing + // happened, which is the same error as a defaulted clinician name. + ward_time_at_review: { type: Date, default: null }, // Null until authentication exists. `attributed` says so explicitly, because // a reader seeing a blank name cannot tell "nobody was identified" from // "the field was not populated". Never taken from the request body. diff --git a/back-end/pythonService/app.py b/back-end/pythonService/app.py index 231ac3f..9afbfe2 100644 --- a/back-end/pythonService/app.py +++ b/back-end/pythonService/app.py @@ -1,7 +1,8 @@ """PulseMind model service. Scores, bands and explains; stores nothing. POST /ward/seed build the ward and backfill its history - POST /ward/tick one more reading per bed + POST /ward/tick one more reading per bed, on the stay's hourly grid + POST /warmup load the 7B ahead of the first explanation POST /explain/patient explain a stored record in plain language (slow) GET /healthz model, band table and scoring device @@ -14,6 +15,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timezone +from http import HTTPStatus from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse @@ -101,7 +103,15 @@ class BedState(BaseModel): class TickRequest(BaseModel): model_config = _STRICT seed: int = 20260817 - beds: list[BedState] = Field(..., max_length=64) + # At least one: the response reports the ward's clock as the newest reading + # across the beds, and there is no such thing for an empty ward. + beds: list[BedState] = Field(..., min_length=1, max_length=64) + + +class WarmupRequest(BaseModel): + """No fields, but a model all the same -- `extra="forbid"` then rejects a + caller who sends options this endpoint would silently ignore.""" + model_config = _STRICT class ExplainRequest(BaseModel): @@ -230,6 +240,26 @@ async def readyz() -> JSONResponse: ) +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """ONE ERROR SHAPE (PM-ERR-001): RFC 9457 `application/problem+json`. + + FastAPI's default is `{"detail": ...}` with a plain JSON content type, which + left this service answering in two shapes -- the `Overloaded` handler above + already did it properly. `detail` survives, so Node's `fromUpstream` reads + the same field it always did. + """ + return JSONResponse( + status_code=exc.status_code, + media_type="application/problem+json", + headers=getattr(exc, "headers", None), + content={"type": "about:blank", + "title": HTTPStatus(exc.status_code).phrase, + "status": exc.status_code, + "detail": exc.detail}, + ) + + @app.post("/ward/seed") def seed(request: SeedRequest) -> dict: """Build the ward and score its backfilled history, oldest reading first. @@ -271,16 +301,87 @@ def seed(request: SeedRequest) -> dict: @app.post("/ward/tick") def tick(request: TickRequest) -> dict: - """One more reading per bed, from the state Node read back out of Mongo.""" - now = datetime.now(timezone.utc).replace(microsecond=0) - out = [] + """One more reading per bed, from the state Node read back out of Mongo. + + The reading time continues each stay's OWN hourly grid rather than the wall + clock. `/ward/seed` already walks that grid (`start + TICK * tick`), and both + `synthetic_ward.TICK` and the dwell in the band table are denominated in it. + Stamping `now()` here made a live tick behave unlike a backfilled one: + + - The latch clock is `observed_at - origin` in minutes, so consecutive readings + sat seconds apart while the physiology advanced an hour. Promotion has zero + dwell and survived that; `demote_dwell_min = 120` did not, so no band could + ever step back down and the recovering bed latched for ever. + - Every parameter aged in seconds. `age_minutes` is what the board shows as + staleness and what carry-forward is judged on, and it read ~0 on a reading + an hour newer than the last. + - `_score_tick` derives the stay's start as `at - TICK * tick`, so a + wall-clock `at` slid that start -- and `ventilation_start` with it -- an + hour further into the past on every reading. + """ + # RESOLVED BEFORE ANY SCORING. No fallback: `_score` subscripts + # `state["origin"]` directly, so a stay without one cannot be scored whatever + # we do here, and substituting the wall clock would stamp a reading BEHIND + # the ones already stored for that bed -- which `getWard` then keeps sorting + # above the new one, freezing the bed on screen with nothing logged. Done as + # a pre-pass because inside the loop a bad eighth bed costs seven GPU + # scorings before the refusal, on every retry. + schedule = [] for bed_state in request.beds: - bed = _bed(bed_state.patient_id) - step = _score_tick(bed, bed_state.tick + 1, now, request.seed, + bed = _bed(bed_state.patient_id) # 404s here, before any scoring + origin = bed_state.stay_state.get("origin") + try: + # Not just falsy: a malformed non-empty string used to reach + # `fromisoformat`, raise, and leave Node reporting "the model service + # did not respond" about a service that answered precisely. + at = datetime.fromisoformat(origin) + sw.TICK * (bed_state.tick + 1) + except (TypeError, ValueError): + raise HTTPException( + 422, f"{bed_state.patient_id}: stay state carries no usable origin " + f"({origin!r}), so its readings cannot be placed on the ward's " + "clock -- re-seed the ward") from None + schedule.append((bed, bed_state, at)) + + out, times = [], [] + for bed, bed_state, at in schedule: + times.append(at) + step = _score_tick(bed, bed_state.tick + 1, at, request.seed, bed_state.stay_state, set(bed_state.offline_devices), bed_state.last_band) out.append(step) - return {"at": now.isoformat(), "patients": out} + # The ward's clock is the NEWEST reading across the beds: per-bed times differ + # when one stay started later, and the board compares its own "now" against + # the newest of them. + return {"at": max(times).isoformat(), "patients": out} + + +@app.post("/warmup") +def warmup(request: WarmupRequest = WarmupRequest()) -> dict: + """Load the 7B now, so the first explanation of a session is not the slow one. + + Goes through the model thread like everything else that touches CUDA, so it + queues behind scoring rather than racing it, and it writes nothing: the + alternative -- explaining some bed to warm the weights -- leaves a real + explanation attached to a reading nobody asked about. Measured cold and warm + figures are in `.claude/rules/demo.md`, in one place, once. + """ + if expl.generator_loaded(): + return {"explainer": "loaded", "was_loaded": True} + try: + rt.on_model_thread(expl.generator) + except rt.Overloaded: + raise # the 503 + Retry-After handler owns this one + except Exception as failure: # noqa: BLE001 + # A generator that cannot load must not take scoring down with it -- + # `explanation.py` wraps the same call for the same reason. Bare, it + # escapes as text/plain and Node reports "did not respond", which sends + # an operator to restart a service that answered correctly. + raise HTTPException( + 503, f"the explainer did not load: {type(failure).__name__}") from failure + # Observed, not asserted: `generator()` returning without loading would make + # a hard-coded "loaded" a false record. + return {"explainer": "loaded" if expl.generator_loaded() else "unavailable", + "was_loaded": False} @app.post("/explain/patient") diff --git a/back-end/pythonService/synthetic_ward.py b/back-end/pythonService/synthetic_ward.py index c6d7e82..8841f27 100644 --- a/back-end/pythonService/synthetic_ward.py +++ b/back-end/pythonService/synthetic_ward.py @@ -21,7 +21,7 @@ from pipeline.core.features import Infusion, PatientContext, Reading, ServingAssets -TICK = timedelta(hours=1) # matches the 60-minute grid the dwell was fitted on +TICK = timedelta(hours=1) # the 60-minute grid the dwell is denominated in ALL_PARAMS = ("spo2", "fio2", "flow_rate", "peep", "pip", "respiratory_rate_total", "minute_volume", "tidal_volume_observed", "etco2", "inspiratory_ratio", "expiratory_ratio") diff --git a/back-end/routes/api/assessment.js b/back-end/routes/api/assessment.js index c62e7b8..5c63355 100644 --- a/back-end/routes/api/assessment.js +++ b/back-end/routes/api/assessment.js @@ -16,6 +16,7 @@ const h = asyncHandler; router.get('/ward', h(assessmentController.getWard)); router.post('/ward/seed', h(assessmentController.seedWard)); router.post('/ward/tick', h(assessmentController.tickWard)); +router.post('/ward/warmup', h(assessmentController.warmExplainer)); router.get('/patient/:patientId', h(assessmentController.getPatient)); router.get('/patient/:patientId/history', h(assessmentController.getHistory)); diff --git a/checks/README.md b/checks/README.md index d2b6afd..ca541c3 100644 --- a/checks/README.md +++ b/checks/README.md @@ -58,7 +58,7 @@ inputs were identical throughout, because the deciding share tracks where the mo attribution landed rather than how many inputs were missing. **`check_llm.py`** — the 7B path end to end: grounding passed, and the stored band, dwell -and score are unchanged by explaining. Cold load is ~40 s, warm ~13 s, and the text is +and score are unchanged by explaining. Cold load is ~40 s through the service, and the text is byte-identical across runs by design. ⚠️ **This leaves ~4.7 GB of VRAM occupied until you stop the model service.** The model is diff --git a/checks/check_llm.py b/checks/check_llm.py index 9967d87..137bf08 100644 --- a/checks/check_llm.py +++ b/checks/check_llm.py @@ -53,7 +53,7 @@ def show(label, expected, got, extra=""): print(f"stored contributors: " f"{[c['feature_name'] for c in target['contributors'][:3]]}") -print("\ngenerating with the 7B (cold load is ~23 s before the first token)") +print("\ngenerating with the 7B (cold load is ~40 s through the service before the first token)") started = time.perf_counter() status, out = call("POST", f"/patient/{patient}/explain", {}) elapsed = time.perf_counter() - started diff --git a/contract/clinical.ts b/contract/clinical.ts index 18e206f..27b12be 100644 --- a/contract/clinical.ts +++ b/contract/clinical.ts @@ -121,7 +121,15 @@ export interface RiskPrompt { export interface ClinicianReview { disposition: Disposition note: string | null + /** The real instant the clinician acted. Wall clock, always. */ reviewed_at: string + /** + * What the ward's clock read at that moment, when it differs. A simulated + * tick advances the ward an hour, so a disposition can be recorded hours + * "before" the prompt it answers. Kept as a separate fact: putting the ward's + * time into `reviewed_at` would record an instant at which nothing happened. + */ + ward_time_at_review: string | null /** * The authenticated principal, or null when there is none. Never supplied by * the caller — a disposition that names whoever asked for it is not an audit diff --git a/front-end/src/App.tsx b/front-end/src/App.tsx index b98b8dc..74ffe45 100644 --- a/front-end/src/App.tsx +++ b/front-end/src/App.tsx @@ -3,30 +3,59 @@ import { WardProvider } from './data/WardProvider' import { AppHeader } from './components/chrome/AppHeader' import { ErrorBoundary } from './components/chrome/ErrorBoundary' import { SafetyFooter } from './components/chrome/SafetyFooter' +import { SimulationBar } from './components/chrome/SimulationBar' import { PatientOverviewBoard } from './screens/PatientOverviewBoard' import { PatientDetail } from './screens/PatientDetail' import { ParameterDetail } from './screens/ParameterDetail' +/** + * The shell. + * + * AT `xl` AND ABOVE THE PAGE DOES NOT SCROLL. It is exactly one viewport tall, + * and the board fills what is left between the chrome; anything long scrolls + * inside its own pane. A ward board whose beds run off the bottom fails at the + * one job it has, and ICU staff are interrupted often enough that a glance must + * not begin with a scroll. + * + * Below `xl` this stays an ordinary scrolling page: the aside already stacks + * under the triage list there, and a small-screen no-scroll layout would be a + * different design rather than this one made narrow. + * + * Two things are load-bearing here. + * + * `min-h-0` on `main`: a flex child's default `min-height:auto` refuses to + * shrink below its content, so without it the pane grows to fit the board and + * the page scrolls again — the overflow rule never gets the chance to apply. + * + * `fixed inset-0` rather than `h-[100dvh]`: height alone left the document with + * a scroll range of its own on the long patient screen, so the window scrolled + * AND the pane scrolled, which is worse than either. Out of flow, `body` has no + * content height and the document cannot scroll at all — the panes are the only + * things that can. + */ export default function App() { return ( -
- -
- - - } /> - } /> - } - /> - } /> - - -
- -
+
+
+ + +
+
+ + + } /> + } /> + } + /> + } /> + + +
+ +
) } diff --git a/front-end/src/components/board/DataLimitedRow.tsx b/front-end/src/components/board/DataLimitedRow.tsx index a1ac27c..6a7709c 100644 --- a/front-end/src/components/board/DataLimitedRow.tsx +++ b/front-end/src/components/board/DataLimitedRow.tsx @@ -37,9 +37,13 @@ export function DataLimitedRow({ assessment, selected, onSelect, now }: DataLimi type="button" onClick={onSelect} aria-pressed={selected} - className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 text-left sm:px-4" + className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-1.5 text-left sm:px-4" > - + {/* Bed and patient on ONE baseline. Both are identifiers for the same + person, and stacked they were 39px — the row's binding height once + the score was inlined. Eight rows of it was the difference between + a board showing six beds and one showing all eight. */} + {assessment.bed_code} diff --git a/front-end/src/components/board/InputStatusPanel.tsx b/front-end/src/components/board/InputStatusPanel.tsx index 9167d15..7d7fd44 100644 --- a/front-end/src/components/board/InputStatusPanel.tsx +++ b/front-end/src/components/board/InputStatusPanel.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react' +import { ChevronRight } from 'lucide-react' import type { DeviceState, InputDevice } from '@contract/clinical' import { useWard } from '../../data/WardProvider' import { cn } from '../../lib/cn' @@ -31,6 +33,13 @@ const STATE_LABEL: Record = { export function InputStatusPanel({ devices, now, patientId }: InputStatusPanelProps) { const { toggleDevice } = useWard() const offline = devices.filter((device) => device.state === 'offline') + // Collapsed by default, and it STAYS where you put it. Not a hover reveal and + // nothing that closes itself: every glance at this screen is a resumption, so + // content that appears and disappears on its own is content a returning nurse + // cannot rely on. Open whenever a source is already dropped, because then the + // controls explain something on screen rather than merely offering to. + const [showControls, setShowControls] = useState(false) + const open = showControls || offline.length > 0 return ( @@ -72,7 +81,19 @@ export function InputStatusPanel({ devices, now, patientId }: InputStatusPanelPr
-

Simulate source loss

+ {offline.length > 0 && (
) diff --git a/front-end/src/components/board/PatientRow.tsx b/front-end/src/components/board/PatientRow.tsx index b33a8a6..2ea4953 100644 --- a/front-end/src/components/board/PatientRow.tsx +++ b/front-end/src/components/board/PatientRow.tsx @@ -43,9 +43,13 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr type="button" onClick={onSelect} aria-pressed={selected} - className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-2.5 text-left sm:px-4" + className="flex min-w-0 flex-1 flex-wrap items-center gap-x-5 gap-y-2 px-3 py-1.5 text-left sm:px-4" > - + {/* Bed and patient on ONE baseline. Both are identifiers for the same + person, and stacked they were 39px — the row's binding height once + the score was inlined. Eight rows of it was the difference between + a board showing six beds and one showing all eight. */} + {assessment.bed_code} @@ -54,7 +58,13 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr - + {/* Numeral and caption on ONE baseline, not stacked. Stacked, this was + 44px and the tallest thing in the row — it, not the bed/patient + pair, set the row height, and eight of them pushed two beds off a + board whose whole job is showing all of them at once. Nothing is + lost: the word still labels the number, beside it instead of under + it. */} + {formatScore(assessment.risk_score)} diff --git a/front-end/src/components/board/SelectedPatientPanel.tsx b/front-end/src/components/board/SelectedPatientPanel.tsx index 1d63b8d..67446a4 100644 --- a/front-end/src/components/board/SelectedPatientPanel.tsx +++ b/front-end/src/components/board/SelectedPatientPanel.tsx @@ -4,6 +4,7 @@ import type { Assessment } from '@contract/clinical' import { isScored } from '@contract/clinical' import { SUFFICIENCY_FLOOR, toObservations } from '../../data/feed' import { usePatientHistory } from '../../hooks/useApi' +import { useWard } from '../../data/WardProvider' import { bandMeaning } from '../../data/bands' import { formatPercent, formatScore } from '../../lib/format' import { BandTag } from '../ui/BandTag' @@ -19,7 +20,10 @@ interface SelectedPatientPanelProps { /** The side panel: what the board's selected patient looks like up close. */ export function SelectedPatientPanel({ assessment }: SelectedPatientPanelProps) { - const { data: history } = usePatientHistory(assessment.patient_id) + // Refetched whenever the ward is re-read, so the strip cannot disagree with + // the board beside it. + const { revision } = useWard() + const { data: history } = usePatientHistory(assessment.patient_id, revision) return ( Selected patient diff --git a/front-end/src/components/board/WardScale.tsx b/front-end/src/components/board/WardScale.tsx index 2415ba3..161db96 100644 --- a/front-end/src/components/board/WardScale.tsx +++ b/front-end/src/components/board/WardScale.tsx @@ -3,6 +3,8 @@ import { BANDS } from '../../data/bands' import { BAND_STYLES } from '../../lib/bandStyles' import { cn } from '../../lib/cn' import { formatScore } from '../../lib/format' +import { ROW_PITCH, placeLabels } from '../../lib/labelPlacement' +import { useMeasuredWidth } from '../../hooks/useMeasuredWidth' interface WardScaleProps { patients: ScoredAssessment[] @@ -10,31 +12,35 @@ interface WardScaleProps { onSelect: (patientId: string) => void } -/** Marks closer together than this share a row, so their labels would collide. */ -const COLLISION_DISTANCE = 0.075 -const LABEL_ROWS = 3 +/** Clear space between the lowest label row and the axis, in pixels. Tall enough + * that a displaced leader line leans rather than lies flat: the widest travel + * measured on this ward is 63px, which over 22px reads as a line and not as a + * rule. */ +const LEADER_HEIGHT = 22 /** - * Assign each mark to a label row so nearby bed codes do not overlap. - * Patients are taken in score order and pushed up a row while they are too close to - * the previous one. + * A reading moves a bed along the axis; it must travel there rather than appear + * there. A band change is the moment the board exists to show, and a mark that + * teleports across a cut reads as a redraw instead of a patient deteriorating. + * + * Inline rather than a utility class because all three layers must carry + * identical timing — a label arriving before its own leader line reads as a + * glitch. 700 ms sits under the fastest cadence, so a mark is visibly at rest + * before the next reading moves it. `prefers-reduced-motion` is handled once, + * globally, in index.css, and a stylesheet `!important` beats an inline style, + * so these need no guard of their own. + * + * Position only. Colour is NOT transitioned and never was: `transition` is not + * inherited, these sit on the button, and every colour lives on its child spans. + * + * This only animates because every mark is keyed by `patient_id`. Key these by + * index and the ranked re-sort swaps element identity on each tick, which the + * browser renders as marks jumping between beds. */ -function assignRows(patients: ScoredAssessment[]): Map { - const ordered = [...patients].sort((a, b) => a.risk_score - b.risk_score) - const rows = new Map() - const lastScoreInRow: number[] = new Array(LABEL_ROWS).fill(-Infinity) - - for (const patient of ordered) { - let row = 0 - while (row < LABEL_ROWS - 1 && patient.risk_score - lastScoreInRow[row] < COLLISION_DISTANCE) { - row += 1 - } - lastScoreInRow[row] = patient.risk_score - rows.set(patient.patient_id, row) - } - - return rows -} +const EASE = 'cubic-bezier(0, 0, 0.2, 1)' +const SLIDE = `left 700ms ${EASE}` +const SLIDE_LABEL = `${SLIDE}, bottom 700ms ${EASE}` +const SLIDE_LEADER = `${SLIDE}, height 700ms ${EASE}, transform 700ms ${EASE}` /** * The ward on one calibrated axis. @@ -45,36 +51,113 @@ function assignRows(patients: ScoredAssessment[]): Map { * * It explains where a patient sits and never decides a band. */ -export function WardScale({ patients, selectedId, onSelect }: WardScaleProps) { - const rows = assignRows(patients) +export function WardScale({ patients: given, selectedId, onSelect }: WardScaleProps) { + const [trackRef, trackWidth] = useMeasuredWidth() + const [probeRef, labelWidth] = useMeasuredWidth() + + // A non-finite score would place its label at `left: NaN%`, which the browser + // ignores — so the bed would sit at the far left looking like a real reading + // rather than a broken one. Drop it instead; a bed missing from the axis is + // visible, a bed lying about its position is not. + const patients = given.filter((p) => Number.isFinite(p.risk_score)) + + // Measured off a hidden copy rather than off the labels themselves, which + // would need a second render pass to place what the first pass just drew. + // Built from the longest bed code actually present, so a longer one later + // widens the probe instead of quietly under-reserving space. + const widestCode = patients.reduce( + (widest, p) => (p.bed_code.length > widest.length ? p.bed_code : widest), + 'ICU 00', + ) + + const { placements, rows } = placeLabels( + patients.map((p) => ({ id: p.patient_id, value: p.risk_score })), + trackWidth, + labelWidth, + ) + const byId = new Map(placements.map((p) => [p.id, p])) + const labelsHeight = rows * ROW_PITCH + const pct = (px: number) => (trackWidth > 0 ? (px / trackWidth) * 100 : 0) return ( -
- {/* Bed labels, stacked into rows so nearby marks stay readable. */} -
+
+ {/* Invisible but LAID OUT, so its width is the real rendered width of a + label at this font rather than an estimate. `visibility: hidden` and + not an off-screen offset: a negative `left` risks the horizontal + overflow this design guarantees against. */} + + + {/* Labels and their leader lines share one positioned box: a label on the + upper row needs a line that reaches down THROUGH the lower row, which + it cannot do from a sibling strip. */} +
+ {patients.map((patient) => { + const placement = byId.get(patient.patient_id) + if (!placement) return null + const selected = patient.patient_id === selectedId + + // The line is anchored at the mark and rotated about its own foot, so + // its head lands on the displaced label by construction: with a + // rotation of atan(shift / rise) and a length of hypot(shift, rise), + // the top end is exactly `shift` across and `rise` up. + const shift = placement.placedX - placement.trueX + const rise = LEADER_HEIGHT + placement.row * ROW_PITCH + const length = Math.hypot(shift, rise) + const angle = (Math.atan2(shift, rise) * 180) / Math.PI + + return ( +
- {/* Leader lines from each label down to the axis. */} -
- {patients.map((patient) => ( -
- {/* The axis. Segment widths are the calibrated cut points. */}
{BANDS.map((definition) => ( @@ -114,7 +182,8 @@ export function WardScale({ patients, selectedId, onSelect }: WardScaleProps) {
))} - {/* Each patient's position, drawn over the segments. */} + {/* Each patient's position, drawn over the segments. Always the true + score — the label may have moved, the mark never does. */} {patients.map((patient) => (
+ {/* No seconds: the ward steps an hour at a time, so a seconds field here + would be a frozen one sitting beside a live one. Hidden below md — + the row is already at its width budget, and this is the only element + in it that appears mid-session. */} + {simulated && ( + + Ward {formatClock(wardNow, false)} + {/* A demo of ~26 ticks crosses midnight, and time-of-day alone then + reads EARLIER than the wall clock beside it. Measured: ward + 12:19 against a real 12:25, a full day apart. */} + {wardNow.getDate() !== now.getDate() + && `+${Math.round((wardNow.getTime() - now.getTime()) / 86_400_000) || 1}d`} + {' · 1 tick = 1 h'} + + )} {formatDate(now)} · device local time diff --git a/front-end/src/components/chrome/SimulationBar.tsx b/front-end/src/components/chrome/SimulationBar.tsx new file mode 100644 index 0000000..24369c6 --- /dev/null +++ b/front-end/src/components/chrome/SimulationBar.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { Flame, Pause, Play, RotateCcw } from 'lucide-react' +import { useWard } from '../../data/WardProvider' +import { seedWard, warmExplainer } from '../../data/feed' +import { STREAM_CADENCES } from '../../hooks/useWardStream' +import { cn } from '../../lib/cn' + +/** + * Backfill used by "Restart ward". + * + * Short on purpose. The shipped default of 24 puts thirteen of this ward's + * seventeen band changes behind the stream before it starts, including every one + * of the early promotions. Four leaves two promotions on the first streamed + * tick, the first COMPLETED demotion on the tenth, and the two-step recovery at + * the twenty-second and twenty-fourth. + * + * "Completed" is load-bearing: three demotions go pending earlier and never + * finish, so "the first demotion" would be wrong by eight ticks. + */ +const DEMO_BACKFILL = 4 + +/** + * The stand-in for a telemetry feed. + * + * There is no HL7 interface and no message broker; in production readings arrive + * on their own and none of this exists. It is a request like any other — the + * consequence is computed by the model service and read back, never invented + * here. + * + * IN THE CHROME, not the aside. It is entirely prototype affordance, and at 307px + * it was the third-tallest thing in the clinician's own column — pushing the + * ward off the bottom of the screen to hold controls no clinician will ever see. + * One line of chrome costs ~40px and is reachable without scrolling. + */ +export function SimulationBar() { + const { stream, refresh } = useWard() + const [busy, setBusy] = useState<'seed' | 'warm' | null>(null) + const [note, setNote] = useState(null) + const [failure, setFailure] = useState(null) + + function begin(what: 'seed' | 'warm') { + setBusy(what) + setNote(null) + setFailure(null) + // Cleared, not left to outrank what happens next: a stale stream error used + // to render beside this action's success note. + stream.clearError() + } + + async function restart() { + stream.stop() + begin('seed') + try { + // Through `withPause`, not straight after `stop()`. Seeding deletes all + // three collections and the server refuses a ward operation while a tick + // is open, so an unwaited restart simply 409'd whenever it landed inside + // one — and the recovery control is the worst one to have fail on stage. + await stream.withPause(async () => { + await seedWard(DEMO_BACKFILL) + await refresh() + }) + setNote(`Rebuilt · ${DEMO_BACKFILL} readings of history`) + } catch (error) { + setFailure(error instanceof Error ? error.message : 'the ward could not be rebuilt') + } finally { + setBusy(null) + } + } + + async function warm() { + begin('warm') + try { + // Holds the stream: the weights load on the one thread that also scores. + const result = await stream.withPause(warmExplainer) + setNote(result.was_loaded ? 'Explainer already loaded' : 'Explainer loaded') + } catch (error) { + setFailure(error instanceof Error ? error.message : 'the explainer did not load') + } finally { + setBusy(null) + } + } + + const button = 'inline-flex items-center gap-1.5 rounded-[2px] border px-2 py-1 ' + + 'text-2xs font-medium transition-colors disabled:cursor-progress disabled:opacity-50' + + return ( +
+
+ + Prototype feed + + + + + + + + +
+ {STREAM_CADENCES.map((ms) => ( + + ))} +
+ + {/* `aria-live` because a 40 s warm-up finishing is otherwise announced to + nobody. `failure` outranks a stale stream error; the note is hidden + while either is showing so they cannot contradict each other. */} +

+ {(failure ?? stream.error) + ? {failure ?? stream.error} + : note + ? {note} + : ( + + No hospital feed in this build · each tick is one reading per bed, an hour + later on the ward's clock + + )} +

+ + {stream.streaming && ( + + {stream.ticks} sent + + )} +
+
+ ) +} diff --git a/front-end/src/components/detail/ExplanationPanel.tsx b/front-end/src/components/detail/ExplanationPanel.tsx index e6e4282..8420728 100644 --- a/front-end/src/components/detail/ExplanationPanel.tsx +++ b/front-end/src/components/detail/ExplanationPanel.tsx @@ -2,11 +2,16 @@ import { useState } from 'react' import { Check, Sparkles } from 'lucide-react' import type { Explanation } from '@contract/clinical' import { generateExplanation } from '../../data/feed' +import { useWard } from '../../data/WardProvider' interface ExplanationPanelProps { /** Null means generation was never attempted, which is a different fact from failure. */ explanation: Explanation | null patientId: string + /** The reading this panel is showing. Named on the request so the text is + * written back to the row a clinician was reading, not to whichever row is + * newest 20 seconds later. */ + assessedAt: string } /** @@ -16,19 +21,34 @@ interface ExplanationPanelProps { * then withheld because grounding failed, and never requested. Nothing is ever * generated to fill an absence. */ -export function ExplanationPanel({ explanation, patientId }: ExplanationPanelProps) { +export function ExplanationPanel({ explanation, patientId, assessedAt }: ExplanationPanelProps) { + const { stream } = useWard() const [generated, setGenerated] = useState(null) + // WHICH reading the local result belongs to. Without it a generated + // explanation outlived the reading it described: the ward advances, a new + // score and band arrive, and the old prose stays on screen underneath them — + // directly above a footer promising a point-in-time rationale for THIS + // reading. Remounting on `assessedAt` would also clear it, but it would throw + // away an in-flight generation and wipe `failure` within one cadence period. + const [generatedFor, setGeneratedFor] = useState(null) const [generating, setGenerating] = useState(false) const [failure, setFailure] = useState(null) - const shown = generated ?? explanation + const shown = generatedFor === assessedAt ? (generated ?? explanation) : explanation async function requestExplanation() { setGenerating(true) setFailure(null) try { - const result = await generateExplanation(patientId) - setGenerated(result as Explanation) + // Held for the duration. One thread owns the GPU, so generating and + // scoring cannot overlap: left running, the next tick stalls for the whole + // generation — and behind a cold load that is most of the 90 s the scoring + // call is allowed. Warming the explainer first is what removes it. + const result = await stream.withPause( + () => generateExplanation(patientId, { assessedAt }), + ) + setGenerated(result) + setGeneratedFor(assessedAt) } catch (error) { setFailure(error instanceof Error ? error.message : 'the generator did not respond') } finally { @@ -36,6 +56,22 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro } } + // One control, three captions. Lifting it out of the never-requested branch is + // what makes a second reading explainable: the panel used to offer generation + // only while there was nothing to show, so once a bed had any explanation the + // affordance disappeared and the text stayed pinned to an old reading. + const button = ( + + ) + if (shown === null) { return (
@@ -57,15 +93,7 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro

)} - + {button}
) } @@ -80,6 +108,15 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro Score, risk level, inputs and ranked factors above remain fully available. Nothing is generated in place of an unavailable explanation.

+ {/* No generate button here on purpose: the usual way to reach this branch + is a bed whose explanation is withheld by policy, where the server + short-circuits the request and the control would do nothing. But if + an attempt was made and failed, say so. */} + {failure && ( +

+ {failure} +

+ )}
) } @@ -99,6 +136,14 @@ export function ExplanationPanel({ explanation, patientId }: ExplanationPanelPro {explanationToRender.explanation_text}

+ {failure && ( +

+ {failure} +

+ )} + + {button} +

Point-in-time rationale for this reading. No claim is made about change over time.

diff --git a/front-end/src/data/WardProvider.tsx b/front-end/src/data/WardProvider.tsx index b2d2cd8..1278cc8 100644 --- a/front-end/src/data/WardProvider.tsx +++ b/front-end/src/data/WardProvider.tsx @@ -9,6 +9,7 @@ import { } from 'react' import type { Assessment } from '@contract/clinical' import { fetchWard, setDeviceOffline, tickWard } from './feed' +import { useWardStream, type WardStream } from '../hooks/useWardStream' interface WardValue { ward: Assessment[] @@ -16,11 +17,20 @@ interface WardValue { error: string | null /** Re-read the board from the API. */ refresh: () => Promise - /** Advance every bed by one reading, then re-read. */ + /** Advance every bed by one reading, then re-read. REJECTS if the tick fails: + * `refresh` swallows its own failures and leaves the board readable, but the + * stream driver needs this one to reach it, or the loop keeps firing against + * a ward that stopped advancing. Every caller must handle the rejection. */ advance: () => Promise /** Switch one patient's input source off or on, then re-read. */ toggleDevice: (patientId: string, deviceId: string) => Promise offlineDeviceIds: Set + stream: WardStream + /** Bumped after every successful re-read. Anything holding data fetched + * alongside the ward — a patient's history, a parameter series — puts this in + * its dependencies so it reloads too. Keyed to the stream's tick count it + * missed device toggles and re-seeds, which change the ward just as much. */ + revision: number } const WardContext = createContext(null) @@ -38,9 +48,12 @@ export function WardProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [revision, setRevision] = useState(0) + const refresh = useCallback(async () => { try { setWard(await fetchWard()) + setRevision((n) => n + 1) setError(null) } catch (failure) { setError(failure instanceof Error ? failure.message : 'the ward could not be loaded') @@ -58,6 +71,8 @@ export function WardProvider({ children }: { children: ReactNode }) { await refresh() }, [refresh]) + const stream = useWardStream(advance) + const toggleDevice = useCallback( async (patientId: string, deviceId: string) => { const patient = ward.find((a) => a.patient_id === patientId) @@ -81,8 +96,10 @@ export function WardProvider({ children }: { children: ReactNode }) { ) const value = useMemo( - () => ({ ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds }), - [ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds], + () => ({ ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds, + stream, revision }), + [ward, loading, error, refresh, advance, toggleDevice, offlineDeviceIds, + stream, revision], ) return {children} diff --git a/front-end/src/data/feed.ts b/front-end/src/data/feed.ts index f2eb887..5f0b009 100644 --- a/front-end/src/data/feed.ts +++ b/front-end/src/data/feed.ts @@ -13,6 +13,7 @@ import type { Assessment, + Explanation, ParameterHistoryPoint, ParameterName, RefusedAssessment, @@ -25,10 +26,32 @@ import { bandRank } from './bands' /** Relative, because Vite proxies /api to the Node service in development. */ const API = '/api' +/** + * The API answers a failure with RFC 9457 `problem+json`, or with a `message`. + * Read it: a status code alone turns "seeding needs PM_ALLOW_DESTRUCTIVE" and + * "the ward was never seeded" into the same unactionable number on screen. + */ +async function failure(path: string, response: Response): Promise { + let detail = '' + try { + const body = await response.json() + const raw = body?.detail ?? body?.message + // FastAPI's validation errors put an ARRAY of objects in `detail`, so the + // obvious read renders as "[object Object]" on screen. Flatten to the + // messages, which is the part a reader can act on. + detail = Array.isArray(raw) + ? raw.map((item) => item?.msg ?? JSON.stringify(item)).join('; ') + : typeof raw === 'string' ? raw : '' + } catch { + // A non-JSON body is itself worth nothing to a reader; fall through. + } + return new Error(detail || `${path} returned ${response.status}`) +} + async function readJson(path: string): Promise { const response = await fetch(`${API}${path}`) if (!response.ok) { - throw new Error(`${path} returned ${response.status}`) + throw await failure(path, response) } return response.json() as Promise } @@ -40,7 +63,7 @@ async function sendJson(path: string, body: unknown): Promise { body: JSON.stringify(body), }) if (!response.ok) { - throw new Error(`${path} returned ${response.status}`) + throw await failure(path, response) } return response.json() as Promise } @@ -76,11 +99,28 @@ export function fetchParameterHistory( // Writes // --------------------------------------------------------------------------- -/** Advance every bed by one reading. */ +/** Advance every bed by one reading. `at` is the ward's own clock, which a tick + * moves forward an hour — not the wall clock. */ export function tickWard(): Promise<{ at: string }> { return sendJson('/ward/tick', {}) } +/** Rebuild the ward from nothing. + * + * DESTRUCTIVE: it deletes every assessment, prompt and stay state, and the + * server refuses unless PM_ALLOW_DESTRUCTIVE is set. Why the demo passes the + * backfill it does is at `DEMO_BACKFILL`, not here. */ +export function seedWard(backfillTicks: number): Promise<{ patients: number }> { + return sendJson('/ward/seed', { backfill_ticks: backfillTicks }) +} + +/** Load the 7B before anyone asks for an explanation. Stores nothing: the + * alternative, explaining some bed to warm the weights, leaves a real + * explanation attached to a reading nobody asked about. */ +export function warmExplainer(): Promise<{ explainer: string; was_loaded: boolean }> { + return sendJson('/ward/warmup', {}) +} + /** Switch an input source off, or back on. */ export function setDeviceOffline( patientId: string, @@ -90,13 +130,23 @@ export function setDeviceOffline( return sendJson(`/patient/${patientId}/device`, { device_id: deviceId, offline }) } -/** Ask the local model to write the explanation. Takes tens of seconds. */ -export function generateExplanation(patientId: string): Promise<{ - status: string - explanation_text: string - grounding_status: string -}> { - return sendJson(`/patient/${patientId}/explain`, {}) +/** Ask the local model to write the explanation. Takes tens of seconds. + * + * `assessedAt` names the reading to explain. Worth passing whenever the board + * is moving: generation takes 18-23 s, and the server's default of "the latest" + * is resolved when the request arrives, so the text can land on a row several + * readings older than the one the clinician was looking at. + * + * `useLlm: false` selects the deterministic template instead — no GPU, instant, + * and the only way to exercise this path on a busy card. */ +export function generateExplanation( + patientId: string, + options: { assessedAt?: string; useLlm?: boolean } = {}, +): Promise { + return sendJson(`/patient/${patientId}/explain`, { + ...(options.assessedAt ? { assessed_at: options.assessedAt } : {}), + ...(options.useLlm === false ? { use_llm: false } : {}), + }) } /** Record a clinician's disposition of a prompt. */ diff --git a/front-end/src/hooks/useApi.ts b/front-end/src/hooks/useApi.ts index 523ec95..db6d393 100644 --- a/front-end/src/hooks/useApi.ts +++ b/front-end/src/hooks/useApi.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { Assessment, ParameterHistoryPoint, ParameterName, PatientContext } from '@contract/clinical' import { fetchHistory, fetchParameterHistory } from '../data/feed' @@ -16,17 +16,31 @@ interface Loaded { error: string | null } -function useFetch(load: () => Promise, deps: unknown[]): Loaded { +/** + * `subject` is what the data is ABOUT, and every caller must pass one. Changing + * it clears the previous answer; refetching the SAME subject keeps it on screen + * until the new one lands. Optional, it silently disabled clearing for whoever + * forgot — `undefined !== undefined` is never true — which is how the SpO2 + * series came to render under the FiO2 heading. + */ +function useFetch(load: () => Promise, deps: unknown[], subject: string): Loaded { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const lastSubject = useRef(subject) useEffect(() => { let current = true setLoading(true) - // Cleared, not left in place: a screen that renders `data` while `loading` - // would show the previous patient's readings under the new patient's name. - setData(null) + // Cleared when the SUBJECT changes, because a screen that renders `data` + // while `loading` shows the previous subject's numbers under the new one's + // name. Kept when the same subject merely has a newer reading: blanking on + // every tick leaves the one chart showing a band history unreadable for + // exactly as long as it is worth watching. + if (lastSubject.current !== subject) { + lastSubject.current = subject + setData(null) + } load() .then((result) => { if (current) { @@ -53,9 +67,17 @@ function useFetch(load: () => Promise, deps: unknown[]): Loaded { return { data, loading, error } } -/** Recent assessments for one patient, oldest first. */ -export function usePatientHistory(patientId: string, limit = 14) { - return useFetch(() => fetchHistory(patientId, limit), [patientId, limit]) +/** Recent assessments for one patient, oldest first. + * + * `revision` is the ward's, from `useWard()`. Without it the observation strip + * is fetched once on mount and then silently stops agreeing with the board + * beside it. */ +export function usePatientHistory(patientId: string, revision = 0, limit = 14) { + return useFetch( + () => fetchHistory(patientId, limit), + [patientId, limit, revision], + patientId, + ) } /** One parameter's charting history, oldest first. */ @@ -67,6 +89,7 @@ export function useParameterHistory( return useFetch( () => fetchParameterHistory(patientId, parameterName, limit), [patientId, parameterName, limit], + `${patientId}:${parameterName}`, ) } @@ -78,5 +101,6 @@ export function usePatientContext(patientId: string) { return r.json() as Promise }), [patientId], + patientId, ) } diff --git a/front-end/src/hooks/useMeasuredWidth.ts b/front-end/src/hooks/useMeasuredWidth.ts new file mode 100644 index 0000000..f8017db --- /dev/null +++ b/front-end/src/hooks/useMeasuredWidth.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react' + +/** + * The rendered width of an element, in CSS pixels. + * + * The first DOM measurement in this codebase, and it exists for one reason: the + * ward scale places labels along a fluid track, but a label is a fixed pixel + * width. Any collision rule written in score-space is therefore right at one + * viewport and wrong at every other — which is exactly how three bed codes came + * to be drawn on top of one another. + * + * Returned as a callback ref rather than a `useRef` + effect so the first + * measurement happens the moment the node attaches, with no render showing an + * unmeasured zero. The cleanup return is React 19's ref-cleanup contract. + */ +export function useMeasuredWidth(): [(node: T | null) => void, number] { + const [width, setWidth] = useState(0) + + const ref = useCallback((node: T | null) => { + if (!node) return undefined + setWidth(node.getBoundingClientRect().width) + + const observer = new ResizeObserver((entries) => { + // BORDER box, not `contentRect`. `contentRect` excludes padding, so a + // label measured through it came back 8px narrower than it draws (px-1 + // each side) the moment the observer first fired — and the collision + // maths then reserved 70px for a 78px label, which is exactly how the + // leftmost one came to hang 4px off the end of the track. + const entry = entries[0] + const measured = entry?.borderBoxSize?.[0]?.inlineSize + ?? node.getBoundingClientRect().width + // Ignore a zero: a hidden or detached node reports 0, and propagating it + // would collapse every placement to the same point. + if (measured) setWidth(measured) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + + return [ref, width] +} diff --git a/front-end/src/hooks/useWardClock.ts b/front-end/src/hooks/useWardClock.ts new file mode 100644 index 0000000..dc5f226 --- /dev/null +++ b/front-end/src/hooks/useWardClock.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import type { Assessment } from '@contract/clinical' +import { useClock } from './useClock' + +/** + * The clock the board measures staleness against. + * + * A simulated tick advances the ward an hour, because that is the grid the band + * table's dwell was fitted on. The ward's newest reading therefore runs ahead of + * the wall clock while the stream is running, and measuring against the browser + * would give every bed a negative age on a screen whose whole job is to say how + * fresh a value is. + * + * Ahead of the wall clock, the ward's own time wins. Otherwise this is exactly + * `useClock`, so nothing changes when nobody is streaming. + */ +export function useWardClock(ward: Assessment[]): { now: Date; simulated: boolean } { + const real = useClock() + + // `Math.max(x, NaN)` is NaN and stays NaN, which would leave `simulated` false + // for ever and switch the whole feature off with nothing to see. Unreachable + // today — `assessed_at` is a Mongoose Date — but the failure mode is silent. + const newest = useMemo( + () => ward.reduce((latest, a) => { + const at = Date.parse(a.assessed_at) + return Number.isFinite(at) ? Math.max(latest, at) : latest + }, 0), + [ward], + ) + + return useMemo(() => { + // A whole minute of slack: seeding lands the newest reading on `now`, and + // without it the ordinary idle board would flicker into "simulated" on + // nothing more than clock jitter between the browser and the service. + const simulated = newest - real.getTime() > 60_000 + return { now: simulated ? new Date(newest) : real, simulated } + }, [newest, real]) +} diff --git a/front-end/src/hooks/useWardStream.ts b/front-end/src/hooks/useWardStream.ts new file mode 100644 index 0000000..5576eab --- /dev/null +++ b/front-end/src/hooks/useWardStream.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +/** Cadences offered in the simulation panel. */ +export const STREAM_CADENCES = [2000, 3000, 5000] as const +const DEFAULT_CADENCE = 3000 + +export interface WardStream { + streaming: boolean + /** Completed ticks. Monotonic across stop/start, so it can be used as a + * "something changed" revision without ever going backwards. */ + ticks: number + error: string | null + cadenceMs: number + setCadenceMs: (ms: number) => void + start: () => void + stop: () => void + clearError: () => void + /** + * Hold the stream for the duration of `work`, then let it continue. + * Waits out any tick already in flight first, so `work` is not the thing + * blocking a tick that already holds the model-thread slot. + */ + withPause: (work: () => Promise) => Promise +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Drives the ward forward one reading at a time. + * + * SELF-CLOCKING, not `setInterval`. One tick is eight sequential scorings on a + * single GPU thread and roughly two dozen Mongo round-trips behind them — call + * it a second or two, not an instant. A fixed interval cannot know the previous + * tick is still running, and the server single-flights `/api/ward/tick` and + * 409s the overlap, so a stacked request would simply stop the stream. + * + * EPOCH-GUARDED. `stop()` cannot end a loop that is parked in `sleep()`, so a + * pause followed by a resume inside the cadence window used to leave the old + * loop alive and start a second one beside it — doubling the tick rate, which + * is the exact condition this hook exists to prevent. Each loop captures an + * epoch and exits the moment it is no longer the current one. + */ +export function useWardStream(advance: () => Promise): WardStream { + const [streaming, setStreaming] = useState(false) + const [ticks, setTicks] = useState(0) + const [error, setError] = useState(null) + const [cadenceMs, setCadenceMs] = useState(DEFAULT_CADENCE) + + const running = useRef(false) + const epoch = useRef(0) + // A DEPTH, not a flag: two things can hold the stream at once, and the first + // to finish must not release it for the other. + const paused = useRef(0) + const inFlight = useRef | null>(null) + + // Read through refs so changing the cadence mid-run takes effect on the next + // tick without restarting the loop. + const advanceRef = useRef(advance) + advanceRef.current = advance + const cadenceRef = useRef(cadenceMs) + cadenceRef.current = cadenceMs + + const loop = useCallback(async (mine: number) => { + while (epoch.current === mine) { + // A PREVIOUS loop's tick may still be open. `stop()` ends a loop; it cannot + // recall a request already in the air, so a Pause immediately followed by a + // Play used to issue a second tick alongside the first. The server + // single-flights ward operations, so that collision came back as a 409 — + // which this loop then treated as fatal, killing the stream on a two-click + // gesture. Wait the old one out instead. + if (inFlight.current) { + await inFlight.current.catch(() => undefined) + // Yield once: awaiting a settled promise only drains microtasks, and the + // owning loop's `finally` needs to run before we look again. + await sleep(0) + continue + } + if (paused.current > 0) { + await sleep(100) + continue + } + let tick: Promise | undefined + try { + tick = advanceRef.current() + inFlight.current = tick + await tick + } catch (failure) { + // A tick that fails because the user stopped mid-flight is not an error + // worth showing; anything else stops the stream rather than retrying, + // because the plausible causes — an unseeded ward, an overlapping + // operation, a shed request — are not fixed by an identical second call. + if (epoch.current === mine) { + running.current = false + epoch.current += 1 + setStreaming(false) + setError(failure instanceof Error ? failure.message : 'the ward stopped advancing') + } + return + } finally { + // Only if it is still OURS. Cleared unconditionally, an abandoned loop + // wipes a newer loop's handle — `withPause` then sees nothing in flight, + // skips its wait, and lets the 7B load race a live scoring tick on the + // one thread that owns the GPU. + if (tick && inFlight.current === tick) inFlight.current = null + } + if (epoch.current !== mine) return + setTicks((n) => n + 1) + await sleep(cadenceRef.current) + } + }, []) + + const start = useCallback(() => { + if (running.current) return + running.current = true + epoch.current += 1 + setStreaming(true) + setError(null) + // `paused` is deliberately untouched: a generation already holding the + // stream must keep holding it across a start. + void loop(epoch.current) + }, [loop]) + + const stop = useCallback(() => { + running.current = false + epoch.current += 1 + setStreaming(false) + }, []) + + const clearError = useCallback(() => setError(null), []) + + const withPause = useCallback(async (work: () => Promise): Promise => { + paused.current += 1 + try { + // One GPU thread serves scoring and the 7B both, so these cannot overlap. + // Waiting out the in-flight tick first means `work` queues behind one tick + // rather than a tick queueing behind 18-23 s of generation. + if (inFlight.current) await inFlight.current.catch(() => undefined) + return await work() + } finally { + paused.current -= 1 + } + }, []) + + // The provider wraps the router, so navigation does not unmount this — the + // stream is meant to survive it, which is why the patient screen can pause it. + // This is for the provider itself going away: a reload, or HMR. + useEffect(() => () => { + running.current = false + epoch.current += 1 + }, []) + + return useMemo( + () => ({ + streaming, ticks, error, cadenceMs, setCadenceMs, + start, stop, clearError, withPause, + }), + [streaming, ticks, error, cadenceMs, start, stop, clearError, withPause], + ) +} diff --git a/front-end/src/lib/labelPlacement.ts b/front-end/src/lib/labelPlacement.ts new file mode 100644 index 0000000..39cac74 --- /dev/null +++ b/front-end/src/lib/labelPlacement.ts @@ -0,0 +1,149 @@ +/** + * Spreading crowded labels along a shared axis. + * + * A bed label is a fixed ~78px wide; the axis it sits on is fluid. When two + * patients score close together their labels overlap, and the previous approach + * — stack into one of three rows, decide by a fixed distance in SCORE space — + * failed twice over: the row counter saturated and wrote surplus labels on top + * of each other without checking, and the score-space threshold was worth 116px + * at one viewport and 48px at another. + * + * This works in pixels, and moves labels sideways rather than upwards. Every + * label keeps its full text; the leader line is what ties it back to its mark. + */ + +/** Clear space between two labels sharing a row, in pixels. */ +const GUTTER = 12 + +/** Vertical distance between label rows, in pixels. */ +export const ROW_PITCH = 20 + +/** Rows are cheap but not free — each one pushes the axis further down. */ +const MAX_ROWS = 3 + +export interface Placeable { + id: string + /** Position along the axis, 0..1. */ + value: number +} + +export interface Placement { + id: string + /** Where the mark belongs, in px. */ + trueX: number + /** Where the label is drawn, in px. */ + placedX: number + row: number +} + +/** + * Rows are for VERTICAL clearance, not for capacity. + * + * Two labels on different rows may overlap horizontally as much as they like, so + * alternating rows lets neighbours sit `need / rows` apart instead of `need`. + * That is the whole reason to add a row: it buys horizontal room, which is what + * decides how far a label has to travel from its own mark. + */ +function rowsFor(xs: number[], need: number, trackWidth: number, labelWidth: number): number { + const tightest = xs.length < 2 + ? Infinity + : Math.min(...xs.slice(1).map((x, i) => x - xs[i])) + + let rows = 1 + if (tightest < need) rows = 2 + if (tightest < need / 2) rows = 3 + + // And enough rows that the whole run physically fits the track. + const usable = Math.max(trackWidth - labelWidth, 1) + const forFit = Math.ceil(((xs.length - 1) * need) / usable) + + return Math.min(MAX_ROWS, Math.max(1, rows, forFit)) +} + +/** + * Merge overlapping labels into groups, centre each group on its members' mean + * position, and keep the run inside the track. + * + * Centring on the mean rather than pushing rightwards keeps the displacement + * symmetric: a cluster opens outwards from where it actually sits instead of + * drifting off in one direction. + */ +function spread(items: Placeable[], gap: number, trackWidth: number, labelWidth: number) { + const half = gap / 2 + const edge = labelWidth / 2 + let groups = items.map((item) => ({ items: [item], centre: item.value * trackWidth })) + + // Bounded rather than `while (true)`: each pass either merges a pair or stops, + // so it cannot run longer than the number of items, and the guard keeps a + // future edit from turning a layout bug into a frozen tab. + for (let pass = 0; pass < items.length + 1; pass++) { + for (const group of groups) { + const reach = ((group.items.length - 1) * gap) / 2 + edge + // A label at score 0 used to hang ~39px off the left edge of the track. + group.centre = Math.min(Math.max(group.centre, reach), trackWidth - reach) + } + + let merged = false + for (let i = 0; i < groups.length - 1; i++) { + const left = groups[i] + const right = groups[i + 1] + if (left.centre + left.items.length * half > right.centre - right.items.length * half) { + const combined = [...left.items, ...right.items] + groups.splice(i, 2, { + items: combined, + centre: combined.reduce((sum, it) => sum + it.value * trackWidth, 0) / combined.length, + }) + merged = true + break + } + } + if (!merged) break + } + + const placed = new Map() + for (const group of groups) { + const start = group.centre - ((group.items.length - 1) * gap) / 2 + group.items.forEach((item, i) => placed.set(item.id, start + i * gap)) + } + return placed +} + +/** + * Place every label. `labelWidth` and `trackWidth` are measured, not assumed — + * see `useMeasuredWidth`. Returns nothing until both are known, so the first + * paint draws no labels rather than drawing them all at zero. + * + * ONE placement pass over every label, in score order, with rows assigned round + * robin afterwards. Placing each row separately looked reasonable and was not: + * two rows centred on their own members drift independently, so a label could + * end up left of one whose mark is further left, and the leader lines crossed. + * A crossed leader is worse than a crowded one — it points at the wrong patient. + * Placing globally keeps position monotonic in score, so they cannot cross. + */ +export function placeLabels( + items: Placeable[], + trackWidth: number, + labelWidth: number, +): { placements: Placement[]; rows: number } { + if (!items.length || trackWidth <= 0 || labelWidth <= 0) { + return { placements: [], rows: 1 } + } + + const need = labelWidth + GUTTER + const ordered = [...items].sort((a, b) => a.value - b.value) + const rows = rowsFor(ordered.map((i) => i.value * trackWidth), need, trackWidth, labelWidth) + + // Neighbours land on different rows, so they only need `need / rows` between + // them; labels `rows` apart share a row and are a full `need` apart. + const placed = spread(ordered, need / rows, trackWidth, labelWidth) + + return { + rows, + placements: ordered.map((item, index) => ({ + id: item.id, + trueX: item.value * trackWidth, + placedX: placed.get(item.id) ?? item.value * trackWidth, + row: index % rows, + })), + } +} diff --git a/front-end/src/screens/PatientDetail.tsx b/front-end/src/screens/PatientDetail.tsx index aaf884b..8ffe944 100644 --- a/front-end/src/screens/PatientDetail.tsx +++ b/front-end/src/screens/PatientDetail.tsx @@ -6,7 +6,7 @@ import { isScored } from '@contract/clinical' import { SUFFICIENCY_FLOOR, reviewPrompt, toObservations } from '../data/feed' import { useAssessment, useWard } from '../data/WardProvider' import { bandMeaning } from '../data/bands' -import { useClock } from '../hooks/useClock' +import { useWardClock } from '../hooks/useWardClock' import { usePatientHistory } from '../hooks/useApi' import { BAND_STATE_LABEL, @@ -28,15 +28,15 @@ import { Panel } from '../components/ui/Panel' export function PatientDetail() { const { patientId = '' } = useParams() - const now = useClock() const [drawerOpen, setDrawerOpen] = useState(false) const [recording, setRecording] = useState(false) const [reviewError, setReviewError] = useState(null) - const { refresh } = useWard() + const { refresh, ward, revision } = useWard() + const { now } = useWardClock(ward) const assessment = useAssessment(patientId) - const { data: history } = usePatientHistory(patientId) + const { data: history } = usePatientHistory(patientId, revision) if (!assessment) { return ( @@ -246,6 +246,7 @@ export function PatientDetail() {
diff --git a/front-end/src/screens/PatientOverviewBoard.tsx b/front-end/src/screens/PatientOverviewBoard.tsx index 018cb14..319246e 100644 --- a/front-end/src/screens/PatientOverviewBoard.tsx +++ b/front-end/src/screens/PatientOverviewBoard.tsx @@ -8,7 +8,7 @@ import { rankedPatients, } from '../data/feed' import { useWard } from '../data/WardProvider' -import { useClock } from '../hooks/useClock' +import { useWardClock } from '../hooks/useWardClock' import { cn } from '../lib/cn' import { pluralise } from '../lib/format' import { DataLimitedRow } from '../components/board/DataLimitedRow' @@ -28,8 +28,10 @@ const FILTERS: Array<{ key: Filter; label: string }> = [ ] export function PatientOverviewBoard() { - const now = useClock() const { ward, loading, error } = useWard() + // Ward time, not browser time: a simulated tick moves the ward an hour, so + // ages measured against the wall clock would go negative while it streams. + const { now } = useWardClock(ward) const [query, setQuery] = useState('') const [filter, setFilter] = useState('all') @@ -81,13 +83,15 @@ export function PatientOverviewBoard() { } return ( -
- {/* The one display-tier element on the screen. Everything else is text. */} -
-

- Adult ventilated ICU patients -

-

+

+ {/* The one display-tier element on the screen. Everything else is text. + Set down from 4xl/5xl: at 49-61px it and its note cost 116px of a + 876px screen, which is two ward beds. The tier survives — deleting the + h1 would remove display type from the board entirely — but it earns + its space at 24px on one line beside the note. */} +
+

Adult ventilated ICU patients

+

A prompt is raised only when a band change is sustained and confirmed — roughly one in every 34 readings. A patient held at HIGH for six hours is one interruption, not seventy. @@ -97,9 +101,9 @@ export function PatientOverviewBoard() { {/* The ward on one calibrated axis. Segment widths are the real cut points, so the geometry says something true: most readings sit in a band that occupies an eighth of the scale, and nearly half the scale is CRITICAL. */} -

+
Respiratory-risk scale @@ -112,8 +116,11 @@ export function PatientOverviewBoard() { />
-
-
+ {/* `min-h-0` on the row AND on both panes: without it each flex child + keeps its automatic minimum height, refuses to shrink, and the + overflow rule never applies — the page just grows again. */} +
+
-
+
+ ) +} + +/** The control that opens the dock. Lives in the prototype feed bar, so the + * dock costs no vertical space at all while it is closed — the board is fitted + * to exactly one screen and a second permanent chrome band would take a bed. */ +export function TelemetryToggle({ open, onToggle }: { open: boolean; onToggle: () => void }) { + const calls = useSyncExternalStore(subscribe, snapshot) + return ( + + ) +} diff --git a/front-end/src/data/feed.ts b/front-end/src/data/feed.ts index 5d3d786..eee6564 100644 --- a/front-end/src/data/feed.ts +++ b/front-end/src/data/feed.ts @@ -16,11 +16,14 @@ import type { Explanation, ParameterHistoryPoint, ParameterName, + PatientContext, RefusedAssessment, RiskBand, ScoredAssessment, } from '@contract/clinical' import { isScored } from '@contract/clinical' + +import * as telemetry from './telemetry' import { bandRank } from './bands' /** Relative, because Vite proxies /api to the Node service in development. */ @@ -48,19 +51,59 @@ async function failure(path: string, response: Response): Promise { return new Error(detail || `${path} returned ${response.status}`) } -async function readJson(path: string): Promise { - const response = await fetch(`${API}${path}`) +/** + * Every request the dashboard makes passes through here, which is why the + * telemetry log can be honest about coverage: one place to instrument, and a + * call that skipped it would be a call the panel silently never showed. + * + * `route` is the TEMPLATE, passed in rather than derived from `path`. Deriving + * it would mean pattern-matching identifiers back out of a URL, and getting + * that subtly wrong puts a patient id on screen. The server logs the matched + * route for the same reason (PM-LOG-001). + */ +async function readJson(path: string, route: string): Promise { + const settle = telemetry.begin('GET', route) + const started = performance.now() + let response: Response + try { + response = await fetch(`${API}${path}`) + } catch (transportFailure) { + // A refused connection never produces a response, and a log that only shows + // completed calls hides exactly the case someone is debugging. + settle({ clientMs: performance.now() - started, failed: true }) + throw transportFailure + } + settle({ + status: response.status, + headers: response.headers, + clientMs: performance.now() - started, + failed: !response.ok, + }) if (!response.ok) { throw await failure(path, response) } return response.json() as Promise } -async function sendJson(path: string, body: unknown): Promise { - const response = await fetch(`${API}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), +async function sendJson(path: string, route: string, body: unknown): Promise { + const settle = telemetry.begin('POST', route) + const started = performance.now() + let response: Response + try { + response = await fetch(`${API}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } catch (transportFailure) { + settle({ clientMs: performance.now() - started, failed: true }) + throw transportFailure + } + settle({ + status: response.status, + headers: response.headers, + clientMs: performance.now() - started, + failed: !response.ok, }) if (!response.ok) { throw await failure(path, response) @@ -74,14 +117,27 @@ async function sendJson(path: string, body: unknown): Promise { /** Every bed's current assessment. */ export function fetchWard(): Promise { - return readJson('/ward') + return readJson('/ward', '/ward') } /** One patient's recent assessments, oldest first. `Assessment[]`, not * `ScoredAssessment[]`: a stay that dipped below the floor has refusals in its * history and the endpoint returns them. Consumers narrow first. */ export function fetchHistory(patientId: string, limit = 14): Promise { - return readJson(`/patient/${patientId}/history?limit=${limit}`) + return readJson( + `/patient/${patientId}/history?limit=${limit}`, + '/patient/:id/history', + ) +} + +/** Borrowed demographics and comorbidities. Recorded context, not a prediction. + * + * Here rather than hand-rolled in the hook that uses it: it used to call + * `fetch` directly, which meant it neither parsed a problem+json body nor + * appeared in the telemetry log — one invisible call is enough to make the + * log's coverage a claim rather than a property. */ +export function fetchPatientContext(patientId: string): Promise { + return readJson(`/patient/${patientId}/context`, '/patient/:id/context') } /** One parameter's charting history, oldest first. */ @@ -92,6 +148,7 @@ export function fetchParameterHistory( ): Promise { return readJson( `/patient/${patientId}/parameter/${parameterName}?limit=${limit}`, + '/patient/:id/parameter/:name', ) } @@ -102,7 +159,7 @@ export function fetchParameterHistory( /** Advance every bed by one reading. `at` is the ward's own clock, which a tick * moves forward an hour — not the wall clock. */ export function tickWard(): Promise<{ at: string }> { - return sendJson('/ward/tick', {}) + return sendJson('/ward/tick', '/ward/tick', {}) } /** Rebuild the ward from nothing. @@ -111,14 +168,14 @@ export function tickWard(): Promise<{ at: string }> { * server refuses unless PM_ALLOW_DESTRUCTIVE is set. Why the demo passes the * backfill it does is at `DEMO_BACKFILL`, not here. */ export function seedWard(backfillTicks: number): Promise<{ patients: number }> { - return sendJson('/ward/seed', { backfill_ticks: backfillTicks }) + return sendJson('/ward/seed', '/ward/seed', { backfill_ticks: backfillTicks }) } /** Load the 7B before anyone asks for an explanation. Stores nothing: the * alternative, explaining some bed to warm the weights, leaves a real * explanation attached to a reading nobody asked about. */ export function warmExplainer(): Promise<{ explainer: string; was_loaded: boolean }> { - return sendJson('/ward/warmup', {}) + return sendJson('/ward/warmup', '/ward/warmup', {}) } /** Switch input sources off, or back on. @@ -132,7 +189,10 @@ export function setDevicesOffline( deviceIds: string[], offline: boolean, ): Promise<{ offline_devices: string[] }> { - return sendJson(`/patient/${patientId}/device`, { device_ids: deviceIds, offline }) + return sendJson(`/patient/${patientId}/device`, '/patient/:id/device', { + device_ids: deviceIds, + offline, + }) } /** Ask the local model to write the explanation. Takes tens of seconds. @@ -148,7 +208,7 @@ export function generateExplanation( patientId: string, options: { assessedAt?: string; useLlm?: boolean } = {}, ): Promise { - return sendJson(`/patient/${patientId}/explain`, { + return sendJson(`/patient/${patientId}/explain`, '/patient/:id/explain', { ...(options.assessedAt ? { assessed_at: options.assessedAt } : {}), ...(options.useLlm === false ? { use_llm: false } : {}), }) @@ -160,7 +220,7 @@ export function reviewPrompt( disposition: string, note?: string, ): Promise { - return sendJson(`/prompt/${promptId}/review`, { disposition, note }) + return sendJson(`/prompt/${promptId}/review`, '/prompt/:id/review', { disposition, note }) } // --------------------------------------------------------------------------- diff --git a/front-end/src/data/telemetry.ts b/front-end/src/data/telemetry.ts new file mode 100644 index 0000000..45d0fbe --- /dev/null +++ b/front-end/src/data/telemetry.ts @@ -0,0 +1,140 @@ +/** + * What the system actually did, as it did it. + * + * Every API call the dashboard makes is recorded here with the durations each + * tier measured for itself, so the pipeline behind a risk band can be watched + * rather than described. The board shows a conclusion; this shows the work. + * + * A MODULE, NOT A CONTEXT. `feed.ts` is where every request goes through, and + * it is not a React module -- it cannot import a hook. A plain observable store + * read through `useSyncExternalStore` lets the one choke point stay ordinary + * TypeScript while the panel still re-renders. + * + * ⚠️ NOTHING FROM A RESPONSE BODY IS STORED HERE. Method, route template, + * status, request id and timings, and that is the whole shape. The explanation + * is prose about one patient's physiology and is the single most tempting thing + * to keep while debugging a grounding failure (PM-LOG-003) -- so the buffer is + * built so that it cannot hold it, rather than trusted not to. + */ + +/** One span as some tier measured it. `ms` is absent when the entry is an + * observation rather than a duration -- a queue depth, a model id. It is never + * 0 standing in for "not measured": a stage that did not run has no span. */ +export interface Span { + name: string + ms?: number + desc?: string +} + +export interface Call { + /** Monotonic within a session; the key React needs and the clock does not give. */ + id: number + /** Wall clock at which the request was issued. */ + at: Date + method: 'GET' | 'POST' + /** + * The ROUTE TEMPLATE, never the resolved path. Our URLs carry patient + * identifiers and this one renders on a screen someone may be recording. + * The server logs the same way and for the same reason (PM-LOG-001). + */ + route: string + /** Absent while the call is still in flight. */ + status?: number + /** Round trip as the browser saw it: always at least the server's `total`. */ + clientMs?: number + /** From `X-Request-Id` — the same id Node logs and FastAPI now echoes. */ + requestId?: string + /** Parsed from `Server-Timing`, in the order the tiers emitted them. */ + spans: Span[] + /** Set when the request threw or answered non-2xx. Never the response body. */ + failed?: boolean +} + +/** Bounded: a demo left streaming at a 2 s cadence issues a call every couple of + * seconds for as long as it runs, and an unbounded log is a leak with a nice UI. */ +const LIMIT = 200 + +let calls: Call[] = [] +let nextId = 1 +const listeners = new Set<() => void>() + +const emit = () => { + // A NEW ARRAY EVERY TIME. `useSyncExternalStore` compares snapshots by + // identity, so mutating in place would update the buffer and never the screen. + calls = calls.slice(0, LIMIT) + listeners.forEach((fn) => fn()) +} + +export const subscribe = (fn: () => void) => { + listeners.add(fn) + return () => listeners.delete(fn) +} + +export const snapshot = () => calls + +export const clear = () => { + calls = [] + emit() +} + +/** + * Parse a `Server-Timing` header into spans. + * + * Hand-written rather than via `PerformanceResourceTiming.serverTiming`: that + * reads from a resource entry which has to be located by URL after the fact, + * and the URLs here carry patient ids. Reading the header off the response the + * call already holds is both simpler and keeps the identifier out of the lookup. + * + * Only same-origin makes this readable at all; `/api` is origin-relative through + * the Vite proxy, which is what makes it work in development. + */ +export function parseServerTiming(header: string | null): Span[] { + if (!header) return [] + const spans: Span[] = [] + // Split on commas that are not inside a quoted desc. + for (const raw of header.match(/(?:[^,"]|"(?:\\.|[^"\\])*")+/g) ?? []) { + const parts = raw.trim().split(';') + const name = parts.shift()?.trim() + if (!name) continue + const span: Span = { name } + for (const part of parts) { + const eq = part.indexOf('=') + if (eq === -1) continue + const key = part.slice(0, eq).trim().toLowerCase() + let value = part.slice(eq + 1).trim() + if (value.startsWith('"')) value = value.slice(1, -1).replace(/\\(.)/g, '$1') + if (key === 'dur') { + const ms = Number(value) + // NaN would render as a plausible-looking blank. An unparseable + // duration is a missing measurement, and missing is a state the panel + // draws differently from zero. + if (Number.isFinite(ms)) span.ms = ms + } else if (key === 'desc') { + span.desc = value + } + } + spans.push(span) + } + return spans +} + +/** Record a call as it is issued. Returns the settle callback. */ +export function begin(method: 'GET' | 'POST', route: string) { + const call: Call = { id: nextId++, at: new Date(), method, route, spans: [] } + calls = [call, ...calls] + emit() + + return (result: { status?: number; headers?: Headers; clientMs: number; failed?: boolean }) => { + // Replaced rather than mutated, for the same identity reason as `emit`. + const settled: Call = { + ...call, + status: result.status, + clientMs: result.clientMs, + failed: result.failed, + requestId: result.headers?.get('X-Request-Id') ?? undefined, + spans: parseServerTiming(result.headers?.get('Server-Timing') ?? null), + } + calls = calls.map((c) => (c.id === call.id ? settled : c)) + emit() + } +} diff --git a/front-end/src/hooks/useApi.ts b/front-end/src/hooks/useApi.ts index db6d393..7cefc5c 100644 --- a/front-end/src/hooks/useApi.ts +++ b/front-end/src/hooks/useApi.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { Assessment, ParameterHistoryPoint, ParameterName, PatientContext } from '@contract/clinical' -import { fetchHistory, fetchParameterHistory } from '../data/feed' +import { fetchHistory, fetchParameterHistory, fetchPatientContext } from '../data/feed' /** * Small fetch-on-mount hooks, one per thing a screen needs. Plain useState and @@ -96,10 +96,7 @@ export function useParameterHistory( /** Borrowed demographics and comorbidities. Recorded context, not a prediction. */ export function usePatientContext(patientId: string) { return useFetch( - () => fetch(`/api/patient/${patientId}/context`).then((r) => { - if (!r.ok) throw new Error(`context returned ${r.status}`) - return r.json() as Promise - }), + () => fetchPatientContext(patientId), [patientId], patientId, ) From 865e27b8ee9b6f14e3e07ed44f72933a256370b6 Mon Sep 17 00:00:00 2001 From: Yoshio Date: Sat, 22 Aug 2026 11:35:38 +0700 Subject: [PATCH 08/11] Raise the type scale for a recorded demo, and fix what it exposed Feedback on the demo video was that the UI is too small to read. text-2xs -- an 11px floor -- was used 101 times across 26 files, and the risk scale and the triage rows, the two things on screen for nearly the whole demo, are built almost entirely from it. One number does it: html { font-size: 106.25% } takes the root to 17px, and every --text-* token, every rem padding and every fixed width grows together, so nothing wraps. Raising the tokens alone would have grown text inside containers that stayed put. 112.5% read better and was measured, but cost 78px more of a viewport that is already short, and the ward board is the thing being shown. WHAT THE ROOT DOES NOT REACH, and what that cost: - ROW_PITCH was 20px against a label that grew to 19px tall. ONE pixel of clearance, and nothing threw. Deriving it from rem failed too: read at module load, the root is still the browser's 16px default because Vite injects the stylesheet after the modules evaluate. It now comes from the MEASURED label box -- the probe that already measures the width -- which has no ordering hazard and measures the constraint itself rather than a proxy. 22px against 18.56px at 18px root, 21 against 17.53 at 17. - 22 lucide size={} props, scaled by hand. - SegmentMeter's bars, moved to rem. Fuzzed the label geometry over 200,000 CLUSTERED configurations at two label widths: 0 crossings, 0 same-row overlaps where the run can fit, 0 escapes. The first version of that fuzz asserted "0 overlaps" outright and failed 35,775 times -- touching is the documented compromise when a run cannot fit, and the feasibility test is over the whole run, not one row, because spread() narrows to a single global gap. Also fixed, all found by looking: - ExplanationPanel set `text-md`, which exists in neither the theme nor Tailwind. It compiled to nothing, so the AI rationale had been rendering at body size since it was written, directly under a comment claiming it was set larger. Now text-base: 17px against a 15.75px body. - BandTag was shrink-0 with no fixed width, so the pill ran 74.6px at LOW to 106.5px at CRITICAL and everything after it shifted -- the score numeral started at four different x positions down one board (378, 383, 403, 410). A column of numbers that does not form a column is the one thing a triage board cannot afford. Fixed 6.75rem column; the pill keeps its natural size inside it, since stretching it would put a wide LOW badge beside a wide CRITICAL one and imply they carry the same weight. - Re-prompting an explanation left the prose static while the references panel beside it visibly cleared and refilled. `generating` was checked INSIDE the `shown === null` branch, so the in-progress state existed only for the first generation. Checked before it now. Dimming the old prose was tried and rejected: greyed-out text still reads as the answer while the panel claims to be writing a new one. - Decoding is greedy, so re-running the model on one reading returns byte-identical prose -- verified with two live calls, same sha256, same 609 characters. The panel says so before anyone waits 20 s for it, because otherwise the model reproducing itself exactly is indistinguishable from a button that did nothing. THE BOARD DOES NOT FIT ONE SCREEN AT THE RECORDING VIEWPORT, AND DID NOT BEFORE THIS. The display runs at 150% scaling, so a maximised window is exactly the 876px viewport commit e8a4fcf tuned against. The ranked list overflows by 240px at 16px, 323px at 17px, 402px at 18px. This made an existing overflow worse rather than creating one: the ward gained a per-row "awaiting clinician review" banner since that fit was tuned, and seven rows at 87px is more than the pane gets. The height is in the bed rows -- not the h1 (dropping it a step bought six pixels), not the note beside it, not the risk scale. Measured, and written down in index.css so the next person does not go looking in the chrome. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FYGadugJ72Vr9QYaqQDjjC --- .../src/components/board/DataLimitedRow.tsx | 2 +- .../src/components/board/InputStatusPanel.tsx | 2 +- front-end/src/components/board/PatientRow.tsx | 18 ++++++- .../components/board/SelectedPatientPanel.tsx | 2 +- front-end/src/components/board/WardScale.tsx | 51 +++++++++++++------ .../src/components/charts/BreathRhythm.tsx | 2 +- front-end/src/components/chrome/AppHeader.tsx | 4 +- .../src/components/detail/ContributorList.tsx | 2 +- .../components/detail/ExplanationPanel.tsx | 42 ++++++++++++--- .../src/components/detail/ParameterTable.tsx | 2 +- .../detail/PatientContextDrawer.tsx | 2 +- front-end/src/components/ui/SegmentMeter.tsx | 13 +++-- front-end/src/hooks/useMeasuredWidth.ts | 28 +++++++--- front-end/src/index.css | 45 ++++++++++++++++ front-end/src/lib/labelPlacement.ts | 30 +++++++++-- front-end/src/screens/ParameterDetail.tsx | 2 +- front-end/src/screens/PatientDetail.tsx | 2 +- .../src/screens/PatientOverviewBoard.tsx | 20 ++++++-- 18 files changed, 213 insertions(+), 56 deletions(-) diff --git a/front-end/src/components/board/DataLimitedRow.tsx b/front-end/src/components/board/DataLimitedRow.tsx index 6a7709c..370d3f0 100644 --- a/front-end/src/components/board/DataLimitedRow.tsx +++ b/front-end/src/components/board/DataLimitedRow.tsx @@ -75,7 +75,7 @@ export function DataLimitedRow({ assessment, selected, onSelect, now }: DataLimi aria-label={`Open ${assessment.bed_code}, patient ${assessment.patient_id}`} > Open - +
diff --git a/front-end/src/components/board/InputStatusPanel.tsx b/front-end/src/components/board/InputStatusPanel.tsx index 654486a..8ff710e 100644 --- a/front-end/src/components/board/InputStatusPanel.tsx +++ b/front-end/src/components/board/InputStatusPanel.tsx @@ -96,7 +96,7 @@ export function InputStatusPanel({ devices, now, patientId }: InputStatusPanelPr className="field-label inline-flex items-center gap-1 text-ink-500 transition-colors hover:text-ink-950" > diff --git a/front-end/src/components/board/PatientRow.tsx b/front-end/src/components/board/PatientRow.tsx index 2ea4953..2b80d4e 100644 --- a/front-end/src/components/board/PatientRow.tsx +++ b/front-end/src/components/board/PatientRow.tsx @@ -56,7 +56,21 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr {assessment.patient_id} - + {/* A FIXED COLUMN, not a shrink-wrapped tag. The four band names are + different lengths, so the pill ran 74.6px at LOW to 106.5px at + CRITICAL — and since everything after it is laid out in source + order, the score numeral started at four different x positions down + a single board (378, 383, 403, 410). A column of numbers that does + not form a column is the one thing a triage board cannot afford: + the eye scans down it. + + The width is the widest tag plus slack, and the pill keeps its + natural size inside it — stretching the pill itself would put a + wide LOW badge next to a wide CRITICAL one and imply they carry the + same weight. */} + + + {/* Numeral and caption on ONE baseline, not stacked. Stacked, this was 44px and the tallest thing in the row — it, not the bed/patient @@ -99,7 +113,7 @@ export function PatientRow({ assessment, selected, onSelect, now }: PatientRowPr aria-label={`Open ${assessment.bed_code}, patient ${assessment.patient_id}`} > Open - +
diff --git a/front-end/src/components/board/SelectedPatientPanel.tsx b/front-end/src/components/board/SelectedPatientPanel.tsx index 67446a4..87b5fdb 100644 --- a/front-end/src/components/board/SelectedPatientPanel.tsx +++ b/front-end/src/components/board/SelectedPatientPanel.tsx @@ -96,7 +96,7 @@ export function SelectedPatientPanel({ assessment }: SelectedPatientPanelProps) className="mt-5 flex w-full items-center justify-center gap-2 rounded-[2px] bg-ink-950 px-4 py-2.5 text-sm font-medium text-surface transition-colors hover:bg-accent" > Open patient detail - + ) diff --git a/front-end/src/components/board/WardScale.tsx b/front-end/src/components/board/WardScale.tsx index 9e06e77..13e94ca 100644 --- a/front-end/src/components/board/WardScale.tsx +++ b/front-end/src/components/board/WardScale.tsx @@ -4,7 +4,7 @@ import { BANDS } from '../../data/bands' import { BAND_STYLES } from '../../lib/bandStyles' import { cn } from '../../lib/cn' import { formatScore } from '../../lib/format' -import { ROW_PITCH, placeLabels } from '../../lib/labelPlacement' +import { placeLabels } from '../../lib/labelPlacement' import { useMeasuredWidth } from '../../hooks/useMeasuredWidth' interface WardScaleProps { @@ -13,11 +13,27 @@ interface WardScaleProps { onSelect: (patientId: string) => void } -/** Clear space between the lowest label row and the axis, in pixels. Tall enough - * that a displaced leader line leans rather than lies flat: the widest travel - * measured on this ward is 63px, which over 22px reads as a line and not as a - * rule. */ -const LEADER_HEIGHT = 22 +/** + * Vertical distance between label rows, as a multiple of a label's own height. + * + * ⚠️ DERIVED FROM THE MEASURED LABEL, not from a constant and not from the root + * font size. A fixed 20px was correct beside an 11px label and left ONE pixel of + * clearance beside a 19px one — measured in the running app after the type scale + * grew. And deriving it from `rem` failed too: read at module load the root is + * still the browser default, because Vite injects the stylesheet after the + * modules evaluate. The label is already measured for its width; its height is + * the thing the pitch has to clear, so measuring that settles it at any scale. + */ +const ROW_PITCH_RATIO = 1.2 + +/** Clear space between the lowest label row and the axis, as a multiple of the + * row pitch. Tall enough that a displaced leader line leans rather than lies + * flat: the widest travel measured on this ward is 63px, which over ~22px reads + * as a line and not as a rule. */ +const LEADER_RATIO = 1.1 + +/** Used only until the probe reports, on the very first paint. */ +const FALLBACK_PITCH = 20 /** * A reading moves a bed along the axis; it must travel there rather than appear @@ -54,7 +70,10 @@ const SLIDE_LEADER = `${SLIDE}, height 700ms ${EASE}, transform 700ms ${EASE}` */ export function WardScale({ patients: given, selectedId, onSelect }: WardScaleProps) { const [trackRef, trackWidth] = useMeasuredWidth() - const [probeRef, labelWidth] = useMeasuredWidth() + const [probeRef, labelWidth, labelHeight] = useMeasuredWidth() + + const rowPitch = labelHeight > 0 ? labelHeight * ROW_PITCH_RATIO : FALLBACK_PITCH + const leaderHeight = rowPitch * LEADER_RATIO // A non-finite score would place its label at `left: NaN%`, which the browser // ignores — so the bed would sit at the far left looking like a real reading @@ -77,7 +96,7 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr labelWidth, ) const byId = new Map(placements.map((p) => [p.id, p])) - const labelsHeight = rows * ROW_PITCH + const labelsHeight = rows * rowPitch const pct = (px: number) => (trackWidth > 0 ? (px / trackWidth) * 100 : 0) return ( @@ -101,7 +120,7 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr
{patients.map((patient) => { const placement = byId.get(patient.patient_id) @@ -120,7 +139,7 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr // configurations; the evenly-spread demo ward happens never to show it. // // Why the split is sound rather than merely better: the diagonals all - // rise the same LEADER_HEIGHT, and with `trueX` and `placedX` both + // rise the same leaderHeight, and with `trueX` and `placedX` both // non-decreasing the gap between two of them is linear in height and // non-negative at both ends, so it cannot change sign between them. // The risers sit at distinct `placedX` and live entirely above the @@ -128,11 +147,11 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr // // The diagonal is anchored at the mark and rotated about its own foot, // so its head lands on the label's column by construction: rotation - // atan(shift / LEADER_HEIGHT), length hypot(shift, LEADER_HEIGHT). + // atan(shift / leaderHeight), length hypot(shift, leaderHeight). const shift = placement.placedX - placement.trueX - const length = Math.hypot(shift, LEADER_HEIGHT) - const angle = (Math.atan2(shift, LEADER_HEIGHT) * 180) / Math.PI - const riser = placement.row * ROW_PITCH + const length = Math.hypot(shift, leaderHeight) + const angle = (Math.atan2(shift, leaderHeight) * 180) / Math.PI + const riser = placement.row * rowPitch return ( @@ -157,7 +176,7 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr )} style={{ left: `${pct(placement.placedX)}%`, - bottom: `${LEADER_HEIGHT}px`, + bottom: `${leaderHeight}px`, height: `${riser}px`, transform: 'translateX(-50%)', transition: SLIDE_LEADER, @@ -185,7 +204,7 @@ export function WardScale({ patients: given, selectedId, onSelect }: WardScalePr className="absolute -translate-x-1/2 whitespace-nowrap bg-page px-1 font-mono text-2xs tabular-nums" style={{ left: `${pct(placement.placedX)}%`, - bottom: `${LEADER_HEIGHT + placement.row * ROW_PITCH}px`, + bottom: `${leaderHeight + placement.row * rowPitch}px`, transition: SLIDE_LABEL, }} > diff --git a/front-end/src/components/charts/BreathRhythm.tsx b/front-end/src/components/charts/BreathRhythm.tsx index 9267add..71fb575 100644 --- a/front-end/src/components/charts/BreathRhythm.tsx +++ b/front-end/src/components/charts/BreathRhythm.tsx @@ -99,7 +99,7 @@ export function BreathRhythm({ assessment, band, size = 'detail' }: BreathRhythm onClick={() => setRun((current) => current + 1)} className="inline-flex shrink-0 items-center gap-1.5 rounded-[2px] border border-rule-strong px-2.5 py-1 text-2xs font-medium text-ink-950 transition-colors hover:border-ink-950 hover:bg-surface-sunken" > - + {playing ? 'Breathing…' : 'Play rhythm'}
diff --git a/front-end/src/components/chrome/AppHeader.tsx b/front-end/src/components/chrome/AppHeader.tsx index c848be2..fc32948 100644 --- a/front-end/src/components/chrome/AppHeader.tsx +++ b/front-end/src/components/chrome/AppHeader.tsx @@ -76,9 +76,9 @@ export function AppHeader() { title={theme === 'day' ? 'Night' : 'Day'} > {theme === 'day' ? ( - + ) : ( - + )}
diff --git a/front-end/src/components/detail/ContributorList.tsx b/front-end/src/components/detail/ContributorList.tsx index 87a8f7b..c203ab9 100644 --- a/front-end/src/components/detail/ContributorList.tsx +++ b/front-end/src/components/detail/ContributorList.tsx @@ -57,7 +57,7 @@ export function ContributorList({ contributors }: ContributorListProps) { {contributor.is_imputed && ( - + Population default )} diff --git a/front-end/src/components/detail/ExplanationPanel.tsx b/front-end/src/components/detail/ExplanationPanel.tsx index b8e716c..5b12b01 100644 --- a/front-end/src/components/detail/ExplanationPanel.tsx +++ b/front-end/src/components/detail/ExplanationPanel.tsx @@ -37,16 +37,28 @@ export function ExplanationPanel( disabled={generating} className="mt-3 inline-flex items-center gap-1.5 rounded-[2px] border border-rule-strong bg-surface px-2.5 py-1.5 text-2xs font-medium text-ink-950 transition-colors hover:border-ink-950 disabled:cursor-progress disabled:text-ink-500" > - + {generating ? 'Generating…' : shown === null ? 'Generate explanation' : 'Explain this reading'} ) - if (shown === null) { + // GENERATING IS CHECKED FIRST, ABOVE `shown === null`, and that ordering is + // the whole fix. This state used to live inside the never-requested branch, so + // it showed on the FIRST generation and never again: asking for another + // explanation left the previous prose sitting there while the references panel + // beside it visibly cleared and refilled. Dimming the old text instead was + // tried and rejected — greyed-out prose still reads as the answer, and the + // panel is claiming to be writing a new one. + // + // The old text is not lost: it is stored on the assessment, and if this + // generation fails `shown` falls back to it on the next render. + if (generating || shown === null) { return (

- {generating ? 'Writing the explanation…' : 'No explanation requested'} + {generating + ? shown === null ? 'Writing the explanation…' : 'Re-running the model…' + : 'No explanation requested'}

{generating @@ -57,6 +69,17 @@ export function ExplanationPanel( 'ranked factors above are complete.'}

+ {/* Say it before they wait twenty seconds for it. Decoding is greedy, so + re-running the model on the same reading returns byte-identical prose + — verified with two live calls, same sha256. Unannounced, the honest + outcome is indistinguishable from a button that did nothing. */} + {generating && shown !== null && ( +

+ Decoding is greedy, so the same reading returns the same wording. The + guideline passages beside this are being selected again in the same call. +

+ )} + {failure && (

{failure} @@ -95,14 +118,21 @@ export function ExplanationPanel(

{explanationToRender.grounding_status === 'passed' && ( - + Checked against this assessment )} {/* Set larger and to a narrower measure than the data around it — this is the one - place on the screen where prose is read as prose. */} -

+ place on the screen where prose is read as prose. + + ⚠️ This said `text-md`, which is not a class. The theme defines + 2xs/xs/sm/base/lg…, Tailwind has no `md` font-size key either, so it + compiled to nothing and the paragraph inherited body's 14px — the same + size as the data it was supposed to stand apart from. The comment above + had been true of the intent and false of the screen since it was + written. `text-base` is what it meant. */} +

{explanationToRender.explanation_text}

diff --git a/front-end/src/components/detail/ParameterTable.tsx b/front-end/src/components/detail/ParameterTable.tsx index 3f05184..8932c22 100644 --- a/front-end/src/components/detail/ParameterTable.tsx +++ b/front-end/src/components/detail/ParameterTable.tsx @@ -115,7 +115,7 @@ export function ParameterTable({ patientId, parameters, contributors }: Paramete - + ) })} diff --git a/front-end/src/components/detail/PatientContextDrawer.tsx b/front-end/src/components/detail/PatientContextDrawer.tsx index 80f2a44..67b950a 100644 --- a/front-end/src/components/detail/PatientContextDrawer.tsx +++ b/front-end/src/components/detail/PatientContextDrawer.tsx @@ -90,7 +90,7 @@ export function PatientContextDrawer({ className="rounded-[2px] p-1 text-ink-500 transition-colors hover:bg-surface-sunken hover:text-ink-950" aria-label="Close patient context" > - +
diff --git a/front-end/src/components/ui/SegmentMeter.tsx b/front-end/src/components/ui/SegmentMeter.tsx index 7a7d91c..e99ee19 100644 --- a/front-end/src/components/ui/SegmentMeter.tsx +++ b/front-end/src/components/ui/SegmentMeter.tsx @@ -26,11 +26,14 @@ export function SegmentMeter({ band, className }: SegmentMeterProps) { diff --git a/front-end/src/hooks/useMeasuredWidth.ts b/front-end/src/hooks/useMeasuredWidth.ts index f8017db..cb83536 100644 --- a/front-end/src/hooks/useMeasuredWidth.ts +++ b/front-end/src/hooks/useMeasuredWidth.ts @@ -1,7 +1,16 @@ import { useCallback, useState } from 'react' /** - * The rendered width of an element, in CSS pixels. + * The rendered width AND height of an element, in CSS pixels. + * + * ⚠️ Height is here because the alternative failed. The label row pitch was + * derived from the root font size read at module load — and in Vite dev the + * stylesheet is injected by JS *after* the modules evaluate, so + * `getComputedStyle(html).fontSize` was still the browser's 16px default. The + * pitch stayed 20px while the labels grew to 19px tall: one pixel of clearance, + * measured in the running app. Nothing threw. Measuring the rendered box has no + * such ordering hazard, and it measures the constraint itself rather than a + * proxy for it. * * The first DOM measurement in this codebase, and it exists for one reason: the * ward scale places labels along a fluid track, but a label is a fixed pixel @@ -13,12 +22,14 @@ import { useCallback, useState } from 'react' * measurement happens the moment the node attaches, with no render showing an * unmeasured zero. The cleanup return is React 19's ref-cleanup contract. */ -export function useMeasuredWidth(): [(node: T | null) => void, number] { - const [width, setWidth] = useState(0) +export function useMeasuredWidth(): + [(node: T | null) => void, number, number] { + const [box, setBox] = useState({ width: 0, height: 0 }) const ref = useCallback((node: T | null) => { if (!node) return undefined - setWidth(node.getBoundingClientRect().width) + const first = node.getBoundingClientRect() + setBox({ width: first.width, height: first.height }) const observer = new ResizeObserver((entries) => { // BORDER box, not `contentRect`. `contentRect` excludes padding, so a @@ -27,15 +38,16 @@ export function useMeasuredWidth(): [(node: T | null) => // maths then reserved 70px for a 78px label, which is exactly how the // leftmost one came to hang 4px off the end of the track. const entry = entries[0] - const measured = entry?.borderBoxSize?.[0]?.inlineSize - ?? node.getBoundingClientRect().width + const rect = node.getBoundingClientRect() + const width = entry?.borderBoxSize?.[0]?.inlineSize ?? rect.width + const height = entry?.borderBoxSize?.[0]?.blockSize ?? rect.height // Ignore a zero: a hidden or detached node reports 0, and propagating it // would collapse every placement to the same point. - if (measured) setWidth(measured) + if (width) setBox({ width, height }) }) observer.observe(node) return () => observer.disconnect() }, []) - return [ref, width] + return [ref, box.width, box.height] } diff --git a/front-end/src/index.css b/front-end/src/index.css index e0ca70a..e9cebb0 100644 --- a/front-end/src/index.css +++ b/front-end/src/index.css @@ -273,6 +273,51 @@ @layer base { html { + /* THE ONE NUMBER THAT SETS THE SIZE OF EVERYTHING. + 106.25% of a 16px default = 17px, so the scale below reads + 11.7 · 13.3 · 14.9 · 17 rather than 11 · 12.5 · 14 · 16. + + Chosen over 112.5% (18px) after measuring both on the board: 18px read + better but cost 78px more of a viewport that is already short, and the + ward board is the thing being demonstrated. See the fit note below. + + Set here rather than by editing the `--text-*` tokens, because a rem is + also what every Tailwind padding, gap and fixed width in this app is + denominated in. Raising the tokens alone grows the text inside containers + that stay put, and `min-w-[9.5rem]`, `w-24` and `w-14` all start wrapping; + raising the root grows both together and none of them do. + + ⚠️ It does NOT reach raw pixel values. The lucide `size={}` props were + scaled by hand and `SegmentMeter`'s bars moved to rem. `WardScale`'s row + pitch and leader height are derived from the MEASURED label box instead — + reading this value back off the document was tried and failed, because in + Vite dev the stylesheet is injected after the modules evaluate, so a + module-load read returns the browser's 16px default and the pitch stayed + 20px beside a 19px label. Anything added later that pairs a px constant + with a text size has to be measured or scaled; assuming a rem is not safe + at module scope. + + ⚠️ THE BOARD NO LONGER FITS ONE SCREEN, AND IT DID NOT BEFORE THIS EITHER. + Measured 2026-08-22 in Chrome on this machine -- 150% display scaling, so + a maximised window is exactly the 876px viewport commit e8a4fcf tuned + against. The ranked list overflows by: + + 240px at 16px root <- the scale before this change + 324px at 17px <- here + 402px at 18px + + So this made an existing overflow worse; it did not create one. The ward + gained a per-row "awaiting clinician review" banner since e8a4fcf, and + seven rows at 87px is simply more than the 412px the list pane gets. The + list has `overflow-y-auto`, so it scrolls rather than clipping. + + The height is IN THE BED ROWS. It is not in the h1 (27px), not in the note + beside it (69px, and worth ~23px if its measure were widened), and not in + the risk scale (203px, most of it the label rows and the axis). Anyone + trying to win it back should start by measuring, not by shrinking chrome. + + Lowering this number is the cheap lever and it buys ~80px a step. */ + font-size: 106.25%; -webkit-text-size-adjust: 100%; background-color: var(--page); } diff --git a/front-end/src/lib/labelPlacement.ts b/front-end/src/lib/labelPlacement.ts index 112d300..2be9110 100644 --- a/front-end/src/lib/labelPlacement.ts +++ b/front-end/src/lib/labelPlacement.ts @@ -12,11 +12,31 @@ * label keeps its full text; the leader line is what ties it back to its mark. */ -/** Clear space between two labels sharing a row, in pixels. */ -const GUTTER = 12 +/** + * CSS pixels in one rem, read from the document rather than assumed to be 16. + * + * The gutter sits beside text sized by the type scale, and the type scale is in + * rem. Hardcoded at 12 it was right at a 16px root and silently wrong the moment + * `html { font-size }` moved. + * + * ⚠️ READ LAZILY, ON FIRST USE — NOT AT MODULE LOAD. Read at load it came back + * 16 even though the root is 18: in Vite dev the stylesheet is injected by JS + * after the modules evaluate, so the document has no styles yet when this file + * runs. `placeLabels` is only ever called during a render, by which point it + * does. The `document === undefined` fallback keeps the function usable outside + * a browser, which is what lets the geometry be fuzzed. + */ +let remCache = 0 +const rem = () => { + if (remCache) return remCache + remCache = typeof document === 'undefined' + ? 16 + : parseFloat(getComputedStyle(document.documentElement).fontSize) || 16 + return remCache +} -/** Vertical distance between label rows, in pixels. */ -export const ROW_PITCH = 20 +/** Clear space between two labels sharing a row, in pixels. */ +const gutter = () => 0.75 * rem() /** Rows are cheap but not free — each one pushes the axis further down. */ const MAX_ROWS = 3 @@ -147,7 +167,7 @@ export function placeLabels( return { placements: [], rows: 1 } } - const need = labelWidth + GUTTER + const need = labelWidth + gutter() const ordered = [...items].sort((a, b) => a.value - b.value) const rows = rowsFor(ordered.map((i) => i.value * trackWidth), need, trackWidth, labelWidth) diff --git a/front-end/src/screens/ParameterDetail.tsx b/front-end/src/screens/ParameterDetail.tsx index 5a4074f..f6c9c26 100644 --- a/front-end/src/screens/ParameterDetail.tsx +++ b/front-end/src/screens/ParameterDetail.tsx @@ -56,7 +56,7 @@ export function ParameterDetail() { to={`/patient/${patientId}`} className="inline-flex items-center gap-1.5 text-2xs text-ink-500 transition-colors hover:text-accent" > - + Back to {assessment.bed_code}

diff --git a/front-end/src/screens/PatientDetail.tsx b/front-end/src/screens/PatientDetail.tsx index 2a30046..97d5775 100644 --- a/front-end/src/screens/PatientDetail.tsx +++ b/front-end/src/screens/PatientDetail.tsx @@ -95,7 +95,7 @@ export function PatientDetail() { to="/" className="inline-flex items-center gap-1.5 text-2xs text-ink-500 transition-colors hover:text-accent" > - + Back to overview
diff --git a/front-end/src/screens/PatientOverviewBoard.tsx b/front-end/src/screens/PatientOverviewBoard.tsx index 319246e..b71ef42 100644 --- a/front-end/src/screens/PatientOverviewBoard.tsx +++ b/front-end/src/screens/PatientOverviewBoard.tsx @@ -88,9 +88,23 @@ export function PatientOverviewBoard() { Set down from 4xl/5xl: at 49-61px it and its note cost 116px of a 876px screen, which is two ward beds. The tier survives — deleting the h1 would remove display type from the board entirely — but it earns - its space at 24px on one line beside the note. */} + its space on one line beside the note. + + ⚠️ Now `text-xl`, down from `text-2xl`. The comment above said "24px" + while the class said 2xl, which is 31 — it had been describing an + intention rather than the screen. + + ⚠️ And this is NOT where the height is. Measured on an 876px viewport + at an 18px root — the largest scale tried, so the most favourable case + for finding room here — the block was 77px of which the h1 was 27 and + the NOTE was 69. They sit side by side, so the taller one sets the + height: dropping the h1 a step bought SIX PIXELS. Widening the note's + `56ch` measure would recover ~23px and is not worth the reading + comfort. The board's real height is in the bed rows, seven of them at + 87px, each carrying an "awaiting clinician review" banner. Do not come + here expecting to find it. */}
-

Adult ventilated ICU patients

+

Adult ventilated ICU patients

A prompt is raised only when a band change is sustained and confirmed — roughly one in every 34 readings. A patient held at HIGH for six hours is one interruption, not @@ -123,7 +137,7 @@ export function PatientOverviewBoard() {