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 `` with `drillable` class and the chevron CSS, and call `onItemClick(event)` on click. + +If ActivityFeed is already structured as a list of clickable rows in some pages, the simplest change is: add the optional prop, leave default behavior unchanged, and only enable the click wrapper when `onItemClick` is passed. + +- [ ] **Step 3: Pass the onItemClick from Dashboard's Recent activity card** + +```tsx +// In Dashboard.tsx, find the "Recent activity" card (~line 233). +import { useNavigate } from "react-router-dom"; +import { eventKindToUrl } from "@/lib/event-routing"; +import { toast } from "sonner"; + +// In the card's ActivityFeed: + { + const url = eventKindToUrl(evt); + if (url) navigate(url); + else toast.info(`Drill for ${evt.kind} coming in a later phase.`); + }} +/> +``` + +- [ ] **Step 4: Smoke** + +Open `/` → "Recent activity" card → click a `claim_paid` event → URL becomes `/claims?claim=…` (drawer doesn't render yet for Phase 2 — navigation works, drawer will arrive in Phase 5). Click a `remit_received` → toast "coming in a later phase". + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/event-routing.ts src/components/ActivityFeed.tsx src/pages/Dashboard.tsx +git commit -m "feat(dashboard): Recent activity events route to entity by kind" +``` + +### Task 2.6: Phase 2 merge to main + +- [ ] **Step 1: Verify green** + +```bash +npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q) +``` + +- [ ] **Step 2: Smoke steps 7, 10, 15** + +Per spec §2.9. + +- [ ] **Step 3: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 3 — ProviderDrawer tabs + remaining activity event routing + +**Closes the rest of smoke step 7** (all 4 event kinds route correctly by Phase 4 merge; Phase 3 stubs `remit_received` to a toast). + +### Task 3.1: ProviderDrawer — Claims tab + +**Files:** +- Create: `src/components/ProviderDrawer/ProviderRecentClaims.tsx` +- Modify: `src/components/ProviderDrawer/ProviderDrawer.tsx` (add Tabs) + +The ProviderDrawer reads `provider.recent_claims` from the extended `/api/config/providers/{npi}` response (Task 1.6 already populated this). + +- [ ] **Step 1: Add Radix Tabs primitive** + +If Tabs isn't already in `src/components/ui/`, add it via the Radix UI pattern (search for `@radix-ui/react-tabs` in package.json; if absent, install with `npm install @radix-ui/react-tabs`). + +- [ ] **Step 2: Implement ProviderRecentClaims** + +```tsx +// src/components/ProviderDrawer/ProviderRecentClaims.tsx +import type { Provider } from "@/types"; +import { fmt } from "@/lib/format"; + +export function ProviderRecentClaims({ provider }: { provider: Provider }) { + const claims = provider.recent_claims ?? []; + if (claims.length === 0) { + return No recent claims.; + } + return ( + + {claims.map((c) => ( + + {c.id} + + {c.patientName ?? "—"} + + {fmt.usd(c.billedAmount)} + + ))} + + View all claims → + + + ); +} +``` + +- [ ] **Step 3: Add tabs to ProviderDrawer** + +```tsx +// In ProviderDrawer.tsx, replace the body with: +import * as Tabs from "@radix-ui/react-tabs"; +import { ProviderRecentClaims } from "./ProviderRecentClaims"; +import { ProviderRecentActivity } from "./ProviderRecentActivity"; + +// In the JSX, after DrillDrawerHeader: + + + + Overview + + + Claims + + + Activity + + + + {data ? : } + + + {data ? : } + + + {data ? : } + + +``` + +- [ ] **Step 4: Stub ProviderRecentActivity** (real impl in next task) + +```tsx +// src/components/ProviderDrawer/ProviderRecentActivity.tsx +import type { Provider } from "@/types"; + +export function ProviderRecentActivity({ provider }: { provider: Provider }) { + const items = provider.recent_activity ?? []; + if (items.length === 0) { + return No recent activity.; + } + return ( + + {items.map((a) => ( + + {new Date(a.ts).toLocaleString()} + {a.kind} + + ))} + + ); +} +``` + +- [ ] **Step 5: Smoke** + +Open `/providers` → click a provider → drawer opens with 3 tabs. Click "Claims" → see top-10 claims from `recent_claims`. Click "Activity" → see top-10 events. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ProviderDrawer/ src/components/ui/tabs.tsx +git commit -m "feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi}" +``` + +### Task 3.2: Wire `provider_added` event routing + +**Files:** +- Modify: `src/lib/event-routing.ts` + +The `provider_added` branch already returns `/providers?provider=…` (from Task 2.5). The Dashboard activity feed uses this already. No additional work needed — verify with a smoke click. + +- [ ] **Step 1: Verify** + +Open `/` → Recent activity → click a `provider_added` event → URL becomes `/providers?provider=NPI` → ProviderDrawer opens with the provider's Overview. + +(No code change; this task is a verification step.) + +- [ ] **Step 2: No commit** + +If nothing changed, skip this commit. + +### Task 3.3: Phase 3 merge to main + +- [ ] **Step 1: Verify green + smoke step 7 (all 4 event kinds)** + +`claim_*` → claim nav (Phase 2). `remit_received` → toast (Phase 4 wires real route). `provider_added` → ProviderDrawer (this phase). + +- [ ] **Step 2: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 4 — RemitDrawer + 4 surfaces + +**Closes smoke steps:** 13 (Remittances row drill), 14 (Remittances claim id cell), 17 (Inbox candidates + unmatched remit), 19 (Activity log `remit_received` event). + +### Task 4.1: useRemitDrawerUrlState hook + +**Files:** +- Create: `src/hooks/useRemitDrawerUrlState.ts` +- Create: `src/hooks/useRemitDrawerUrlState.test.ts` + +The shape mirrors `useProviderDrawerUrlState` (Task 2.1) but reads/writes `?remit=` instead of `?provider=`. + +- [ ] **Step 1: Write the failing test** + +Same structure as Task 2.1 with `?remit=` instead of `?provider=`. Use the actual file from Task 2.1 as a template. + +- [ ] **Step 2: Implement** + +```ts +// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState +// and substitute "remit" everywhere. +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/hooks/useRemitDrawerUrlState.ts src/hooks/useRemitDrawerUrlState.test.ts +git commit -m "feat(drill): useRemitDrawerUrlState — ?remit= URL sync" +``` + +### Task 4.2: RemitDrawer shell + +**Files:** +- Modify: `src/components/RemitDrawer/RemitDrawer.tsx` (existing file — wire URL state) +- Modify: `src/components/RemitDrawer/index.ts` + +The RemitDrawer components already exist per the file tree (RemitDrawer.tsx, RemitDrawerHeader.tsx, ClaimPaymentsTable.tsx, CasAdjustmentsPanel.tsx, FinancialSummaryCard.tsx, PartiesGrid.tsx, RemitDrawerSkeleton.tsx, RemitDrawerError.tsx). They just aren't mounted anywhere. + +- [ ] **Step 1: Read RemitDrawer.tsx** + +Open `src/components/RemitDrawer/RemitDrawer.tsx`. Inspect its current props (likely takes `remittanceId` and `onClose`). + +- [ ] **Step 2: Verify the component works standalone** + +Write a smoke test: + +```tsx +// src/components/RemitDrawer/RemitDrawer.test.tsx — add to existing tests if not present +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RemitDrawer } from "@/components/RemitDrawer"; + +describe("RemitDrawer", () => { + it("renders without crashing given a remittanceId", () => { + render( {}} />); + // The drawer fetches data async; just verify the skeleton renders + // while loading. + expect(screen.getByRole("dialog") || document.body).toBeDefined(); + }); +}); +``` + +- [ ] **Step 3: Update barrel export if needed** + +```ts +// src/components/RemitDrawer/index.ts +export { RemitDrawer } from "./RemitDrawer"; +``` + +- [ ] **Step 4: Commit (if changes were needed)** + +If RemitDrawer was already standalone and no changes needed, skip this commit. + +### Task 4.3: Wire Remittances row click + +**Files:** +- Modify: `src/pages/Remittances.tsx` + +The Remittances page currently uses inline expand for CAS adjustments. Replace that with a row-click → drawer pattern. + +- [ ] **Step 1: Mount the drawer** + +```tsx +// In Remittances.tsx: +import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; +import { RemitDrawer } from "@/components/RemitDrawer"; + +const { remitId, open, close } = useRemitDrawerUrlState(); + +// At end of returned JSX: + +``` + +- [ ] **Step 2: Make rows clickable** + +In the table row map (around line 218), change: + +```tsx +// Before: + hasAdjustments ? toggleExpand(r.id) : undefined} + ... +> + +// After: + open(r.id)} + className="cursor-pointer drillable" +> +``` + +Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `` block (lines ~276-303). The drawer's CAS panel replaces the inline expand. + +- [ ] **Step 3: Smoke** + +Click a row on `/remittances` → URL becomes `/remittances?remit=…` → RemitDrawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Remitittances.tsx +git commit -m "feat(remits): row click opens RemitDrawer (replaces inline CAS expand)" +``` + +### Task 4.4: Wire Inbox candidates + unmatched remit rows + +**Files:** +- Modify: `src/pages/Inbox.tsx` + +Inbox lane rows have a no-op `onRowClick` today. Wire the candidates and unmatched-remit lanes to open the RemitDrawer. + +- [ ] **Step 1: Locate the Lane components** + +In `src/pages/Inbox.tsx`, the 5 `` instances are around line 207. Find `lane="candidates"` and the `unmatched` lane's `unmatched` row sub-component. The Lane component takes an `onRowClick` prop. + +- [ ] **Step 2: Wire the click handlers** + +```tsx +import { useNavigate } from "react-router-dom"; + +const navigate = useNavigate(); + +// Candidates lane: + navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)} + onSelectionChange={...} +/> + +// For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch: + { + if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`); + else navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + }} + onSelectionChange={...} +/> +``` + +The exact row shape depends on the `LaneRow` type — verify by reading `src/components/inbox/Lane.tsx`. The key dispatch is: remit rows → RemitDrawer; claim rows → ClaimDrawer. + +- [ ] **Step 3: Smoke** + +Open `/inbox` → click a candidates row → navigates to `/remittances?remit=…` → drawer opens. Click an unmatched remit row → same. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Inbox.tsx +git commit -m "feat(inbox): candidates + unmatched-remit rows drillable" +``` + +### Task 4.5: Wire Activity log `remit_received` event + +**Files:** +- Modify: `src/lib/event-routing.ts` +- Modify: `src/pages/ActivityLog.tsx` + +- [ ] **Step 1: Update event-routing helper** + +```ts +// src/lib/event-routing.ts +case "remit_received": + return `/remittances?remit=${encodeURIComponent(event.entityId)}`; +``` + +- [ ] **Step 2: Pass onItemClick to ActivityFeed in ActivityLog** + +Same pattern as Task 2.5: import `eventKindToUrl`, pass to ``. + +- [ ] **Step 3: Smoke** + +Open `/activity` → click a `remit_received` event → URL becomes `/remittances?remit=…` → drawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/event-routing.ts src/pages/ActivityLog.tsx +git commit -m "feat(activity): remit_received events drill to RemitDrawer" +``` + +### Task 4.6: Wire Remittances claim-id cell + +**Files:** +- Modify: `src/pages/Remittances.tsx` + +- [ ] **Step 1: Wrap the Claim with DrillableCell** + +Find the `{r.claimId}` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`: + +```tsx + + navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}> + {r.claimId} + + +``` + +- [ ] **Step 2: Smoke** + +Click a claim id in the remits table → navigates to `/claims?claim=…` (claim drawer will open once Phase 5 lands). + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Remittances.tsx +git commit -m "feat(remits): claim id cell drills to /claims?claim=ID" +``` + +### Task 4.7: Wire ClaimDrawer matched-remit link + +**Files:** +- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx` + +The existing card already has a "View remittance →" link that navigates somewhere. Update it to navigate to `/remittances?remit=ID` and ensure the remits page mounts the drawer. + +- [ ] **Step 1: Read MatchedRemitCard** + +Open the file. Find the link/button that takes the operator to the remittance detail. + +- [ ] **Step 2: Update the navigation target** + +```tsx + + View remittance → + +``` + +- [ ] **Step 3: Smoke** + +Open a claim with a matched remittance → click "View remittance →" → navigates to `/remittances?remit=…` → RemitDrawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ClaimDrawer/MatchedRemitCard.tsx +git commit -m "feat(claim-drawer): matched-remit link drills to RemitDrawer" +``` + +### Task 4.8: Phase 4 merge to main + +- [ ] **Step 1: Verify green + smoke steps 13, 14, 17, 19** + +- [ ] **Step 2: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 5 — AckDrawer + 8 final surfaces + +**Closes smoke steps:** 8, 9, 11, 12, 16, 18, 20, 21, 22, 23. + +### Task 5.1: useAckDrawerUrlState hook + +**Files:** +- Create: `src/hooks/useAckDrawerUrlState.ts` + +Same shape as useProviderDrawerUrlState but for `?ack=`. + +- [ ] **Step 1: Implement** (mirror Task 2.1 with `?ack=`) + +- [ ] **Step 2: Commit** + +```bash +git add src/hooks/useAckDrawerUrlState.ts +git commit -m "feat(drill): useAckDrawerUrlState — ?ack= URL sync" +``` + +### Task 5.2: AckDrawer + +**Files:** +- Create: `src/components/AckDrawer/AckDrawer.tsx` +- Create: `src/components/AckDrawer/AckHeader.tsx` +- Create: `src/components/AckDrawer/SegmentStatusList.tsx` +- Create: `src/components/AckDrawer/index.ts` +- Create: `src/components/AckDrawer/AckDrawer.test.tsx` +- Create: `src/hooks/useAckDetail.ts` + +- [ ] **Step 1: Add useAckDetail** + +```ts +// src/hooks/useAckDetail.ts +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function useAckDetail(ackId: string | null) { + return useQuery({ + queryKey: ["ack-detail", ackId], + queryFn: () => api.getAck(ackId as string), + enabled: ackId !== null, + staleTime: 60 * 1000, + }); +} +``` + +- [ ] **Step 2: Write the failing test** + +```tsx +// src/components/AckDrawer/AckDrawer.test.tsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AckDrawer } from "@/components/AckDrawer"; + +vi.mock("@/hooks/useAckDetail", () => ({ + useAckDetail: () => ({ + data: { + id: 42, + sourceBatchId: "BATCH-123", + ackCode: "A", + acceptedCount: 10, + rejectedCount: 2, + receivedCount: 12, + parsedAt: "2026-06-20T12:00:00Z", + segments: [ + { segment: "ST", value: "999", status: "accepted" }, + { segment: "AK1", value: "HC*1234", status: "accepted" }, + ], + }, + isLoading: false, + }), +})); + +describe("AckDrawer", () => { + it("renders the ACK header and segment status list", () => { + render( {}} />); + expect(screen.getByText("999 ACK #42")).toBeInTheDocument(); + expect(screen.getByText("BATCH-123")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Implement** + +```tsx +// src/components/AckDrawer/AckHeader.tsx +import { Download } from "lucide-react"; +import { AckCodeBadge } from "@/components/AcksPage"; // may need extraction +import { fmt } from "@/lib/format"; +import type { Ack } from "@/types"; + +export function AckHeader({ ack, onDownload }: { ack: Ack; onDownload: () => void }) { + return ( + + + + 999 ACK #{ack.id} + + + {ack.sourceBatchId} + + + Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"} + + + + + + 999 + + + + ); +} +``` + +```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 ( + + + + + Segment + Value + Status + + + + {segments.map((s, i) => ( + + {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 `` elements for selection. Add a separate click target on the card body that opens the entity drawer, separate from the selection button. + +- [ ] **Step 1: Restructure each card** + +```tsx +// Before: + setSelectedClaim(c.id)} aria-pressed={active} ...> + ... card content ... + + +// After: + + navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}> + {c.id} + {c.patientName} + ... + + setSelectedClaim(c.id)} + aria-pressed={active} + className="w-full text-xs py-1.5 border-t border-border/30 hover:bg-muted/40" + > + {active ? "Selected ✓" : "Select for match"} + + +``` + +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 ? ( + + navigate(`/claims?claim=${encodeURIComponent(claim.claim_id)}`)} + className="drillable text-[12.5px] text-accent hover:underline" + disabled={!persistedClaimIds.has(claim.claim_id)} + title={persistedClaimIds.has(claim.claim_id) ? "" : "Claim not yet persisted; check Claims page after parse completes."} + > + See claim in detail → + + +) : 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?
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 `` with `drillable` class and the chevron CSS, and call `onItemClick(event)` on click. + +If ActivityFeed is already structured as a list of clickable rows in some pages, the simplest change is: add the optional prop, leave default behavior unchanged, and only enable the click wrapper when `onItemClick` is passed. + +- [ ] **Step 3: Pass the onItemClick from Dashboard's Recent activity card** + +```tsx +// In Dashboard.tsx, find the "Recent activity" card (~line 233). +import { useNavigate } from "react-router-dom"; +import { eventKindToUrl } from "@/lib/event-routing"; +import { toast } from "sonner"; + +// In the card's ActivityFeed: + { + const url = eventKindToUrl(evt); + if (url) navigate(url); + else toast.info(`Drill for ${evt.kind} coming in a later phase.`); + }} +/> +``` + +- [ ] **Step 4: Smoke** + +Open `/` → "Recent activity" card → click a `claim_paid` event → URL becomes `/claims?claim=…` (drawer doesn't render yet for Phase 2 — navigation works, drawer will arrive in Phase 5). Click a `remit_received` → toast "coming in a later phase". + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/event-routing.ts src/components/ActivityFeed.tsx src/pages/Dashboard.tsx +git commit -m "feat(dashboard): Recent activity events route to entity by kind" +``` + +### Task 2.6: Phase 2 merge to main + +- [ ] **Step 1: Verify green** + +```bash +npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q) +``` + +- [ ] **Step 2: Smoke steps 7, 10, 15** + +Per spec §2.9. + +- [ ] **Step 3: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 3 — ProviderDrawer tabs + remaining activity event routing + +**Closes the rest of smoke step 7** (all 4 event kinds route correctly by Phase 4 merge; Phase 3 stubs `remit_received` to a toast). + +### Task 3.1: ProviderDrawer — Claims tab + +**Files:** +- Create: `src/components/ProviderDrawer/ProviderRecentClaims.tsx` +- Modify: `src/components/ProviderDrawer/ProviderDrawer.tsx` (add Tabs) + +The ProviderDrawer reads `provider.recent_claims` from the extended `/api/config/providers/{npi}` response (Task 1.6 already populated this). + +- [ ] **Step 1: Add Radix Tabs primitive** + +If Tabs isn't already in `src/components/ui/`, add it via the Radix UI pattern (search for `@radix-ui/react-tabs` in package.json; if absent, install with `npm install @radix-ui/react-tabs`). + +- [ ] **Step 2: Implement ProviderRecentClaims** + +```tsx +// src/components/ProviderDrawer/ProviderRecentClaims.tsx +import type { Provider } from "@/types"; +import { fmt } from "@/lib/format"; + +export function ProviderRecentClaims({ provider }: { provider: Provider }) { + const claims = provider.recent_claims ?? []; + if (claims.length === 0) { + return No recent claims.; + } + return ( + + {claims.map((c) => ( + + {c.id} + + {c.patientName ?? "—"} + + {fmt.usd(c.billedAmount)} + + ))} + + View all claims → + + + ); +} +``` + +- [ ] **Step 3: Add tabs to ProviderDrawer** + +```tsx +// In ProviderDrawer.tsx, replace the body with: +import * as Tabs from "@radix-ui/react-tabs"; +import { ProviderRecentClaims } from "./ProviderRecentClaims"; +import { ProviderRecentActivity } from "./ProviderRecentActivity"; + +// In the JSX, after DrillDrawerHeader: + + + + Overview + + + Claims + + + Activity + + + + {data ? : } + + + {data ? : } + + + {data ? : } + + +``` + +- [ ] **Step 4: Stub ProviderRecentActivity** (real impl in next task) + +```tsx +// src/components/ProviderDrawer/ProviderRecentActivity.tsx +import type { Provider } from "@/types"; + +export function ProviderRecentActivity({ provider }: { provider: Provider }) { + const items = provider.recent_activity ?? []; + if (items.length === 0) { + return No recent activity.; + } + return ( + + {items.map((a) => ( + + {new Date(a.ts).toLocaleString()} + {a.kind} + + ))} + + ); +} +``` + +- [ ] **Step 5: Smoke** + +Open `/providers` → click a provider → drawer opens with 3 tabs. Click "Claims" → see top-10 claims from `recent_claims`. Click "Activity" → see top-10 events. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ProviderDrawer/ src/components/ui/tabs.tsx +git commit -m "feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi}" +``` + +### Task 3.2: Wire `provider_added` event routing + +**Files:** +- Modify: `src/lib/event-routing.ts` + +The `provider_added` branch already returns `/providers?provider=…` (from Task 2.5). The Dashboard activity feed uses this already. No additional work needed — verify with a smoke click. + +- [ ] **Step 1: Verify** + +Open `/` → Recent activity → click a `provider_added` event → URL becomes `/providers?provider=NPI` → ProviderDrawer opens with the provider's Overview. + +(No code change; this task is a verification step.) + +- [ ] **Step 2: No commit** + +If nothing changed, skip this commit. + +### Task 3.3: Phase 3 merge to main + +- [ ] **Step 1: Verify green + smoke step 7 (all 4 event kinds)** + +`claim_*` → claim nav (Phase 2). `remit_received` → toast (Phase 4 wires real route). `provider_added` → ProviderDrawer (this phase). + +- [ ] **Step 2: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 4 — RemitDrawer + 4 surfaces + +**Closes smoke steps:** 13 (Remittances row drill), 14 (Remittances claim id cell), 17 (Inbox candidates + unmatched remit), 19 (Activity log `remit_received` event). + +### Task 4.1: useRemitDrawerUrlState hook + +**Files:** +- Create: `src/hooks/useRemitDrawerUrlState.ts` +- Create: `src/hooks/useRemitDrawerUrlState.test.ts` + +The shape mirrors `useProviderDrawerUrlState` (Task 2.1) but reads/writes `?remit=` instead of `?provider=`. + +- [ ] **Step 1: Write the failing test** + +Same structure as Task 2.1 with `?remit=` instead of `?provider=`. Use the actual file from Task 2.1 as a template. + +- [ ] **Step 2: Implement** + +```ts +// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState +// and substitute "remit" everywhere. +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/hooks/useRemitDrawerUrlState.ts src/hooks/useRemitDrawerUrlState.test.ts +git commit -m "feat(drill): useRemitDrawerUrlState — ?remit= URL sync" +``` + +### Task 4.2: RemitDrawer shell + +**Files:** +- Modify: `src/components/RemitDrawer/RemitDrawer.tsx` (existing file — wire URL state) +- Modify: `src/components/RemitDrawer/index.ts` + +The RemitDrawer components already exist per the file tree (RemitDrawer.tsx, RemitDrawerHeader.tsx, ClaimPaymentsTable.tsx, CasAdjustmentsPanel.tsx, FinancialSummaryCard.tsx, PartiesGrid.tsx, RemitDrawerSkeleton.tsx, RemitDrawerError.tsx). They just aren't mounted anywhere. + +- [ ] **Step 1: Read RemitDrawer.tsx** + +Open `src/components/RemitDrawer/RemitDrawer.tsx`. Inspect its current props (likely takes `remittanceId` and `onClose`). + +- [ ] **Step 2: Verify the component works standalone** + +Write a smoke test: + +```tsx +// src/components/RemitDrawer/RemitDrawer.test.tsx — add to existing tests if not present +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RemitDrawer } from "@/components/RemitDrawer"; + +describe("RemitDrawer", () => { + it("renders without crashing given a remittanceId", () => { + render( {}} />); + // The drawer fetches data async; just verify the skeleton renders + // while loading. + expect(screen.getByRole("dialog") || document.body).toBeDefined(); + }); +}); +``` + +- [ ] **Step 3: Update barrel export if needed** + +```ts +// src/components/RemitDrawer/index.ts +export { RemitDrawer } from "./RemitDrawer"; +``` + +- [ ] **Step 4: Commit (if changes were needed)** + +If RemitDrawer was already standalone and no changes needed, skip this commit. + +### Task 4.3: Wire Remittances row click + +**Files:** +- Modify: `src/pages/Remittances.tsx` + +The Remittances page currently uses inline expand for CAS adjustments. Replace that with a row-click → drawer pattern. + +- [ ] **Step 1: Mount the drawer** + +```tsx +// In Remittances.tsx: +import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; +import { RemitDrawer } from "@/components/RemitDrawer"; + +const { remitId, open, close } = useRemitDrawerUrlState(); + +// At end of returned JSX: + +``` + +- [ ] **Step 2: Make rows clickable** + +In the table row map (around line 218), change: + +```tsx +// Before: + hasAdjustments ? toggleExpand(r.id) : undefined} + ... +> + +// After: + open(r.id)} + className="cursor-pointer drillable" +> +``` + +Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `` block (lines ~276-303). The drawer's CAS panel replaces the inline expand. + +- [ ] **Step 3: Smoke** + +Click a row on `/remittances` → URL becomes `/remittances?remit=…` → RemitDrawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Remitittances.tsx +git commit -m "feat(remits): row click opens RemitDrawer (replaces inline CAS expand)" +``` + +### Task 4.4: Wire Inbox candidates + unmatched remit rows + +**Files:** +- Modify: `src/pages/Inbox.tsx` + +Inbox lane rows have a no-op `onRowClick` today. Wire the candidates and unmatched-remit lanes to open the RemitDrawer. + +- [ ] **Step 1: Locate the Lane components** + +In `src/pages/Inbox.tsx`, the 5 `` instances are around line 207. Find `lane="candidates"` and the `unmatched` lane's `unmatched` row sub-component. The Lane component takes an `onRowClick` prop. + +- [ ] **Step 2: Wire the click handlers** + +```tsx +import { useNavigate } from "react-router-dom"; + +const navigate = useNavigate(); + +// Candidates lane: + navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)} + onSelectionChange={...} +/> + +// For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch: + { + if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`); + else navigate(`/claims?claim=${encodeURIComponent(row.id)}`); + }} + onSelectionChange={...} +/> +``` + +The exact row shape depends on the `LaneRow` type — verify by reading `src/components/inbox/Lane.tsx`. The key dispatch is: remit rows → RemitDrawer; claim rows → ClaimDrawer. + +- [ ] **Step 3: Smoke** + +Open `/inbox` → click a candidates row → navigates to `/remittances?remit=…` → drawer opens. Click an unmatched remit row → same. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/Inbox.tsx +git commit -m "feat(inbox): candidates + unmatched-remit rows drillable" +``` + +### Task 4.5: Wire Activity log `remit_received` event + +**Files:** +- Modify: `src/lib/event-routing.ts` +- Modify: `src/pages/ActivityLog.tsx` + +- [ ] **Step 1: Update event-routing helper** + +```ts +// src/lib/event-routing.ts +case "remit_received": + return `/remittances?remit=${encodeURIComponent(event.entityId)}`; +``` + +- [ ] **Step 2: Pass onItemClick to ActivityFeed in ActivityLog** + +Same pattern as Task 2.5: import `eventKindToUrl`, pass to ``. + +- [ ] **Step 3: Smoke** + +Open `/activity` → click a `remit_received` event → URL becomes `/remittances?remit=…` → drawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/event-routing.ts src/pages/ActivityLog.tsx +git commit -m "feat(activity): remit_received events drill to RemitDrawer" +``` + +### Task 4.6: Wire Remittances claim-id cell + +**Files:** +- Modify: `src/pages/Remittances.tsx` + +- [ ] **Step 1: Wrap the Claim with DrillableCell** + +Find the `{r.claimId}` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`: + +```tsx + + navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}> + {r.claimId} + + +``` + +- [ ] **Step 2: Smoke** + +Click a claim id in the remits table → navigates to `/claims?claim=…` (claim drawer will open once Phase 5 lands). + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Remittances.tsx +git commit -m "feat(remits): claim id cell drills to /claims?claim=ID" +``` + +### Task 4.7: Wire ClaimDrawer matched-remit link + +**Files:** +- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx` + +The existing card already has a "View remittance →" link that navigates somewhere. Update it to navigate to `/remittances?remit=ID` and ensure the remits page mounts the drawer. + +- [ ] **Step 1: Read MatchedRemitCard** + +Open the file. Find the link/button that takes the operator to the remittance detail. + +- [ ] **Step 2: Update the navigation target** + +```tsx + + View remittance → + +``` + +- [ ] **Step 3: Smoke** + +Open a claim with a matched remittance → click "View remittance →" → navigates to `/remittances?remit=…` → RemitDrawer opens. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/ClaimDrawer/MatchedRemitCard.tsx +git commit -m "feat(claim-drawer): matched-remit link drills to RemitDrawer" +``` + +### Task 4.8: Phase 4 merge to main + +- [ ] **Step 1: Verify green + smoke steps 13, 14, 17, 19** + +- [ ] **Step 2: FF-merge + push** + +```bash +git checkout main && git merge --ff-only universal-drilldown && git push origin main +``` + +--- + +## Phase 5 — AckDrawer + 8 final surfaces + +**Closes smoke steps:** 8, 9, 11, 12, 16, 18, 20, 21, 22, 23. + +### Task 5.1: useAckDrawerUrlState hook + +**Files:** +- Create: `src/hooks/useAckDrawerUrlState.ts` + +Same shape as useProviderDrawerUrlState but for `?ack=`. + +- [ ] **Step 1: Implement** (mirror Task 2.1 with `?ack=`) + +- [ ] **Step 2: Commit** + +```bash +git add src/hooks/useAckDrawerUrlState.ts +git commit -m "feat(drill): useAckDrawerUrlState — ?ack= URL sync" +``` + +### Task 5.2: AckDrawer + +**Files:** +- Create: `src/components/AckDrawer/AckDrawer.tsx` +- Create: `src/components/AckDrawer/AckHeader.tsx` +- Create: `src/components/AckDrawer/SegmentStatusList.tsx` +- Create: `src/components/AckDrawer/index.ts` +- Create: `src/components/AckDrawer/AckDrawer.test.tsx` +- Create: `src/hooks/useAckDetail.ts` + +- [ ] **Step 1: Add useAckDetail** + +```ts +// src/hooks/useAckDetail.ts +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function useAckDetail(ackId: string | null) { + return useQuery({ + queryKey: ["ack-detail", ackId], + queryFn: () => api.getAck(ackId as string), + enabled: ackId !== null, + staleTime: 60 * 1000, + }); +} +``` + +- [ ] **Step 2: Write the failing test** + +```tsx +// src/components/AckDrawer/AckDrawer.test.tsx +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AckDrawer } from "@/components/AckDrawer"; + +vi.mock("@/hooks/useAckDetail", () => ({ + useAckDetail: () => ({ + data: { + id: 42, + sourceBatchId: "BATCH-123", + ackCode: "A", + acceptedCount: 10, + rejectedCount: 2, + receivedCount: 12, + parsedAt: "2026-06-20T12:00:00Z", + segments: [ + { segment: "ST", value: "999", status: "accepted" }, + { segment: "AK1", value: "HC*1234", status: "accepted" }, + ], + }, + isLoading: false, + }), +})); + +describe("AckDrawer", () => { + it("renders the ACK header and segment status list", () => { + render( {}} />); + expect(screen.getByText("999 ACK #42")).toBeInTheDocument(); + expect(screen.getByText("BATCH-123")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Implement** + +```tsx +// src/components/AckDrawer/AckHeader.tsx +import { Download } from "lucide-react"; +import { AckCodeBadge } from "@/components/AcksPage"; // may need extraction +import { fmt } from "@/lib/format"; +import type { Ack } from "@/types"; + +export function AckHeader({ ack, onDownload }: { ack: Ack; onDownload: () => void }) { + return ( + + + + 999 ACK #{ack.id} + + + {ack.sourceBatchId} + + + Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"} + + + + + + 999 + + + + ); +} +``` + +```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 ( + + + + + Segment + Value + Status + + + + {segments.map((s, i) => ( + + {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 `` elements for selection. Add a separate click target on the card body that opens the entity drawer, separate from the selection button. + +- [ ] **Step 1: Restructure each card** + +```tsx +// Before: + setSelectedClaim(c.id)} aria-pressed={active} ...> + ... card content ... + + +// After: + + navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}> + {c.id} + {c.patientName} + ... + + setSelectedClaim(c.id)} + aria-pressed={active} + className="w-full text-xs py-1.5 border-t border-border/30 hover:bg-muted/40" + > + {active ? "Selected ✓" : "Select for match"} + + +``` + +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 ? ( + + navigate(`/claims?claim=${encodeURIComponent(claim.claim_id)}`)} + className="drillable text-[12.5px] text-accent hover:underline" + disabled={!persistedClaimIds.has(claim.claim_id)} + title={persistedClaimIds.has(claim.claim_id) ? "" : "Claim not yet persisted; check Claims page after parse completes."} + > + See claim in detail → + + +) : 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?