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
+51
View File
@@ -0,0 +1,51 @@
// @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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
// Mock the API module so the hook doesn't touch the real /api/acks endpoint.
// The mock mirrors the wire shape that listAcks returns.
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listAcks: vi.fn().mockResolvedValue({
items: [],
total: 1161,
returned: 0,
has_more: false,
aggregates: { accepted: 1156, rejected: 5, received: 1161 },
}),
},
}));
import { useAckStats } from "./useAckStats";
function withClient(): (props: { children: ReactNode }) => ReactNode {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
describe("useAckStats", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns server-computed aggregates for sidebar badge use", async () => {
const { result } = renderHook(() => useAckStats(), { wrapper: withClient() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.aggregates.rejected).toBe(5);
expect(result.current.data?.aggregates.accepted).toBe(1156);
});
it("calls listAcks with limit=1 so the lightweight payload is smallest", async () => {
const { api } = await import("@/lib/api");
renderHook(() => useAckStats(), { wrapper: withClient() });
await waitFor(() => expect(api.listAcks).toHaveBeenCalled());
expect(api.listAcks).toHaveBeenCalledWith({ limit: 1 });
});
});
+29
View File
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { api, type AckAggregates } from "@/lib/api";
/**
* Lightweight 999 ACK aggregates for sidebar / nav badges.
*
* Hits `/api/acks?limit=1` so the server-side aggregates (which are
* computed by iterating every persisted row) come back without
* pulling 100 rows for a sidebar badge. The 1-row payload is the
* smallest the endpoint accepts (limit ge=1).
*
* Used by the Sidebar's "999 ACKs" nav badge to surface the
* total rejected segment count without forcing a full page fetch.
* 60s stale time matches the page-level fetch cadence — the badge
* refreshes when the user navigates to the page anyway.
*
* Distinct from useAcks (which fetches paginated rows + aggregates
* for the page body). The query key intentionally differs so React
* Query doesn't dedupe the two into a single entry.
*/
export function useAckStats() {
return useQuery<{ aggregates: AckAggregates }>({
queryKey: ["acks", "stats"],
queryFn: () => api.listAcks({ limit: 1 }) as Promise<{ aggregates: AckAggregates }>,
enabled: api.isConfigured,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}