Files
cyclone/docs/superpowers/specs/2026-06-20-cyclone-claim-drawer-design.md

258 lines
12 KiB
Markdown

# Sub-project 4 — Per-Claim Detail Drawer: Design Spec
**Date:** 2026-06-20
**Status:** Draft (awaiting user review)
**Branch:** `claim-drawer`
**Aesthetic direction:** Modern (geometric sans + bold borders + electric blue accent)
## 1. Scope
A right-side slide-over drawer that opens when the operator clicks any claim row on `/claims`. The drawer shows the full context for one claim in a self-contained panel: header, validation issues, service lines, diagnoses, parties (provider/subscriber/payer), raw segments, matched remittance (if any), and state history timeline.
Sequential navigation: `j`/`k` or `↑`/`↓` steps between claims with the drawer staying open. URL syncs to `?claim={id}` so the drawer state is shareable and bookmarkable.
## 2. Out of scope (deferred to future sub-projects)
- **Resubmit / edit actions** — needs backend write endpoints; deferred to a later SP4 phase.
- **Notes / comments on claims** — separate feature, no DB schema today.
- **Bulk actions** — single-operator tool; no multi-select needed.
- **Auto-advance to next denied claim after closing drawer** — nice-to-have, not v1.
- **Remittance detail drawer** — covered by SP3's `/api/remittances/{id}` and a follow-up if needed.
- **The other 4 SP4 features from the README** — batch diff view, NDJSON streaming UI, advanced filters + saved sets, keyboard cheatsheet for the rest of the app. The cheatsheet entry for THIS drawer ships; the rest of the app's keyboard nav is deferred.
## 3. Locked decisions
### 3.1 Component decomposition
The drawer is composed of 1 root component + 10 leaf components, each with one purpose. This keeps individual files small and tests focused.
| Component | Responsibility |
|-----------|---------------|
| `ClaimDrawer.tsx` | Root: renders `Sheet`, fetches detail via `useClaimDetail`, handles j/k keyboard nav, manages URL sync |
| `ClaimDrawerHeader.tsx` | Claim ID, state badge, total amount, close button |
| `ValidationPanel.tsx` | Errors + warnings grouped by rule; per-rule counts |
| `ServiceLinesTable.tsx` | All service lines with procedure, modifiers, charge, units, date |
| `DiagnosesList.tsx` | HI segment codes with qualifiers |
| `PartiesGrid.tsx` | 3 cards: Billing Provider, Subscriber, Payer |
| `RawSegmentsPanel.tsx` | Collapsible `<details>` with full segment list (debug aid) |
| `MatchedRemitCard.tsx` | Conditional; shows matched remittance summary + "View remittance →" link |
| `StateHistoryTimeline.tsx` | Vertical timeline of `ActivityEvent` rows for this claim |
| `ClaimDrawerSkeleton.tsx` | Loading state with shimmering placeholders |
| `ClaimDrawerError.tsx` | 404 / network error / retry |
All live under `src/components/ClaimDrawer/`. Each is a leaf component with explicit props (no context drilling beyond `claim: ClaimDetail`).
### 3.2 Backend endpoint: `GET /api/claims/{claim_id}`
New endpoint returning the full claim detail. Read-only.
**Response shape:**
```json
{
"id": "C-12345",
"batchId": "8f4a...",
"state": "submitted",
"stateLabel": "Submitted",
"billedAmount": 100.00,
"patientName": "John Doe",
"providerNpi": "1234567890",
"providerName": "Test Provider",
"payerName": "COHCPF",
"payerId": "SKCO0",
"submissionDate": "2026-06-11T00:00:00Z",
"serviceDateFrom": "2026-06-11",
"serviceDateTo": "2026-06-11",
"parsedAt": "2026-06-11T12:00:00Z",
"diagnoses": [{"code": "Z00", "qualifier": "ABK"}],
"serviceLines": [
{"lineNumber": 1, "procedureQualifier": "HC", "procedureCode": "99213", "modifiers": [], "charge": 80.00, "units": 1.0, "unitType": "UN", "serviceDate": "2026-06-11"}
],
"parties": {
"billingProvider": {"name": "...", "npi": "...", "taxId": "...", "address": {...}},
"subscriber": {"firstName": "...", "lastName": "...", "memberId": "...", "dob": "...", "gender": "..."},
"payer": {"name": "...", "id": "..."}
},
"validation": {
"passed": false,
"errors": [{"rule": "R050_diagnosis_present", "severity": "error", "message": "..."}],
"warnings": []
},
"rawSegments": [["ISA", ...], ["CLM", ...]],
"matchedRemittance": null,
"stateHistory": [
{"kind": "claim_submitted", "ts": "2026-06-11T12:00:00Z", "batchId": "8f4a..."},
{"kind": "manual_match", "ts": "2026-06-12T10:00:00Z", "remittanceId": "R-9876"}
]
}
```
**Implementation:**
- Add `CycloneStore.get_claim_detail(claim_id) -> dict | None` to `backend/src/cyclone/store.py`
- Reads from `claims.raw_json` (already populated by SP1 ingest) → deserializes to `ClaimOutput` → maps to UI shape
- Queries `activity_events WHERE claim_id = ?` ordered by `ts DESC` for `stateHistory`
- If `claims.matched_remittance_id` is set, looks up the remittance and embeds a summary (`{id, totalPaid, status, ackDate}`)
- 404 if claim not found
### 3.3 Visual design tokens
```css
:root {
/* Surface */
--surface: #FAFAF7; /* cream-white drawer background */
--surface-dim: #6B6B6B22; /* list dim overlay when drawer open */
/* Ink */
--ink-primary: #0A0A0A;
--ink-secondary: #6B6B6B;
--ink-tertiary: #A0A0A0;
/* Borders (the brutalist accent) */
--border-heavy: #1A1A1A; /* 2px section dividers */
--border-default: #E5E5E2; /* 1px hairline borders */
/* Accent */
--accent: #1D4ED8; /* electric blue */
--accent-hover: #1E40AF;
--focus-ring: #1D4ED8;
/* Semantic */
--error: #DC2626;
--error-bg: #FEF2F2;
--success: #15803D;
--success-bg: #F0FDF4;
--warning: #B45309;
--warning-bg: #FFFBEB;
}
```
**Type stack:**
- Display / labels / body: **Inter Tight** (400, 500, 600, 700, 800 weights) via Google Fonts or local WOFF2
- Monospace (IDs, amounts, codes): **JetBrains Mono** (400, 600) — same family as SP3 Industrial direction
- Section labels: 10px uppercase, letter-spacing 0.18em, font-weight 700, color `--ink-secondary`
**Layout:**
- Drawer width: **560px** on desktop (`≥1024px`), **420px** on tablet (768-1023px), **full-screen** sheet on `<768px`
- Section dividers: 2px solid `--border-heavy` (signature Modern touch)
- Inner padding: 24px sides, 20px vertical per section
- Big numbers: 32px font for the total charge; 24px for service-line charges; 18px for diagnosis codes
### 3.4 Interaction patterns
**Open:**
- Click any row on `/claims` (whole row is the click target except the row checkbox if present).
- URL updates to `?claim={id}` via `history.replaceState` (no navigation, no re-fetch of list).
- Drawer slides in from right, 240ms ease-out. List dimmed to 60% opacity via overlay.
**Sequential navigation:**
- `j` or `↓` → next claim in the current sort order.
- `k` or `↑` → previous claim.
- Wraps: from last claim, `j` goes to first.
- New claim detail fetches in background; previous detail stays visible during fetch (no flash of skeleton).
- URL updates as you navigate (`?claim={newId}`).
**Close:**
- `ESC` key, X button, click on the dimmed overlay.
- URL strips the `?claim=` param.
**Loading:**
- Skeleton with shimmering placeholders matching section heights (~8-12 skeleton blocks).
- Fetch expected ~200-400ms on local backend; user perceives ~instant.
**Errors:**
- 404 (claim ID in URL but no row): "Claim not found" + Close button + a hint to refresh the list.
- Network error: "Couldn't reach the server" + Retry button.
- Both inline in the drawer, not a toast (drawer is the focused surface).
**Keyboard cheatsheet:**
- `?` toggles a small centered overlay (the drawer is dimmed further).
- Lists: `j` next, `k` prev, `↑`/`↓` same as j/k, `esc` close, `?` toggle this help.
- Dismisses on any other key or click outside.
### 3.5 URL state contract
- `/claims` → list, no drawer.
- `/claims?claim=C-12345` → list with drawer open for C-12345.
- Browser back/forward navigates through claim selections; Esc closes and pops the URL state.
- Deep link works: opening `/claims?claim=C-12345` directly loads the list AND opens the drawer for that claim.
- If the claim ID in the URL doesn't exist (was deleted, stale link), the drawer shows the 404 state.
### 3.6 Frontend file layout
```
src/
├── components/
│ └── ClaimDrawer/
│ ├── ClaimDrawer.tsx
│ ├── ClaimDrawerHeader.tsx
│ ├── ValidationPanel.tsx
│ ├── ServiceLinesTable.tsx
│ ├── DiagnosesList.tsx
│ ├── PartiesGrid.tsx
│ ├── RawSegmentsPanel.tsx
│ ├── MatchedRemitCard.tsx
│ ├── StateHistoryTimeline.tsx
│ ├── ClaimDrawerSkeleton.tsx
│ ├── ClaimDrawerError.tsx
│ └── index.ts # barrel export
├── hooks/
│ ├── useClaimDetail.ts # fetches GET /api/claims/{id}
│ ├── useDrawerKeyboard.ts # j/k/esc handlers
│ └── useDrawerUrlState.ts # ?claim= sync
├── lib/
│ └── api.ts # +api.getClaimDetail(id), +api.getClaimHistory(id) (optional split)
└── pages/
├── Claims.tsx # wire click + drawer render
└── Claims.test.tsx # +new tests
```
### 3.7 Testing targets
**Frontend (Vitest + React Testing Library):**
- `ClaimDrawerHeader.test.tsx` — renders ID, state badge, total; close button calls callback
- `ValidationPanel.test.tsx` — empty state, error-only, warning-only, both; grouping by rule
- `ServiceLinesTable.test.tsx` — empty, single line, multiple lines; procedure code formatting
- `DiagnosesList.test.tsx` — empty, single, multiple; qualifier shown when present
- `PartiesGrid.test.tsx` — all 3 cards; missing fields gracefully omitted
- `RawSegmentsPanel.test.tsx` — collapsed by default; toggle expands
- `MatchedRemitCard.test.tsx` — null state (no "View" button); populated state with link
- `StateHistoryTimeline.test.tsx` — empty, single event, multiple events; kind-color mapping
- `ClaimDrawer.test.tsx` — full lifecycle: open, fetch detail, display sections, j/k nav, esc close, URL sync
- `Claims.test.tsx` — click row opens drawer, deep link opens drawer, esc closes, URL clears
**Backend (pytest):**
- `test_get_claim_detail_happy_path` — full claim with all relations
- `test_get_claim_detail_not_found_returns_404`
- `test_get_claim_detail_state_history_includes_manual_match`
- `test_get_claim_detail_matched_remittance_summary_inlined`
**Target counts:** ~8 new frontend tests, ~4 new backend tests. Existing 10 frontend + 409 backend tests must all stay green.
### 3.8 Smoke test (end-of-SP4)
1. Start backend (`backend/.venv/bin/python -m cyclone serve`).
2. Start frontend (`npm run dev`).
3. Open `http://localhost:5173/claims`.
4. Click first claim row → drawer opens with all sections populated.
5. Press `k` → drawer updates to previous claim; URL updates.
6. Press `j` repeatedly → wrap behavior works.
7. Press `?` → cheatsheet shows.
8. Press `esc` → drawer closes; URL strips `?claim=`.
9. Open `http://localhost:5173/claims?claim=<known-id>` directly → drawer opens on page load.
10. Commit empty `smoke: end-to-end SP4 (claim drawer) flow passes`.
## 4. Risk areas
1. **Backend payload weight.** A claim with 50 service lines and 50 raw segments is ~50KB JSON. The drawer endpoint loads on every j/k press. Cache the parsed `ClaimOutput` in the React Query / SWR cache so navigation between claims is instant after the first fetch.
2. **ActivityEvent volume.** A claim with 20 manual_match/unmatch events is verbose. Default `stateHistory` to last 50; expose `?limit=N` if the operator wants more.
3. **Keyboard nav collisions.** `j`/`k` collide with browser find-in-page. Acceptable tradeoff for an internal tool — operator can `esc` out of the drawer to use browser search. Documented in the cheatsheet.
4. **Drawer + mobile.** Full-screen sheet on mobile means the list is hidden. Operator on mobile loses context. Acceptable for v1 (operator is on desktop per project context).
5. **URL sync race condition.** User opens drawer, navigates to claim 5, then refreshes. URL has `?claim=5` but cache has claim 1's detail. Page load must honor the URL claim, not the cache. The `useDrawerUrlState` hook reads the URL on mount and triggers the fetch.
## 5. Migration / rollout
- Single feature branch `claim-drawer` cut from `main` (parent: post-SP3 merge, `a1dc8a5`).
- Worktree at `.worktrees/claim-drawer/`, following SP1-3 pattern.
- No DB migrations (uses existing tables: `claims`, `batches`, `activity_events`, `remittances`).
- No new dependencies (Radix `Dialog` / `Sheet` from shadcn/ui — already in package.json).
- Merge to main after smoke passes; cleanup worktree + branch.