From 365e64e25a80da7e82ccc2c6dae6e0a6bbc85f0e Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 17:11:56 -0600 Subject: [PATCH] feat(frontend): Claims page wires live tail + TailStatusPill --- src/pages/Claims.test.tsx | 111 ++++++++++++++++++++++++++++++++++++++ src/pages/Claims.tsx | 38 ++++++++++++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/pages/Claims.test.tsx b/src/pages/Claims.test.tsx index b870ffb..32ac777 100644 --- a/src/pages/Claims.test.tsx +++ b/src/pages/Claims.test.tsx @@ -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 `` 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).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 → `` 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 + // → 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 + // 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(); + }); }); diff --git a/src/pages/Claims.tsx b/src/pages/Claims.tsx index 5b4dae8..2286b70 100644 --- a/src/pages/Claims.tsx +++ b/src/pages/Claims.tsx @@ -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() { ))} + + {/* SP5: live-tail status pill. `ml-auto` pins it to the right + edge of the toolbar so it doesn't crowd the search input. */} +
+ +