diff --git a/src/components/ActivityFilters.tsx b/src/components/ActivityFilters.tsx
new file mode 100644
index 0000000..2155778
--- /dev/null
+++ b/src/components/ActivityFilters.tsx
@@ -0,0 +1,165 @@
+import { X } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import type { ActivityKind } from "@/types";
+
+/**
+ * URL-friendly relative time filters. The page converts each value into an
+ * ISO timestamp before passing it to `useActivity` so the backend's
+ * lexicographic `timestamp >= since` comparison works against the stored
+ * ISO strings.
+ *
+ * `all` is the implicit default and is rendered as "All time".
+ */
+export type SinceValue = "1h" | "24h" | "7d" | "all";
+
+const KIND_OPTIONS: { value: ActivityKind; label: string }[] = [
+ { value: "claim_submitted", label: "Submitted" },
+ { value: "claim_accepted", label: "Accepted" },
+ { value: "claim_paid", label: "Paid" },
+ { value: "claim_denied", label: "Denied" },
+ { value: "remit_received", label: "Remit received" },
+ { value: "provider_added", label: "Provider added" },
+];
+
+const SINCE_OPTIONS: { value: SinceValue; label: string }[] = [
+ { value: "all", label: "All time" },
+ { value: "1h", label: "Last 1 hour" },
+ { value: "24h", label: "Last 24 hours" },
+ { value: "7d", label: "Last 7 days" },
+];
+
+type ActivityFiltersProps = {
+ /** Currently selected activity kinds (multi-select). Empty array = no filter. */
+ selectedKinds: ActivityKind[];
+ /** Toggle a kind in or out of `selectedKinds`. */
+ onKindsChange: (kinds: ActivityKind[]) => void;
+ /** Active "since" window; `"all"` means no time filter. */
+ since: SinceValue;
+ /** Update the "since" window. */
+ onSinceChange: (since: SinceValue) => void;
+ /** Reset both filters (and any other future filters). */
+ onClear: () => void;
+};
+
+/**
+ * Filter strip for the Activity Log page.
+ *
+ * - "Kind": a row of toggleable chips (multi-select). Each chip uses
+ * `role="checkbox"` + `aria-checked` so screen readers announce the
+ * toggle state correctly. Selected chips lift to the electric blue
+ * accent, matching the single-select `FilterChips` visual language.
+ *
+ * - "Since": a Radix Select dropdown with the four relative windows.
+ * `SelectValue` renders the current label so the user always sees
+ * what's applied (rather than a bare `1h` slug).
+ *
+ * - "Clear filters": a small X-pill appears once any filter is active.
+ * Mirrors the single-select `FilterChips` "Clear" affordance so the
+ * operator's mental model is consistent across pages.
+ *
+ * The component is purely presentational — it doesn't read or write URL
+ * state itself. `ActivityLog` owns URL sync via `useSearchParams` so the
+ * component is trivially testable in isolation if needed.
+ */
+export function ActivityFilters({
+ selectedKinds,
+ onKindsChange,
+ since,
+ onSinceChange,
+ onClear,
+}: ActivityFiltersProps) {
+ const hasFilters = selectedKinds.length > 0 || since !== "all";
+
+ const toggleKind = (value: ActivityKind) => {
+ if (selectedKinds.includes(value)) {
+ onKindsChange(selectedKinds.filter((k) => k !== value));
+ } else {
+ onKindsChange([...selectedKinds, value]);
+ }
+ };
+
+ return (
+
+
+
+ Kind
+
+ {KIND_OPTIONS.map((opt) => {
+ const isActive = selectedKinds.includes(opt.value);
+ return (
+
+ );
+ })}
+
+
+
+
+ Since
+
+
+
+ {hasFilters ? (
+
+ ) : null}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/ActivityLog.test.tsx b/src/pages/ActivityLog.test.tsx
new file mode 100644
index 0000000..1575fe1
--- /dev/null
+++ b/src/pages/ActivityLog.test.tsx
@@ -0,0 +1,449 @@
+// @vitest-environment happy-dom
+// Tell React this is an `act`-aware test environment so react-query's
+// internal state updates flush through without noisy console warnings.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
+ true;
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ActivityLog } from "./ActivityLog";
+import { api } from "@/lib/api";
+import type { Activity } from "@/types";
+
+// Module-level mock — vitest hoists vi.mock above imports. We only stub
+// the API method this page touches (`listActivity`). `isConfigured: true`
+// is critical: without it, `useActivity` falls back to the empty zustand
+// store instead of calling our mocked API (see useActivity.ts).
+vi.mock("@/lib/api", () => ({
+ api: {
+ isConfigured: true,
+ listActivity: vi.fn(),
+ },
+}));
+
+/**
+ * Empty activity set by default — individual tests override the resolved
+ * value with whatever shape they need to assert on.
+ */
+const EMPTY: { items: Activity[]; total: number; returned: number; has_more: boolean } = {
+ items: [],
+ total: 0,
+ returned: 0,
+ has_more: false,
+};
+
+const SAMPLE: Activity[] = [
+ {
+ id: "A-1",
+ kind: "claim_submitted",
+ message: "Claim CLM-1 submitted",
+ timestamp: "2026-06-20T12:00:00Z",
+ },
+ {
+ id: "A-2",
+ kind: "claim_paid",
+ message: "Claim CLM-2 paid",
+ timestamp: "2026-06-20T11:00:00Z",
+ npi: "1234567890",
+ amount: 100,
+ },
+ {
+ id: "A-3",
+ kind: "remit_received",
+ message: "Remit PCN-1 received",
+ timestamp: "2026-06-20T10:00:00Z",
+ },
+];
+
+type RenderOpts = { initialEntries?: string[] };
+
+/**
+ * Render the page wrapped in QueryClient + MemoryRouter. We use
+ * `MemoryRouter` rather than the default `BrowserRouter` because
+ * happy-dom's URL state has to be driven explicitly per test — a memory
+ * router accepts `initialEntries` so each test gets a clean URL without
+ * the `setLocation` dance that `useDrawerUrlState.test.ts` uses.
+ */
+function renderActivity({ initialEntries = ["/activity"] }: RenderOpts = {}): {
+ unmount: () => void;
+} {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
+ });
+ const root: Root = createRoot(container);
+ act(() => {
+ root.render(
+ React.createElement(
+ QueryClientProvider,
+ { client: qc },
+ React.createElement(
+ MemoryRouter,
+ { initialEntries },
+ React.createElement(ActivityLog),
+ ),
+ ),
+ );
+ });
+ return {
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+/**
+ * Flush microtasks + react state updates until `predicate` holds. Radix
+ * portals render asynchronously after a Select opens, so polling the
+ * DOM is the only way to avoid flakiness without `@testing-library/dom`.
+ */
+async function settle(
+ predicate: () => boolean,
+ timeoutMs = 2000,
+): Promise {
+ const start = Date.now();
+ while (!predicate()) {
+ if (Date.now() - start > timeoutMs) {
+ throw new Error("settle: predicate did not hold within timeout");
+ }
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 0));
+ });
+ }
+}
+
+/**
+ * Open the Radix "Since" Select and click the option whose label
+ * contains `label`. The trigger has `aria-label="Since"` and the
+ * options portal into `document.body`.
+ */
+async function pickSinceOption(label: string): Promise {
+ // Find the trigger. Radix Select renders a button with role="combobox".
+ const triggers = Array.from(
+ document.body.querySelectorAll('button[role="combobox"]'),
+ );
+ const trigger = triggers.find(
+ (t) => t.getAttribute("aria-label") === "Since",
+ ) as HTMLButtonElement | undefined;
+ expect(trigger).toBeDefined();
+ await act(async () => {
+ trigger!.click();
+ });
+ await settle(
+ () =>
+ Array.from(document.body.querySelectorAll('[role="option"]')).some(
+ (o) => o.textContent?.includes(label) ?? false,
+ ),
+ );
+ const option = Array.from(
+ document.body.querySelectorAll('[role="option"]'),
+ ).find((o) => o.textContent?.includes(label)) as HTMLElement | undefined;
+ expect(option).toBeDefined();
+ await act(async () => {
+ option!.click();
+ });
+ // Wait for the portal to close so subsequent assertions see the new
+ // query state, not the still-open menu.
+ await settle(() => document.body.querySelector('[role="option"]') === null);
+}
+
+describe("ActivityLog page filters", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (api.listActivity as unknown as ReturnType).mockResolvedValue(
+ EMPTY,
+ );
+ });
+
+ it("test_renders_filter_controls_when_mounted", async () => {
+ const { unmount } = renderActivity();
+ // ActivityFilters mounts synchronously with the page; no async wait.
+ expect(
+ document.body.querySelector('[data-testid="activity-filters"]'),
+ ).not.toBeNull();
+
+ // All six kind chips render (one per ActivityKind enum value).
+ const chips = document.body.querySelectorAll(
+ '[data-testid^="kind-chip-"]',
+ );
+ expect(chips.length).toBe(6);
+
+ // "Since" trigger is present. The label "All time" is the default.
+ expect(document.body.textContent).toContain("All time");
+
+ // Clear button is NOT present when no filter is active.
+ expect(
+ document.body.querySelector('[data-testid="clear-filters"]'),
+ ).toBeNull();
+
+ unmount();
+ });
+
+ it("test_selecting_a_kind_updates_url_and_triggers_refetch", async () => {
+ (api.listActivity as unknown as ReturnType).mockResolvedValue({
+ items: [SAMPLE[0]],
+ total: 1,
+ returned: 1,
+ has_more: false,
+ });
+
+ const { unmount } = renderActivity({ initialEntries: ["/activity"] });
+
+ // Wait for the initial (empty) fetch to settle so we can compare
+ // the pre-click and post-click call args cleanly.
+ await settle(() => (api.listActivity as ReturnType).mock.calls.length >= 1);
+ const callsBefore = (api.listActivity as ReturnType).mock.calls.length;
+
+ // Click the "Submitted" kind chip.
+ const chip = document.body.querySelector(
+ '[data-testid="kind-chip-claim_submitted"]',
+ ) as HTMLButtonElement | null;
+ expect(chip).not.toBeNull();
+ await act(async () => {
+ chip!.click();
+ });
+
+ // URL must reflect the selection. We're inside a MemoryRouter, so
+ // `window.location` doesn't update — instead the hook's setSearchParams
+ // pushes a new history entry inside the router. The assertion below
+ // reaches into React Router's `useLocation` by reading the rendered
+ // search input — but the simpler invariant is that `api.listActivity`
+ // is called again with `{ kind: "claim_submitted" }`.
+ await settle(
+ () =>
+ (api.listActivity as ReturnType).mock.calls.length >
+ callsBefore,
+ );
+ const lastCall = (api.listActivity as ReturnType).mock.calls.at(
+ -1,
+ )![0] as { kind?: string };
+ expect(lastCall.kind).toBe("claim_submitted");
+
+ unmount();
+ });
+
+ it("test_since_select_changes_the_api_call_param", async () => {
+ const { unmount } = renderActivity({ initialEntries: ["/activity"] });
+
+ // Wait for the initial fetch (no `since`) so we can compare.
+ await settle(
+ () => (api.listActivity as ReturnType).mock.calls.length >= 1,
+ );
+ const callsBefore = (api.listActivity as ReturnType).mock.calls.length;
+
+ // Pick "Last 1 hour" from the dropdown.
+ await pickSinceOption("Last 1 hour");
+
+ // A new fetch should have fired with a non-empty `since` ISO string.
+ await settle(
+ () =>
+ (api.listActivity as ReturnType).mock.calls.length >
+ callsBefore,
+ );
+ const calls = (api.listActivity as ReturnType).mock.calls;
+ const lastCall = calls.at(-1)![0] as { since?: string };
+ expect(lastCall.since).toBeDefined();
+ // The backend compares against ISO timestamps lexicographically, so
+ // the page must convert "1h" → ISO before sending. We don't pin the
+ // exact value (clock drifts), but it must be parseable as a recent
+ // timestamp and lie within the last hour from now.
+ const sinceMs = Date.parse(lastCall.since!);
+ expect(Number.isFinite(sinceMs)).toBe(true);
+ expect(Date.now() - sinceMs).toBeGreaterThanOrEqual(0);
+ expect(Date.now() - sinceMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5_000);
+
+ unmount();
+ });
+
+ it("test_clear_filters_resets_url_and_refetches", async () => {
+ // Mount with both filters already set in the URL — Clear should drop
+ // them and trigger an unfiltered refetch.
+ (api.listActivity as unknown as ReturnType).mockResolvedValue({
+ items: SAMPLE,
+ total: SAMPLE.length,
+ returned: SAMPLE.length,
+ has_more: false,
+ });
+
+ const { unmount } = renderActivity({
+ initialEntries: ["/activity?kind=claim_paid&since=24h"],
+ });
+
+ // Wait for the initial filtered fetch.
+ await settle(
+ () => (api.listActivity as ReturnType).mock.calls.length >= 1,
+ );
+ const initialCall = (api.listActivity as ReturnType).mock.calls[0]![0] as {
+ kind?: string;
+ since?: string;
+ };
+ expect(initialCall.kind).toBe("claim_paid");
+ expect(initialCall.since).toBeDefined();
+
+ // Sanity: the "Submitted" chip is NOT active (only "Paid" is selected).
+ const paidChip = document.body.querySelector(
+ '[data-testid="kind-chip-claim_paid"]',
+ ) as HTMLButtonElement | null;
+ expect(paidChip?.getAttribute("aria-checked")).toBe("true");
+ const submittedChip = document.body.querySelector(
+ '[data-testid="kind-chip-claim_submitted"]',
+ ) as HTMLButtonElement | null;
+ expect(submittedChip?.getAttribute("aria-checked")).toBe("false");
+
+ // Clear button must be visible while filters are active.
+ const clearBtn = document.body.querySelector(
+ '[data-testid="clear-filters"]',
+ ) as HTMLButtonElement | null;
+ expect(clearBtn).not.toBeNull();
+ const callsBeforeClear = (api.listActivity as ReturnType).mock.calls.length;
+
+ await act(async () => {
+ clearBtn!.click();
+ });
+
+ // All kind chips should now report unchecked.
+ await settle(() => {
+ const checked = document.body.querySelectorAll(
+ '[role="checkbox"][aria-checked="true"]',
+ );
+ return checked.length === 0;
+ });
+ expect(
+ document.body.querySelectorAll('[role="checkbox"][aria-checked="true"]')
+ .length,
+ ).toBe(0);
+
+ // Clear button disappears once filters are inactive.
+ await settle(
+ () => document.body.querySelector('[data-testid="clear-filters"]') === null,
+ );
+ expect(
+ document.body.querySelector('[data-testid="clear-filters"]'),
+ ).toBeNull();
+
+ // A new fetch should have fired with no kind/since params.
+ await settle(
+ () =>
+ (api.listActivity as ReturnType).mock.calls.length >
+ callsBeforeClear,
+ );
+ const calls = (api.listActivity as ReturnType).mock.calls;
+ const lastCall = calls.at(-1)![0] as { kind?: string; since?: string };
+ expect(lastCall.kind).toBeUndefined();
+ expect(lastCall.since).toBeUndefined();
+
+ unmount();
+ });
+
+ it("test_url_params_on_initial_load_populate_the_filter_ui", async () => {
+ (api.listActivity as unknown as ReturnType).mockResolvedValue({
+ items: [SAMPLE[1]],
+ total: 1,
+ returned: 1,
+ has_more: false,
+ });
+
+ // Deep link with two kinds + a since window — the most aggressive
+ // initial-load case.
+ const { unmount } = renderActivity({
+ initialEntries: [
+ "/activity?kind=claim_paid&kind=remit_received&since=7d",
+ ],
+ });
+
+ // Wait for the initial fetch.
+ await settle(
+ () => (api.listActivity as ReturnType).mock.calls.length >= 1,
+ );
+
+ // Both selected chips must report aria-checked=true.
+ await settle(() => {
+ const paid = document.body.querySelector(
+ '[data-testid="kind-chip-claim_paid"]',
+ );
+ const remit = document.body.querySelector(
+ '[data-testid="kind-chip-remit_received"]',
+ );
+ return (
+ paid?.getAttribute("aria-checked") === "true" &&
+ remit?.getAttribute("aria-checked") === "true"
+ );
+ });
+ expect(
+ document.body
+ .querySelector('[data-testid="kind-chip-claim_paid"]')
+ ?.getAttribute("aria-checked"),
+ ).toBe("true");
+ expect(
+ document.body
+ .querySelector('[data-testid="kind-chip-remit_received"]')
+ ?.getAttribute("aria-checked"),
+ ).toBe("true");
+
+ // Other chips must report aria-checked=false.
+ expect(
+ document.body
+ .querySelector('[data-testid="kind-chip-claim_submitted"]')
+ ?.getAttribute("aria-checked"),
+ ).toBe("false");
+
+ // "Since" Select must reflect "Last 7 days". Radix Select renders the
+ // current value inside the trigger's `SelectValue` slot.
+ expect(document.body.textContent).toContain("Last 7 days");
+
+ // API call: with TWO kinds selected, the page drops `kind` from the
+ // backend params (it only handles single-kind server-side filtering)
+ // and the since ISO timestamp is sent instead of the "7d" slug.
+ const lastCall = (api.listActivity as ReturnType).mock.calls.at(
+ -1,
+ )![0] as { kind?: string; since?: string };
+ expect(lastCall.kind).toBeUndefined();
+ expect(lastCall.since).toBeDefined();
+ expect(Date.parse(lastCall.since!)).toBeGreaterThan(
+ Date.now() - 8 * 24 * 60 * 60 * 1000,
+ );
+
+ unmount();
+ });
+
+ it("test_unknown_url_params_are_ignored", async () => {
+ // Garbage in the URL (unknown kind, unknown since value) must not
+ // throw, must not pass garbage to the API, and must leave the UI in
+ // the "no filters" state.
+ const { unmount } = renderActivity({
+ initialEntries: ["/activity?kind=not_a_kind&since=nope"],
+ });
+
+ await settle(
+ () => (api.listActivity as ReturnType).mock.calls.length >= 1,
+ );
+
+ const lastCall = (api.listActivity as ReturnType).mock.calls.at(
+ -1,
+ )![0] as { kind?: string; since?: string };
+ expect(lastCall.kind).toBeUndefined();
+ expect(lastCall.since).toBeUndefined();
+
+ // No chip is checked.
+ expect(
+ document.body.querySelectorAll(
+ '[role="checkbox"][aria-checked="true"]',
+ ).length,
+ ).toBe(0);
+
+ // "Since" trigger shows the default ("All time").
+ expect(document.body.textContent).toContain("All time");
+
+ // Clear button hidden.
+ expect(
+ document.body.querySelector('[data-testid="clear-filters"]'),
+ ).toBeNull();
+
+ unmount();
+ });
+});
\ No newline at end of file
diff --git a/src/pages/ActivityLog.tsx b/src/pages/ActivityLog.tsx
index fedfd99..5eaca51 100644
--- a/src/pages/ActivityLog.tsx
+++ b/src/pages/ActivityLog.tsx
@@ -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(
+ () =>
+ 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 (
@@ -22,6 +133,16 @@ export function ActivityLog() {
+
+
{isError ? (
) : items.length === 0 ? (
) : (
@@ -48,4 +173,4 @@ export function ActivityLog() {
);
-}
+}
\ No newline at end of file