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

12 KiB

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:

{
  "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

: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.