# 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) => ( ))}
    Segment Value Status
    {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?