Files
cyclone/docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md
Tyler 3fa61bb3f6 plan(SP21): universal drill-down — 5 phases, ~40 tasks
Phase 1: drill primitives (DrillStackProvider, DrillableCell, PeekModal,
DrillDrawerHeader) + PayerPeekContent + ValidationRulePeekContent +
/api/payers/{id}/summary backend + Dashboard KPI/provider/denial drills.
Phase 2: ProviderDrawer + activity event routing for claim_* events.
Phase 3: ProviderDrawer tabs (Claims/Activity) + remaining event routing.
Phase 4: RemitDrawer + 4 surfaces (Remittances, BatchDiff, Inbox,
Reconciliation navigate).
Phase 5: AckDrawer + 8 final surfaces (Claims, Batches, Acks, Providers,
ActivityLog, BatchDiff, Inbox, Reconciliation).

Each phase = 1 PR, shippable independently with its own smoke slice.

Spec: docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md
2026-06-21 11:33:39 -06:00

86 KiB
Raw Permalink Blame History

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

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
// 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 <DrillStackProvider>{children}</DrillStackProvider>;
}

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
// 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<DrillState>((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<typeof makeStore>;

const Ctx = createContext<StoreApi | null>(null);

export function DrillStackProvider({ children }: { children: ReactNode }) {
  // useMemo so the store instance is stable across renders.
  const store = useMemo(makeStore, []);
  return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}

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

// 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(
      <DrillableCell onClick={onClick}>
        <span>CLM-114</span>
      </DrillableCell>,
    );
    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(
      <DrillableCell onClick={onClick} disabled>
        <span>unavailable</span>
      </DrillableCell>,
    );
    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
// 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 <button> so it gets keyboard activation (Enter/Space)
 * for free. Disabled cells render a plain span without the affordance.
 */
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
  if (disabled) {
    return <span className="text-muted-foreground">{children}</span>;
  }
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={ariaLabel}
      className={cn(
        "drillable",
        "inline-flex items-center gap-0 rounded-sm border-0 bg-transparent p-0 text-left",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
      )}
    >
      {children}
    </button>
  );
}
  • Step 4: Run test to verify it passes

Run: npm test -- DrillableCell.test.tsx Expected: 2 tests pass.

  • Step 5: Commit
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

// 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(
      <PeekModal
        open
        onClose={onClose}
        eyebrow="Payer"
        title="CO Medicaid"
      >
        <p>1,247 claims</p>
      </PeekModal>,
    );
    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(
      <PeekModal open={false} onClose={() => {}} title="hidden">
        <p>should not appear</p>
      </PeekModal>,
    );
    expect(container).toBeEmptyDOMElement();
  });

  it("esc key closes", () => {
    const onClose = vi.fn();
    render(
      <PeekModal open onClose={onClose} title="t">
        <p>x</p>
      </PeekModal>,
    );
    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
// 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 (
    <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="max-w-[480px] w-[90vw]"
        aria-describedby={undefined}
      >
        {eyebrow ? (
          <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
            {eyebrow}
          </div>
        ) : null}
        <h2 className="text-[18px] font-semibold tracking-tight">{title}</h2>
        <div className="mt-2">{children}</div>
      </DialogContent>
    </Dialog>
  );
}
  • Step 4: Run test to verify it passes

Run: npm test -- PeekModal.test.tsx Expected: 3 tests pass.

  • Step 5: Commit
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 <DrillStackProvider>)

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

/* 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 <BrowserRouter> or <QueryClientProvider> wrapping routes). Wrap the existing tree with <DrillStackProvider>:

// src/App.tsx — add this import at the top
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";

// Then wrap the existing return. Example before:
//   return <BrowserRouter>...</BrowserRouter>;
// After:
//   return (
//     <DrillStackProvider>
//       <BrowserRouter>...</BrowserRouter>
//     </DrillStackProvider>
//   );
  • 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
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

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

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:

# 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
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

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

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

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

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<PayerSummary> {
  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
// 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
// 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(<PayerPeekContent payerId="SKCO0" loading />);
    // 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(
      <PayerPeekContent
        payerId="SKCO0"
        loading={false}
        data={{
          payer_id: "SKCO0",
          name: "CO Medicaid",
          claim_count: 1247,
          billed_total: 548000,
          received_total: 521000,
          denial_rate: 0.042,
          top_providers: [{ npi: "1881068062", count: 184 }],
        }}
      />,
    );
    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
// 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 (
      <div className="space-y-2">
        <Skeleton variant="row" />
        <Skeleton variant="row" />
        <Skeleton variant="row" />
      </div>
    );
  }
  return <Loaded payer={data} />;
}

function Loaded({ payer }: { payer: PayerSummary }) {
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3">
        <Stat label="Claims" value={fmt.num(payer.claim_count)} />
        <Stat label="Denial rate" value={fmt.pct(payer.denial_rate)} />
        <Stat label="Billed" value={fmt.usd(payer.billed_total)} accent="accent" />
        <Stat label="Received" value={fmt.usd(payer.received_total)} accent="success" />
      </div>
      {payer.top_providers.length > 0 ? (
        <div>
          <div className="eyebrow mb-1.5">Top providers</div>
          <ul className="text-[12.5px] space-y-1">
            {payer.top_providers.slice(0, 3).map((p) => (
              <li key={p.npi} className="flex justify-between">
                <span className="mono">{p.npi}</span>
                <span className="mono text-muted-foreground">{fmt.num(p.count)} claims</span>
              </li>
            ))}
          </ul>
        </div>
      ) : null}
      <Link
        to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
        className="text-[12.5px] text-accent hover:underline"
      >
        View all claims 
      </Link>
    </div>
  );
}

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 (
    <div>
      <div className="eyebrow">{label}</div>
      <div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
    </div>
  );
}
  • Step 5: Run test to verify it passes

Run: npm test -- PayerPeekContent.test.tsx Expected: 2 tests pass.

  • Step 6: Commit
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 <DrillableCell> 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 <KpiCard> invocations inside the <section aria-label="Key performance indicators"> block (around lines 165-226).

  • Step 2: Wrap each KpiCard in DrillableCell

Add imports at top of Dashboard.tsx:

import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";

Inside the Dashboard() function body, before the return:

const navigate = useNavigate();

Then wrap each <KpiCard>:

// Before:
<KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />

// After:
<DrillableCell onClick={() => navigate("/claims")}>
  <KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />
</DrillableCell>

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
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 <li> 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
// Inside the topProviders.map, before the <li>:
<DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)}>
  <li key={p.npi} className="flex items-center gap-3">...</li>
</DrillableCell>

Note: DrillableCell is a <button>, so wrapping a <li> in a <button> is technically invalid HTML (block inside inline). Adjust DrillableCell to support as="li" polymorphism if needed — or, simpler, move the click handler onto the <li> itself with the affordance classes applied directly. Pick whichever the codebase already uses for "clickable list item" patterns (search cursor-pointer in src/pages/Providers.tsx).

If refactoring is needed, the simplest path:

<li
  key={p.npi}
  onClick={() => navigate(...)}
  className="drillable flex items-center gap-3 cursor-pointer ..."
  role="button"
  tabIndex={0}
  onKeyDown={(e) => { if (e.key === "Enter") navigate(...); }}
>
  ...
</li>
  • Step 3: Verify

Smoke check at http://localhost:5173/:

  • Hover a provider row → cursor pointer + tint + chevron

  • Click → URL becomes /providers?provider=NPI (the drawer won't actually open yet — that's Task 2.2/2.3)

  • Step 4: Commit

git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Top providers row drillable to /providers?provider=NPI"

Task 1.10: Dashboard — wire Recent denials row drill

Files:

  • Modify: src/pages/Dashboard.tsx

Same pattern: each denial <li> becomes drillable, opening the claim drawer (/claims?claim=ID).

  • Step 1: Wire the click

Around line 305, in the topDenials.map((c) => (…)) block:

// Before:
<li key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">

// After:
<li
  key={c.id}
  onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
  className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0 cursor-pointer"
  role="button"
  tabIndex={0}
  onKeyDown={(e) => { if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`); }}
>
  • Step 2: Smoke

Click a denial row → URL changes to /claims?claim=…. (The drawer won't open until Phase 5 task 5.10 refactors ClaimDrawer onto the DrillDrawer shell — for now the navigation works, the drawer won't render yet. That's expected.)

  • Step 3: Commit
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Recent denials row drillable to /claims?claim=ID"

Task 1.11: Phase 1 merge to main

  • Step 1: Verify everything green locally
npm run typecheck      # 0 errors
npm test -- --run       # all tests pass (existing + new)
cd backend && .venv/bin/pytest -v  # all tests pass
  • Step 2: Smoke steps 4, 5, 6 in browser

Per spec §2.9. Verify each step works (or note which deferred pieces are expected to not work yet — claim drawer doesn't open from Recent denials until PR5).

  • Step 3: Fast-forward main
cd /Users/openclaw/dev/cyclone
git checkout main
git merge --ff-only universal-drilldown

If main has moved on, rebase instead: git rebase main universal-drilldown then merge.

  • Step 4: Tag + push
git tag sp21-phase1
git push origin main --tags

Phase 2 — ProviderDrawer + activity event routing for claim_* events

Closes smoke steps: 7 (Dashboard Recent activity for claim_* events), 10 (Claims provider cell), 15 (Providers page).

Task 2.1: useProviderDrawerUrlState hook

Files:

  • Create: src/hooks/useProviderDrawerUrlState.ts

  • Create: src/hooks/useProviderDrawerUrlState.test.ts

  • Step 1: Write the failing test

// src/hooks/useProviderDrawerUrlState.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";

describe("useProviderDrawerUrlState", () => {
  beforeEach(() => {
    window.history.replaceState(null, "", "/providers");
  });

  it("reads ?provider= from URL on mount", () => {
    window.history.replaceState(null, "", "/providers?provider=1881068062");
    const { result } = renderHook(() => useProviderDrawerUrlState());
    expect(result.current.providerNpi).toBe("1881068062");
  });

  it("open() pushes URL with ?provider=", () => {
    const { result } = renderHook(() => useProviderDrawerUrlState());
    act(() => result.current.open("1881068062"));
    expect(result.current.providerNpi).toBe("1881068062");
    expect(window.location.search).toBe("?provider=1881068062");
  });

  it("close() strips the param", () => {
    window.history.replaceState(null, "", "/providers?provider=1881068062");
    const { result } = renderHook(() => useProviderDrawerUrlState());
    act(() => result.current.close());
    expect(result.current.providerNpi).toBeNull();
    expect(window.location.search).toBe("");
  });
});
  • Step 2: Run test to verify it fails

Run: npm test -- useProviderDrawerUrlState.test.ts Expected: FAIL.

  • Step 3: Implement the hook
// src/hooks/useProviderDrawerUrlState.ts
import { useCallback, useEffect, useState } from "react";

function readProviderNpi(): string | null {
  const v = new URLSearchParams(window.location.search).get("provider");
  return v === "" ? null : v;
}

function buildUrl(npi: string | null): string {
  const url = new URL(window.location.href);
  if (npi === null) url.searchParams.delete("provider");
  else url.searchParams.set("provider", npi);
  return url.pathname + url.search + url.hash;
}

export function useProviderDrawerUrlState() {
  const [providerNpi, setProviderNpi] = useState<string | null>(() => readProviderNpi());

  const open = useCallback((npi: string) => {
    window.history.pushState(null, "", buildUrl(npi));
    setProviderNpi(npi);
  }, []);

  const close = useCallback(() => {
    window.history.pushState(null, "", buildUrl(null));
    setProviderNpi(null);
  }, []);

  useEffect(() => {
    const onPop = () => setProviderNpi(readProviderNpi());
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  return { providerNpi, open, close };
}
  • Step 4: Run test to verify it passes

Run: npm test -- useProviderDrawerUrlState.test.ts Expected: 3 tests pass.

  • Step 5: Commit
git add src/hooks/useProviderDrawerUrlState.ts src/hooks/useProviderDrawerUrlState.test.ts
git commit -m "feat(drill): useProviderDrawerUrlState — ?provider= URL sync"

Task 2.2: ProviderDrawer (Overview tab only in this phase)

Files:

  • Create: src/components/ProviderDrawer/ProviderDrawer.tsx

  • Create: src/components/ProviderDrawer/ProviderOverview.tsx

  • Create: src/components/ProviderDrawer/index.ts

  • Create: src/components/ProviderDrawer/ProviderDrawer.test.tsx

  • Create: src/hooks/useProviderDetail.ts

  • Step 1: Add useProviderDetail

// src/hooks/useProviderDetail.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function useProviderDetail(npi: string | null) {
  return useQuery({
    queryKey: ["provider-detail", npi],
    queryFn: () => api.getProvider(npi as string),
    enabled: npi !== null,
    staleTime: 60 * 1000,
  });
}

The api.getProvider method already exists — confirm in src/lib/api.ts (the SP9 implementation returns a Provider with npi, name, taxId, etc.).

  • Step 2: Write the failing ProviderDrawer test
// src/components/ProviderDrawer/ProviderDrawer.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProviderDrawer } from "@/components/ProviderDrawer";

// Mock the api.getProvider hook to return a known provider.
vi.mock("@/hooks/useProviderDetail", () => ({
  useProviderDetail: () => ({
    data: {
      npi: "1881068062",
      name: "Montrose Memorial",
      taxId: "721587149",
      address: "123 Main St",
      city: "Montrose",
      state: "CO",
      zip: "81401",
      phone: "(970) 555-1234",
      claimCount: 184,
      outstandingAr: 12450,
    },
    isLoading: false,
    isError: false,
  }),
}));

describe("ProviderDrawer", () => {
  it("renders Overview tab content for a known provider", () => {
    render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
    expect(screen.getByText("Montrose Memorial")).toBeInTheDocument();
    expect(screen.getByText(/1881068062/)).toBeInTheDocument();
    expect(screen.getByText(/721587149/)).toBeInTheDocument();
  });
});

Note: the test imports vi from vitest. Adjust the import line if the codebase uses a different pattern.

  • Step 3: Implement ProviderDrawer + ProviderOverview
// src/components/ProviderDrawer/ProviderOverview.tsx
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";

export function ProviderOverview({ provider }: { provider: Provider }) {
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3">
        <Field label="NPI" value={provider.npi} mono />
        <Field label="Tax ID" value={provider.taxId} mono />
        <Field label="Address" value={`${provider.address}, ${provider.city}, ${provider.state} ${provider.zip}`} />
        <Field label="Phone" value={provider.phone} mono />
      </div>
      <div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/30">
        <Field label="Claims" value={fmt.num(provider.claimCount)} mono />
        <Field label="Outstanding AR" value={fmt.usd(provider.outstandingAr)} mono />
      </div>
    </div>
  );
}

function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
  return (
    <div>
      <div className="eyebrow">{label}</div>
      <div className={`text-[13px] mt-1 ${mono ? "display mono" : ""}`}>{value}</div>
    </div>
  );
}
// src/components/ProviderDrawer/ProviderDrawer.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { ProviderOverview } from "./ProviderOverview";
import { useProviderDetail } from "@/hooks/useProviderDetail";
import { Skeleton } from "@/components/ui/skeleton";

interface Props {
  npi: string | null;
  onClose: () => void;
}

export function ProviderDrawer({ npi, onClose }: Props) {
  const { data, isLoading } = useProviderDetail(npi);
  return (
    <Dialog open={npi !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
        aria-describedby={undefined}
      >
        {npi === null ? null : (
          <>
            <DrillDrawerHeader
              eyebrow="Provider"
              title={data?.name ?? "Loading…"}
              onClose={onClose}
            />
            <div className="p-6 overflow-y-auto h-[calc(100%-64px)]">
              {isLoading || !data ? (
                <div className="space-y-2">
                  <Skeleton variant="row" />
                  <Skeleton variant="row" />
                  <Skeleton variant="row" />
                </div>
              ) : (
                <ProviderOverview provider={data} />
              )}
            </div>
          </>
        )}
      </DialogContent>
    </Dialog>
  );
}
// src/components/drill/DrillDrawerHeader.tsx (create this small helper)
import { X } from "lucide-react";

interface Props {
  eyebrow: string;
  title: string;
  onClose: () => void;
}

export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
  return (
    <div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
      <div>
        <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
          {eyebrow}
        </div>
        <h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
      </div>
      <button
        type="button"
        onClick={onClose}
        aria-label="Close drawer"
        className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        <X className="h-4 w-4" aria-hidden />
      </button>
    </div>
  );
}
// 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
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
// 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:
<ProviderDrawer npi={providerNpi} onClose={close} />

// In the cards map, change the <article> to be clickable:
<article
  key={p.npi}
  onClick={() => 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
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 <TableCell> for "Provider" (around line 286). It currently renders <div>{provider?.name ?? "Unknown"}</div><div className="mono …">{c.providerNpi}</div>.

  • Step 2: Wrap with DrillableCell
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 <TableCell>:
<TableCell>
  <DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}>
    <div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
    <div className="mono text-[10.5px] text-muted-foreground">{c.providerNpi}</div>
  </DrillableCell>
</TableCell>
  • Step 3: Smoke

Click a provider cell on /claims → navigates to /providers?provider=NPI → ProviderDrawer opens.

  • Step 4: Commit
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
// 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<Activity, "kind" | "entityId">): 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 <button> 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
// 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:
<ActivityFeed
  items={activity.slice(0, 10)}
  onItemClick={(evt) => {
    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
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
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
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
// 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 <div className="text-muted-foreground text-[13px]">No recent claims.</div>;
  }
  return (
    <div className="space-y-2">
      {claims.map((c) => (
        <div key={c.id} className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0">
          <div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
          <div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
            {c.patientName ?? "—"}
          </div>
          <div className="display mono text-[13px]">{fmt.usd(c.billedAmount)}</div>
        </div>
      ))}
      <a href="/claims" className="text-[12.5px] text-accent hover:underline">
        View all claims 
      </a>
    </div>
  );
}
  • Step 3: Add tabs to ProviderDrawer
// 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:
<Tabs.Root defaultValue="overview" className="px-6 py-4">
  <Tabs.List className="flex gap-2 border-b border-border/30 mb-4">
    <Tabs.Trigger value="overview" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Overview
    </Tabs.Trigger>
    <Tabs.Trigger value="claims" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Claims
    </Tabs.Trigger>
    <Tabs.Trigger value="activity" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Activity
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">
    {data ? <ProviderOverview provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
  <Tabs.Content value="claims">
    {data ? <ProviderRecentClaims provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
  <Tabs.Content value="activity">
    {data ? <ProviderRecentActivity provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
</Tabs.Root>
  • Step 4: Stub ProviderRecentActivity (real impl in next task)
// 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 <div className="text-muted-foreground text-[13px]">No recent activity.</div>;
  }
  return (
    <ul className="space-y-1.5 text-[12.5px]">
      {items.map((a) => (
        <li key={a.id} className="flex items-center gap-2">
          <span className="mono text-[10.5px] text-muted-foreground">{new Date(a.ts).toLocaleString()}</span>
          <span>{a.kind}</span>
        </li>
      ))}
    </ul>
  );
}
  • 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
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
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
// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState
// and substitute "remit" everywhere.
  • Step 3: Commit
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:

// 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(<RemitDrawer remittanceId="R-9876" onClose={() => {}} />);
    // 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
// 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
// In Remittances.tsx:
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { RemitDrawer } from "@/components/RemitDrawer";

const { remitId, open, close } = useRemitDrawerUrlState();

// At end of returned JSX:
<RemitDrawer remittanceId={remitId} onClose={close} />
  • Step 2: Make rows clickable

In the table row map (around line 218), change:

// Before:
<TableRow
  key={...}
  onClick={() => hasAdjustments ? toggleExpand(r.id) : undefined}
  ...
>

// After:
<TableRow
  key={...}
  onClick={() => open(r.id)}
  className="cursor-pointer drillable"
>

Remove the expanded state, the toggleExpand function, and the conditional inline-expand <TableRow> 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
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 <Lane> 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
import { useNavigate } from "react-router-dom";

const navigate = useNavigate();

// Candidates lane:
<Lane
  name="CANDIDATES"
  rows={lanes.candidates}
  onRowClick={(row) => navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)}
  onSelectionChange={...}
/>

// For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch:
<Lane
  name="UNMATCHED"
  rows={lanes.unmatched}
  onRowClick={(row) => {
    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
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

// 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 <ActivityFeed onItemClick={...} />.

  • Step 3: Smoke

Open /activity → click a remit_received event → URL becomes /remittances?remit=… → drawer opens.

  • Step 4: Commit
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 <TableCell className="display mono text-[12.5px] text-muted-foreground">{r.claimId}</TableCell> line in the remits table. Wrap with DrillableCell that navigates to /claims?claim=ID:

<TableCell>
  <DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}>
    {r.claimId}
  </DrillableCell>
</TableCell>
  • 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
git add src/pages/Remittances.tsx
git commit -m "feat(remits): claim id cell drills to /claims?claim=ID"

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
<Link to={`/remittances?remit=${matched.id}`} className="…">
  View remittance 
</Link>
  • Step 3: Smoke

Open a claim with a matched remittance → click "View remittance →" → navigates to /remittances?remit=… → RemitDrawer opens.

  • Step 4: Commit
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

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

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

// 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
// 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(<AckDrawer ackId="42" onClose={() => {}} />);
    expect(screen.getByText("999 ACK #42")).toBeInTheDocument();
    expect(screen.getByText("BATCH-123")).toBeInTheDocument();
  });
});
  • Step 3: Implement
// 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 (
    <div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
      <div>
        <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
          999 ACK #{ack.id}
        </div>
        <h2 className="text-[18px] font-semibold tracking-tight mt-0.5 mono">
          {ack.sourceBatchId}
        </h2>
        <div className="text-[12px] text-muted-foreground mt-1">
          Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"}
        </div>
      </div>
      <div className="flex items-center gap-3">
        <AckCodeBadge code={ack.ackCode} />
        <button
          type="button"
          onClick={onDownload}
          className="rounded-md border border-border/60 px-2.5 py-1 text-[11.5px] hover:bg-muted/40"
          aria-label="Download 999"
        >
          <Download className="h-3 w-3 inline mr-1" /> 999
        </button>
      </div>
    </div>
  );
}
// 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 (
      <div className="text-[13px] text-muted-foreground">
        {ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received
      </div>
    );
  }
  return (
    <div className="rounded-md border border-border/40 overflow-x-auto">
      <table className="w-full text-[12px]">
        <thead className="bg-muted/20 text-muted-foreground">
          <tr>
            <th className="px-3 py-2 text-left font-medium">Segment</th>
            <th className="px-3 py-2 text-left font-medium">Value</th>
            <th className="px-3 py-2 text-left font-medium">Status</th>
          </tr>
        </thead>
        <tbody>
          {segments.map((s, i) => (
            <tr key={i} className="border-t border-border/30">
              <td className="px-3 py-2 mono">{s.segment}</td>
              <td className="px-3 py-2 mono">{s.value}</td>
              <td className="px-3 py-2">{s.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
// 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 (
    <Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
        aria-describedby={undefined}
      >
        {ackId === null ? null : isLoading || !data ? (
          <div className="p-6 space-y-2">
            <Skeleton variant="row" />
            <Skeleton variant="row" />
            <Skeleton variant="row" />
          </div>
        ) : (
          <>
            <AckHeader
              ack={data}
              onDownload={async () => {
                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);
                }
              }}
            />
            <div className="p-6 overflow-y-auto h-[calc(100%-72px)] space-y-4">
              <SegmentStatusList ack={data} />
            </div>
          </>
        )}
      </DialogContent>
    </Dialog>
  );
}
// src/components/AckDrawer/index.ts
export { AckDrawer } from "./AckDrawer";
  • Step 4: Run test + commit
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

// 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:
<TableRow
  key={a.id}
  onClick={() => open(String(a.id))}
  className="cursor-pointer drillable"
  ...
>

// At the end of returned JSX:
<AckDrawer ackId={ackId} onClose={close} />
  • Step 2: Smoke

Click an ACK row → URL becomes /acks?ack=… → drawer opens with header + segment list.

  • Step 3: Commit
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:

// Rejected (claims): all rows → ClaimDrawer
<Lane
  name="REJECTED"
  rows={lanes.rejected}
  onRowClick={(row) => navigate(`/claims?claim=${encodeURIComponent(row.id)}`)}
  onSelectionChange={...}
/>

// Payer-Rejected: same as rejected
<Lane name="PAYER REJECTED" rows={lanes.payer_rejected} onRowClick={...} />

// Done today: rows have a kind (claim or remit)
<Lane
  name="DONE"
  rows={lanes.done_today}
  onRowClick={(row) => {
    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
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 <button> 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
// Before:
<button key={c.id} type="button" onClick={() => setSelectedClaim(c.id)} aria-pressed={active} ...>
  <div>... card content ...</div>
</button>

// After:
<div
  key={c.id}
  className={cn(
    "rounded-md border transition-colors",
    active ? "border-accent bg-accent/10" : "border-border/60 hover:bg-muted/30",
  )}
>
  <div className="p-3 cursor-pointer drillable" onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}>
    <div className="display mono text-[13px]">{c.id}</div>
    <div className="text-[12px] text-muted-foreground">{c.patientName}</div>
    ...
  </div>
  <button
    type="button"
    onClick={() => 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"}
  </button>
</div>

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

<DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(id)}`)}>
  {id}
</DrillableCell>

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
git add src/components/BatchDiffView.tsx
git commit -m "feat(batch-diff): claim ids drillable to /claims?claim=ID"

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:

{claim.claim_id ? (
  <div className="pt-2 border-t border-border/30">
    <button
      type="button"
      onClick={() => 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 
    </button>
  </div>
) : 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
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
// 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):
<PeekModal
  open={payerPeekOpen}
  onClose={closeTop}
  eyebrow="Payer"
  title={payerPeekOpen ? topPeek!.payerId : ""}
>
  {payerPeekOpen ? <PayerPeekContent payerId={topPeek!.payerId} /> : null}
</PeekModal>
  • Step 2: Wire the payer cell click

In PartiesGrid.tsx, wrap the payer name with DrillableCell:

<DrillableCell onClick={() => openPeek({ kind: "payer", payerId: payer.id })}>
  {payer.name}
</DrillableCell>

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

const RULE_HELP: Record<string, { description: string; x12?: string }> = {
  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 (
    <div className="space-y-3 text-[13px]">
      <div>
        <div className="eyebrow">Rule</div>
        <div className="mono mt-1">{rule}</div>
      </div>
      <p>{help.description}</p>
      {help.x12 ? (
        <div>
          <div className="eyebrow">X12 reference</div>
          <p className="mono text-[11.5px] text-muted-foreground mt-1">{help.x12}</p>
        </div>
      ) : null}
    </div>
  );
}
  • Step 2: Wire the peek in ClaimDrawer

Add to the useDrillStack block in ClaimDrawer.tsx:

<PeekModal
  open={topPeek?.kind === "rule"}
  onClose={closeTop}
  eyebrow="Validation rule"
  title={topPeek?.kind === "rule" ? topPeek.rule : ""}
>
  {topPeek?.kind === "rule" ? <ValidationRulePeekContent rule={topPeek.rule} /> : null}
</PeekModal>
  • Step 3: Wire the validation issue row click

In ValidationPanel.tsx, wrap the <li> rule row:

<DrillableCell onClick={() => openPeek({ kind: "rule", rule: issue.rule })}>
  <li key={i} className="flex items-start gap-2 text-destructive">
    <XCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" strokeWidth={1.75} />
    <span><span className="mono">{issue.rule}</span>  {issue.message}</span>
  </li>
</DrillableCell>
  • Step 4: Smoke

Open a claim with validation errors → click a rule → peek opens with rule description.

  • Step 5: Commit
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
// In ClaimDrawer.tsx, replace the existing header with:
<DrillDrawerHeader
  eyebrow={`Claim · ${claim.status ?? ""}`}
  title={claim.id}
  onClose={onClose}
  // Optional primary action slot (e.g. "Download 837") can be passed
  // via a new `action` prop on DrillDrawerHeader. If needed, add it
  // in this task and wire the Download 837 button here.
  action={onDownload837 ? <Download837Button claimId={claim.id} /> : 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
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
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
git tag sp21-complete
git checkout main
git merge --ff-only universal-drilldown
git push origin main --tags
  • Step 4: Cleanup worktree
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?