Pattern gallery

Recent activity feed card

A capped, timestamped list of what happened lately, one tone dot and a connector line at a time.

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 events
Today
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.

@versable-git/ui · composites proven in the apps