Recent activity feed card
What this solves
Gestalt hierarchy and discovery: a dashboard needs a home for what changed lately that reads at a glance, without opening a job or a log. A tone-colored dot plus a connector line groups related events into a timeline the eye scans in one pass, so a user notices what happened without hunting through a table.
Use it when
- A dashboard card summarizing what changed latelywalmart-mvp Home.tsx: the Recent activity card beside Jobs in progress, capped at 10 rows
- A claimable side rail that stays open while the user works elsewherespeedway ActivityRail.tsx: the same event shape as a PeekPanel rail, with the connector line
Rendered
Fixture data; check both themes.Recent activity
8 eventsToday
fitment-update.csv finished its pipeline: 1,204 parts enriched
New job: brake-pads-q3
wiper-blades-import hit an ingest error
Yesterday
spark-plugs-refresh published 88 listings
patterns/recent-activity-feed/example.tsxCopy this into a fresh route and it renders as above.
"use client";import { Button, Card, EmptyState, StatusDot, Tabs, Timestamp } from "@versable-git/ui";import { useState } from "react";// walmart's dashboard card (Home.tsx: StatusDot plus a sentence with the// entity bolded, capped at MAX_ACTIVITY_ROWS) and speedway's claimable rail// (ActivityRail.tsx: the same event shape, plus a connector line behind the// dots) read one underlying shape. Day grouping and the load-more button// below are this composite's own addition for a longer feed than either app// shows today; the Tabs knob switches the same card through its populated// and empty states.type Tone = "ok" | "warn" | "err" | "info" | "review" | "neutral";interface Event { id: string; ts: string; tone: Tone; before?: string; entity: string; after?: string;}const HOUR_MS = 3_600_000;const DAY_MS = 24 * HOUR_MS;const NOW = Date.now();const EVENTS: Event[] = [ { id: "1", ts: new Date(NOW - 1 * HOUR_MS).toISOString(), tone: "ok", entity: "fitment-update.csv", after: " finished its pipeline: 1,204 parts enriched" }, { id: "2", ts: new Date(NOW - 3 * HOUR_MS).toISOString(), tone: "neutral", before: "New job: ", entity: "brake-pads-q3" }, { id: "3", ts: new Date(NOW - 5 * HOUR_MS).toISOString(), tone: "err", entity: "wiper-blades-import", after: " hit an ingest error" }, { id: "4", ts: new Date(NOW - 22 * HOUR_MS).toISOString(), tone: "ok", entity: "spark-plugs-refresh", after: " published 88 listings" }, { id: "5", ts: new Date(NOW - DAY_MS - 2 * HOUR_MS).toISOString(), tone: "review", entity: "cabin-filters", after: " has 6 rows awaiting review" }, { id: "6", ts: new Date(NOW - DAY_MS - 6 * HOUR_MS).toISOString(), tone: "neutral", before: "New job: ", entity: "oil-filters-batch2" }, { id: "7", ts: new Date(NOW - 2 * DAY_MS - 1 * HOUR_MS).toISOString(), tone: "ok", entity: "headlight-bulbs", after: " finished its pipeline: 340 parts enriched" }, { id: "8", ts: new Date(NOW - 2 * DAY_MS - 9 * HOUR_MS).toISOString(), tone: "warn", entity: "battery-terminals", after: " is stalled: awaiting a source file" },];const PAGE_SIZE = 4;function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();}function dayLabel(iso: string): string { const d = new Date(iso); const diffDays = Math.round((startOfDay(new Date()) - startOfDay(d)) / DAY_MS); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Yesterday"; return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });}// Events already arrive newest first, so a single pass groups consecutive// same-day rows without a second sort.function groupByDay(events: Event[]): { label: string; events: Event[] }[] { const groups: { label: string; events: Event[] }[] = []; for (const e of events) { const label = dayLabel(e.ts); const current = groups.at(-1); if (current && current.label === label) current.events.push(e); else groups.push({ label, events: [e] }); } return groups;}type View = "activity" | "empty";export function RecentActivityFeed() { const [view, setView] = useState<View>("activity"); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const events = view === "activity" ? EVENTS.slice(0, visibleCount) : []; const groups = groupByDay(events); const hasMore = view === "activity" && visibleCount < EVENTS.length; return ( <div className="flex flex-col gap-3"> <Tabs size="sm" value={view} onChange={(v) => { setView(v as View); setVisibleCount(PAGE_SIZE); }} items={[ { value: "activity", label: "Activity" }, { value: "empty", label: "Empty" }, ]} /> <Card title="Recent activity" subtitle={view === "activity" ? `${EVENTS.length} events` : undefined} titleDivider noAnimate> {events.length === 0 ? ( <EmptyState compact Icon="Clock" title="Nothing yet" description="Activity across your catalog will show up here." /> ) : ( <> <div className="flex flex-col gap-4"> {groups.map((g) => ( <div key={g.label} className="flex flex-col gap-1.5"> <span className="text-base-content/65 text-xs font-semibold tracking-wide uppercase">{g.label}</span> <div className="relative flex flex-col gap-1"> {/* Connector line behind the dots, speedway's ActivityRail touch: it only earns its place once there is more than one row to link. */} {g.events.length > 1 && ( <span aria-hidden="true" className="bg-base-content/15 absolute top-2 bottom-2 left-1 w-0.5 rounded-full" /> )} {g.events.map((e) => ( <div key={e.id} className="relative flex items-start gap-3 py-1"> <StatusDot kind={e.tone} size="sm" className="mt-1 shrink-0" /> <span className="text-base-content/65 flex-1 text-sm leading-snug"> {e.before} <b className="text-base-content font-medium">{e.entity}</b> {e.after} </span> <Timestamp iso={e.ts} className="text-base-content/45 shrink-0 text-xs whitespace-nowrap" /> </div> ))} </div> </div> ))} </div> {hasMore && ( <Button size="sm" variant="ghost" shade content={`Load ${Math.min(PAGE_SIZE, EVENTS.length - visibleCount)} more`} onClick={() => setVisibleCount((n) => Math.min(n + PAGE_SIZE, EVENTS.length))} className="mt-3 w-fit" /> )} </> )} </Card> </div> );}Where it ships
walmart-mvp/frontend/src/pages/Home.tsx:350-372the dashboard card: kit StatusDot plus a sentence with the entity bolded, capped at MAX_ACTIVITY_ROWS (Home.tsx:21)speedway/app/components/ActivityRail.tsx:74-111the same event shape as a claimable rail, with a connector line behind the dots once there is more than one row
App-specific: Neither app groups by day or offers a load-more today: walmart's card just caps at 10 rows and speedway's rail lists everything the fetch returned, no pagination. Day grouping and the load-more button are this composite's own addition, for a feed longer than either app currently shows. Real callers wire tone from a domain enum (walmart's item.tone, speedway's DashActivity.tone), not a fixture array, and speedway hand-rolls its dot color from TONE_DOT rather than the kit's StatusDot; this page shows the kit-primitive route.