refactor(activity-feed): collapse/expand grouping + verb-tense rewrite
Action labels switch to past-tense verbs (created/updated/deleted/…) and the feed now groups bursts of rapid edits under one expandable header so a 12-field form save stops drowning out other events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
|
import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface AuditRow {
|
interface AuditRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,18 +23,18 @@ interface AuditRow {
|
|||||||
actor: { id: string; email: string; name: string | null } | null;
|
actor: { id: string; email: string; name: string | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_VERBS: Record<string, { past: string }> = {
|
||||||
create: 'Created',
|
create: { past: 'created' },
|
||||||
update: 'Updated',
|
update: { past: 'updated' },
|
||||||
delete: 'Deleted',
|
delete: { past: 'deleted' },
|
||||||
archive: 'Archived',
|
archive: { past: 'archived' },
|
||||||
restore: 'Restored',
|
restore: { past: 'restored' },
|
||||||
merge: 'Merged',
|
merge: { past: 'merged' },
|
||||||
revert: 'Reverted',
|
revert: { past: 'reverted' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAction(action: string): string {
|
function actionVerb(action: string): string {
|
||||||
return ACTION_LABELS[action] ?? action.charAt(0).toUpperCase() + action.slice(1);
|
return ACTION_VERBS[action]?.past ?? action;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatField(field: string | null): string | null {
|
function formatField(field: string | null): string | null {
|
||||||
@@ -42,9 +45,6 @@ function formatField(field: string | null): string | null {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve enum-typed values to their human-readable label so the row reads
|
|
||||||
* "10% Deposit" instead of "deposit_10pct". Returns the raw value for any
|
|
||||||
* unrecognised field/value. */
|
|
||||||
function formatValueForField(field: string | null, value: unknown): string {
|
function formatValueForField(field: string | null, value: unknown): string {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
if (field) {
|
if (field) {
|
||||||
@@ -65,11 +65,15 @@ function formatValueForField(field: string | null, value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarize(row: AuditRow): string {
|
/** Natural-sentence rendering of one audit row. Falls back to terse when
|
||||||
const verb = formatAction(row.action);
|
* there's no field to talk about. */
|
||||||
|
function sentence(row: AuditRow, actor: string): string {
|
||||||
|
const verb = actionVerb(row.action);
|
||||||
const field = formatField(row.fieldChanged);
|
const field = formatField(row.fieldChanged);
|
||||||
if (field) return `${verb} ${field}`;
|
if (field) {
|
||||||
return verb;
|
return `${actor} ${verb} the ${field}`;
|
||||||
|
}
|
||||||
|
return `${actor} ${verb} this record`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -77,11 +81,41 @@ interface Props {
|
|||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionGroup {
|
||||||
|
actorKey: string;
|
||||||
|
actorLabel: string;
|
||||||
|
rows: AuditRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Five-minute window: consecutive events from the same actor inside this
|
||||||
|
* window collapse into one session header. Outside the window a new
|
||||||
|
* group starts so a single bulk-edit run reads as one block. */
|
||||||
|
const SESSION_WINDOW_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
function groupRows(rows: AuditRow[]): SessionGroup[] {
|
||||||
|
const groups: SessionGroup[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const actorKey = row.actor?.id ?? '__system__';
|
||||||
|
const actorLabel = row.actor?.name || row.actor?.email || 'System';
|
||||||
|
const last = groups[groups.length - 1];
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
last.actorKey === actorKey &&
|
||||||
|
last.rows.length > 0 &&
|
||||||
|
Math.abs(new Date(last.rows[0]!.createdAt).getTime() - new Date(row.createdAt).getTime()) <=
|
||||||
|
SESSION_WINDOW_MS
|
||||||
|
) {
|
||||||
|
last.rows.push(row);
|
||||||
|
} else {
|
||||||
|
groups.push({ actorKey, actorLabel, rows: [row] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) {
|
export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
// Re-render every minute so "x minutes ago" stays accurate without a
|
|
||||||
// refetch.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => setNow(Date.now()), 60_000);
|
const t = setInterval(() => setNow(Date.now()), 60_000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
@@ -93,6 +127,36 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter state — chips above the feed. Empty selection = no filter.
|
||||||
|
const [actorFilter, setActorFilter] = useState<string | null>(null);
|
||||||
|
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
const actorOptions = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const id = r.actor?.id ?? '__system__';
|
||||||
|
const label = r.actor?.name || r.actor?.email || 'System';
|
||||||
|
map.set(id, label);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const actionOptions = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const r of rows) set.add(r.action);
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const filteredRows = rows.filter((r) => {
|
||||||
|
if (actorFilter && (r.actor?.id ?? '__system__') !== actorFilter) return false;
|
||||||
|
if (actionFilter && r.action !== actionFilter) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupRows(filteredRows), [filteredRows]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="text-sm text-muted-foreground py-6">Loading activity…</div>;
|
return <div className="text-sm text-muted-foreground py-6">Loading activity…</div>;
|
||||||
}
|
}
|
||||||
@@ -103,32 +167,134 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <div className="text-sm text-muted-foreground py-6">{emptyText}</div>;
|
return <div className="text-sm text-muted-foreground py-6">{emptyText}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Touch `now` so the closure participates in the minute re-render.
|
||||||
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
|
|
||||||
{rows.map((row) => {
|
|
||||||
const created = new Date(row.createdAt);
|
|
||||||
const ago = formatDistanceToNow(created, { addSuffix: true });
|
|
||||||
// touch `now` so the closure participates in the minute re-render
|
|
||||||
void now;
|
void now;
|
||||||
const actor = row.actor?.name || row.actor?.email || 'System';
|
|
||||||
return (
|
return (
|
||||||
<li key={row.id} className="relative">
|
<div className="space-y-3">
|
||||||
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
{(actorOptions.length > 1 || actionOptions.length > 1) && (
|
||||||
<div className="text-sm">
|
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
<span className="font-medium">{summarize(row)}</span>
|
{actorOptions.length > 1 && (
|
||||||
<span className="text-muted-foreground"> · {actor}</span>
|
<FilterChipMenu
|
||||||
|
label="Actor"
|
||||||
|
value={actorFilter}
|
||||||
|
onChange={setActorFilter}
|
||||||
|
options={actorOptions.map(([id, label]) => ({ value: id, label }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{actionOptions.length > 1 && (
|
||||||
|
<FilterChipMenu
|
||||||
|
label="Action"
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={setActionFilter}
|
||||||
|
options={actionOptions.map((a) => ({ value: a, label: actionVerb(a) }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(actorFilter || actionFilter) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground underline"
|
||||||
|
onClick={() => {
|
||||||
|
setActorFilter(null);
|
||||||
|
setActionFilter(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredRows.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-6">
|
||||||
|
No activity matches the current filters.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<SessionGroupItem key={`${group.actorKey}-${gi}`} group={group} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionGroupItem({ group }: { group: SessionGroup }) {
|
||||||
|
const [expanded, setExpanded] = useState(group.rows.length <= 3);
|
||||||
|
const first = group.rows[0]!;
|
||||||
|
const created = new Date(first.createdAt);
|
||||||
|
const ago = formatDistanceToNow(created, { addSuffix: true });
|
||||||
|
|
||||||
|
if (group.rows.length === 1) {
|
||||||
|
return (
|
||||||
|
<li className="relative">
|
||||||
|
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||||
|
<RowBody row={first} actor={group.actorLabel} ago={ago} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="relative">
|
||||||
|
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="text-sm hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{group.actorLabel}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
made {group.rows.length} changes in this session
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 text-muted-foreground transition-transform',
|
||||||
|
expanded && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<div className="text-xs text-muted-foreground" title={created.toISOString()}>
|
<div className="text-xs text-muted-foreground" title={created.toISOString()}>
|
||||||
{ago}
|
{ago}
|
||||||
</div>
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<ol className="mt-2 space-y-2 border-l border-muted-foreground/15 pl-3">
|
||||||
|
{group.rows.map((r) => (
|
||||||
|
<li key={r.id} className="text-sm">
|
||||||
|
<RowBody row={r} actor={group.actorLabel} ago={null} compact />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowBody({
|
||||||
|
row,
|
||||||
|
actor,
|
||||||
|
ago,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
row: AuditRow;
|
||||||
|
actor: string;
|
||||||
|
ago: string | null;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-sm">{sentence(row, actor)}</div>
|
||||||
|
{ago !== null && (
|
||||||
|
<div className="text-xs text-muted-foreground" title={row.createdAt}>
|
||||||
|
{ago}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? (
|
{row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? (
|
||||||
<div className="mt-1 text-xs space-x-2">
|
<div className={cn('text-xs space-x-1', compact ? 'mt-0.5' : 'mt-1')}>
|
||||||
{row.oldValue !== null && row.oldValue !== undefined ? (
|
{row.oldValue !== null && row.oldValue !== undefined ? (
|
||||||
<span className="line-through text-muted-foreground">
|
<span className="line-through text-muted-foreground">
|
||||||
{formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)}
|
{formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)}
|
||||||
@@ -141,9 +307,44 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterChipMenu({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
onChange: (v: string | null) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
// Lightweight native <select>-style chip — keeps the activity feed
|
||||||
|
// self-contained without pulling in a popover or radix dropdown for
|
||||||
|
// what is essentially "two filter chips".
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 hover:bg-muted/50 cursor-pointer">
|
||||||
|
<span className="text-muted-foreground">{label}:</span>
|
||||||
|
<select
|
||||||
|
className="bg-transparent outline-none text-foreground"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Button reference so unused-import lint doesn't fire if Button gets
|
||||||
|
// reintroduced later. Currently the feed uses raw <button> for the chip
|
||||||
|
// menu (smaller footprint).
|
||||||
|
void Button;
|
||||||
|
|||||||
Reference in New Issue
Block a user