merge: SP5 live-tail — pub/sub + 3 stream endpoints + frontend tail integration
Brings the SP5 live-tail implementation into main:
Backend
* EventBus (cyclone.pubsub): per-kind fan-out with drop-oldest overflow
* FastAPI lifespan initializes the bus + db once per process
* store.add() publishes claim_written / remittance_written /
activity_recorded events on every batch write
* GET /api/{claims,remittances,activity}/stream: NDJSON snapshot +
live subscription + 15s idle heartbeat
* EventBus.unsubscribe() lets the tail loop release its queue on
client disconnect (no queue leak per open stream)
Frontend
* src/lib/tail-stream.ts: streamTail() async-generator over fetch
* src/store/tail-store.ts: zustand with FIFO cap 10k per slice
* src/hooks/useTailStream.ts: connecting/live/reconnecting/stalled/error/closed
state machine with 1→2→4→8→16→30s backoff
* src/hooks/useMergedTail.ts: base + tail merge with filter
* src/components/TailStatusPill.tsx: badge + Reconnect button
* Claims, Remittances, ActivityLog pages wired to the tail
Tests
* 437 backend tests pass (was 418 before SP5)
* 154 frontend tests pass (was 124)
* npm run typecheck clean
* end-to-end smoke: open /api/claims/stream, POST 837, see new claims
arrive in real time without refresh
# Conflicts:
# src/pages/ActivityLog.tsx
# src/pages/Remittances.test.tsx
# src/pages/Remittances.tsx
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useActivity } from "@/hooks/useActivity";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -69,12 +72,17 @@ export function ActivityLog() {
|
||||
});
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
|
||||
// SP5: live-tail wiring. `useMergedTail` appends tail-arriving events
|
||||
// to the base query result, then we apply the same client-side
|
||||
// multi-kind filter so live events respect the active filter set.
|
||||
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||
useTailStream("activity");
|
||||
const merged = useMergedTail("activity", allItems);
|
||||
const items = useMemo(() => {
|
||||
// Server already returned a single-kind slice when `apiKind` is set;
|
||||
// otherwise apply the (multi-)kind filter locally.
|
||||
if (selectedKinds.length <= 1) return allItems;
|
||||
return allItems.filter((a) => selectedKinds.includes(a.kind));
|
||||
}, [allItems, selectedKinds]);
|
||||
if (selectedKinds.length <= 1) return merged;
|
||||
return merged.filter((a) => selectedKinds.includes(a.kind));
|
||||
}, [merged, selectedKinds]);
|
||||
|
||||
const writeParams = useCallback(
|
||||
(mutate: (next: URLSearchParams) => void) => {
|
||||
@@ -152,6 +160,16 @@ export function ActivityLog() {
|
||||
) : null}
|
||||
|
||||
<div className="surface rounded-xl p-6">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
|
||||
<div className="ml-auto">
|
||||
<TailStatusPill
|
||||
status={tailStatus}
|
||||
lastEventAt={tailLastEventAt}
|
||||
onReconnect={forceReconnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Claims } from "./Claims";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
import type { Claim, ClaimDetail } from "@/types";
|
||||
|
||||
// Module-level mock — vitest hoists vi.mock above imports. We mock the
|
||||
@@ -38,6 +39,30 @@ vi.mock("@/lib/api", () => {
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests).
|
||||
//
|
||||
// We mock `useTailStream` directly (per the plan's "latter is simpler"
|
||||
// guidance) so the page-level tests don't have to drive the real
|
||||
// `streamTail` parser or the real AbortController-based reconnect loop.
|
||||
// `useTailStream` is exercised exhaustively in its own test file
|
||||
// (`useTailStream.test.ts`); the page only needs to observe that the
|
||||
// returned status flows through to `<TailStatusPill>` and that items
|
||||
// pushed into the tail store surface as rows via `useMergedTail`.
|
||||
//
|
||||
// The mock returns `status: "live"` by default — that matches what the
|
||||
// real hook settles to after a `snapshot_end` event arrives, which is
|
||||
// the state the page sees in production ~all of the time.
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock("@/hooks/useTailStream", () => ({
|
||||
useTailStream: vi.fn(() => ({
|
||||
status: "live",
|
||||
lastEventAt: null,
|
||||
error: null,
|
||||
forceReconnect: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const SAMPLE_CLAIMS: Claim[] = [
|
||||
{
|
||||
id: "CLM-1",
|
||||
@@ -190,6 +215,10 @@ describe("Claims page drawer wiring", () => {
|
||||
(api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
SAMPLE_DETAIL
|
||||
);
|
||||
// Live-tail slice is a singleton zustand store — clear it between
|
||||
// tests so the live-arrival test doesn't see rows from a previous
|
||||
// case.
|
||||
useTailStore.getState().reset("claims");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -420,4 +449,86 @@ describe("Claims page drawer wiring", () => {
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Live-tail integration (sub-project 5, Phase 5 Task 22 page tests).
|
||||
//
|
||||
// We mock `useTailStream` at the module level (see the top of this
|
||||
// file) so the page sees a settled `status: "live"` without driving
|
||||
// the real streamTail parser. To exercise the "tail arrival triggers a
|
||||
// row" path we push a new claim directly into `useTailStore` — this
|
||||
// is exactly what the real `useTailStream` hook does on `item` events,
|
||||
// so we cover the same render path (zustand notify → useMergedTail
|
||||
// re-derive → `<TableRow>` mount).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
it("test_live_tail_arrival_triggers_row_in_table", async () => {
|
||||
const { unmount } = renderClaims();
|
||||
|
||||
// Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first.
|
||||
await settle(
|
||||
() => document.body.textContent?.includes("CLM-1") ?? false,
|
||||
);
|
||||
|
||||
// The new claim id is brand new (not in base, not in tail store yet).
|
||||
const TAIL_ID = "CLM-TAIL-1";
|
||||
expect(
|
||||
document.body.textContent?.includes(TAIL_ID) ?? false,
|
||||
).toBe(false);
|
||||
|
||||
// Simulate a live-arriving claim — this is what `useTailStream`
|
||||
// does when it dispatches an `item` event. Wrap in `act` because
|
||||
// zustand subscribers re-render synchronously.
|
||||
await act(async () => {
|
||||
useTailStore.getState().addClaim({
|
||||
id: TAIL_ID,
|
||||
patientName: "Live Arrival",
|
||||
providerNpi: "1234567890",
|
||||
payerName: "Colorado Medicaid",
|
||||
cptCode: "99215",
|
||||
billedAmount: 999,
|
||||
receivedAmount: 0,
|
||||
status: "submitted",
|
||||
submissionDate: "2026-06-20",
|
||||
});
|
||||
});
|
||||
|
||||
// The new row must appear — proves the end-to-end path
|
||||
// zustand.addClaim → useTailStore notify → useMergedTail re-derive
|
||||
// → <TableBody> mounts the row with the new id.
|
||||
await settle(
|
||||
() => document.body.textContent?.includes(TAIL_ID) ?? false,
|
||||
);
|
||||
expect(document.body.textContent).toContain(TAIL_ID);
|
||||
// The row's secondary cells should also be present so we know the
|
||||
// row fully rendered, not just that the id appeared somewhere
|
||||
// (e.g. in the search-input placeholder or similar).
|
||||
expect(document.body.textContent).toContain("Live Arrival");
|
||||
expect(document.body.textContent).toContain("99215");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_status_pill_shows_live_after_stream_connects", async () => {
|
||||
const { unmount } = renderClaims();
|
||||
|
||||
// Wait for the base table to mount — once it's there, the toolbar
|
||||
// (which contains the pill) has also mounted.
|
||||
await settle(
|
||||
() => document.body.textContent?.includes("CLM-1") ?? false,
|
||||
);
|
||||
|
||||
// The mocked `useTailStream` returns `status: "live"`, so the
|
||||
// <TailStatusPill> should render with the human label "Live"
|
||||
// (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the
|
||||
// pill's textContent so this stays robust against future styling
|
||||
// changes (the test doesn't depend on Tailwind classes).
|
||||
const pill = document.body.querySelector(
|
||||
'[data-testid="tail-status-pill"]',
|
||||
);
|
||||
expect(pill).not.toBeNull();
|
||||
expect(pill?.textContent).toContain("Live");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
+36
-2
@@ -27,9 +27,12 @@ import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { useClaims } from "@/hooks/useClaims";
|
||||
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { useAppStore } from "@/store";
|
||||
import { fmt } from "@/lib/format";
|
||||
import type { ClaimStatus } from "@/types";
|
||||
import type { Claim, ClaimStatus } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL = "all" as const;
|
||||
@@ -74,7 +77,28 @@ export function Claims() {
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useClaims(params);
|
||||
const items = data?.items ?? [];
|
||||
|
||||
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 22).
|
||||
//
|
||||
// The tail stream emits every claim that lands in the DB — including
|
||||
// ones that don't match the user's current `status` / `provider_npi`
|
||||
// filter. Mirror the server-side filter the page already applies via
|
||||
// `useClaims` so a newly-arrived item that wouldn't pass the same
|
||||
// predicate is dropped before it can show up in the table. `useMergedTail`
|
||||
// applies this to the tail slice only; base items are already filtered
|
||||
// server-side.
|
||||
const tailFilterFn = useMemo(
|
||||
() => (c: Claim) => {
|
||||
if (status !== ALL && c.status !== status) return false;
|
||||
if (npi !== ALL && c.providerNpi !== npi) return false;
|
||||
return true;
|
||||
},
|
||||
[status, npi],
|
||||
);
|
||||
|
||||
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||
useTailStream("claims");
|
||||
const items = useMergedTail("claims", data?.items ?? [], tailFilterFn);
|
||||
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
@@ -190,6 +214,16 @@ export function Claims() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* SP5: live-tail status pill. `ml-auto` pins it to the right
|
||||
edge of the toolbar so it doesn't crowd the search input. */}
|
||||
<div className="ml-auto">
|
||||
<TailStatusPill
|
||||
status={tailStatus}
|
||||
lastEventAt={tailLastEventAt}
|
||||
onReconnect={forceReconnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Remittances } from "./Remittances";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
|
||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
|
||||
// stub the method this page touches via the `useRemittances` hook.
|
||||
@@ -23,6 +24,18 @@ vi.mock("@/lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the live-tail hook so the page renders the pill in the settled
|
||||
// `live` state without driving the real streamTail parser. The hook's
|
||||
// own test file (`useTailStream.test.ts`) covers the lifecycle in depth.
|
||||
vi.mock("@/hooks/useTailStream", () => ({
|
||||
useTailStream: vi.fn(() => ({
|
||||
status: "live",
|
||||
lastEventAt: null,
|
||||
error: null,
|
||||
forceReconnect: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
|
||||
@@ -178,6 +191,10 @@ const SAMPLE_REMITS = [
|
||||
describe("Remittances", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Singleton tail-store: clear the remittances slice between tests
|
||||
// so a tail-arrival case (if added later) doesn't see rows from a
|
||||
// previous test.
|
||||
useTailStore.getState().reset("remittances");
|
||||
(
|
||||
api.listRemittances as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
@@ -389,4 +406,36 @@ describe("Remittances", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Live-tail status pill (sub-project 5, Phase 5 Task 23, optional
|
||||
// page-level test). Cheap smoke check: the toolbar renders the pill
|
||||
// with the `live` label whenever the mocked stream is settled. The
|
||||
// hook's lifecycle is covered exhaustively in its own test file.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
it("test_status_pill_shows_live_in_toolbar", async () => {
|
||||
(
|
||||
api.listRemittances as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 0,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
|
||||
// Wait for the empty-state to mount — once the page body is rendered,
|
||||
// the toolbar (and thus the pill) is in the DOM.
|
||||
await waitForText("Remittances · awaiting first 835");
|
||||
|
||||
const pill = document.body.querySelector(
|
||||
'[data-testid="tail-status-pill"]',
|
||||
);
|
||||
expect(pill).not.toBeNull();
|
||||
expect(pill?.textContent).toContain("Live");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
+43
-12
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -17,9 +17,12 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { useRemittances } from "@/hooks/useRemittances";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { useTailStream } from "@/hooks/useTailStream";
|
||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
||||
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -71,7 +74,25 @@ export function Remittances() {
|
||||
limit: PAGE_SIZE,
|
||||
offset: (page - 1) * PAGE_SIZE,
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
|
||||
// SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The tail
|
||||
// stream emits every remittance that lands — including ones that
|
||||
// don't match the user's current `status` filter chip. Drop those
|
||||
// tail items so a newly-arrived remit that wouldn't match the page's
|
||||
// intent doesn't flash into the table. Base items are NOT filtered
|
||||
// here (they reflect whatever the list query returned, matching the
|
||||
// existing page behavior).
|
||||
const tailFilterFn = useMemo(
|
||||
() => (r: Remittance) => {
|
||||
if (status && r.status !== status) return false;
|
||||
return true;
|
||||
},
|
||||
[status],
|
||||
);
|
||||
|
||||
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
|
||||
useTailStream("remittances");
|
||||
const items = useMergedTail("remittances", data?.items ?? [], tailFilterFn);
|
||||
|
||||
const total = items.reduce(
|
||||
(acc, r) => ({
|
||||
@@ -167,15 +188,25 @@ export function Remittances() {
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl p-4">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
{/* SP5: live-tail status pill, right-aligned in the toolbar. */}
|
||||
<div className="ml-auto">
|
||||
<TailStatusPill
|
||||
status={tailStatus}
|
||||
lastEventAt={tailLastEventAt}
|
||||
onReconnect={forceReconnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user