feat(frontend): Remittances + ActivityLog wire live tail

This commit is contained in:
Tyler
2026-06-20 17:14:37 -06:00
parent 365e64e25a
commit fa23eb182e
3 changed files with 115 additions and 13 deletions
+23 -1
View File
@@ -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 (
<div className="space-y-8 animate-fade-in">
@@ -31,6 +43,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) => (
+49
View File
@@ -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<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
View File
@@ -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() {
</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">