From fa23eb182eb39c2c7f96aba600d46f31e4d33081 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 17:14:37 -0600 Subject: [PATCH] feat(frontend): Remittances + ActivityLog wire live tail --- src/pages/ActivityLog.tsx | 24 ++++++++++++++- src/pages/Remittances.test.tsx | 49 ++++++++++++++++++++++++++++++ src/pages/Remittances.tsx | 55 ++++++++++++++++++++++++++-------- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/pages/ActivityLog.tsx b/src/pages/ActivityLog.tsx index fedfd99..aa64a2c 100644 --- a/src/pages/ActivityLog.tsx +++ b/src/pages/ActivityLog.tsx @@ -1,4 +1,7 @@ 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 { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; @@ -6,7 +9,16 @@ import { ErrorState } from "@/components/ui/error-state"; export function ActivityLog() { const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 }); - const items = data?.items ?? []; + + // SP5 live-tail wiring (sub-project 5, Phase 5 Task 23). The page has + // no existing kind filter UI (the plan calls out "filters by activity + // kind" but the current ActivityLog renders all events unfiltered), + // so we pass no `filterFn` to `useMergedTail` — every live-arriving + // activity event is shown. If a kind filter is added later, this is + // the place to thread it through. + const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } = + useTailStream("activity"); + const items = useMergedTail("activity", data?.items ?? []); return (
@@ -31,6 +43,16 @@ export function ActivityLog() { ) : null}
+
+ {/* SP5: live-tail status pill, right-aligned in the toolbar. */} +
+ +
+
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( diff --git a/src/pages/Remittances.test.tsx b/src/pages/Remittances.test.tsx index 49e0059..1bce185 100644 --- a/src/pages/Remittances.test.tsx +++ b/src/pages/Remittances.test.tsx @@ -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 @@ -78,6 +91,10 @@ async function waitForText( 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"); }); it("renders a CAS adjustment label inside the expanded detail row", async () => { @@ -146,4 +163,36 @@ describe("Remittances", () => { expect(document.body.textContent).toContain("PR-1"); 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 + ).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(); + }); }); \ No newline at end of file diff --git a/src/pages/Remittances.tsx b/src/pages/Remittances.tsx index 8ee2837..8159c3c 100644 --- a/src/pages/Remittances.tsx +++ b/src/pages/Remittances.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, Receipt } from "lucide-react"; import { Table, @@ -15,9 +15,12 @@ import { ErrorState } from "@/components/ui/error-state"; import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips"; import { Pagination } from "@/components/ui/pagination"; import { useRemittances } from "@/hooks/useRemittances"; +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; @@ -64,7 +67,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) => ({ @@ -126,15 +147,25 @@ export function Remittances() {
- { - setStatus((v as RemittanceStatus | null) ?? null); - setPage(1); - }} - eyebrow="Status" - /> +
+ { + setStatus((v as RemittanceStatus | null) ?? null); + setPage(1); + }} + eyebrow="Status" + /> + {/* SP5: live-tail status pill, right-aligned in the toolbar. */} +
+ +
+