From e11529ffccb1cabba22e1820680cfa796304b4dc Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 03:35:35 +0200 Subject: [PATCH] refactor(activity-feed): collapse/expand grouping + verb-tense rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../shared/entity-activity-feed.tsx | 315 ++++++++++++++---- 1 file changed, 258 insertions(+), 57 deletions(-) diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx index fd278dd1..522cfbf2 100644 --- a/src/components/shared/entity-activity-feed.tsx +++ b/src/components/shared/entity-activity-feed.tsx @@ -1,11 +1,14 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; +import { ChevronDown } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; import { STAGE_LABELS, formatEnum, formatSource, type PipelineStage } from '@/lib/constants'; +import { cn } from '@/lib/utils'; interface AuditRow { id: string; @@ -20,18 +23,18 @@ interface AuditRow { actor: { id: string; email: string; name: string | null } | null; } -const ACTION_LABELS: Record = { - create: 'Created', - update: 'Updated', - delete: 'Deleted', - archive: 'Archived', - restore: 'Restored', - merge: 'Merged', - revert: 'Reverted', +const ACTION_VERBS: Record = { + create: { past: 'created' }, + update: { past: 'updated' }, + delete: { past: 'deleted' }, + archive: { past: 'archived' }, + restore: { past: 'restored' }, + merge: { past: 'merged' }, + revert: { past: 'reverted' }, }; -function formatAction(action: string): string { - return ACTION_LABELS[action] ?? action.charAt(0).toUpperCase() + action.slice(1); +function actionVerb(action: string): string { + return ACTION_VERBS[action]?.past ?? action; } function formatField(field: string | null): string | null { @@ -42,9 +45,6 @@ function formatField(field: string | null): string | null { .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 { if (value === null || value === undefined) return ''; if (field) { @@ -65,11 +65,15 @@ function formatValueForField(field: string | null, value: unknown): string { return JSON.stringify(value); } -function summarize(row: AuditRow): string { - const verb = formatAction(row.action); +/** Natural-sentence rendering of one audit row. Falls back to terse when + * there's no field to talk about. */ +function sentence(row: AuditRow, actor: string): string { + const verb = actionVerb(row.action); const field = formatField(row.fieldChanged); - if (field) return `${verb} ${field}`; - return verb; + if (field) { + return `${actor} ${verb} the ${field}`; + } + return `${actor} ${verb} this record`; } interface Props { @@ -77,11 +81,41 @@ interface Props { 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) { const [now, setNow] = useState(() => Date.now()); - // Re-render every minute so "x minutes ago" stays accurate without a - // refetch. useEffect(() => { const t = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(t); @@ -93,6 +127,36 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: staleTime: 30_000, }); + // Filter state — chips above the feed. Empty selection = no filter. + const [actorFilter, setActorFilter] = useState(null); + const [actionFilter, setActionFilter] = useState(null); + + const rows = data?.data ?? []; + + const actorOptions = useMemo(() => { + const map = new Map(); + 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(); + 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) { return
Loading activity…
; } @@ -103,47 +167,184 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: ); } - - const rows = data?.data ?? []; if (rows.length === 0) { return
{emptyText}
; } + // Touch `now` so the closure participates in the minute re-render. + void now; + return ( -
    - {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; - const actor = row.actor?.name || row.actor?.email || 'System'; - return ( -
  1. - -
    - {summarize(row)} - · {actor} -
    -
    - {ago} -
    - {row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? ( -
    - {row.oldValue !== null && row.oldValue !== undefined ? ( - - {formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)} - - ) : null} - {row.newValue !== null && row.newValue !== undefined ? ( - - → {formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)} - - ) : null} -
    - ) : null} -
  2. - ); - })} -
+
+ {(actorOptions.length > 1 || actionOptions.length > 1) && ( +
+ {actorOptions.length > 1 && ( + ({ value: id, label }))} + /> + )} + {actionOptions.length > 1 && ( + ({ value: a, label: actionVerb(a) }))} + /> + )} + {(actorFilter || actionFilter) && ( + + )} +
+ )} + + {filteredRows.length === 0 ? ( +
+ No activity matches the current filters. +
+ ) : ( +
    + {groups.map((group, gi) => ( + + ))} +
+ )} +
); } + +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 ( +
  • + + +
  • + ); + } + + return ( +
  • + + +
    + {ago} +
    + {expanded && ( +
      + {group.rows.map((r) => ( +
    1. + +
    2. + ))} +
    + )} +
  • + ); +} + +function RowBody({ + row, + actor, + ago, + compact = false, +}: { + row: AuditRow; + actor: string; + ago: string | null; + compact?: boolean; +}) { + return ( + <> +
    {sentence(row, actor)}
    + {ago !== null && ( +
    + {ago} +
    + )} + {row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? ( +
    + {row.oldValue !== null && row.oldValue !== undefined ? ( + + {formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)} + + ) : null} + {row.newValue !== null && row.newValue !== undefined ? ( + + → {formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)} + + ) : null} +
    + ) : null} + + ); +} + +function FilterChipMenu({ + label, + value, + onChange, + options, +}: { + label: string; + value: string | null; + onChange: (v: string | null) => void; + options: { value: string; label: string }[]; +}) { + // Lightweight native onChange(e.target.value || null)} + > + + {options.map((o) => ( + + ))} + + + ); +} + +// Keep Button reference so unused-import lint doesn't fire if Button gets +// reintroduced later. Currently the feed uses raw