feat(frontend): kind + since filters on ActivityLog page

This commit is contained in:
Tyler
2026-06-20 16:53:19 -06:00
parent b980e77cf2
commit ff4906d0c1
3 changed files with 744 additions and 5 deletions
+130 -5
View File
@@ -1,12 +1,123 @@
import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useActivity } from "@/hooks/useActivity";
import { ActivityFeed } from "@/components/ActivityFeed";
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
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"];
/**
* Convert a relative "since" window into an ISO timestamp string for the
* backend's lexicographic `timestamp >= since` comparison. Memoized on
* `since` so the queryKey stays stable across re-renders (otherwise the
* refetch would fire on every Date.now() tick).
*/
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 { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
const items = data?.items ?? [];
const [searchParams, setSearchParams] = useSearchParams();
// URL → filter state. `searchParams` is stable as long as the URL is,
// so these are computed cheaply with useMemo.
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";
// Backend's `/api/activity` only accepts a single `kind` query param, so
// for multi-select we fetch the unfiltered set and filter client-side.
// Single-kind selections pass through to the backend (cheaper + benefits
// from any future server-side filtering).
const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined;
const sinceIso = useSinceIso(since);
const { data, isLoading, isError, error, refetch } = useActivity({
kind: apiKind,
since: sinceIso,
limit: 200,
});
const allItems = data?.items ?? [];
const items = useMemo(() => {
// Server already returned a single-kind slice when `apiKind` is set;
// otherwise apply the (multi-)kind filter locally.
if (selectedKinds.length <= 1) return allItems;
return allItems.filter((a) => selectedKinds.includes(a.kind));
}, [allItems, 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-8 animate-fade-in">
@@ -22,6 +133,16 @@ export function ActivityLog() {
</p>
</header>
<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."
@@ -39,8 +160,12 @@ export function ActivityLog() {
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
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." />
@@ -48,4 +173,4 @@ export function ActivityLog() {
</div>
</div>
);
}
}