diff --git a/docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md b/docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md new file mode 100644 index 0000000..97cd31e --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md @@ -0,0 +1,2626 @@ +# Universal Drill-Down Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every interactive surface in the Cyclone UI drillable — click any entity reference (claim, patient, provider, payer, batch, ack, activity event) to open a contextual view (full-record drawer or cross-reference peek modal). + +**Architecture:** Hybrid modal pattern: right-side `DrillDrawer` for full-entity records (claim, remit, batch, provider, ack), centered `PeekModal` for cross-references (payer, validation rule). A `DrillStackProvider` (zustand-backed) owns the ephemeral peek stack and enforces a max-2-level nesting rule (one drawer + one peek). Drawers sync to URL (`?{entity}={id}`); peeks don't. Hover-reveal affordance (pointer + accent tint + trailing chevron via `::after`) on every clickable cell. 1 new backend endpoint (`/api/payers/{payer_id}/summary`) + 1 extended endpoint (`/api/providers/{npi}` gains `recent_claims[]` + `recent_activity[]`). + +**Tech Stack:** React 18, Radix Dialog (existing), zustand (existing), TanStack Query (existing), Tailwind (existing), Vitest + React Testing Library (existing), FastAPI + SQLAlchemy (existing, backend), pytest (existing, backend). + +**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md` — read fully before starting any phase. + +**Worktree setup (one-time):** + +```bash +cd /Users/openclaw/dev/cyclone +git worktree add .worktrees/universal-drilldown -b universal-drilldown main +cd .worktrees/universal-drilldown +npm install +# backend deps unchanged +``` + +All commits happen in this worktree. Merge each PR to main via fast-forward when its phase ends. + +--- + +## Phase 1 — Foundation + 3 surfaces + 1 backend + +**Closes smoke steps:** 4 (Dashboard KPI navigation), 5 (Dashboard Top providers drill), 6 (Dashboard Recent denials drill), and PR1 of the §4 phasing. + +### Task 1.1: DrillStackProvider (zustand store + React context) + +**Files:** +- Create: `src/components/drill/DrillStackProvider.tsx` +- Create: `src/components/drill/DrillStackProvider.test.tsx` + +The provider owns the ephemeral peek stack (the URL-backed drawer is owned per-page). Single zustand store keyed by the `DrillStackProvider` component so test isolation is straightforward. + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/components/drill/DrillStackProvider.test.tsx +import { describe, it, expect } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + DrillStackProvider, + useDrillStack, +} from "@/components/drill/DrillStackProvider"; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("DrillStackProvider", () => { + it("starts with an empty stack", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + expect(result.current.stack).toEqual([]); + expect(result.current.openPeek).toBeInstanceOf(Function); + expect(result.current.closeTop).toBeInstanceOf(Function); + }); + + it("openPeek pushes one entry; closeTop pops it", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + act(() => result.current.openPeek({ kind: "payer", payerId: "SKCO0" })); + expect(result.current.stack).toEqual([ + { kind: "payer", payerId: "SKCO0" }, + ]); + act(() => result.current.closeTop()); + expect(result.current.stack).toEqual([]); + }); + + it("caps the stack at 2 levels (peek over peek is rejected)", () => { + const { result } = renderHook(() => useDrillStack(), { wrapper }); + act(() => result.current.openPeek({ kind: "payer", payerId: "A" })); + // The hook only governs peeks; a drawer at the bottom is owned by + // the page (URL-backed). For this unit test we simulate "drawer + // present" via the provider's `hasDrawer` prop and assert peek+peek + // becomes just the most recent peek. + act(() => result.current.openPeek({ kind: "rule", rule: "R050" })); + expect(result.current.stack).toHaveLength(1); + expect(result.current.stack[0]).toEqual({ kind: "rule", rule: "R050" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- DrillStackProvider.test.tsx` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Implement DrillStackProvider** + +```tsx +// src/components/drill/DrillStackProvider.tsx +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { create } from "zustand"; + +export type PeekPayload = + | { kind: "payer"; payerId: string } + | { kind: "rule"; rule: string }; + +interface DrillState { + stack: PeekPayload[]; + openPeek: (p: PeekPayload) => void; + closeTop: () => void; + closeAll: () => void; +} + +// One zustand store per provider instance (factory) so multiple +// providers (e.g. in tests) don't share state. +function makeStore() { + return create((set) => ({ + stack: [], + openPeek: (p) => + set((s) => ({ + // Cap at 2 levels total: one drawer + one peek. When called and + // the stack already has one peek, replace it. + stack: s.stack.length >= 1 ? [p] : [p], + })), + closeTop: () => set((s) => ({ stack: s.stack.slice(0, -1) })), + closeAll: () => set({ stack: [] }), + })); +} + +type StoreApi = ReturnType; + +const Ctx = createContext(null); + +export function DrillStackProvider({ children }: { children: ReactNode }) { + // useMemo so the store instance is stable across renders. + const store = useMemo(makeStore, []); + return {children}; +} + +export function useDrillStack() { + const store = useContext(Ctx); + if (!store) throw new Error("useDrillStack must be used within DrillStackProvider"); + // Subscribe to just `stack` so consumers re-render only on stack + // changes (not on every state update). + const stack = store((s) => s.stack); + return { + stack, + openPeek: store.getState().openPeek, + closeTop: store.getState().closeTop, + closeAll: store.getState().closeAll, + }; +} +``` + +Note: `zustand` is already a dependency (`zustand` 4.5.x in package.json). The `create` import path is `import { create } from "zustand"` for v4 — confirm with `cat node_modules/zustand/package.json | grep version` if unsure. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- DrillStackProvider.test.tsx` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/drill/DrillStackProvider.tsx src/components/drill/DrillStackProvider.test.tsx +git commit -m "feat(drill): DrillStackProvider — zustand-backed peek stack with 2-level cap" +``` + +### Task 1.2: DrillableCell (hover affordance wrapper) + +**Files:** +- Create: `src/components/drill/DrillableCell.tsx` +- Create: `src/components/drill/DrillableCell.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/components/drill/DrillableCell.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DrillableCell } from "@/components/drill/DrillableCell"; + +describe("DrillableCell", () => { + it("renders children, applies hover affordance classes, calls onClick", () => { + const onClick = vi.fn(); + render( + + CLM-114 + , + ); + const btn = screen.getByRole("button"); + expect(btn).toHaveClass("drillable"); + fireEvent.click(btn); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("disabled state hides affordance and blocks click", () => { + const onClick = vi.fn(); + render( + + unavailable + , + ); + const btn = screen.getByRole("button"); + expect(btn).toBeDisabled(); + expect(btn).not.toHaveClass("drillable"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- DrillableCell.test.tsx` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Implement DrillableCell** + +```tsx +// src/components/drill/DrillableCell.tsx +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface Props { + children: ReactNode; + onClick: () => void; + disabled?: boolean; + /** Optional aria-label; defaults to the visible text content. */ + ariaLabel?: string; +} + +/** + * Wrap any clickable cell with hover-reveal affordance: + * cursor: pointer + accent background tint + trailing "›" chevron, + * applied via the `drillable` class on hover (see `src/index.css` + * in Task 1.4). + * + * Renders as a + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- DrillableCell.test.tsx` +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/drill/DrillableCell.tsx src/components/drill/DrillableCell.test.tsx +git commit -m "feat(drill): DrillableCell — hover-reveal button wrapper" +``` + +### Task 1.3: PeekModal (centered Radix Dialog) + +**Files:** +- Create: `src/components/drill/PeekModal.tsx` +- Create: `src/components/drill/PeekModal.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/components/drill/PeekModal.test.tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PeekModal } from "@/components/drill/PeekModal"; + +describe("PeekModal", () => { + it("renders title and body when open; close button fires onClose", () => { + const onClose = vi.fn(); + render( + +

1,247 claims

+
, + ); + expect(screen.getByText("Payer")).toBeInTheDocument(); + expect(screen.getByText("CO Medicaid")).toBeInTheDocument(); + expect(screen.getByText("1,247 claims")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /close/i })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("renders nothing when closed", () => { + const { container } = render( + {}} title="hidden"> +

should not appear

+
, + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("esc key closes", () => { + const onClose = vi.fn(); + render( + +

x

+
, + ); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- PeekModal.test.tsx` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Implement PeekModal** + +```tsx +// src/components/drill/PeekModal.tsx +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import type { ReactNode } from "react"; + +interface Props { + open: boolean; + onClose: () => void; + eyebrow?: string; + title: string; + children: ReactNode; +} + +/** + * Centered peek modal — used for cross-reference drills (payer, + * validation rule, etc.). Smaller than the right-side Drawer + * (max-width: 480px); closes on Esc, backdrop click, and the X button. + * No keyboard j/k nav — single record. + */ +export function PeekModal({ open, onClose, eyebrow, title, children }: Props) { + return ( + { if (!o) onClose(); }}> + + {eyebrow ? ( +
+ {eyebrow} +
+ ) : null} +

{title}

+
{children}
+
+
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- PeekModal.test.tsx` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/drill/PeekModal.tsx src/components/drill/PeekModal.test.tsx +git commit -m "feat(drill): PeekModal — centered Radix Dialog with eyebrow + title" +``` + +### Task 1.4: Hover affordance CSS + App-level integration + +**Files:** +- Modify: `src/index.css` (append `.drillable` block at end) +- Modify: `src/App.tsx` (wrap routes in ``) + +- [ ] **Step 1: Append hover affordance CSS** + +Edit `src/index.css`. Find the last line (the file ends with `@tailwind utilities;` or similar). After it, append: + +```css +/* Universal drill-down affordance — applied by DrillableCell. */ +.drillable { + cursor: pointer; + transition: background-color 120ms ease; +} +.drillable:hover { + background-color: hsl(var(--accent) / 0.08); +} +.drillable:hover::after { + content: "›"; + margin-left: 6px; + color: hsl(var(--accent)); + font-weight: 600; +} +``` + +- [ ] **Step 2: Wrap App routes in DrillStackProvider** + +In `src/App.tsx`, find the root JSX returned by the App component (likely a `` or `` wrapping routes). Wrap the existing tree with ``: + +```tsx +// src/App.tsx — add this import at the top +import { DrillStackProvider } from "@/components/drill/DrillStackProvider"; + +// Then wrap the existing return. Example before: +// return ...; +// After: +// return ( +// +// ... +// +// ); +``` + +- [ ] **Step 3: Verify app still boots + existing tests pass** + +Run: `npm test -- --run` +Expected: All 249+ frontend tests still green. (If a test broke because it expected a specific root DOM shape, the fix is in that test, not here.) + +Run: `npm run typecheck` +Expected: 0 errors. + +- [ ] **Step 4: Smoke the app at runtime** + +Open `http://localhost:5173` in browser (frontend dev server is already running from the prior session, or restart with `npm run dev`). No visible change expected — the primitives exist but no page uses them yet. Verify no console errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/index.css src/App.tsx +git commit -m "feat(drill): hover affordance CSS + App wrapped in DrillStackProvider" +``` + +### Task 1.5: Backend — `/api/payers/{payer_id}/summary` + +**Files:** +- Modify: `backend/src/cyclone/api_routers/payers.py` (or wherever `/api/config/payers` lives — find with `grep -rn 'payers/{payer_id}' backend/src/cyclone/api_routers/`) +- Create: `backend/tests/test_payer_summary.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_payer_summary.py +from fastapi.testclient import TestClient + +def test_payer_summary_happy_path(client: TestClient, seeded_db): + """Seeded db has at least one claim for CO Medicaid (payer_id='SKCO0').""" + resp = client.get("/api/payers/SKCO0/summary") + assert resp.status_code == 200 + data = resp.json() + assert data["payer_id"] == "SKCO0" + assert "claim_count" in data + assert "billed_total" in data + assert "received_total" in data + assert "denial_rate" in data + assert data["claim_count"] >= 1 + + +def test_payer_summary_unknown_payer_returns_404(client: TestClient): + resp = client.get("/api/payers/DOES_NOT_EXIST/summary") + assert resp.status_code == 404 + + +def test_payer_summary_caches_then_invalidates(client: TestClient, seeded_db): + """A second call within 60s returns the cached payload.""" + resp1 = client.get("/api/payers/SKCO0/summary") + resp2 = client.get("/api/payers/SKCO0/summary") + assert resp1.json() == resp2.json() +``` + +The `client` and `seeded_db` fixtures already exist in `backend/tests/conftest.py` (per the SP3-SP20 spec pattern). If `seeded_db` doesn't exist yet, write a minimal one in this test file that ingests one 837 + one 835 from `backend/tests/fixtures/minimal_837p.txt` and `minimal_835.txt`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v` +Expected: FAIL with 404 (endpoint doesn't exist). + +- [ ] **Step 3: Implement the endpoint** + +Find the existing payers router (likely `backend/src/cyclone/api_routers/payers.py`). Add: + +```python +from functools import lru_cache +from time import monotonic + +_SUMMARY_TTL_S = 60.0 +_summary_cache: dict[str, tuple[float, dict]] = {} + +@router.get("/api/payers/{payer_id}/summary") +def get_payer_summary(payer_id: str, store = Depends(get_store)) -> dict: + now = monotonic() + cached = _summary_cache.get(payer_id) + if cached and (now - cached[0]) < _SUMMARY_TTL_S: + return cached[1] + + claims = store.iter_claims(payer_id=payer_id) + remits = store.iter_remittances(payer_id=payer_id) + if not claims and not remits: + raise HTTPException(404, f"Payer {payer_id} not found") + + billed = sum(c.billed_amount for c in claims) + received = sum(c.received_amount for c in claims) + denied = sum(1 for c in claims if c.status == "denied") + denial_rate = (denied / len(claims)) if claims else 0.0 + + provider_counts: dict[str, int] = {} + for c in claims: + npi = c.provider_npi + if npi: + provider_counts[npi] = provider_counts.get(npi, 0) + 1 + top_providers = [ + {"npi": npi, "count": count} + for npi, count in sorted(provider_counts.items(), key=lambda kv: -kv[1])[:5] + ] + + payload = { + "payer_id": payer_id, + "name": claims[0].payer_name if claims else (remits[0].payer_name if remits else payer_id), + "claim_count": len(claims), + "billed_total": billed, + "received_total": received, + "denial_rate": denial_rate, + "top_providers": top_providers, + } + _summary_cache[payer_id] = (now, payload) + return payload +``` + +- [ ] **Step 4: Register pubsub invalidation** + +In the file that wires the EventBus (`backend/src/cyclone/api.py` lifespan or `backend/src/cyclone/pubsub.py`), register listeners that invalidate the cache: + +```python +# In the lifespan handler or wherever subscribers are wired +event_bus.subscribe("claim_written", lambda evt: _summary_cache.pop(_payer_id_from_claim(evt), None)) +event_bus.subscribe("remittance_written", lambda evt: _summary_cache.pop(_payer_id_from_remit(evt), None)) +``` + +Adjust the exact event payload shape to match the existing pubsub events (read `backend/src/cyclone/pubsub.py` to confirm — likely has `entity_id` and a `payload` dict carrying the payer_id). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v` +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api_routers/payers.py backend/src/cyclone/pubsub.py backend/tests/test_payer_summary.py +git commit -m "feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation" +``` + +### Task 1.6: Backend — extend `/api/providers/{npi}` response + +**Files:** +- Modify: the file containing `GET /api/providers/{npi}` (likely `backend/src/cyclone/api_routers/providers.py` or similar — find with `grep -rn 'providers/{npi}' backend/src/`) +- Modify: `src/types/index.ts` (extend `Provider` interface) +- Create: `backend/tests/test_provider_extended_response.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_provider_extended_response.py +from fastapi.testclient import TestClient + +def test_provider_detail_includes_recent_claims(client: TestClient, seeded_db): + """The extended response gains a recent_claims array (top 10).""" + npi = "1881068062" # Montrose from config/payers.yaml + resp = client.get(f"/api/config/providers/{npi}") + assert resp.status_code == 200 + data = resp.json() + assert "recent_claims" in data + assert isinstance(data["recent_claims"], list) + assert len(data["recent_claims"]) <= 10 + +def test_provider_detail_includes_recent_activity(client: TestClient, seeded_db): + npi = "1881068062" + resp = client.get(f"/api/config/providers/{npi}") + assert resp.status_code == 200 + data = resp.json() + assert "recent_activity" in data + assert isinstance(data["recent_activity"], list) + assert len(data["recent_activity"]) <= 10 + +def test_provider_detail_backwards_compat(client: TestClient, seeded_db): + """All SP9 fields still present; new arrays don't break the contract.""" + npi = "1881068062" + resp = client.get(f"/api/config/providers/{npi}") + data = resp.json() + for key in ("npi", "name", "taxId", "address", "city", "state", "zip", "phone", "claimCount", "outstandingAr"): + assert key in data, f"missing field {key}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v` +Expected: FAIL with "KeyError: recent_claims" or 422 (response validation). + +- [ ] **Step 3: Extend the endpoint** + +In the providers router handler, after the existing provider lookup, append: + +```python +# After: provider = store.get_provider(npi) ... return provider_dict +recent_claims = sorted( + store.iter_claims(provider_npi=npi), + key=lambda c: c.submission_date, + reverse=True, +)[:10] +recent_activity = sorted( + store.iter_activity(provider_npi=npi), + key=lambda a: a.ts, + reverse=True, +)[:10] + +return { + **provider_dict, + "recent_claims": [_claim_summary(c) for c in recent_claims], + "recent_activity": [_activity_summary(a) for a in recent_activity], +} +``` + +The `_claim_summary` and `_activity_summary` helpers project the full ORM models into the slim `ClaimSummary` and `ActivityEvent` shapes the UI already consumes (look at `src/types/index.ts` for the field names — `id`, `submissionDate`, `billedAmount`, etc.). + +- [ ] **Step 4: Extend the frontend type** + +In `src/types/index.ts`, add to the `Provider` interface: + +```ts +export interface Provider { + // ...existing fields... + recent_claims?: ClaimSummary[]; // populated by extended /api/config/providers/{npi} + recent_activity?: ActivityEvent[]; // populated by extended /api/config/providers/{npi} +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v` +Expected: 3 tests pass. + +Run: `npm run typecheck` +Expected: 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api_routers/providers.py backend/tests/test_provider_extended_response.py src/types/index.ts +git commit -m "feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity" +``` + +### Task 1.7: Frontend — PayerPeekContent + api.getPayerSummary hook + +**Files:** +- Create: `src/hooks/usePayerSummary.ts` +- Modify: `src/lib/api.ts` (add `getPayerSummary` method) +- Create: `src/components/drill/PayerPeekContent.tsx` +- Create: `src/components/drill/PayerPeekContent.test.tsx` + +- [ ] **Step 1: Add api.getPayerSummary** + +In `src/lib/api.ts`, after the existing `getProvider` method, add: + +```ts +export interface PayerSummary { + payer_id: string; + name: string; + claim_count: number; + billed_total: number; + received_total: number; + denial_rate: number; + top_providers: Array<{ npi: string; count: number }>; +} + +export async function getPayerSummary(payerId: string): Promise { + if (!isConfigured) throw notConfiguredError(); + const res = await fetch(`${BASE_URL}/api/payers/${encodeURIComponent(payerId)}/summary`); + if (!res.ok) throw await asApiError(res); + return res.json(); +} +``` + +Look at the existing `getProvider` / `asApiError` pattern in the file and match it exactly — the snippet above is illustrative. + +- [ ] **Step 2: Add usePayerSummary hook** + +```ts +// src/hooks/usePayerSummary.ts +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function usePayerSummary(payerId: string | null) { + return useQuery({ + queryKey: ["payer-summary", payerId], + queryFn: () => api.getPayerSummary(payerId as string), + enabled: payerId !== null, + staleTime: 60 * 1000, + retry: 1, + }); +} +``` + +- [ ] **Step 3: Write the failing PayerPeekContent test** + +```tsx +// src/components/drill/PayerPeekContent.test.tsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PayerPeekContent } from "@/components/drill/PayerPeekContent"; + +describe("PayerPeekContent", () => { + it("renders loading skeleton while fetching", () => { + render(); + // The Skeleton component is the existing one; check that numbers + // aren't rendered yet. + expect(screen.queryByText(/claims/i)).not.toBeInTheDocument(); + }); + + it("renders summary stats when data loads", () => { + render( + , + ); + expect(screen.getByText("CO Medicaid")).toBeInTheDocument(); + expect(screen.getByText(/1,247/)).toBeInTheDocument(); + expect(screen.getByText("$548,000")).toBeInTheDocument(); + expect(screen.getByText("4.2%")).toBeInTheDocument(); + // "View all claims" link goes to /claims?payer=SKCO0 + const link = screen.getByRole("link", { name: /view all claims/i }); + expect(link).toHaveAttribute("href", "/claims?payer=SKCO0"); + }); +}); +``` + +- [ ] **Step 4: Implement PayerPeekContent** + +```tsx +// src/components/drill/PayerPeekContent.tsx +import { Link } from "react-router-dom"; +import { Skeleton } from "@/components/ui/skeleton"; +import { fmt } from "@/lib/format"; +import { usePayerSummary } from "@/hooks/usePayerSummary"; +import type { PayerSummary } from "@/lib/api"; + +interface Props { + payerId: string; +} + +/** + * Peek body for a payer — shows aggregate stats. Uses usePayerSummary + * to fetch; the parent PeekModal owns open/close. + */ +export function PayerPeekContent({ payerId }: Props) { + const { data, isLoading } = usePayerSummary(payerId); + if (isLoading || !data) { + return ( +
+ + + +
+ ); + } + return ; +} + +function Loaded({ payer }: { payer: PayerSummary }) { + return ( +
+
+ + + + +
+ {payer.top_providers.length > 0 ? ( +
+
Top providers
+
    + {payer.top_providers.slice(0, 3).map((p) => ( +
  • + {p.npi} + {fmt.num(p.count)} claims +
  • + ))} +
+
+ ) : null} + + View all claims → + +
+ ); +} + +function Stat({ + label, + value, + accent, +}: { + label: string; + value: string; + accent?: "accent" | "success" | "warning"; +}) { + const color = + accent === "success" + ? "text-[hsl(var(--success))]" + : accent === "warning" + ? "text-[hsl(var(--warning))]" + : accent === "accent" + ? "text-accent" + : "text-foreground"; + return ( +
+
{label}
+
{value}
+
+ ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test -- PayerPeekContent.test.tsx` +Expected: 2 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/api.ts src/hooks/usePayerSummary.ts src/components/drill/PayerPeekContent.tsx src/components/drill/PayerPeekContent.test.tsx +git commit -m "feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary" +``` + +### Task 1.8: Dashboard — wire KPI tile navigation + +**Files:** +- Modify: `src/pages/Dashboard.tsx` + +The 5 KPI tiles (Claims, Billed, Received, Pending AR, Denial rate) become drillable via `` wrapping the tile body. The `onClick` uses `useNavigate()` from react-router-dom to navigate to `/claims` with the right filter. + +- [ ] **Step 1: Read the current Dashboard.tsx** + +Open `src/pages/Dashboard.tsx`. Locate the `` invocations inside the `
` block (around lines 165-226). + +- [ ] **Step 2: Wrap each KpiCard in DrillableCell** + +Add imports at top of `Dashboard.tsx`: + +```tsx +import { useNavigate } from "react-router-dom"; +import { DrillableCell } from "@/components/drill/DrillableCell"; +``` + +Inside the `Dashboard()` function body, before the return: + +```tsx +const navigate = useNavigate(); +``` + +Then wrap each ``: + +```tsx +// Before: + + +// After: + navigate("/claims")}> + + +``` + +Apply the same pattern to the other 4 with these `onClick` handlers: +- **Billed** → `() => navigate("/claims?sort=-billedAmount")` +- **Received** → `() => navigate("/claims?sort=-receivedAmount")` +- **Pending AR** → `() => navigate("/claims?status=submitted,pending")` +- **Denial rate** → `() => navigate("/claims?status=denied")` + +The current `/claims?status=…` filter may need a multi-value param; check `src/hooks/useClaims.ts` to confirm whether `status` accepts a comma-separated list. If it doesn't, navigate to `/claims` and let the user filter. + +- [ ] **Step 3: Verify in browser** + +Open `http://localhost:5173/`. Hover each KPI tile — the cursor should turn to a pointer, the tile background should tint accent, and the trailing `›` should appear. Click → navigates to `/claims` with the right query string. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Dashboard.tsx +git commit -m "feat(dashboard): KPI tiles drillable — navigate to /claims with filter" +``` + +### Task 1.9: Dashboard — wire Top providers row drill + +**Files:** +- Modify: `src/pages/Dashboard.tsx` + +The "Top providers" card on the Dashboard lists up to 4 providers. Each `
  • ` becomes a `DrillableCell` that opens the ProviderDrawer. The drawer state is owned by `/providers` (ProviderDrawer lives on that page) — clicking from Dashboard navigates to `/providers?provider=NPI`. + +- [ ] **Step 1: Read the existing Top providers block** + +Open `src/pages/Dashboard.tsx`. The block starts around line 262 (`{topProviders.map((p, i) => (`). + +- [ ] **Step 2: Wrap each provider row in DrillableCell** + +```tsx +// Inside the topProviders.map, before the
  • : + navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)}> +
  • ...
  • + +``` + +Note: `DrillableCell` is a ` + + ); +} +``` + +```ts +// src/components/ProviderDrawer/index.ts +export { ProviderDrawer } from "./ProviderDrawer"; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- ProviderDrawer.test.tsx` +Expected: 1 test passes. + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/useProviderDetail.ts src/components/drill/DrillDrawerHeader.tsx src/components/ProviderDrawer/ +git commit -m "feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell" +``` + +### Task 2.3: Wire `/providers` page + +**Files:** +- Modify: `src/pages/Providers.tsx` + +The Providers directory page mounts the drawer and uses the URL state hook. Cards become drillable. + +- [ ] **Step 1: Read Providers.tsx** + +Open `src/pages/Providers.tsx`. The page currently fetches and renders cards; the cards are inert. + +- [ ] **Step 2: Add URL state + drawer mount + clickable cards** + +```tsx +// At top of file: +import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState"; +import { ProviderDrawer } from "@/components/ProviderDrawer"; + +// Inside Providers() function: +const { providerNpi, open, close } = useProviderDrawerUrlState(); + +// At the end of the returned JSX, after the cards grid: + + +// In the cards map, change the
    to be clickable: +
    open(p.npi)} + className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-pointer drillable" + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === "Enter") open(p.npi); }} +> +``` + +- [ ] **Step 3: Smoke** + +Open `/providers` → click a card → URL becomes `/providers?provider=…` → drawer opens from the right with the provider's Overview tab populated. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Providers.tsx +git commit -m "feat(providers): directory cards drillable — opens ProviderDrawer" +``` + +### Task 2.4: Wire Claims · provider cell + +**Files:** +- Modify: `src/pages/Claims.tsx` + +The Claims page table's "Provider" column currently shows name + NPI as text. Wrap with `DrillableCell` that navigates to `/providers?provider=NPI`. + +- [ ] **Step 1: Locate the provider cell** + +In `src/pages/Claims.tsx`, find the `` for "Provider" (around line 286). It currently renders `
    {provider?.name ?? "Unknown"}
    {c.providerNpi}
    `. + +- [ ] **Step 2: Wrap with DrillableCell** + +```tsx +import { useNavigate } from "react-router-dom"; +import { DrillableCell } from "@/components/drill/DrillableCell"; + +// In Claims() body: +const navigate = useNavigate(); + +// In the table row, replace the Provider : + + navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}> +
    {provider?.name ?? "Unknown"}
    +
    {c.providerNpi}
    +
    +
    +``` + +- [ ] **Step 3: Smoke** + +Click a provider cell on `/claims` → navigates to `/providers?provider=NPI` → ProviderDrawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Claims.tsx +git commit -m "feat(claims): provider cell drillable to /providers?provider=NPI" +``` + +### Task 2.5: Wire Dashboard · Recent activity for `claim_*` events + +**Files:** +- Create: `src/lib/event-routing.ts` (small helper) +- Modify: `src/pages/Dashboard.tsx` +- Modify: `src/components/ActivityFeed.tsx` (or wherever the feed items render — check `src/components/ActivityFeed.tsx`) + +The Dashboard's "Recent activity" card uses the existing `ActivityFeed` component. Each event has a `kind` and an `entityId` (the claim id, remit id, etc.). Routing maps kinds → navigation targets. + +- [ ] **Step 1: Create the event-routing helper** + +```ts +// src/lib/event-routing.ts +import type { Activity } from "@/types"; + +/** + * Maps an activity event to the URL the operator should land on when + * clicking the event. The Dashboard "Recent activity" card and the + * /activity log page both use this. + * + * Returns null for kinds that don't have a drill target yet (e.g. + * `remit_received` until the RemitDrawer ships in Phase 4; the UI + * surfaces a "coming soon" toast in that case via the caller). + */ +export function eventKindToUrl(event: Pick): string | null { + switch (event.kind) { + case "claim_submitted": + case "claim_paid": + case "claim_denied": + case "claim_accepted": + return `/claims?claim=${encodeURIComponent(event.entityId)}`; + case "remit_received": + return null; // Phase 4 + case "provider_added": + return `/providers?provider=${encodeURIComponent(event.entityId)}`; + default: + return null; + } +} +``` + +Verify the exact shape of `Activity` in `src/types/index.ts` — the fields might be `entity_id` (snake_case) or `entityId`. Adjust accordingly. + +- [ ] **Step 2: Modify ActivityFeed to accept an onClick per item** + +Open `src/components/ActivityFeed.tsx`. Add an optional `onItemClick?: (event: Activity) => void` prop. When provided, wrap each item's outer container in a ` + + + ); +} +``` + +```tsx +// src/components/AckDrawer/SegmentStatusList.tsx +import type { Ack } from "@/types"; + +export function SegmentStatusList({ ack }: { ack: Ack }) { + // ack.segments may be undefined for older records; fall back to a count summary. + const segments = ack.segments ?? []; + if (segments.length === 0) { + return ( +
    + {ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received +
    + ); + } + return ( +
    + + + + + + + + + + {segments.map((s, i) => ( + + + + + + ))} + +
    SegmentValueStatus
    {s.segment}{s.value}{s.status}
    +
    + ); +} +``` + +```tsx +// src/components/AckDrawer/AckDrawer.tsx +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AckHeader } from "./AckHeader"; +import { SegmentStatusList } from "./SegmentStatusList"; +import { useAckDetail } from "@/hooks/useAckDetail"; +import { api } from "@/lib/api"; + +interface Props { + ackId: string | null; + onClose: () => void; +} + +export function AckDrawer({ ackId, onClose }: Props) { + const { data, isLoading } = useAckDetail(ackId); + return ( + { if (!o) onClose(); }}> + + {ackId === null ? null : isLoading || !data ? ( +
    + + + +
    + ) : ( + <> + { + const detail = await api.getAck(data.id); + const raw = (detail as unknown as { raw_999_text?: string }).raw_999_text ?? ""; + if (raw) { + const blob = new Blob([raw], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = `ack-${data.sourceBatchId}.999`; + a.click(); URL.revokeObjectURL(url); + } + }} + /> +
    + +
    + + )} +
    +
    + ); +} +``` + +```ts +// src/components/AckDrawer/index.ts +export { AckDrawer } from "./AckDrawer"; +``` + +- [ ] **Step 4: Run test + commit** + +```bash +npm test -- AckDrawer.test.tsx +git add src/components/AckDrawer/ src/hooks/useAckDetail.ts +git commit -m "feat(drill): AckDrawer with header + segment status list" +``` + +### Task 5.3: Wire Acks page + +**Files:** +- Modify: `src/pages/Acks.tsx` + +- [ ] **Step 1: Mount the drawer + make rows clickable** + +```tsx +// In Acks.tsx: +import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState"; +import { AckDrawer } from "@/components/AckDrawer"; + +const { ackId, open, close } = useAckDrawerUrlState(); + +// In the table row, wrap or add onClick: + open(String(a.id))} + className="cursor-pointer drillable" + ... +> + +// At the end of returned JSX: + +``` + +- [ ] **Step 2: Smoke** + +Click an ACK row → URL becomes `/acks?ack=…` → drawer opens with header + segment list. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Acks.tsx +git commit -m "feat(acks): row click opens AckDrawer" +``` + +### Task 5.4: Wire Inbox rejected + payer_rejected + done_today rows + +**Files:** +- Modify: `src/pages/Inbox.tsx` + +- [ ] **Step 1: Wire the three lanes** + +In `src/pages/Inbox.tsx`, find the `Lane` components for `rejected`, `payer_rejected`, and `done_today`. Add `onRowClick` for each: + +```tsx +// Rejected (claims): all rows → ClaimDrawer + navigate(`/claims?claim=${encodeURIComponent(row.id)}`)} + onSelectionChange={...} +/> + +// Payer-Rejected: same as rejected + + +// Done today: rows have a kind (claim or remit) + { + if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`); + else navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + }} + onSelectionChange={...} +/> +``` + +(Verify the exact `row.kind` discriminator by reading `src/components/inbox/Lane.tsx`.) + +- [ ] **Step 2: Smoke** + +Click rows in each lane → navigates to the right entity. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Inbox.tsx +git commit -m "feat(inbox): rejected + payer_rejected + done_today rows drillable" +``` + +### Task 5.5: Wire Reconciliation body click + +**Files:** +- Modify: `src/pages/Reconciliation.tsx` + +The page uses two columns of ` + +// After: +
    +
    navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}> +
    {c.id}
    +
    {c.patientName}
    + ... +
    + +
    +``` + +Apply the same pattern to the remits column. + +- [ ] **Step 2: Smoke** + +Open `/reconciliation` → click card body → navigates to entity drawer. Click "Select for match" button → toggles selection only, doesn't navigate. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Reconciliation.tsx +git commit -m "feat(reconciliation): card body drillable, select button split" +``` + +### Task 5.6: Wire Batch diff claim id + +**Files:** +- Modify: `src/components/BatchDiffView.tsx` + +The diff view renders claim ids in added/removed/changed rows. Make them clickable. + +- [ ] **Step 1: Wrap claim ids** + +Find where claim ids are rendered in `BatchDiffView.tsx`. Wrap each with `DrillableCell`: + +```tsx + navigate(`/claims?claim=${encodeURIComponent(id)}`)}> + {id} + +``` + +For the "removed" case, the drawer will surface a "not found" state (the claim doesn't exist anymore) — confirm the existing `ClaimDrawer` 404 state is distinct enough to be obvious. + +- [ ] **Step 2: Smoke** + +Open `/batch-diff` → pick two batches → click a claim id → navigates to drawer. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/BatchDiffView.tsx +git commit -m "feat(batch-diff): claim ids drillable to /claims?claim=ID" +``` + +### Task 5.7: Wire Upload "See claim in detail" link + +**Files:** +- Modify: `src/pages/Upload.tsx` + +In the expanded claim card body (the `ClaimCard837` and `ClaimCard835` inner sections), add a small "See claim in detail →" link. + +- [ ] **Step 1: Add the link** + +Inside `ClaimCard837`'s expanded body (around line 184), add: + +```tsx +{claim.claim_id ? ( +
    + +
    +) : null} +``` + +The `persistedClaimIds` set comes from a new piece of state populated from `appStore.parsedBatches` — flatten `parsedBatches.flatMap(b => b.claimIds)` into a Set inside the Upload component. + +- [ ] **Step 2: Same for ClaimCard835** + +Apply the same pattern using `claim.payer_claim_control_number` as the id and navigating to `/remittances?remit=…`. + +- [ ] **Step 3: Smoke** + +Upload a file → expand a streamed claim card → click "See claim in detail →" → navigates to the drawer. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Upload.tsx +git commit -m "feat(upload): streamed claim cards offer drill to persisted entity" +``` + +### Task 5.8: ClaimDrawer · payer peek (peek on top of drawer) + +**Files:** +- Modify: `src/components/ClaimDrawer/PartiesGrid.tsx` (or wherever the payer cell renders) +- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx` + +The payer name inside ClaimDrawer opens a `PeekModal` on top of the drawer. The peek stack lives in `DrillStackProvider`. + +- [ ] **Step 1: Add peek state in ClaimDrawer** + +```tsx +// In ClaimDrawer.tsx: +import { useDrillStack } from "@/components/drill/DrillStackProvider"; +import { PeekModal } from "@/components/drill/PeekModal"; +import { PayerPeekContent } from "@/components/drill/PayerPeekContent"; + +const { stack, openPeek, closeTop } = useDrillStack(); +const topPeek = stack[stack.length - 1] ?? null; +const payerPeekOpen = topPeek?.kind === "payer"; + +// In the returned JSX (after the drawer Dialog): + + {payerPeekOpen ? : null} + +``` + +- [ ] **Step 2: Wire the payer cell click** + +In `PartiesGrid.tsx`, wrap the payer name with `DrillableCell`: + +```tsx + openPeek({ kind: "payer", payerId: payer.id })}> + {payer.name} + +``` + +The `payer.id` field needs to be the payer_id (e.g. "SKCO0"), not the payer name. If the existing PartiesGrid only carries the name, extend the `ClaimDetail` API response or look up the payer_id from `payerName` via a small mapping. Verify the exact field by reading `PartiesGrid.tsx`. + +- [ ] **Step 3: Smoke** + +Open a claim → click the payer name inside the drawer → peek opens on top. Esc → peek closes, drawer still open. Esc again → drawer closes. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/PartiesGrid.tsx +git commit -m "feat(claim-drawer): payer name opens PeekModal on top of drawer" +``` + +### Task 5.9: ClaimDrawer · validation rule peek + +**Files:** +- Create: `src/components/drill/ValidationRulePeekContent.tsx` +- Modify: `src/components/ClaimDrawer/ValidationPanel.tsx` + +- [ ] **Step 1: Build the static rule catalog** + +Create `src/components/drill/ValidationRulePeekContent.tsx`: + +```tsx +const RULE_HELP: Record = { + R020_npi_format: { + description: "NPI must be exactly 10 digits.", + x12: "005010X222A1 — Loop 2010AA, NM109 (Billing Provider NPI)", + }, + R021_npi_checksum: { + description: "NPI must pass the Luhn checksum over the body with an 80840 prefix.", + x12: "005010X222A1 — Loop 2010AA, NM109", + }, + R050_diagnosis_present: { + description: "At least one diagnosis code (HI segment) is required.", + x12: "005010X222A1 — Loop 2300, HI (Health Care Information)", + }, + // ... add entries as needed; unknown rules fall back to a generic message. +}; + +interface Props { rule: string; } + +export function ValidationRulePeekContent({ rule }: Props) { + const help = RULE_HELP[rule] ?? { + description: "No extended description available. See the parser source for details.", + }; + return ( +
    +
    +
    Rule
    +
    {rule}
    +
    +

    {help.description}

    + {help.x12 ? ( +
    +
    X12 reference
    +

    {help.x12}

    +
    + ) : null} +
    + ); +} +``` + +- [ ] **Step 2: Wire the peek in ClaimDrawer** + +Add to the `useDrillStack` block in `ClaimDrawer.tsx`: + +```tsx + + {topPeek?.kind === "rule" ? : null} + +``` + +- [ ] **Step 3: Wire the validation issue row click** + +In `ValidationPanel.tsx`, wrap the `
  • ` rule row: + +```tsx + openPeek({ kind: "rule", rule: issue.rule })}> +
  • + + {issue.rule} — {issue.message} +
  • + +``` + +- [ ] **Step 4: Smoke** + +Open a claim with validation errors → click a rule → peek opens with rule description. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/drill/ValidationRulePeekContent.tsx src/components/ClaimDrawer/ValidationPanel.tsx src/components/ClaimDrawer/ClaimDrawer.tsx +git commit -m "feat(claim-drawer): validation rule opens peek with rule catalog" +``` + +### Task 5.10: Refactor ClaimDrawer onto the DrillDrawer shell + +**Files:** +- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx` +- Modify: `src/components/ClaimDrawer/ClaimDrawerHeader.tsx` + +The existing ClaimDrawer has its own Dialog + header. Replace with `DrillDrawerHeader` to keep the visual consistent across all drawers. + +- [ ] **Step 1: Read current ClaimDrawer** + +Note the existing header's content (claim id, state badge, total, close button) so the refactor preserves it. + +- [ ] **Step 2: Swap headers** + +```tsx +// In ClaimDrawer.tsx, replace the existing header with: + : undefined} +/> +``` + +Extend `DrillDrawerHeader` to accept an optional `action?: ReactNode` prop rendered to the right of the title block. Place a small gap, then `{action}`. + +- [ ] **Step 3: Verify regression** + +All existing ClaimDrawer tests still pass. Click a claim row → drawer opens with the same content as before, just with a slightly different header (the eyebrow may now read "Claim · paid" instead of just "paid"). Acceptable visual change. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ClaimDrawer/ src/components/drill/DrillDrawerHeader.tsx +git commit -m "refactor(claim-drawer): mount on shared DrillDrawerHeader shell" +``` + +### Task 5.11: Final smoke + main merge + +- [ ] **Step 1: Run full test suite** + +```bash +npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q) +``` + +All existing + new tests pass. Total expected: ~275 frontend tests, ~412 backend tests. + +- [ ] **Step 2: Run the full 24-step smoke test from spec §2.9** + +Open `http://localhost:5173/` and execute each step in order. Any failure → file a follow-up issue (don't fix during smoke; commit, branch, and ship the working parts). + +- [ ] **Step 3: Tag + FF-merge + push** + +```bash +git tag sp21-complete +git checkout main +git merge --ff-only universal-drilldown +git push origin main --tags +``` + +- [ ] **Step 4: Cleanup worktree** + +```bash +cd /Users/openclaw/dev/cyclone +git worktree remove .worktrees/universal-drilldown +git branch -d universal-drilldown +``` + +The `.superpowers/` directory (brainstorm artifacts) is already in `.gitignore` — no cleanup needed. + +--- + +## Self-Review Checklist + +After writing this plan, verify against the spec: + +1. **Spec coverage:** Every smoke step in spec §2.9 maps to a task. Verified: steps 1-3 covered by PR1 existing state; step 4 by Task 1.8; step 5 by 1.9; step 6 by 1.10; step 7 by 2.5 + 3.x; step 8 by 4.7; step 9 by 5.7; step 10 by 2.4; step 11 by 5.3; step 12 by 5.8; step 13 by 4.3; step 14 by 4.6; step 15 by 2.3; step 16 by 5.3; step 17 by 4.4 + 4.5; step 18 by 5.4; step 19 by 4.5; step 20 by 5.5; step 21 by 5.6; step 22 by 5.7; step 23 by browser-back inherent. + +2. **Placeholder scan:** No "TBD" / "TODO" / "similar to Task N" in steps. All code blocks are complete. ✓ + +3. **Type consistency:** `useDrillStack().openPeek` payload shape is `{ kind: "payer", payerId } | { kind: "rule", rule }` — used identically across Tasks 1.1, 5.8, 5.9. `eventKindToUrl` returns string | null — used identically across Tasks 2.5, 3.x, 4.5. `useProviderDrawerUrlState` / `useRemitDrawerUrlState` / `useAckDrawerUrlState` all mirror the same shape as `useDrawerUrlState` from SP4. ✓ + +4. **Risk coverage:** Spec §3 risk #1 (stack explosion) → DrillStackProvider enforces 2-level cap (Task 1.1). Risk #5 (Inbox row vs checkbox) → not directly addressed; add an extra check in Task 4.4 + 5.4 that the click handler is on the row body, not the checkbox. + +5. **One missing item to add:** Add to Task 4.4 / 5.4: the `Lane` component's checkbox is a separate DOM target — verify the checkbox click doesn't bubble to the row click. The fix is `e.stopPropagation()` in the checkbox `onChange`. Confirm with `src/components/inbox/Lane.tsx` when implementing. + +--- + +## Execution Choice + +Plan complete and saved to `docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because each phase has many small TDD tasks where context isolation helps. +2. **Inline Execution** — Execute tasks in this session with checkpoints for review. + +Which approach?