feat(acks): surface 999 rejections via sidebar badge + table sort

The 1,156 accepted acks were burying the 5 rejections — the operator
had to scroll the whole table to find them. Two changes:

1. Sidebar '999 ACKs' nav item gets a warning-toned badge showing
   the total rejected segment count from aggregates.rejected_count
   (mirrors the Reconciliation unmatched badge pattern). One number,
   visible from any page.

2. Acks page table now sorts rejected rows to the top, then
   newest-id-first. All 5 rejections fit on page 1 since they're a
   tiny fraction of the total — no pagination needed.

New files:
- src/hooks/useAckStats.ts (lightweight aggregates-only fetch)
- src/hooks/useAckStats.test.tsx (2 tests)
- src/components/Sidebar.test.tsx (3 tests)

Pre-existing baseline: 10 frontend failures (api.test.ts exportBatch837,
tail-stream.test.ts acks/ta1_acks targeting, Inbox/InboxHeader copy).
Unchanged.
This commit is contained in:
Nora
2026-07-02 10:59:46 -06:00
parent aca9667ff8
commit f88a7c7c4f
6 changed files with 277 additions and 2 deletions
+96
View File
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, waitFor, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Stub the auth hook so Sidebar doesn't try to read a token.
vi.mock("@/auth/useAuth", () => ({
useAuth: () => ({ user: { id: 1, username: "admin", role: "admin" }, logout: vi.fn() }),
}));
// Mutable state so each test can flip the rejected-acks count without
// re-importing the module. `vi.hoisted` ensures the mock factory closes
// over the same `state` object across calls.
const state = vi.hoisted(() => ({
unmatchedClaims: 0,
unmatchedRemits: 0,
rejectedAcks: 0,
}));
vi.mock("@/hooks/useReconciliation", () => ({
useReconciliation: () => ({
unmatched: {
data: {
claims: Array.from({ length: state.unmatchedClaims }),
remittances: Array.from({ length: state.unmatchedRemits }),
},
},
}),
}));
vi.mock("@/hooks/useAckStats", () => ({
useAckStats: () => ({
data: state.rejectedAcks > 0
? { aggregates: { accepted: 1000, rejected: state.rejectedAcks, received: 1005 } }
: undefined,
}),
}));
import { Sidebar } from "./Sidebar";
function renderSidebar() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<Sidebar />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("Sidebar — 999 ACKs rejected badge", () => {
beforeEach(() => {
cleanup();
state.unmatchedClaims = 0;
state.unmatchedRemits = 0;
state.rejectedAcks = 0;
});
it("shows a '5' badge when 5 segments are rejected", async () => {
state.rejectedAcks = 5;
const { container } = renderSidebar();
await waitFor(() => {
const badge = container.querySelector(
'[data-testid="sidebar-acks-rejected-badge"]',
);
expect(badge).not.toBeNull();
expect(badge?.textContent).toBe("5");
});
});
it("shows '99+' when rejected segments exceed 99", async () => {
state.rejectedAcks = 250;
const { container } = renderSidebar();
await waitFor(() => {
const badge = container.querySelector(
'[data-testid="sidebar-acks-rejected-badge"]',
);
expect(badge?.textContent).toBe("99+");
});
});
it("hides the badge when there are no rejected acks", async () => {
state.rejectedAcks = 0;
const { container } = renderSidebar();
// Allow the synchronous mock to settle.
await waitFor(() => {
expect(
container.querySelector('[data-testid="sidebar-acks-rejected-badge"]'),
).toBeNull();
});
});
});
+22
View File
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useReconciliation } from "@/hooks/useReconciliation";
import { useAckStats } from "@/hooks/useAckStats";
import { useAuth } from "@/auth/useAuth";
const nav = [
@@ -43,6 +44,13 @@ export function Sidebar() {
(unmatched.data?.claims.length ?? 0) +
(unmatched.data?.remittances.length ?? 0);
// 999 ACKs — total rejected segments across all ack files. Drives
// the badge on the "999 ACKs" nav item so the operator can see
// there are rejections to review from any page (mirrors the
// Reconciliation unmatched badge above).
const { data: ackStats } = useAckStats();
const rejectedAckCount = ackStats?.aggregates.rejected ?? 0;
return (
<aside
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
@@ -152,6 +160,20 @@ export function Sidebar() {
>
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
<span>999 ACKs</span>
{rejectedAckCount > 0 ? (
// Rejection-count badge — same right-aligned mono shape
// as the Reconciliation unmatched badge above so the
// operator's eye finds both at the same scan position.
// Warning tone (not signal/destructive) so it doesn't
// compete with the more urgent Payer-Rejected lane.
<span
className="ml-auto mono text-[10px] text-[hsl(var(--warning))]"
data-testid="sidebar-acks-rejected-badge"
title={`${rejectedAckCount} rejected 999 segment${rejectedAckCount === 1 ? "" : "s"} across all ack files`}
>
{rejectedAckCount > 99 ? "99+" : rejectedAckCount}
</span>
) : null}
</NavLink>
</li>
<li>