diff --git a/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md b/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md index a07fe77..bf7ad25 100644 --- a/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md +++ b/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md @@ -18,7 +18,7 @@ The goal is **"dial down to anything"**: from the dashboard, from any list row, - New `DrillStackProvider` context to manage a max-2-level stack (one drawer + one peek) - Hover-reveal affordance (pointer + accent tint + trailing chevron) on every clickable cell - Per-surface drill map (every click target → its destination) -- 4 new backend read endpoints (`/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`, `/api/payers/{payer_id}/summary`, `/api/activity/{event_id}`); the rest of the work is frontend wiring +- 1 new backend read endpoint (`/api/payers/{payer_id}/summary`) plus an extended response shape on the existing `/api/providers/{npi}` (adds `recent_claims[]` and `recent_activity[]` arrays). The rest of the work is frontend wiring. **Out of scope (deferred):** @@ -41,12 +41,12 @@ The goal is **"dial down to anything"**: from the dashboard, from any list row, | `PeekModalHeader.tsx` | Eyebrow + title + close button. | | `DrillStackProvider.tsx` | React context + zustand-backed store. Owns the stack of `[{ kind: 'drawer' | 'peek', key: string, payload?: unknown }]`. Exposes `open()`, `closeTop()`, `closeAll()`. Enforces max 2 levels. | | `DrillableCell.tsx` | Tiny wrapper that adds the hover-reveal affordance (pointer + accent tint + trailing chevron) and the click handler. Used inside tables, KPI tiles, activity feeds. | -| `ProviderDrawer.tsx` | New full-entity drawer for `/api/providers/{npi}`. Tabs: Overview / Claims / Activity. | -| `AckDrawer.tsx` | New full-entity drawer for `/api/activity/{event_id}`? No — for `/api/acks/{id}` (reuses existing `GET /api/acks/{id}`). | +| `ProviderDrawer.tsx` | New full-entity drawer for `/api/providers/{npi}`. Tabs: Overview (NPI + address + counts) / Claims (top 10 recent claims + "View all →" link) / Activity (top 10 recent activity + "View all →" link). | +| `AckDrawer.tsx` | New full-entity drawer for `/api/acks/{id}` (reuses the existing `GET /api/acks/{id}` endpoint). | | `RemitDrawer.tsx` | New full-entity drawer for `/api/remittances/{id}`. Already documented as "deferred to a follow-up" in the SP4 claim-drawer spec; SP21 ships it. | | `useDrillStack.ts` | Hook wrapper around `DrillStackProvider`'s store, with selector helpers for "is this drawer open?", "open this drawer", "the active peek payload". | -| `PayerPeekContent.tsx` | Payer peek body: name, claim count, billed, received, denial rate, "view all claims →" link. | -| `ActivityPeekContent.tsx` | Activity event peek body: kind, timestamp, entity id link, optional payload preview (e.g. claim ids touched). | +| `PayerPeekContent.tsx` | Payer peek body (backend-fed): name, claim count, billed, received, denial rate, "view all claims →" link. | +| `ValidationRulePeekContent.tsx` | Validation-rule peek body (static lookup, no backend call): rule code, severity, full description, X12 spec reference if any. Opened from ClaimDrawer validation issue rows. | All live under `src/components/drill/` (new shared folder) plus `src/components/ProviderDrawer/`, `src/components/AckDrawer/`, `src/components/RemitDrawer/`. @@ -84,8 +84,8 @@ The complete click → destination table. Each row is one click target on one su | Inbox · Unmatched remit row | row body | `RemitDrawer(?remit=ID)` | top drawer | | Inbox · Done today row | row body | routed by row kind (claim vs remit) | top drawer | | Reconciliation · claim button | button | (unchanged — select for match) | — | -| Reconciliation · claim card body | body | `PeekModal(claim)` | top peek | -| Reconciliation · remit card body | body | `PeekModal(remit)` | top peek | +| Reconciliation · claim card body | body | `DrillDrawer(claim, ?claim=ID)` | top drawer (navigates to `/claims?claim=ID`) | +| Reconciliation · remit card body | body | `RemitDrawer(?remit=ID)` | top drawer (navigates to `/remittances?remit=ID`) | | Batch diff · claim id (added) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Batch diff · claim id (removed) | id | `DrillDrawer(claim, ?claim=ID)` (will 404 if removed — show distinct not-found state) | top drawer | | Batch diff · claim id (changed) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | @@ -104,29 +104,33 @@ The complete click → destination table. Each row is one click target on one su ### 2.3 Backend endpoints -**New:** +**New endpoint:** | Method | Path | Purpose | |---|---|---| -| `GET` | `/api/providers/{npi}/claims` | Paginated claims for a provider. Mirrors `/api/claims` filter shape but pre-scoped to the provider. Limit/offset/sort/order all honored. | -| `GET` | `/api/providers/{npi}/activity` | Paginated activity for a provider. Mirrors `/api/activity` filter shape pre-scoped to the provider. | -| `GET` | `/api/payers/{payer_id}/summary` | Aggregate snapshot for a payer: `{ payer_id, name, claim_count, billed_total, received_total, denial_rate, top_providers: [{npi, count}] }`. Computed on read from the existing `claims` + `remittances` tables. Cached for 60s. | -| `GET` | `/api/activity/{event_id}` | One activity event with its full payload. Used by the ActivityPeekContent. | +| `GET` | `/api/payers/{payer_id}/summary` | Aggregate snapshot for a payer: `{ payer_id, name, claim_count, billed_total, received_total, denial_rate, top_providers: [{npi, count}] }`. Computed on read from the existing `claims` + `remittances` tables. Cached for 60s; invalidated on `claim_written` / `remittance_written` pubsub events. | + +**Extended endpoint (existing, response shape widened):** + +| Method | Path | Change | +|---|---|---| +| `GET` | `/api/providers/{npi}` | Response gains `recent_claims: ClaimSummary[]` (top 10 by `submission_date DESC`) and `recent_activity: ActivityEvent[]` (top 10 by `ts DESC`). Backwards-compatible: existing clients ignore the new arrays. | **Reused as-is:** - `GET /api/claims/{id}` — already exists (SP4). Used by `DrillDrawer(claim)`. - `GET /api/remittances/{id}` — already exists (SP3). Used by `RemitDrawer`. - `GET /api/batches/{id}` — already exists. Used by `BatchDetail`. -- `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab. -- `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header. +- `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab AND Claims / Activity tabs (via the newly extended arrays). +- `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header (the payer name). - `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`. **Implementation notes:** -- Add the four new handlers in `backend/src/cyclone/api_routers/` (split: providers gets two new methods, payers gets one, activity gets one). +- Add the one new handler in `backend/src/cyclone/api_routers/payers.py`. - Payer summary cache lives in a module-level `lru_cache` with a TTL guard (60s); invalidated on a `claim_written` / `remittance_written` pubsub event. -- All four new endpoints inherit the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers. +- The new endpoint inherits the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers. +- The extended `/api/providers/{npi}` response is bounded — only 10 + 10 entries per category — so the existing payload cap (per SP19 body limit, default 50 MB) is comfortably respected. ### 2.4 Visual design tokens @@ -221,7 +225,7 @@ The trailing chevron is injected via `::after` so `DrillableCell` doesn't need t - `/acks?ack=42` — list with ack drawer. - `/activity?event=evt-uuid` — list with activity event drawer. -Each page's `useDrawerUrlState` hook handles its own `?{entity}=` param. Opening a different entity-type drawer from inside a page navigates to the page that owns that entity-type drawer (e.g. clicking a payer name on `/claims` opens a payer peek — no nav; but clicking a claim id on `/remittances` opens the claim drawer, which is the `/claims` page's drawer — this case keeps the operator on `/remittances` and uses a global drawer pattern). To keep things simple in v1: **all entity drawers mount from the page that owns them**, and clicking a cross-page entity from inside another page **navigates to that entity's owning page with `?{entity}=` set**. The page transition is a soft fade (no full reload). Peeks never trigger navigation. +Each entity-drawer type has its own URL state hook following the existing per-page pattern (`useDrawerUrlState` for Claims, `useBatchDrawerUrlState` for Batches, plus a new `useProviderDrawerUrlState` for Providers, `useRemitDrawerUrlState` for Remittances, `useAckDrawerUrlState` for Acks). All hooks read & write the same `?{entity}={id}` convention. **Each drawer is mounted by the page that owns it** — clicking a cross-page entity from inside another page navigates to that entity's owning page with `?{entity}={id}` set (soft fade, no full reload). The opening source's filter state (e.g. `/remittances?status=posted`) is preserved when navigating back via browser-back. Peeks never trigger navigation. ### 2.7 Frontend file layout @@ -237,7 +241,7 @@ src/ │ │ ├── DrillStackMount.tsx # the actual portal mount + z-index mgmt │ │ ├── DrillableCell.tsx │ │ ├── PayerPeekContent.tsx -│ │ ├── ActivityPeekContent.tsx +│ │ ├── ValidationRulePeekContent.tsx │ │ ├── DrillStackProvider.test.tsx │ │ ├── DrillableCell.test.tsx │ │ ├── PeekModal.test.tsx @@ -245,10 +249,10 @@ src/ │ ├── ProviderDrawer/ # NEW │ │ ├── ProviderDrawer.tsx │ │ ├── ProviderOverview.tsx -│ │ ├── ProviderClaims.tsx # paginated mini-table -│ │ ├── ProviderActivity.tsx # paginated mini-feed +│ │ ├── ProviderRecentClaims.tsx # top-10 mini-table (data from extended /providers/{npi}) +│ │ ├── ProviderRecentActivity.tsx # top-10 mini-feed (data from extended /providers/{npi}) │ │ └── index.ts -│ ├── RemitDrawer/ # NEW (some files exist per tree) +│ ├── RemitDrawer/ # NEW │ │ ├── RemitDrawer.tsx │ │ ├── RemitHeader.tsx │ │ ├── ClaimPaymentsTable.tsx @@ -262,15 +266,14 @@ src/ │ │ └── index.ts │ └── ClaimDrawer/ # existing — refactor header to use DrillDrawerHeader ├── hooks/ -│ ├── useDrillStack.ts # NEW -│ ├── useProviderClaims.ts # NEW -│ ├── useProviderActivity.ts # NEW +│ ├── useDrillStack.ts # NEW (zustand-backed ephemeral peek stack) │ ├── usePayerSummary.ts # NEW -│ ├── useActivityEvent.ts # NEW -│ └── useProviderDetail.ts # NEW (wraps existing api.getProvider) +│ ├── useProviderDrawerUrlState.ts # NEW (?provider=) +│ ├── useRemitDrawerUrlState.ts # NEW (?remit=) +│ └── useAckDrawerUrlState.ts # NEW (?ack=) ├── lib/ -│ └── api.ts # +api.getProviderClaims, getProviderActivity, getPayerSummary, getActivityEvent -└── pages/ # wire drills per the drill map; replace ?claim= etc. state hooks with the shared useDrillStack hooks +│ └── api.ts # +api.getPayerSummary(id); /api/providers/{npi} response widened in backend +└── pages/ # wire drills per the drill map; refactor existing useDrawerUrlState usages to follow the per-drawer convention ├── Claims.tsx ├── Remittances.tsx ├── Dashboard.tsx @@ -295,17 +298,16 @@ src/ - `RemitDrawer.test.tsx` — header summary, claim payments table, CAS panel, payer cell opens payer peek - `AckDrawer.test.tsx` — header (source batch, ack code), per-segment status list, download button (unchanged) - `PayerPeekContent.test.tsx` — loading skeleton, populated peek, "view all claims" link href -- `ActivityPeekContent.test.tsx` — empty payload, claim_* payload, remit_received payload +- `ValidationRulePeekContent.test.tsx` — known rule (R050_diagnosis_present) renders full description + X12 reference; unknown rule shows fallback copy - Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly **Backend (pytest):** -- `test_provider_claims_endpoint` — filters correctly, paginates, 404 on unknown NPI -- `test_provider_activity_endpoint` — filters by kind, paginates, 404 on unknown NPI - `test_payer_summary_endpoint` — happy path, cache hit/miss, invalidation on `claim_written` -- `test_activity_event_endpoint` — happy path, 404 on unknown event id +- `test_provider_detail_extended_response` — existing fields unchanged; new `recent_claims` + `recent_activity` arrays populated (10 each, sorted); empty arrays when the provider has no claims/activity +- `test_provider_detail_extended_response_backwards_compat` — clients that ignore the new arrays don't break; old fields identical to SP9 response shape -**Target counts:** ~25 new frontend tests, ~6 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green. +**Target counts:** ~25 new frontend tests, ~3 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green. ### 2.9 Smoke test (end-of-SP21) @@ -328,7 +330,7 @@ src/ 17. **Activity log:** click a `remit_received` event → remit drawer opens. 18. **Inbox:** click a rejected row → claim drawer opens (was: no-op). 19. **Inbox:** click a candidates row → remit drawer opens (was: no-op). -20. **Reconciliation:** click the body of an unmatched claim (not the select button) → claim peek modal opens. +20. **Reconciliation:** click the body of an unmatched claim (not the select button) → navigates to `/claims?claim=ID` with the claim drawer open. 21. **Batch diff:** click an added claim id → claim drawer opens. 22. **Upload:** click a streamed claim's "See claim in detail" link → claim drawer opens (if persisted). 23. Browser back from any drawer state → drawer closes. @@ -342,7 +344,7 @@ src/ 4. **Drawer nav state across page navigation.** When the user clicks a claim id on `/remittances`, lands on `/claims?claim=…`, and presses browser back, they go back to `/remittances` (the remits filter is restored). The claim drawer closes. Tested in smoke step 23. 5. **Inbox row click vs checkbox click.** Inbox rows have multi-select checkboxes. Row body click opens the drawer; checkbox click toggles selection. The `DrillableCell` wrapper goes around the row body, NOT the checkbox. Tested in `Inbox.test.tsx`. 6. **Payer summary cache staleness.** The 60s cache + pubsub invalidation is best-effort. Worst case: a payer summary is up to 60s stale after a `claim_written`. Acceptable for v1. -7. **Patient name in URL.** Navigating to `/claims?patient=John%20Doe` puts the patient name in the URL bar. The existing ClaimDrawer spec already redacts patient names from logs but not from URL. Mitigation: when navigating from a patient cell, hash the name (sha256 first 8 chars) before putting it in the URL; the Claims page filter accepts the hash and looks up by hash. Alternative: just use the full name in URL — Cyclone is local-only (127.0.0.1) per project context. Going with the full name in URL for v1; flag for SP22 if multi-operator expansion happens. +7. **Patient name in URL.** Navigating to `/claims?patient=John%20Doe` puts the patient name in the URL bar. Cyclone is local-only (127.0.0.1) per project context, so this is acceptable for v1. Flagged for SP22 if multi-operator / networked expansion happens. The existing PII scrubber (SP18) only scrubs log lines, not URL bars. 8. **Drawer dim overlay z-index.** With a peek over a drawer, three z-index layers: page (z=0), drawer overlay (z=40), peek overlay (z=50). The `` portal renders peeks in a dedicated DOM node at `z-50`; the existing drawer overlays at `z-40` continue to work. Tested in `PeekModal.test.tsx` (asserts peek is rendered above drawer overlay). ## 4. Migration / rollout @@ -351,10 +353,10 @@ src/ - Worktree at `.worktrees/universal-drilldown/`, following SP1-3 pattern. - DB migrations: **none** (uses existing tables: `claims`, `remittances`, `batches`, `activity_events`, `providers`, `payers`, `acks`). - No new dependencies (Radix Dialog + zustand already in `package.json`). -- Phasing within the branch (each PR is shippable independently, each closes one smoke step): - 1. **PR1:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives. Wire Dashboard KPI navigation + Dashboard Top providers drill. Backend: `/api/payers/{payer_id}/summary`. (Smoke steps 4-6.) - 2. **PR2:** `ProviderDrawer` (full drawer, Overview tab only). Wire Dashboard Recent denials, Dashboard Recent activity, Providers page, Claims provider cell. Backend: `/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`. (Smoke steps 7, 10, 15.) - 3. **PR3:** `RemitDrawer`. Wire Remittances row click, Inbox candidates + unmatched remit rows, Activity `remit_received` events, Remittances claim-id cell, ClaimDrawer matched-remit link. (Smoke steps 13, 14, 17, 19.) - 4. **PR4:** `AckDrawer`. Wire Acks row, ClaimDrawer payer peek. Backend: `/api/activity/{event_id}` (also used by step 5). (Smoke steps 12, 16.) - 5. **PR5:** Wire the remaining surfaces: Activity log event click (PR4's backend), Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link. Refactor existing `ClaimDrawer` to use `DrillDrawer` shell. (Smoke steps 8, 9, 11, 18, 20, 21, 22, 23.) +- Phasing within the branch (each PR is shippable independently, each closes its slice of smoke steps): + 1. **PR1 — Foundation primitives + first 3 surfaces:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives, the App-level `` portal, the hover affordance CSS, the extended `/api/providers/{npi}` response. Wire Dashboard KPI navigation + Dashboard Top providers drill + Dashboard Recent denials drill. Backend: `/api/payers/{payer_id}/summary` (the actual provider-overview data lives in the extended `/api/providers/{npi}` response — no separate provider endpoints needed). (Smoke steps 4-6.) + 2. **PR2 — `ProviderDrawer` + activity event routing:** Build the ProviderDrawer (Overview tab only in this PR; Claims/Activity tabs come in PR3 with the recent_claims/recent_activity payload rendering). Wire Dashboard Recent activity for `claim_*` events (the activity → entity mapping lives in a small `eventKindToEntity()` helper introduced here). Wire Providers directory page + Claims provider cell. (Smoke steps 7, 10, 15.) + 3. **PR3 — `ProviderDrawer` tabs + remaining event routing:** Add the Claims and Activity tabs to ProviderDrawer, using the extended `/api/providers/{npi}` payload. Wire Dashboard Recent activity for `remit_received` and `provider_added` event kinds (still routed via the PR2 `eventKindToEntity()` helper; `provider_added` routes to ProviderDrawer, `remit_received` will route to the PR4 RemitDrawer — until PR4 lands it shows a graceful "Remit drawer coming in PR4" toast). (Smoke step 7 finishes — all 4 event kinds route correctly by PR4 merge.) + 4. **PR4 — `RemitDrawer` + 4 surfaces:** Build the RemitDrawer. Wire Remittances row click (replaces inline CAS expand), Inbox candidates + unmatched remit rows, Activity log `remit_received` event click, Remittances claim-id cell, ClaimDrawer matched-remit link. Now `eventKindToEntity()`'s `remit_received` branch resolves to a real drawer. (Smoke steps 13, 14, 17, 19.) + 5. **PR5 — `AckDrawer` + final 8 surfaces:** Build the AckDrawer. Wire Acks row, Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link, ClaimDrawer payer peek (`PayerPeekContent`), ClaimDrawer validation-rule peek (`ValidationRulePeekContent`), refactor existing `ClaimDrawer` to use the `DrillDrawer` shell + `DrillDrawerHeader`. (Smoke steps 8, 9, 11, 12, 16, 18, 20, 21, 22, 23.) - Merge to main after final PR + smoke pass; cleanup worktree + branch.