diff --git a/src/pages/Acks.test.tsx b/src/pages/Acks.test.tsx index 8029b57..4ca7eef 100644 --- a/src/pages/Acks.test.tsx +++ b/src/pages/Acks.test.tsx @@ -21,6 +21,21 @@ vi.mock("@/lib/api", async (importOriginal) => { }; }); +// SP25: the Acks page now opens two live-tail streams (one per ack +// flavor) via `useTailStream`. The hook has its own dedicated test +// suite that covers the full lifecycle; here we just want the page to +// render against a stubbed hook so the test never opens a real fetch. +// The mock matches the production-default state (`live`) so the pill +// renders the "Live" label without surfacing a misleading error. +vi.mock("@/hooks/useTailStream", () => ({ + useTailStream: vi.fn(() => ({ + status: "live", + lastEventAt: null, + error: null, + forceReconnect: vi.fn(), + })), +})); + function renderIntoContainer(element: React.ReactElement): { container: HTMLDivElement; unmount: () => void; @@ -567,4 +582,40 @@ describe("Acks", () => { unmount(); }); + + // ------------------------------------------------------------------------- + // SP25: the page now mounts two live-tail streams (one per ack + // flavor) via useTailStream. The mocked hook returns status: "live", + // so the page should render TWO TailStatusPills — one in the 999 + // hero and one in the TA1 section — both showing the human label + // "Live" (per `STATUS_LABEL` in `TailStatusPill.tsx`). + // ------------------------------------------------------------------------- + it("test_renders_two_tail_status_pills_live", async () => { + (api.listAcks as unknown as ReturnType).mockResolvedValue({ + items: [], + total: 0, + returned: 0, + has_more: false, + aggregates: { accepted_count: 0, rejected_count: 0, received_count: 0 }, + }); + + const { unmount } = renderIntoContainer(React.createElement(Acks)); + + // Wait for the page to settle — both pills need the query to + // resolve (and even the TA1 section renders a tail pill even + // when items is empty). + await settle( + () => document.body.textContent?.includes("Live") ?? false, + ); + + // Two "Live" labels: one for the 999 stream, one for the TA1 + // stream. (Plus the description text on the page may say "live" + // in another context — check via the surrounding pill markup.) + const pills = Array.from( + document.querySelectorAll('[data-testid="tail-status-pill"]'), + ); + expect(pills.length).toBe(2); + + unmount(); + }); }); \ No newline at end of file diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index d379c46..2a4c5e5 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -20,7 +20,10 @@ import { Pagination } from "@/components/ui/pagination"; import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState"; import { useAcks } from "@/hooks/useAcks"; import { useTa1Acks } from "@/hooks/useTa1Acks"; +import { useMergedTail } from "@/hooks/useMergedTail"; +import { useTailStream } from "@/hooks/useTailStream"; import { useRowKeyboard } from "@/hooks/useRowKeyboard"; +import { TailStatusPill } from "@/components/TailStatusPill"; import { api } from "@/lib/api"; import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -47,7 +50,20 @@ export function Acks() { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }); - const items = data?.items ?? []; + // SP25: subscribe to /api/acks/stream so new 999 acks (whether from + // the SFTP poller or a manual upload) land in the table the moment + // they hit the database. The status surfaces in the hero pill so the + // operator can see whether the connection is healthy. + const { + status: tailStatus, + lastEventAt: tailLastEventAt, + forceReconnect, + } = useTailStream("acks"); + // Merge the page's base list with the live-tail delta. The hook + // dedupes by id (first write wins), so a snapshot replay on + // reconnect can't double-count an existing row. + const mergedItems = useMergedTail("acks", data?.items ?? []); + const items = mergedItems; const totalCount = data?.total ?? 0; // Server-side aggregates — sum over the *full* ACK set, not just the // visible page. Without this, the KPI strip silently under-reports @@ -213,6 +229,16 @@ export function Acks() { {anyRejected ? "Mixed envelope" : "Envelope accepted"} + {/* SP25: live-tail connection status. Sits next to the + envelope-accepted pill so the operator can see at a + glance whether the stream is healthy without having to + open the drawer. Reconnect button only renders when + the stream is in a visibly-failed state. */} + {/* Keyboard hint — same rhythm as Reconciliation, sits under @@ -604,7 +630,14 @@ function downloadBlob(filename: string, content: string) { // --------------------------------------------------------------------------- function Ta1AcksSection() { const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 }); - const items = data?.items ?? []; + // SP25: same live-tail triplet as the 999 register above, just + // bound to /api/ta1-acks/stream and the ta1Acks store slice. + const { + status: tailStatus, + lastEventAt: tailLastEventAt, + forceReconnect, + } = useTailStream("ta1_acks"); + const items = useMergedTail("ta1_acks", data?.items ?? []); const totals = items.reduce( (acc, t) => { @@ -631,12 +664,22 @@ function Ta1AcksSection() { TA1 envelopes, newest first. -

- One row per inbound ISA/IEA interchange — the - envelope-level sibling of the 999. Gainwell's Colorado - MFT does not ship TA1s today; this section surfaces them - when they appear. -

+
+

+ One row per inbound ISA/IEA interchange — the + envelope-level sibling of the 999. Gainwell's Colorado + MFT does not ship TA1s today; this section surfaces them + when they appear. +

+ {/* SP25: live-tail status for the TA1 stream. Quietly + placed after the description so the eyebrow / display + title remains the dominant scan target. */} + +