4c05c6527b
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.
232 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|