diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 8ac1f58..bb07b5f 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -1668,7 +1668,16 @@ class CycloneStore: return list(by_npi.values()) def recent_activity(self, *, limit: int = 200) -> list[dict]: - """Return recent activity events from the DB, newest first.""" + """Return recent activity events from the DB, newest first. + + SP21 Task 2.5: each row also carries ``claimId`` and + ``remittanceId`` (read from the ORM columns) so the Dashboard's + Recent-activity card can route clicks to the right entity + drawer via ``src/lib/event-routing.ts``. Both are nullable + strings; the wire shape uses camelCase keys to match the + existing ``npi`` / ``amount`` fields and the frontend + ``Activity`` interface. + """ with db.SessionLocal()() as s: rows = ( s.query(ActivityEvent) @@ -1684,6 +1693,8 @@ class CycloneStore: "timestamp": r.ts.isoformat().replace("+00:00", "Z"), "npi": (r.payload_json or {}).get("npi"), "amount": (r.payload_json or {}).get("amount"), + "claimId": r.claim_id, + "remittanceId": r.remittance_id, } for r in rows ] diff --git a/src/components/ActivityFeed.test.tsx b/src/components/ActivityFeed.test.tsx index 6af07a1..490668c 100644 --- a/src/components/ActivityFeed.test.tsx +++ b/src/components/ActivityFeed.test.tsx @@ -1,11 +1,15 @@ // @vitest-environment happy-dom (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { ActivityFeed } from "./ActivityFeed"; import type { Activity } from "@/types"; +// happy-dom keeps `document.body` between tests; without cleanup, +// `screen.getByRole("button")` finds buttons from earlier renders. +afterEach(() => cleanup()); + const baseActivity: Activity = { id: "a-1", kind: "claim_submitted", @@ -47,4 +51,99 @@ describe("ActivityFeed", () => { expect(() => render()).not.toThrow(); expect(screen.getByText("future_kind event arrived")).toBeTruthy(); }); + + // ------------------------------------------------------------------- + // SP21 Task 2.5: optional `onItemClick` prop makes each row a + // drillable target. Default behavior (no handler) must stay unchanged. + // ------------------------------------------------------------------- + + it("does not attach row-level click attrs when onItemClick is omitted", () => { + render(); + const li = screen.getByRole("listitem"); + expect(li.getAttribute("role")).not.toBe("button"); + expect(li.getAttribute("tabindex")).toBeNull(); + expect(li.classList.contains("drillable")).toBe(false); + }); + + it("attaches role/tabIndex/drillable class when onItemClick is provided", () => { + render( + {}} + />, + ); + const li = screen.getByRole("button", { name: /View claim submitted/ }); + expect(li.tagName).toBe("LI"); + expect(li.getAttribute("tabindex")).toBe("0"); + expect(li.classList.contains("drillable")).toBe(true); + }); + + it("calls onItemClick with the event on click", () => { + const onItemClick = vi.fn(); + render( + , + ); + const li = screen.getByRole("button"); + fireEvent.click(li); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(onItemClick).toHaveBeenCalledWith(baseActivity); + }); + + it("calls onItemClick on Enter keydown", () => { + const onItemClick = vi.fn(); + render( + , + ); + const li = screen.getByRole("button"); + fireEvent.keyDown(li, { key: "Enter" }); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(onItemClick).toHaveBeenCalledWith(baseActivity); + }); + + it("calls onItemClick on Space keydown", () => { + const onItemClick = vi.fn(); + render( + , + ); + fireEvent.keyDown(screen.getByRole("button"), { key: " " }); + expect(onItemClick).toHaveBeenCalledTimes(1); + }); + + it("does not call onItemClick for unrelated keys", () => { + const onItemClick = vi.fn(); + render( + , + ); + fireEvent.keyDown(screen.getByRole("button"), { key: "a" }); + expect(onItemClick).not.toHaveBeenCalled(); + }); + + // Regression for the Task 2.4 event-bubbling pattern: clicks on a + // drillable row must not bubble to a parent row click. Without + // stopPropagation a Dashboard parent handler could fire alongside + // the row click and corrupt history. + it("stops click propagation so a parent row handler does not also fire", () => { + const onItemClick = vi.fn(); + const parentClick = vi.fn(); + render( +
    + +
, + ); + fireEvent.click(screen.getByRole("button")); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(parentClick).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index 80d9bd1..cf4753a 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -1,3 +1,4 @@ +import type { KeyboardEvent, MouseEvent } from "react"; import { Banknote, CheckCircle2, @@ -63,9 +64,22 @@ const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = { export function ActivityFeed({ items, emptyMessage = "No activity yet.", + onItemClick, }: { items: Activity[]; emptyMessage?: string; + /** + * Optional click handler for SP21 Universal Drill-Down (Task 2.5). + * When provided, each row becomes a clickable target: the row's + * outer `
  • ` gets `role="button"`, `tabIndex`, the `drillable` + * hover affordance (chevron + tint), and an Enter/Space keybinding. + * + * `e.stopPropagation()` is called before invoking the handler so the + * click doesn't bubble to a hypothetical parent row click — same + * fix that landed on `DrillableCell` in Task 2.4. When omitted, the + * feed renders exactly as before (no extra DOM, no extra attrs). + */ + onItemClick?: (event: Activity) => void; }) { if (items.length === 0) { return ( @@ -79,11 +93,36 @@ export function ActivityFeed({ {items.map((a) => { const cfg = kindConfig[a.kind] ?? FALLBACK_KIND; const Icon = cfg.icon; + // SP21 Task 2.5: when a click handler is wired, the row + // becomes a keyboard-focusable button-like element with the + // drillable hover affordance. We attach the handler to the + //
  • directly (matching the Dashboard's existing patterns + // for "Top providers" / "Recent denials" rows) so the click + // target covers the full row width including padding. + const rowProps = onItemClick + ? { + role: "button" as const, + tabIndex: 0, + "aria-label": `View ${a.kind.replace(/_/g, " ")}: ${a.message}`, + className: + "drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", + onClick: (e: MouseEvent) => { + e.stopPropagation(); + onItemClick(a); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onItemClick(a); + } + }, + } + : { + className: "flex items-start gap-3 py-3 first:pt-0 last:pb-0", + }; return ( -
  • +
  • & { kind: RoutableEvent["kind"] }): RoutableEvent { + return { + claimId: null, + remittanceId: null, + npi: undefined, + ...overrides, + }; +} + +describe("eventKindToUrl", () => { + // ----- claim_* → /claims?claim=ID ------------------------------- + it("routes claim_submitted to /claims?claim=ID", () => { + expect( + eventKindToUrl(evt({ kind: "claim_submitted", claimId: "CLM-1" })), + ).toBe("/claims?claim=CLM-1"); + }); + + it("routes claim_paid to /claims?claim=ID", () => { + expect( + eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM-2" })), + ).toBe("/claims?claim=CLM-2"); + }); + + it("routes claim_denied to /claims?claim=ID", () => { + expect( + eventKindToUrl(evt({ kind: "claim_denied", claimId: "CLM-3" })), + ).toBe("/claims?claim=CLM-3"); + }); + + it("routes claim_accepted to /claims?claim=ID", () => { + expect( + eventKindToUrl(evt({ kind: "claim_accepted", claimId: "CLM-4" })), + ).toBe("/claims?claim=CLM-4"); + }); + + it("percent-encodes the claim id when it contains special chars", () => { + expect( + eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM/1+2" })), + ).toBe("/claims?claim=CLM%2F1%2B2"); + }); + + it("returns null for claim_* when claimId is missing", () => { + expect( + eventKindToUrl(evt({ kind: "claim_paid", claimId: null })), + ).toBeNull(); + expect( + eventKindToUrl(evt({ kind: "claim_paid", claimId: undefined })), + ).toBeNull(); + }); + + // ----- remit_received → null (Phase 4) -------------------------- + it("returns null for remit_received (Phase 4 drawer not built yet)", () => { + expect( + eventKindToUrl(evt({ kind: "remit_received", remittanceId: "REM-1" })), + ).toBeNull(); + }); + + // ----- provider_added → /providers?provider=NPI ----------------- + it("routes provider_added to /providers?provider=NPI", () => { + expect( + eventKindToUrl(evt({ kind: "provider_added", npi: "1730187395" })), + ).toBe("/providers?provider=1730187395"); + }); + + it("returns null for provider_added when npi is missing", () => { + expect(eventKindToUrl(evt({ kind: "provider_added", npi: undefined }))).toBeNull(); + }); + + // ----- default branch ------------------------------------------ + it("returns null for unhandled kinds (manual_match)", () => { + expect(eventKindToUrl(evt({ kind: "manual_match" }))).toBeNull(); + }); + + it("returns null for unknown kinds (defensive default)", () => { + // Cast through unknown so the type-checker doesn't widen the + // literal away from the exhaustive union. + const unknown = { kind: "future_kind" } as unknown as RoutableEvent; + expect(eventKindToUrl(unknown)).toBeNull(); + }); +}); diff --git a/src/lib/event-routing.ts b/src/lib/event-routing.ts new file mode 100644 index 0000000..767c99f --- /dev/null +++ b/src/lib/event-routing.ts @@ -0,0 +1,55 @@ +import type { Activity } from "@/types"; + +/** + * The minimum an activity event must carry to be routable. Centralizing + * this here means callers can pass an `Activity` (full feed row) or a + * narrower shape (e.g. a future "activity event" peek payload) — both + * satisfy the kind + entity-id fields the helper inspects. + * + * - `claimId` / `remittanceId` come from the backend `ActivityEvent` + * ORM columns (SP21 Task 2.5). `claimId` is set on `claim_*` events; + * `remittanceId` is set on `remit_received`. + * - `npi` is the existing `Activity.npi` field. For `provider_added` + * events the provider NPI IS the entity id (the URL target). + */ +export type RoutableEvent = Pick< + Activity, + "kind" | "claimId" | "remittanceId" | "npi" +>; + +/** + * Maps an activity event to the URL the operator should land on when + * clicking the event. Used by: + * + * - Dashboard "Recent activity" card (this PR, Task 2.5). + * - `/activity` log page (Task 2.5 wired here; full integration is + * a later task). + * + * Returns `null` for kinds that don't have a drill target yet, or + * when the entity id field is missing on the event. Callers are + * expected to surface a "coming soon" toast in that case so the click + * still gives feedback. + */ +export function eventKindToUrl(event: RoutableEvent): string | null { + switch (event.kind) { + case "claim_submitted": + case "claim_paid": + case "claim_denied": + case "claim_accepted": { + if (!event.claimId) return null; + return `/claims?claim=${encodeURIComponent(event.claimId)}`; + } + case "remit_received": + // Phase 4 — the `RemitDrawer` isn't built yet; the helper stays + // honest and returns null so the caller's "coming soon" toast + // fires. When the drawer lands, the route becomes + // `/remittances?remit=${encodeURIComponent(event.remittanceId ?? "")}`. + return null; + case "provider_added": { + if (!event.npi) return null; + return `/providers?provider=${encodeURIComponent(event.npi)}`; + } + default: + return null; + } +} diff --git a/src/pages/Dashboard.test.tsx b/src/pages/Dashboard.test.tsx new file mode 100644 index 0000000..5ecd924 --- /dev/null +++ b/src/pages/Dashboard.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +// SP21 Task 2.5: Dashboard "Recent activity" card routes clicks by event +// kind. This test verifies the wiring end-to-end: a `claim_paid` event +// in the activity slice becomes a clickable row that navigates to +// `/claims?claim=`, while a `remit_received` event surfaces a +// "coming in a later phase" toast (since the RemitDrawer lands in +// Phase 4). +// +// `Dashboard` reads `claims`, `providers`, `activity` from the +// `useAppStore` slice, so we override the slice directly via +// `useAppStore.setState` instead of mocking `useActivity` — the +// sample-data mode is exactly what the dashboard sees when no API is +// configured, so the test stays faithful to production behavior. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Dashboard } from "./Dashboard"; +import { useAppStore } from "@/store"; +import type { Activity } from "@/types"; + +// sonner renders toasts through a portal; jsdom has no layout so the +// `position` style is a no-op. We just verify that `toast.info` is +// called with the expected message — sonner itself has its own tests. +vi.mock("sonner", () => ({ + toast: { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Capture navigation side effects so we can assert on the URL the +// Dashboard would push. We use a `MemoryRouter` (initialEntries=["/"]) +// and observe the rendered route via a tiny listener component that +// reads the current `` entry — no real `window.history` +// or `BrowserRouter` required. +import { useLocation } from "react-router-dom"; + +function LocationProbe() { + const loc = useLocation(); + return ( +
    + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + // Reset the activity slice so tests don't bleed into each other. + useAppStore.setState({ activity: [] }); +}); + +describe("Dashboard · Recent activity event routing (SP21 Task 2.5)", () => { + it("navigates to /claims?claim=ID when a claim_paid event is clicked", () => { + const activity: Activity[] = [ + { + id: "ae-1", + kind: "claim_paid", + message: "Paid CLM-42 · Jordan Smith", + timestamp: "2026-06-20T12:00:00Z", + claimId: "CLM-42", + remittanceId: null, + }, + ]; + useAppStore.setState({ activity }); + + const { getByTestId, getByRole } = render( + + + + , + ); + + // The row becomes a button-like
  • with role="button" and a + // descriptive aria-label derived from the kind. + const row = getByRole("button", { name: /View claim paid/ }); + fireEvent.click(row); + + const probe = getByTestId("location"); + expect(probe.getAttribute("data-pathname")).toBe("/claims"); + expect(probe.getAttribute("data-search")).toBe("?claim=CLM-42"); + }); + + it("navigates to /providers?provider=NPI when a provider_added event is clicked", () => { + const activity: Activity[] = [ + { + id: "ae-2", + kind: "provider_added", + message: "Added Cedar Park Family Medicine", + timestamp: "2026-06-20T12:00:00Z", + npi: "1730187395", + claimId: null, + remittanceId: null, + }, + ]; + useAppStore.setState({ activity }); + + const { getByTestId, getByRole } = render( + + + + , + ); + + fireEvent.click(getByRole("button", { name: /View provider added/ })); + + const probe = getByTestId("location"); + expect(probe.getAttribute("data-pathname")).toBe("/providers"); + expect(probe.getAttribute("data-search")).toBe("?provider=1730187395"); + }); + + it("fires the toast (no navigation) for remit_received — Phase 4 drawer", async () => { + const { toast } = await import("sonner"); + const activity: Activity[] = [ + { + id: "ae-3", + kind: "remit_received", + message: "Remit REM-7 received", + timestamp: "2026-06-20T12:00:00Z", + claimId: null, + remittanceId: "REM-7", + }, + ]; + useAppStore.setState({ activity }); + + const { getByTestId, getByRole } = render( + + + + , + ); + + fireEvent.click(getByRole("button", { name: /View remit received/ })); + + expect(toast.info).toHaveBeenCalledTimes(1); + const msg = (toast.info as ReturnType).mock.calls[0]?.[0] as string; + expect(msg).toMatch(/remit received/i); + expect(msg).toMatch(/coming in a later phase/i); + + // URL must not have changed — the toast path is non-navigating. + const probe = getByTestId("location"); + expect(probe.getAttribute("data-pathname")).toBe("/"); + expect(probe.getAttribute("data-search")).toBe(""); + }); +}); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2fca5f3..6350565 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -16,7 +16,9 @@ import { ActivityFeed } from "@/components/ActivityFeed"; import { AnimatedNumber } from "@/components/AnimatedNumber"; import { DrillableCell } from "@/components/drill/DrillableCell"; import { fmt } from "@/lib/format"; +import { eventKindToUrl } from "@/lib/event-routing"; import { useAppStore } from "@/store"; +import { toast } from "sonner"; const MONTHS_BACK = 6; @@ -275,7 +277,24 @@ export function Dashboard() { - + { + // SP21 Task 2.5: route by event kind. claim_* kinds + // navigate to the matching claim drawer (URL-driven, + // drawer mounts in Phase 5); provider_added opens the + // ProviderDrawer. remit_received and any unmapped + // kind surface a "coming soon" toast so the click + // still gives feedback until the RemitDrawer lands in + // Phase 4. + const url = eventKindToUrl(evt); + if (url) navigate(url); + else + toast.info( + `Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`, + ); + }} + /> diff --git a/src/store/index.ts b/src/store/index.ts index 02b7950..497e512 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -49,6 +49,10 @@ export const useAppStore = create((set) => ({ timestamp: claim.submissionDate, npi: claim.providerNpi, amount: claim.billedAmount, + // SP21 Task 2.5: mirror the backend wire shape so the + // Dashboard routing helper finds the claim id. + claimId: claim.id, + remittanceId: null, }, ...s.activity, ], diff --git a/src/types/index.ts b/src/types/index.ts index c48a4d8..498fc6d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -155,6 +155,23 @@ export interface Activity { timestamp: string; npi?: string; amount?: number; + /** + * SP21 Task 2.5: read from the backend `ActivityEvent.claim_id` so + * the Dashboard "Recent activity" card can route a click on a + * `claim_*` event to the matching claim drawer (via + * `src/lib/event-routing.ts`). Populated only for events that + * correspond to a specific claim; `null` for orphan events such as + * `remit_received` or `provider_added`. + */ + claimId?: string | null; + /** + * SP21 Task 2.5: read from the backend `ActivityEvent.remittance_id`. + * Populated only for events that reference a specific remittance + * (e.g. `remit_received`); `null` otherwise. The Dashboard toast + * covers the Phase 2 case where this routes to a not-yet-built + * `RemitDrawer` (Phase 4). + */ + remittanceId?: string | null; } // ---------------------------------------------------------------------------