merge: ui/activity-filter — kind + since filters on ActivityLog

This commit is contained in:
Tyler
2026-06-20 17:00:07 -06:00
3 changed files with 744 additions and 5 deletions
+165
View File
@@ -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 (
<div
className="flex flex-col gap-3"
data-testid="activity-filters"
>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Kind
</span>
{KIND_OPTIONS.map((opt) => {
const isActive = selectedKinds.includes(opt.value);
return (
<button
key={opt.value}
type="button"
role="checkbox"
aria-checked={isActive}
data-testid={`kind-chip-${opt.value}`}
onClick={() => toggleKind(opt.value)}
className={cn(
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isActive
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
: "bg-transparent text-muted-foreground ring-border/60 hover:text-foreground hover:ring-border",
)}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-3">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Since
</span>
<Select
value={since}
onValueChange={(v) => onSinceChange(v as SinceValue)}
>
<SelectTrigger className="w-[180px]" aria-label="Since">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
data-testid={`since-option-${opt.value}`}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasFilters ? (
<button
type="button"
onClick={onClear}
data-testid="clear-filters"
aria-label="Clear filters"
className={cn(
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
"inline-flex items-center gap-1",
"text-muted-foreground hover:text-foreground",
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
)}
>
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
Clear filters
</button>
) : null}
</div>
</div>
);
}