feat(dashboard): Recent activity events route to entity by kind

SP21 Task 2.5 — the Dashboard's 'Recent activity' card now routes clicks
to the matching entity drawer / page by event kind:

  - claim_*       → /claims?claim=<id>  (drawer in Phase 5)
  - provider_added → /providers?provider=<npi>  (ProviderDrawer)
  - remit_received → toast 'coming in a later phase' (RemitDrawer in Phase 4)
  - anything else  → toast (manual_match, unknown kinds)

Implementation:
  - New src/lib/event-routing.ts with the eventKindToUrl() helper,
    plus a unit test covering all 6 + default branches.
  - src/components/ActivityFeed.tsx gains an optional onItemClick
    prop; when set, each row gets role='button', tabIndex=0, the
    drillable hover affordance (chevron + tint), and an Enter/Space
    keybinding. e.stopPropagation() is called before the handler so a
    parent row click can't double-fire (same fix as Task 2.4).
  - src/pages/Dashboard.tsx wires the handler on the 'Recent activity'
    card via eventKindToUrl + sonner toast for unhandled kinds.
  - Backend: CycloneStore.recent_activity() now exposes claimId and
    remittanceId on each row (read from ActivityEvent.claim_id /
    remittance_id) so the routing helper has the entity ids it needs.
  - The frontend Activity interface gains optional claimId /
    remittanceId fields; the in-memory sample data and the
    addClaim store action populate them so the dashboard works in
    both API-configured and sample-data modes.
This commit is contained in:
Tyler
2026-06-21 16:27:56 -06:00
parent d15c04d983
commit b8aae8bff1
10 changed files with 495 additions and 8 deletions
+12 -1
View File
@@ -1668,7 +1668,16 @@ class CycloneStore:
return list(by_npi.values()) return list(by_npi.values())
def recent_activity(self, *, limit: int = 200) -> list[dict]: 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: with db.SessionLocal()() as s:
rows = ( rows = (
s.query(ActivityEvent) s.query(ActivityEvent)
@@ -1684,6 +1693,8 @@ class CycloneStore:
"timestamp": r.ts.isoformat().replace("+00:00", "Z"), "timestamp": r.ts.isoformat().replace("+00:00", "Z"),
"npi": (r.payload_json or {}).get("npi"), "npi": (r.payload_json or {}).get("npi"),
"amount": (r.payload_json or {}).get("amount"), "amount": (r.payload_json or {}).get("amount"),
"claimId": r.claim_id,
"remittanceId": r.remittance_id,
} }
for r in rows for r in rows
] ]
+101 -2
View File
@@ -1,11 +1,15 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ActivityFeed } from "./ActivityFeed"; import { ActivityFeed } from "./ActivityFeed";
import type { Activity } from "@/types"; 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 = { const baseActivity: Activity = {
id: "a-1", id: "a-1",
kind: "claim_submitted", kind: "claim_submitted",
@@ -47,4 +51,99 @@ describe("ActivityFeed", () => {
expect(() => render(<ActivityFeed items={[unknown]} />)).not.toThrow(); expect(() => render(<ActivityFeed items={[unknown]} />)).not.toThrow();
expect(screen.getByText("future_kind event arrived")).toBeTruthy(); 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(<ActivityFeed items={[baseActivity]} />);
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(
<ActivityFeed
items={[baseActivity]}
onItemClick={() => {}}
/>,
);
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(
<ActivityFeed
items={[baseActivity]}
onItemClick={onItemClick}
/>,
);
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(
<ActivityFeed
items={[baseActivity]}
onItemClick={onItemClick}
/>,
);
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(
<ActivityFeed
items={[baseActivity]}
onItemClick={onItemClick}
/>,
);
fireEvent.keyDown(screen.getByRole("button"), { key: " " });
expect(onItemClick).toHaveBeenCalledTimes(1);
});
it("does not call onItemClick for unrelated keys", () => {
const onItemClick = vi.fn();
render(
<ActivityFeed
items={[baseActivity]}
onItemClick={onItemClick}
/>,
);
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(
<ul onClick={parentClick}>
<ActivityFeed items={[baseActivity]} onItemClick={onItemClick} />
</ul>,
);
fireEvent.click(screen.getByRole("button"));
expect(onItemClick).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});
}); });
+43 -4
View File
@@ -1,3 +1,4 @@
import type { KeyboardEvent, MouseEvent } from "react";
import { import {
Banknote, Banknote,
CheckCircle2, CheckCircle2,
@@ -63,9 +64,22 @@ const FALLBACK_KIND: { icon: LucideIcon; tone: string; tint: string } = {
export function ActivityFeed({ export function ActivityFeed({
items, items,
emptyMessage = "No activity yet.", emptyMessage = "No activity yet.",
onItemClick,
}: { }: {
items: Activity[]; items: Activity[];
emptyMessage?: string; 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 `<li>` 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) { if (items.length === 0) {
return ( return (
@@ -79,11 +93,36 @@ export function ActivityFeed({
{items.map((a) => { {items.map((a) => {
const cfg = kindConfig[a.kind] ?? FALLBACK_KIND; const cfg = kindConfig[a.kind] ?? FALLBACK_KIND;
const Icon = cfg.icon; 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
// <li> 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<HTMLLIElement>) => {
e.stopPropagation();
onItemClick(a);
},
onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => {
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 ( return (
<li <li key={a.id} {...rowProps}>
key={a.id}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<div <div
className={cn( className={cn(
"mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center", "mt-0.5 h-7 w-7 shrink-0 rounded-md ring-1 ring-inset ring-border/40 flex items-center justify-center",
+6
View File
@@ -164,6 +164,12 @@ function buildActivity(claims: Claim[]): Activity[] {
timestamp: c.submissionDate, timestamp: c.submissionDate,
npi: c.providerNpi, npi: c.providerNpi,
amount: c.billedAmount, amount: c.billedAmount,
// SP21 Task 2.5: mirror the backend `recent_activity()` wire
// shape so the Dashboard routing helper can find the claim id.
// Sample-data remits are never a 1:1 Activity row, so the
// remittance id is always null here.
claimId: c.id,
remittanceId: null,
}; };
}); });
return events.sort( return events.sort(
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { eventKindToUrl, type RoutableEvent } from "./event-routing";
// A minimal event factory keeps the assertions short and keeps the
// focus on what the helper actually inspects (kind + the relevant
// entity-id field for that kind).
function evt(overrides: Partial<RoutableEvent> & { 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();
});
});
+55
View File
@@ -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;
}
}
+151
View File
@@ -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=<id>`, 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 `<MemoryRouter>` entry — no real `window.history`
// or `BrowserRouter` required.
import { useLocation } from "react-router-dom";
function LocationProbe() {
const loc = useLocation();
return (
<div
data-testid="location"
data-pathname={loc.pathname}
data-search={loc.search}
/>
);
}
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(
<MemoryRouter initialEntries={["/"]}>
<Dashboard />
<LocationProbe />
</MemoryRouter>,
);
// The row becomes a button-like <li> 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(
<MemoryRouter initialEntries={["/"]}>
<Dashboard />
<LocationProbe />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/"]}>
<Dashboard />
<LocationProbe />
</MemoryRouter>,
);
fireEvent.click(getByRole("button", { name: /View remit received/ }));
expect(toast.info).toHaveBeenCalledTimes(1);
const msg = (toast.info as ReturnType<typeof vi.fn>).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("");
});
});
+20 -1
View File
@@ -16,7 +16,9 @@ import { ActivityFeed } from "@/components/ActivityFeed";
import { AnimatedNumber } from "@/components/AnimatedNumber"; import { AnimatedNumber } from "@/components/AnimatedNumber";
import { DrillableCell } from "@/components/drill/DrillableCell"; import { DrillableCell } from "@/components/drill/DrillableCell";
import { fmt } from "@/lib/format"; import { fmt } from "@/lib/format";
import { eventKindToUrl } from "@/lib/event-routing";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { toast } from "sonner";
const MONTHS_BACK = 6; const MONTHS_BACK = 6;
@@ -275,7 +277,24 @@ export function Dashboard() {
</span> </span>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<ActivityFeed items={activity.slice(0, 10)} /> <ActivityFeed
items={activity.slice(0, 10)}
onItemClick={(evt) => {
// 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.`,
);
}}
/>
</CardContent> </CardContent>
</Card> </Card>
+4
View File
@@ -49,6 +49,10 @@ export const useAppStore = create<AppState>((set) => ({
timestamp: claim.submissionDate, timestamp: claim.submissionDate,
npi: claim.providerNpi, npi: claim.providerNpi,
amount: claim.billedAmount, 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, ...s.activity,
], ],
+17
View File
@@ -155,6 +155,23 @@ export interface Activity {
timestamp: string; timestamp: string;
npi?: string; npi?: string;
amount?: number; 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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------