feat(frontend): kind + since filters on ActivityLog page
This commit is contained in:
+130
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user