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
+63
View File
@@ -166,6 +166,69 @@ describe("Acks", () => {
unmount();
});
// -------------------------------------------------------------------------
// 2026-07-02: rejections were getting buried inside the accepted acks
// (1,156 accepted + 5 rejected) — the operator had to scroll the
// entire table to find the 5 they cared about. Fix: client-side sort
// bubbles rejected rows to the top of page 1. Within each group
// (rejected vs accepted) newest-id-first is preserved.
// -------------------------------------------------------------------------
it("test_rejected_acks_float_to_top_of_table", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [
{
id: 100,
sourceBatchId: "b-newest-accepted",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-07-01T12:00:00Z",
},
{
id: 99,
sourceBatchId: "b-rejected",
acceptedCount: 0,
rejectedCount: 1,
receivedCount: 1,
ackCode: "R",
parsedAt: "2026-07-01T11:00:00Z",
},
{
id: 98,
sourceBatchId: "b-old-accepted",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-06-25T12:00:00Z",
},
],
total: 3,
returned: 3,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("100");
// The first table row must be the rejected one (id=99), even though
// id=100 has a higher id. After that, accepted rows sort newest-first
// (id=100 before id=98).
const tableRows = Array.from(
document.querySelectorAll("table tbody tr"),
).filter((row) => {
// Exclude any TA1-section rows by requiring the sourceBatchId
// pattern (TA1 rows have a different layout).
return row.textContent?.includes("b-");
});
expect(tableRows.length).toBe(3);
expect(tableRows[0]?.textContent).toContain("b-rejected");
expect(tableRows[1]?.textContent).toContain("b-newest-accepted");
expect(tableRows[2]?.textContent).toContain("b-old-accepted");
unmount();
});
it("test_initial_render_shows_no_selection_highlight", async () => {
// Three rows in the fixture so there's plenty to select; the test
// asserts the *initial* state has no row marked selected.