Files
cyclone/src/pages/ActivityLog.tsx
T
Nora 4c05c6527b hotfix(acks): paginate /api/acks and surface server-side aggregates
The Acks page silently capped at 100 of 1056 rows in the operator's
DB: the eyebrow read `${items.length} on file` (not the server's
`total`), the KPI strip summed from the 100 visible items, and the
endpoint accepted no `offset` — so the user had no signal that 956
more acks existed. Same class of bug on the Activity page (cap=200,
no 'X of Y' hint).

Backend
- /api/acks (acks.py): add `offset`, bump `le=1000`→`le=5000`,
  slice `rows[offset:offset+limit]`, return server-side
  `aggregates` (accepted/rejected/received summed over the full
  row set, not the page) so the KPI strip reflects every persisted
  999 instead of just the visible 50.
- /api/activity list + stream (api.py): bump `le=500`→`le=5000` so
  the page can ask for a denser snapshot.
- /api/277ca-acks (api.py): bump `le=1000`→`le=5000` for
  consistency.
- /api/ta1-acks: left at `le=1000` — TA1s aren't shipped today and
  the structural fix (offset + aggregates) wasn't applied, so a
  larger cap would just make the same latent silent-failure easier
  to hit. (TODO: fold in the same shape when Gainwell starts
  shipping TA1s.)

Frontend
- listAcks (api.ts): accept `offset`, surface `aggregates`,
  adapt wire `*_count` keys to the in-page
  `accepted`/`rejected`/`received` shape so the page can use
  `data.aggregates` as a drop-in for the page-local fallback
  accumulator.
- useAcks (hooks): pass `offset` through; return type carries
  `aggregates`.
- Acks.tsx: add `page` state (PAGE_SIZE=50), use `data.total`
  for eyebrow + watermark (not `items.length`), use
  `data.aggregates` for the KPI strip (with in-page fallback
  accumulator on first paint), render `<Pagination>` when
  `totalCount > PAGE_SIZE`. Footer row reads "N rows on file"
  instead of "N rows".
- ActivityLog.tsx: bump `limit: 200`→`limit: 500`, eyebrow reads
  "Activity · showing N most recent" to make the bounded-window
  semantics honest (the endpoint doesn't expose a true total — it
  reports events matching the current kind/since filter, capped at
  the request limit).

Tests
- test_acks.py: 4 new tests pin the fix:
  1. `offset` walks the full set; `has_more` flips at the
     boundary.
  2. `aggregates` reflects the full row set, not the page (the
     silent-failure pin) — and stays stable across page slices.
  3. `limit` cap of 5000 is enforced (422 above it).
  4. `offset` past the end returns an empty page with stable
     aggregates (a stale UI page state across a row count change
     must not 500 or zero the KPIs).

Live smoke-verified: /api/acks?limit=2&offset=0 vs ?offset=2 return
the expected row slices, aggregates stable at 15/10/15 for 5 seeded
rows, /api/acks?limit=10000 rejected with 422.

Triage note: the TA1 section (`Ta1AcksSection`, lines 609-616 of
Acks.tsx) has the same latent silent-failure pattern (page-sums
KPIs, no offset on /api/ta1-acks). Left untouched because the
empty-state copy says Gainwell doesn't ship TA1s today and the
larger structural fix belongs in a follow-up.
2026-06-29 11:19:10 -06:00

232 lines
8.0 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useActivity } from "@/hooks/useActivity";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { eventKindToUrl } from "@/lib/event-routing";
import { TailStatusPill } from "@/components/TailStatusPill";
import { PageHeader } from "@/components/PageHeader";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
import { RemitDrawer } from "@/components/RemitDrawer";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import type { ActivityKind } from "@/types";
const VALID_KINDS: readonly ActivityKind[] = [
"claim_submitted",
"claim_paid",
"claim_denied",
"claim_accepted",
"remit_received",
"provider_added",
];
const VALID_SINCE: readonly SinceValue[] = ["1h", "24h", "7d", "all"];
function useSinceIso(since: SinceValue): string | undefined {
return useMemo(() => {
const now = Date.now();
if (since === "1h") return new Date(now - 60 * 60 * 1000).toISOString();
if (since === "24h") return new Date(now - 24 * 60 * 60 * 1000).toISOString();
if (since === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
return undefined;
}, [since]);
}
export function ActivityLog() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
// SP21 Phase 4 Task 4.7: `remit_received` events with a
// remittanceId drill into the RemitDrawer. Calling `open(id)`
// pushes `?remit=ID` onto the current URL (no navigation away
// from `/activity`), so the activity feed stays visible behind
// the drawer and the drawer portals in over it.
const { remitId, open, close } = useRemitDrawerUrlState();
const selectedKinds = useMemo<ActivityKind[]>(
() =>
searchParams
.getAll("kind")
.filter((k): k is ActivityKind =>
(VALID_KINDS as readonly string[]).includes(k),
),
[searchParams],
);
const sinceRaw = searchParams.get("since") ?? "all";
const since: SinceValue = (VALID_SINCE as readonly string[]).includes(sinceRaw)
? (sinceRaw as SinceValue)
: "all";
const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined;
const sinceIso = useSinceIso(since);
const { data, isLoading, isError, error, refetch } = useActivity({
kind: apiKind,
since: sinceIso,
// 500 keeps the wire size manageable (and the operator can rely
// on the live tail to surface new events as they land). The
// activity list endpoint doesn't expose a true total — `data.total`
// reflects events matching the current kind/since filter, capped
// at the request limit — so the eyebrow below uses "Showing N
// most recent" instead of "X of Y" to avoid a misleading ratio.
limit: 500,
});
const allItems = data?.items ?? [];
const { status: tailStatus, lastEventAt: tailLastEventAt, forceReconnect } =
useTailStream("activity");
const merged = useMergedTail("activity", allItems);
const items = useMemo(() => {
if (selectedKinds.length <= 1) return merged;
return merged.filter((a) => selectedKinds.includes(a.kind));
}, [merged, selectedKinds]);
const writeParams = useCallback(
(mutate: (next: URLSearchParams) => void) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
mutate(next);
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const setSelectedKinds = useCallback(
(kinds: ActivityKind[]) => {
writeParams((next) => {
next.delete("kind");
for (const k of kinds) next.append("kind", k);
});
},
[writeParams],
);
const setSince = useCallback(
(value: SinceValue) => {
writeParams((next) => {
if (value === "all") next.delete("since");
else next.set("since", value);
});
},
[writeParams],
);
const clearFilters = useCallback(() => {
writeParams((next) => {
next.delete("kind");
next.delete("since");
});
}, [writeParams]);
const hasFilters = selectedKinds.length > 0 || since !== "all";
return (
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow={
allItems.length > 0
? `Activity · showing ${allItems.length.toLocaleString()} most recent`
: "Activity"
}
title={<>Activity <span className="display italic text-muted-foreground">log</span></>}
subtitle="Every claim submission, denial, payment, and provider event, in reverse chronological order. Auto-refreshes every 30s."
actions={
<TailStatusPill
status={tailStatus}
lastEventAt={tailLastEventAt}
onReconnect={forceReconnect}
/>
}
/>
<div className="surface rounded-xl p-4">
<ActivityFilters
selectedKinds={selectedKinds}
onKindsChange={setSelectedKinds}
since={since}
onSinceChange={setSince}
onClear={clearFilters}
/>
</div>
{isError ? (
<ErrorState
message="Couldn't load activity from the backend."
detail={error instanceof Error ? error.message : String(error)}
onRetry={() => refetch()}
/>
) : null}
<div className="surface rounded-xl p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow={hasFilters ? "Activity · no matches" : "Activity · log idle"}
message={
hasFilters
? "No activity matches the current filters."
: "Activity will appear here after the first parse."
}
/>
) : (
<ActivityFeed
items={items}
emptyMessage="No activity recorded yet."
onItemClick={(evt) => {
// SP21 Phase 4 Task 4.7: drill into the right surface
// based on event kind. `claim_*` and `provider_added`
// navigate away via `eventKindToUrl` (the Dashboard uses
// the same helper). `remit_received` events stay on
// `/activity` and open the RemitDrawer via `open(id)`
// — `eventKindToUrl` still returns `null` for that kind
// because cross-page navigation isn't the right UX here
// (we want to keep the activity feed as context behind
// the drawer). Anything else falls back to the
// "coming soon" toast so the click still gives feedback.
const url = eventKindToUrl(evt);
if (url) navigate(url);
else if (evt.kind === "remit_received" && evt.remittanceId) {
open(evt.remittanceId);
} else {
toast.info(
`Drill for ${evt.kind.replace(/_/g, " ")} coming in a later phase.`,
);
}
}}
/>
)}
</div>
{/* SP21 Phase 4 Task 4.7: RemitDrawer mount. The activity
feed's `remit_received` rows drill into the drawer via
`open()`. `remits` is empty (the activity feed doesn't
keep a flat list of remits around), so j/k is a no-op
here — closing reverts the URL via `close()`. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// ActivityLog has no cheatsheet; `?` is a no-op here.
}}
/>
</div>
);
}