Activity log UI
Activity screens for apps. A timeline by day with filters, an audit table with before and after, record history with restore, a side feed and a summary.
Who changed what, and when
An activity log makes shared work traceable. When several people edit the same records, it shows who changed what and when, and it lets someone restore an old value instead of asking around.
Timeline sets everything by day on a line, filtered by person, kind, period and words, with comments quoted and changes as from and to. Audit table lists who did what, where and when, sortable and paged, each row opens before and after with device and address, and the view exports as CSV. Record history puts the current values of one record beside every change field by field, and an old value comes back with one click. Side feed is a narrow feed beside the work with a mark for what is new and a tab for what concerns you. Summary compares changes, comments, new items and active people per day or week with a chart, the most active people and the busiest items.
Write entries as plain sentences with the person first, Lena moved the card to review. Keep the audit table for admins and the side feed for everyone else. Show from and to for every change, a bare changed tells nobody anything.
Variants
Record historyNew in 1.2
The current values of one record beside every change field by field, filtered by field, and an old value restored with one click.
Installv1.0.0
aiw add @ai-website/activity-logBrings along utils, kit, avatar, bar-chart, button, icon, icon-button, motion, reveal, sidebar
Usage
<ActivityLog variant="record-history" title="Deal history" record={{ title, fields: [{ id, label, value }] }} events={[{ …, changes: [{ field, from, to }] }]} />Details
What Activity log brings along, read from the registry and the source.
- Category
- Team and collaboration
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/activity-log.tsx
- npm packages
- None
- Languages
- English and German, 84 texts of its own
- Studio
- Adjust in code
- In collections
- App dashboard
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ActivityLogProps interface from components/ui/activity-log.tsx, with the comments from the code.
variantoptionalActivityLogVarianttimelineaudit-tablerecord-historycompact-feeddigest
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
meoptionalstringYour person id, written as "You"
peoplerequiredActivityPerson[]eventsrequiredActivityEvent[]moreoptionalActivityEvent[]compact-feed: older events that "Load more" brings in, in pages
recordoptionalActivityRecordrecord-history: the record the changes belong to
pageSizeoptionalnumberaudit-table: rows per page
childrenoptionalReact.ReactNodecompact-feed: the page beside the feed on desktops
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on export, restore, load and seen. Without it the block simulates the request.
Built-in texts
Activity log writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 84 texts
| Key | English | German |
|---|---|---|
| search | English Search activity | German Aktivitäten durchsuchen |
| person | English Person | German Person |
| everyone | English Everyone | German Alle Personen |
| kind | English Kind | German Art |
| allKinds | English Every kind | German Alle Arten |
| period | English Period | German Zeitraum |
| day | English 24 hours | German 24 Stunden |
| week | English 7 days | German 7 Tage |
| month | English 30 days | German 30 Tage |
| all | English All | German Alle |
| create | English Created | German Angelegt |
| update | English Changed | German Geändert |
| delete | English Deleted | German Gelöscht |
| comment | English Comments | German Kommentare |
| share | English Shared | German Geteilt |
| upload | English Uploads | German Uploads |
| status | English Status | German Status |
| login | English Sign ins | German Anmeldungen |
| invite | English Invites | German Einladungen |
| clear | English Clear filters | German Filter zurücksetzen |
| results | English {n} activities | German {n} Aktivitäten |
| oneResult | English 1 activity | German 1 Aktivität |
| none | English Nothing happened in this view. Widen the period or clear the filters. | German In dieser Ansicht ist nichts passiert. Erweitere den Zeitraum oder setze die Filter zurück. |
| today | English Today | German Heute |
| yesterday | English Yesterday | German Gestern |
| you | English You | German Du |
| changedTo | English changed to | German geändert auf |
| time | English Time | German Zeit |
| actor | English Who | German Wer |
| action | English Action | German Aktion |
| object | English Object | German Objekt |
| place | English Where | German Wo |
| sortBy | English Sort by {col} | German Nach {col} sortieren |
| details | English Details of {what} | German Details zu {what} |
| before | English Before | German Vorher |
| after | English After | German Nachher |
| field | English Field | German Feld |
| empty | English empty | German leer |
| device | English Device | German Gerät |
| ip | English IP address | German IP-Adresse |
| eventId | English Event | German Ereignis |
| export | English Export CSV | German Als CSV exportieren |
| exported | English {n} rows exported as CSV. | German {n} Zeilen als CSV exportiert. |
| page | English Page {n} of {total} | German Seite {n} von {total} |
| showing | English {from} to {to} of {total} | German {from} bis {to} von {total} |
| prev | English Previous page | German Vorherige Seite |
| next | English Next page | German Nächste Seite |
| history | English History | German Verlauf |
| current | English Current values | German Aktuelle Werte |
| fields | English Fields | German Felder |
| allFields | English All fields | German Alle Felder |
| changedN | English changed {n} fields | German hat {n} Felder geändert |
| changedOne | English changed {field} | German hat {field} geändert |
| restore | English Restore {value} | German {value} wiederherstellen |
| restored | English {field} is back to {value}. | German {field} ist wieder {value}. |
| restoredAction | English restored | German hat wiederhergestellt |
| showRecord | English Show current values | German Aktuelle Werte zeigen |
| hideRecord | English Hide current values | German Aktuelle Werte ausblenden |
| activity | English Activity | German Aktivität |
| mentions | English For you | German Für dich |
| markSeen | English Mark all as seen | German Alle als gesehen markieren |
| seen | English Everything is seen. | German Alles gesehen. |
| newSince | English New since your last visit | German Neu seit deinem letzten Besuch |
| loadMore | English Load more | German Mehr laden |
| loading | English Loading… | German Wird geladen… |
| loaded | English {n} more activities. | German {n} weitere Aktivitäten. |
| end | English That is everything from the last weeks. | German Das ist alles aus den letzten Wochen. |
| digest | English Summary | German Zusammenfassung |
| daily | English Day | German Tag |
| weekly | English Week | German Woche |
| prevPeriod | English Earlier period | German Früherer Zeitraum |
| nextPeriod | English Later period | German Späterer Zeitraum |
| changes | English Changes | German Änderungen |
| comments | English Comments | German Kommentare |
| created | English New items | German Neu angelegt |
| activePeople | English Active people | German Aktive Personen |
| vsPrev | English vs. period before | German zum Zeitraum davor |
| perDay | English Activity per day | German Aktivität je Tag |
| perHour | English Activity per hour | German Aktivität je Stunde |
| top | English Most active | German Am aktivsten |
| hot | English Most worked on | German Am meisten bearbeitet |
| nEvents | English {n} activities | German {n} Aktivitäten |
| noData | English No activity in this period. | German In diesem Zeitraum gab es keine Aktivität. |
| weekOf | English Week of {date} | German Woche ab {date} |
Accessibility
Read from the source of the component.
- Activity log handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed and aria-sort, so screen readers announce purpose and state.
- Uses the roles group, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,246 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { BarChart } from '@/components/ui/bar-chart';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type ActivityLogVariant = 'timeline' | 'audit-table' | 'record-history' | 'compact-feed' | 'digest';
export type ActivityKind = 'create' | 'update' | 'delete' | 'comment' | 'share' | 'upload' | 'status' | 'login' | 'invite';
export interface ActivityPerson {
id: string;
name: string;
role?: string;
}
export interface ActivityChange {
/** Field id, matched against record.fields */
field: string;
from?: string;
to?: string;
}
export interface ActivityEvent {
id: string;
/** Person id */
actor: string;
kind: ActivityKind;
/** The verb part of the sentence, e.g. "moved" or "verschob" */
action: string;
/** What it happened to, e.g. "Homepage draft" */
target?: string;
/** Kind of object, e.g. "Task" */
targetType?: string;
/** ISO date and time */
time: string;
/** A comment or a note, quoted under the sentence */
detail?: string;
changes?: ActivityChange[];
ip?: string;
location?: string;
device?: string;
/** compact-feed: not seen yet */
unread?: boolean;
}
export interface ActivityRecord {
title: string;
/** e.g. "Deal" */
type?: string;
fields: { id: string; label: string; value: string }[];
}
export interface ActivityLogProps {
variant?: ActivityLogVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** Your person id, written as "You" */
me?: string;
people: ActivityPerson[];
events: ActivityEvent[];
/** compact-feed: older events that "Load more" brings in, in pages */
more?: ActivityEvent[];
/** record-history: the record the changes belong to */
record?: ActivityRecord;
/** audit-table: rows per page */
pageSize?: number;
/** compact-feed: the page beside the feed on desktops */
children?: React.ReactNode;
/** Called on export, restore, load and seen. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search activity',
person: 'Person',
everyone: 'Everyone',
kind: 'Kind',
allKinds: 'Every kind',
period: 'Period',
day: '24 hours',
week: '7 days',
month: '30 days',
all: 'All',
create: 'Created',
update: 'Changed',
delete: 'Deleted',
comment: 'Comments',
share: 'Shared',
upload: 'Uploads',
status: 'Status',
login: 'Sign ins',
invite: 'Invites',
clear: 'Clear filters',
results: '{n} activities',
oneResult: '1 activity',
none: 'Nothing happened in this view. Widen the period or clear the filters.',
today: 'Today',
yesterday: 'Yesterday',
you: 'You',
changedTo: 'changed to',
time: 'Time',
actor: 'Who',
action: 'Action',
object: 'Object',
place: 'Where',
sortBy: 'Sort by {col}',
details: 'Details of {what}',
before: 'Before',
after: 'After',
field: 'Field',
empty: 'empty',
device: 'Device',
ip: 'IP address',
eventId: 'Event',
export: 'Export CSV',
exported: '{n} rows exported as CSV.',
page: 'Page {n} of {total}',
showing: '{from} to {to} of {total}',
prev: 'Previous page',
next: 'Next page',
history: 'History',
current: 'Current values',
fields: 'Fields',
allFields: 'All fields',
changedN: 'changed {n} fields',
changedOne: 'changed {field}',
restore: 'Restore {value}',
restored: '{field} is back to {value}.',
restoredAction: 'restored',
showRecord: 'Show current values',
hideRecord: 'Hide current values',
activity: 'Activity',
mentions: 'For you',
markSeen: 'Mark all as seen',
seen: 'Everything is seen.',
newSince: 'New since your last visit',
loadMore: 'Load more',
loading: 'Loading…',
loaded: '{n} more activities.',
end: 'That is everything from the last weeks.',
digest: 'Summary',
daily: 'Day',
weekly: 'Week',
prevPeriod: 'Earlier period',
nextPeriod: 'Later period',
changes: 'Changes',
comments: 'Comments',
created: 'New items',
activePeople: 'Active people',
vsPrev: 'vs. period before',
perDay: 'Activity per day',
perHour: 'Activity per hour',
top: 'Most active',
hot: 'Most worked on',
nEvents: '{n} activities',
noData: 'No activity in this period.',
weekOf: 'Week of {date}',
},
de: {
search: 'Aktivitäten durchsuchen',
person: 'Person',
everyone: 'Alle Personen',
kind: 'Art',
allKinds: 'Alle Arten',
period: 'Zeitraum',
day: '24 Stunden',
week: '7 Tage',
month: '30 Tage',
all: 'Alle',
create: 'Angelegt',
update: 'Geändert',
delete: 'Gelöscht',
comment: 'Kommentare',
share: 'Geteilt',
upload: 'Uploads',
status: 'Status',
login: 'Anmeldungen',
invite: 'Einladungen',
clear: 'Filter zurücksetzen',
results: '{n} Aktivitäten',
oneResult: '1 Aktivität',
none: 'In dieser Ansicht ist nichts passiert. Erweitere den Zeitraum oder setze die Filter zurück.',
today: 'Heute',
yesterday: 'Gestern',
you: 'Du',
changedTo: 'geändert auf',
time: 'Zeit',
actor: 'Wer',
action: 'Aktion',
object: 'Objekt',
place: 'Wo',
sortBy: 'Nach {col} sortieren',
details: 'Details zu {what}',
before: 'Vorher',
after: 'Nachher',
field: 'Feld',
empty: 'leer',
device: 'Gerät',
ip: 'IP-Adresse',
eventId: 'Ereignis',
export: 'Als CSV exportieren',
exported: '{n} Zeilen als CSV exportiert.',
page: 'Seite {n} von {total}',
showing: '{from} bis {to} von {total}',
prev: 'Vorherige Seite',
next: 'Nächste Seite',
history: 'Verlauf',
current: 'Aktuelle Werte',
fields: 'Felder',
allFields: 'Alle Felder',
changedN: 'hat {n} Felder geändert',
changedOne: 'hat {field} geändert',
restore: '{value} wiederherstellen',
restored: '{field} ist wieder {value}.',
restoredAction: 'hat wiederhergestellt',
showRecord: 'Aktuelle Werte zeigen',
hideRecord: 'Aktuelle Werte ausblenden',
activity: 'Aktivität',
mentions: 'Für dich',
markSeen: 'Alle als gesehen markieren',
seen: 'Alles gesehen.',
newSince: 'Neu seit deinem letzten Besuch',
loadMore: 'Mehr laden',
loading: 'Wird geladen…',
loaded: '{n} weitere Aktivitäten.',
end: 'Das ist alles aus den letzten Wochen.',
digest: 'Zusammenfassung',
daily: 'Tag',
weekly: 'Woche',
prevPeriod: 'Früherer Zeitraum',
nextPeriod: 'Späterer Zeitraum',
changes: 'Änderungen',
comments: 'Kommentare',
created: 'Neu angelegt',
activePeople: 'Aktive Personen',
vsPrev: 'zum Zeitraum davor',
perDay: 'Aktivität je Tag',
perHour: 'Aktivität je Stunde',
top: 'Am aktivsten',
hot: 'Am meisten bearbeitet',
nEvents: '{n} Aktivitäten',
noData: 'In diesem Zeitraum gab es keine Aktivität.',
weekOf: 'Woche ab {date}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
const CSS =
'@keyframes b5a-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5a-pop{from{opacity:0;transform:scale(.6)}}@keyframes b5a-flash{from{background-color:color-mix(in oklab,var(--primary) 18%,transparent)}}@keyframes b5a-line{from{transform:scaleY(0)}}' +
'.b5a-line{transform-origin:top}' +
'@media (prefers-reduced-motion:no-preference){.b5a-rise{animation:b5a-rise .55s var(--ease-kit) var(--b5a-d,0ms) both}.b5a-pop{animation:b5a-pop .4s cubic-bezier(.34,1.56,.64,1) var(--b5a-d,0ms) both}.b5a-flash{animation:b5a-flash 2s ease-out both}.b5a-line{animation:b5a-line 1.1s var(--ease-kit) .1s both}}';
function Style() {
return (
<style href="kitb5-activity-log" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const DAY = 1440 * MIN;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const dayKey = (iso: string) => iso.slice(0, 10);
const isoOf = (ms: number) => new Date(ms).toISOString().slice(0, 16);
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : ms));
async function run(onAction: ActivityLogProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(ms);
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto', style: 'long' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 1) return rtf.format(0, 'minute');
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
if (a < 1440 * 30) return rtf.format(Math.round(m / 1440), 'day');
return rtf.format(Math.round(m / (1440 * 30)), 'month');
}
/** Short relative time for narrow columns: 5 min, 3 h, 2 d */
function agoShort(iso: string, now: string, l: Locale) {
const m = Math.max(0, Math.round((toMs(now) - toMs(iso)) / MIN));
const u = (n: number, unit: 'minute' | 'hour' | 'day') => new Intl.NumberFormat(intl(l), { style: 'unit', unit, unitDisplay: 'narrow' }).format(n);
if (m < 60) return u(Math.max(1, m), 'minute');
if (m < 1440) return u(Math.round(m / 60), 'hour');
return u(Math.round(m / 1440), 'day');
}
const clock = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
const stampFull = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'short', year: 'numeric', hour: l === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMs(iso)));
function dayLabel(iso: string, now: string, l: Locale, t: Texts) {
const d = Math.round((toMs(dayKey(now)) - toMs(dayKey(iso))) / DAY);
if (d === 0) return t.today;
if (d === 1) return t.yesterday;
return new Intl.DateTimeFormat(intl(l), { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(toMs(iso)));
}
const KIND: Record<ActivityKind, { icon: IconName; tone: string }> = {
create: { icon: 'plus', tone: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' },
update: { icon: 'edit', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]' },
delete: { icon: 'trash', tone: 'bg-[color-mix(in_oklab,var(--destructive)_12%,var(--card))] text-destructive' },
comment: { icon: 'message-circle', tone: 'bg-surface-2 text-foreground' },
share: { icon: 'share-out', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]' },
upload: { icon: 'upload', tone: 'bg-surface-2 text-foreground' },
status: { icon: 'flag', tone: 'bg-[color-mix(in_oklab,var(--warning)_18%,var(--card))] text-warning-foreground' },
login: { icon: 'log-in', tone: 'bg-surface-2 text-muted-foreground' },
invite: { icon: 'user-plus', tone: 'bg-[color-mix(in_oklab,var(--success)_15%,var(--card))] text-success-foreground' },
};
const KINDS = Object.keys(KIND) as ActivityKind[];
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
type Ctx = Omit<ActivityLogProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; now: string; person: (id: string) => ActivityPerson; who: (id: string) => string };
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: string; children?: React.ReactNode }) {
return (
<div className="b5a-rise flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
return (
<label className={cn('relative block min-w-0', className)}>
<span className="sr-only">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8 hover:border-border-strong')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</label>
);
}
function SearchBox({ label, value, onChange, className }: { label: string; value: string; onChange: (v: string) => void; className?: string }) {
return (
<label className={cn('relative block', className)}>
<span className="sr-only">{label}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className={cn(field, 'ps-9')} />
</label>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + options.length) % options.length;
onChange(options[n].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{o.label}
</button>
))}
</div>
);
}
function KindDot({ kind, size = 'md', className }: { kind: ActivityKind; size?: 'sm' | 'md'; className?: string }) {
const k = KIND[kind];
return (
<span className={cn('grid shrink-0 place-items-center rounded-full ring-4 ring-background', size === 'md' ? 'size-8' : 'size-6', k.tone, className)}>
<Icon name={k.icon} size={size === 'md' ? 15 : 12} />
</span>
);
}
/** "Mia moved Homepage draft" with the names set off */
function Sentence({ e, c, className }: { e: ActivityEvent; c: Ctx; className?: string }) {
return (
<p className={cn('text-pretty text-[14px] leading-snug text-muted-foreground', className)}>
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.action}
{e.target && (
<>
{' '}
<span className="font-medium text-foreground">{e.target}</span>
</>
)}
</p>
);
}
function ChangeChips({ changes, c, labelOf }: { changes: ActivityChange[]; c: Ctx; labelOf?: (id: string) => string }) {
return (
<ul className="mt-2 flex flex-wrap gap-1.5">
{changes.map((ch, i) => (
<li key={i} className="inline-flex max-w-full flex-wrap items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-[12.5px]">
<span className="text-muted-foreground">{labelOf ? labelOf(ch.field) : ch.field}</span>
{ch.from && <span className="text-subtle-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)]">{ch.from}</span>}
<Icon name="arrow-right" size={12} className="text-subtle-foreground" />
<span className="font-medium text-foreground">{ch.to || c.t.empty}</span>
</li>
))}
</ul>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline(c: Ctx) {
const { t, locale, now, people, events } = c;
const [q, setQ] = useState('');
const [who, setWho] = useState('all');
const [period, setPeriod] = useState<'day' | 'week' | 'month' | 'all'>('week');
const [kinds, setKinds] = useState<ActivityKind[]>([]);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const span = { day: 1, week: 7, month: 30, all: Infinity }[period];
const used = KINDS.filter((k) => events.some((e) => e.kind === k));
const shown = events
.filter((e) => (who === 'all' || e.actor === who) && (!kinds.length || kinds.includes(e.kind)) && (toMs(now) - toMs(e.time)) / DAY <= span && (!q || `${c.who(e.actor)} ${e.action} ${e.target ?? ''} ${e.detail ?? ''}`.toLowerCase().includes(q.toLowerCase())))
.sort((a, b) => toMs(b.time) - toMs(a.time));
const days = [...new Set(shown.map((e) => dayKey(e.time)))];
const filtered = !!q || who !== 'all' || kinds.length > 0 || period !== 'week';
const count = shown.length === 1 ? t.oneResult : fill(t.results, { n: shown.length });
return (
<Main className="max-w-[60rem]">
<Header title={c.title} subtitle={c.subtitle} />
<div className="b5a-rise mt-6 grid gap-3 [--b5a-d:60ms]">
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-[minmax(0,1fr)_12rem_auto]">
<SearchBox label={t.search} value={q} onChange={setQ} />
<Pick label={t.person} value={who} onChange={setWho}>
<option value="all">{t.everyone}</option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{c.who(p.id)}
</option>
))}
</Pick>
<Segmented
label={t.period}
value={period}
onChange={setPeriod}
className="flex w-full md:w-auto"
options={[
{ value: 'day', label: t.day },
{ value: 'week', label: t.week },
{ value: 'month', label: t.month },
{ value: 'all', label: t.all },
]}
/>
</div>
<div ref={rowRef} role="group" aria-label={t.kind} className={cn('kit-no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 py-0.5 sm:mx-0 sm:flex-wrap sm:px-0', fade)}>
{used.map((k) => {
const on = kinds.includes(k);
return (
<button key={k} type="button" aria-pressed={on} onClick={() => setKinds((l) => (on ? l.filter((x) => x !== k) : [...l, k]))} className={cn('inline-flex h-11 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors sm:h-8', on ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground', ring)}>
<Icon name={KIND[k].icon} size={13} />
{t[k]}
</button>
);
})}
</div>
<div className="flex min-h-9 items-center justify-between gap-3">
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{count}
</p>
{filtered && (
<Button
size="sm"
variant="ghost"
iconLeft="x"
onClick={() => {
setQ('');
setWho('all');
setKinds([]);
setPeriod('week');
}}
>
{t.clear}
</Button>
)}
</div>
</div>
<div className="mt-2 grid gap-8">
{days.map((d, di) => {
const list = shown.filter((e) => dayKey(e.time) === d);
return (
<Reveal key={d} as="div" delay={di < 2 ? di * 80 : 0}>
<section aria-labelledby={`d-${d}`}>
<h2 id={`d-${d}`} className="sticky top-14 z-[1] -mx-1 flex items-baseline gap-2 bg-background/90 px-1 py-2 backdrop-blur md:top-0">
<span className="text-[14px] font-semibold text-foreground">{dayLabel(d, now, locale, t)}</span>
<span className="text-[12.5px] tabular-nums text-subtle-foreground">{list.length === 1 ? t.oneResult : fill(t.results, { n: list.length })}</span>
</h2>
<ol className="relative mt-2">
<span aria-hidden className="b5a-line absolute bottom-4 start-4 top-4 w-px bg-border" />
{list.map((e) => (
<li key={e.id} className="group/ev relative flex gap-3.5 pb-5 last:pb-0">
<KindDot kind={e.kind} className="relative transition-transform duration-300 group-hover/ev:scale-110" />
<div className="min-w-0 flex-1 pt-1">
<div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<Sentence e={e} c={c} />
<time dateTime={e.time} title={stampFull(e.time, locale)} className="shrink-0 text-[12.5px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
</time>
</div>
{e.targetType && <p className="mt-0.5 text-[12.5px] text-subtle-foreground">{e.targetType}</p>}
{e.detail && <blockquote className="mt-2 rounded-lg border border-border bg-surface px-3.5 py-2.5 text-pretty text-[13.5px] leading-relaxed text-foreground transition-colors group-hover/ev:border-border-strong">{e.detail}</blockquote>}
{e.changes?.length ? <ChangeChips changes={e.changes} c={c} /> : null}
</div>
</li>
))}
</ol>
</section>
</Reveal>
);
})}
{!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* audit-table */
/* ------------------------------------------------------------------ */
type SortKey = 'time' | 'actor' | 'kind';
function AuditTable(c: Ctx) {
const { t, locale, now, people, events, onAction } = c;
const id = useId();
const [q, setQ] = useState('');
const [who, setWho] = useState('all');
const [kind, setKind] = useState('all');
const [sort, setSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
const [open, setOpen] = useState<string | null>(events.find((e) => e.changes?.length)?.id ?? null);
const [page, setPage] = useState(0);
const [live, setLive] = useState('');
const size = c.pageSize ?? 7;
const rows = useMemo(() => {
const list = events.filter((e) => (who === 'all' || e.actor === who) && (kind === 'all' || e.kind === kind) && (!q || `${c.who(e.actor)} ${e.action} ${e.target ?? ''} ${e.ip ?? ''} ${e.location ?? ''}`.toLowerCase().includes(q.toLowerCase())));
const v = (e: ActivityEvent) => (sort.key === 'time' ? toMs(e.time) : sort.key === 'actor' ? c.who(e.actor) : t[e.kind]);
return list.sort((a, b) => {
const x = v(a);
const y = v(b);
const r = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), intl(locale));
return sort.dir === 'asc' ? r : -r;
});
}, [events, who, kind, q, sort, c, t, locale]);
const pages = Math.max(1, Math.ceil(rows.length / size));
const p = Math.min(page, pages - 1);
const slice = rows.slice(p * size, p * size + size);
useEffect(() => setPage(0), [q, who, kind]);
const exportCsv = async () => {
const head = [t.time, t.actor, t.action, t.object, t.place, t.ip];
const esc = (s: string) => `"${s.replace(/"/g, '""')}"`;
const csv = [head, ...rows.map((e) => [e.time, c.who(e.actor), `${t[e.kind]} ${e.action}`, e.target ?? '', e.location ?? '', e.ip ?? ''])].map((r) => r.map(esc).join(',')).join('\n');
if (onAction) await onAction('export', { rows: rows.length, csv });
else {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = Object.assign(document.createElement('a'), { href: url, download: `activity-${now.slice(0, 10)}.csv` });
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
setLive(fill(t.exported, { n: rows.length }));
};
const sortTh = (k: SortKey, label: string, className?: string) => (
<th scope="col" aria-sort={sort.key === k ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-2 font-medium', className)}>
<button type="button" onClick={() => setSort((s) => ({ key: k, dir: s.key === k && s.dir === 'desc' ? 'asc' : 'desc' }))} className={cn('-ms-1.5 inline-flex h-8 items-center gap-1 rounded-md px-1.5 transition-colors hover:bg-accent hover:text-foreground', sort.key === k && 'text-foreground', ring)}>
{label}
<Icon name={sort.key === k ? (sort.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'} size={13} className={sort.key === k ? '' : 'text-subtle-foreground'} />
</button>
</th>
);
const detail = (e: ActivityEvent) => (
<div className="grid gap-4 bg-surface px-4 py-4 sm:px-5 md:grid-cols-[minmax(0,1fr)_16rem]">
{e.changes?.length ? (
<table className="w-full table-fixed overflow-hidden rounded-lg border border-border bg-card text-left text-[13px]">
<thead className="border-b border-border text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-[28%] px-3 py-2 font-medium">{t.field}</th>
<th scope="col" className="px-3 py-2 font-medium">{t.before}</th>
<th scope="col" className="px-3 py-2 font-medium">{t.after}</th>
</tr>
</thead>
<tbody>
{e.changes.map((ch, i) => (
<tr key={i} className="border-b border-border last:border-b-0 align-top">
<th scope="row" className="px-3 py-2 font-medium text-foreground [overflow-wrap:anywhere]">{ch.field}</th>
<td className="px-3 py-2">
<span className="rounded bg-[color-mix(in_oklab,var(--destructive)_10%,transparent)] px-1 text-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)] [overflow-wrap:anywhere]">{ch.from || t.empty}</span>
</td>
<td className="px-3 py-2">
<span className="rounded bg-[color-mix(in_oklab,var(--success)_13%,transparent)] px-1 text-foreground [overflow-wrap:anywhere]">{ch.to || t.empty}</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<Sentence e={e} c={c} className="self-center" />
)}
<dl className="grid content-start gap-2 text-[12.5px]">
{[
[t.eventId, e.id],
[t.ip, e.ip],
[t.place, e.location],
[t.device, e.device],
[t.time, stampFull(e.time, locale)],
]
.filter(([, v]) => v)
.map(([k, v]) => (
<div key={k} className="flex justify-between gap-3 border-b border-border pb-2 last:border-b-0">
<dt className="text-muted-foreground">{k}</dt>
<dd className="truncate text-right tabular-nums text-foreground">{v}</dd>
</div>
))}
</dl>
</div>
);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button size="sm" variant="outline" iconLeft="download" onClick={exportCsv}>
{t.export}
</Button>
</Header>
<div className="b5a-rise mt-6 grid grid-cols-[minmax(0,1fr)] gap-3 [--b5a-d:60ms] sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_12rem_12rem]">
<SearchBox label={t.search} value={q} onChange={setQ} className="sm:col-span-2 lg:col-span-1" />
<Pick label={t.person} value={who} onChange={setWho}>
<option value="all">{t.everyone}</option>
{people.map((x) => (
<option key={x.id} value={x.id}>
{c.who(x.id)}
</option>
))}
</Pick>
<Pick label={t.kind} value={kind} onChange={setKind}>
<option value="all">{t.allKinds}</option>
{KINDS.filter((k) => events.some((e) => e.kind === k)).map((k) => (
<option key={k} value={k}>
{t[k]}
</option>
))}
</Pick>
</div>
<div className="b5a-rise mt-4 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:120ms]">
{/* Tablet and desktop */}
<table className="w-full table-fixed text-left text-[13.5px] max-md:hidden">
<thead className="border-b border-border bg-surface text-[12px] text-muted-foreground">
<tr>
<th scope="col" className="w-12 ps-3">
<span className="sr-only">{t.details.replace('{what}', '')}</span>
</th>
{sortTh('time', t.time, 'w-36')}
{sortTh('actor', t.actor, 'w-44')}
{sortTh('kind', t.action)}
<th scope="col" className="w-52 py-2 pe-4 font-medium max-lg:hidden">{t.place}</th>
</tr>
</thead>
{slice.map((e) => {
const isOpen = open === e.id;
return (
<tbody key={e.id} className="border-b border-border last:border-b-0">
<tr className={cn('transition-colors', isOpen ? 'bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')}>
<td className="ps-3">
<button type="button" aria-expanded={isOpen} aria-controls={`${id}-${e.id}`} aria-label={fill(t.details, { what: `${c.who(e.actor)} ${e.action} ${e.target ?? ''}` })} onClick={() => setOpen(isOpen ? null : e.id)} className={cn('relative grid size-8 place-items-center rounded-md text-muted-foreground transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground', ring)}>
<Icon name="chevron-right" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', isOpen && 'rotate-90')} />
</button>
</td>
<td className="py-3 tabular-nums text-muted-foreground">
<time dateTime={e.time} title={stampFull(e.time, locale)}>
{ago(e.time, now, locale)}
</time>
</td>
<td className="py-3">
<span className="flex min-w-0 items-center gap-2">
<Avatar name={c.person(e.actor).name} size="xs" alt="" />
<span className="truncate font-medium text-foreground">{c.who(e.actor)}</span>
</span>
</td>
<td className="py-3 pe-3">
<span className="flex min-w-0 items-center gap-2.5">
<KindDot kind={e.kind} size="sm" className="ring-0" />
<span className="min-w-0 line-clamp-2 text-pretty break-words text-muted-foreground">
{e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</span>
</span>
</td>
<td className="py-3 pe-4 max-lg:hidden">
<span className="block truncate text-foreground">{e.location}</span>
<span className="block truncate text-[12px] tabular-nums text-subtle-foreground">{e.ip}</span>
</td>
</tr>
<tr id={`${id}-${e.id}`} hidden={!isOpen}>
<td colSpan={5} className="border-t border-border p-0">
{isOpen && (
<div className="b5a-rise">
{detail(e)}
</div>
)}
</td>
</tr>
</tbody>
);
})}
</table>
{/* Phones */}
<ul className="md:hidden">
{slice.map((e) => {
const isOpen = open === e.id;
return (
<li key={e.id} className="border-b border-border last:border-b-0">
<button type="button" aria-expanded={isOpen} aria-controls={`${id}-m-${e.id}`} onClick={() => setOpen(isOpen ? null : e.id)} className={cn('flex w-full items-start gap-3 px-4 py-3.5 text-left', ringIn)}>
<KindDot kind={e.kind} size="sm" className="mt-0.5 ring-0" />
<span className="min-w-0 flex-1">
<Sentence e={e} c={c} className="text-[13.5px]" />
<span className="mt-1 block truncate text-[12px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
{e.location && ` · ${e.location}`}
</span>
</span>
<Icon name="chevron-down" size={16} className={cn('mt-1 shrink-0 text-muted-foreground transition-transform duration-300', isOpen && 'rotate-180')} />
</button>
<div id={`${id}-m-${e.id}`} hidden={!isOpen} className="border-t border-border">
{isOpen && detail(e)}
</div>
</li>
);
})}
</ul>
{!slice.length && <p className="px-4 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>}
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface px-4 py-2.5 text-[12.5px] text-muted-foreground sm:px-5">
<span className="tabular-nums" aria-live="polite">
{rows.length ? fill(t.showing, { from: p * size + 1, to: Math.min(rows.length, p * size + size), total: rows.length }) : ''}
</span>
<span className="flex items-center gap-1">
<span className="me-2 tabular-nums max-sm:hidden">{fill(t.page, { n: p + 1, total: pages })}</span>
<IconButton icon="chevron-left" size="sm" variant="outline" label={t.prev} disabled={p === 0} onClick={() => setPage(p - 1)} />
<IconButton icon="chevron-right" size="sm" variant="outline" label={t.next} disabled={p >= pages - 1} onClick={() => setPage(p + 1)} />
</span>
</div>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* record-history */
/* ------------------------------------------------------------------ */
function RecordHistory(c: Ctx) {
const { t, locale, now, onAction } = c;
const [record, setRecord] = useState(c.record ?? { title: '', fields: [] });
const [events, setEvents] = useState(() => [...c.events].sort((a, b) => toMs(b.time) - toMs(a.time)));
const [only, setOnly] = useState('all');
const [flash, setFlash] = useState<string>();
const [fresh, setFresh] = useState<string>();
const [showRecord, setShowRecord] = useState(false);
const [live, setLive] = useState('');
const id = useId();
const label = (fid: string) => record.fields.find((f) => f.id === fid)?.label ?? fid;
const shown = events.filter((e) => only === 'all' || e.changes?.some((ch) => ch.field === only));
const restore = async (ch: ActivityChange) => {
const cur = record.fields.find((f) => f.id === ch.field);
if (!cur || ch.from === undefined) return;
const ev: ActivityEvent = { id: `x${Date.now()}`, actor: c.me ?? '', kind: 'update', action: t.restoredAction, time: now, changes: [{ field: ch.field, from: cur.value, to: ch.from }] };
setRecord((r) => ({ ...r, fields: r.fields.map((f) => (f.id === ch.field ? { ...f, value: ch.from ?? '' } : f)) }));
setEvents((l) => [ev, ...l]);
setFresh(ev.id);
setFlash(undefined);
requestAnimationFrame(() => setFlash(ch.field));
setLive(fill(t.restored, { field: cur.label, value: ch.from || t.empty }));
await run(onAction, 'restore', { field: ch.field, value: ch.from });
};
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[20rem_minmax(0,1fr)] lg:items-start">
<section aria-labelledby={`${id}-r`} className="b5a-rise rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:60ms] lg:sticky lg:top-6">
<div className="flex items-center gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
<Icon name="briefcase" size={18} />
</span>
<div className="min-w-0 flex-1">
{record.type && <p className="text-[12px] text-muted-foreground">{record.type}</p>}
<h2 id={`${id}-r`} className="truncate text-[15px] font-semibold text-foreground">
{record.title}
</h2>
</div>
<button type="button" aria-expanded={showRecord} aria-controls={`${id}-f`} onClick={() => setShowRecord((v) => !v)} className={cn('-me-1 grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent lg:hidden', ring)}>
<Icon name="chevron-down" size={18} className={cn('transition-transform duration-300', showRecord && 'rotate-180')} />
<span className="sr-only">{showRecord ? t.hideRecord : t.showRecord}</span>
</button>
</div>
<dl id={`${id}-f`} className={cn('px-4 py-2 sm:px-5', !showRecord && 'max-lg:hidden')}>
<p className="pb-1 pt-2 text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.current}</p>
{record.fields.map((f) => (
<div key={f.id} className={cn('-mx-2 flex items-baseline justify-between gap-4 rounded-md border-b border-border px-2 py-2.5 last:border-b-0', flash === f.id && 'b5a-flash')}>
<dt className="text-[13px] text-muted-foreground">{f.label}</dt>
<dd className="text-right text-[13.5px] font-medium text-foreground [overflow-wrap:anywhere]">{f.value || t.empty}</dd>
</div>
))}
</dl>
</section>
<section aria-label={t.history} className="min-w-0">
<div className="b5a-rise flex flex-wrap items-center justify-between gap-3 [--b5a-d:100ms]">
<h2 className="text-[15px] font-semibold text-foreground">{t.history}</h2>
<Pick label={t.fields} value={only} onChange={setOnly} className="w-full sm:w-52">
<option value="all">{t.allFields}</option>
{record.fields.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</Pick>
</div>
<ol className="relative mt-4 grid gap-3">
<span aria-hidden className="b5a-line absolute bottom-6 start-[1.15rem] top-6 w-px bg-border max-sm:hidden" />
{shown.map((e, i) => {
const list = (e.changes ?? []).filter((ch) => only === 'all' || ch.field === only);
return (
<li key={e.id} className={cn('relative flex gap-3 sm:gap-4', e.id === fresh ? 'b5a-rise' : 'b5a-rise')} style={{ '--b5a-d': `${e.id === fresh ? 0 : 140 + Math.min(i, 6) * 60}ms` } as React.CSSProperties}>
<span className="relative max-sm:hidden">
<Avatar name={c.person(e.actor).name} size="sm" alt="" className="ring-4 ring-background" />
</span>
<article className={cn('min-w-0 flex-1 rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color] hover:border-border-strong', e.id === fresh ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-border')}>
<header className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 border-b border-border px-4 py-2.5">
<p className="text-[13.5px] text-muted-foreground">
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.changes?.length ? (e.action || (e.changes.length === 1 ? fill(t.changedOne, { field: label(e.changes[0].field) }) : fill(t.changedN, { n: e.changes.length }))) : e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</p>
<time dateTime={e.time} title={stampFull(e.time, locale)} className="text-[12.5px] tabular-nums text-subtle-foreground">
{ago(e.time, now, locale)}
</time>
</header>
{list.length > 0 && (
<ul>
{list.map((ch, k) => {
const cur = record.fields.find((f) => f.id === ch.field)?.value;
const canRestore = ch.from !== undefined && ch.from !== cur && e.id !== fresh;
return (
<li key={k} className="grid grid-cols-[minmax(0,1fr)] items-center gap-2 border-b border-border px-4 py-3 last:border-b-0 sm:grid-cols-[8.5rem_minmax(0,1fr)_auto] sm:gap-4 lg:grid-cols-[minmax(0,1fr)] lg:gap-2 xl:grid-cols-[8.5rem_minmax(0,1fr)_auto] xl:gap-4">
<span className="text-[12.5px] font-medium text-muted-foreground">{label(ch.field)}</span>
<span className="flex min-w-0 flex-wrap items-center gap-2 text-[13.5px]">
<span className="rounded bg-[color-mix(in_oklab,var(--destructive)_9%,transparent)] px-1.5 py-0.5 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)] [overflow-wrap:anywhere]">{ch.from || t.empty}</span>
<Icon name="arrow-right" size={13} className="shrink-0 text-subtle-foreground" />
<span className="rounded bg-[color-mix(in_oklab,var(--success)_12%,transparent)] px-1.5 py-0.5 font-medium text-foreground [overflow-wrap:anywhere]">{ch.to || t.empty}</span>
</span>
{canRestore ? (
<Button size="sm" variant="ghost" iconLeft="rotate-ccw" className="justify-self-start sm:justify-self-end lg:justify-self-start xl:justify-self-end" onClick={() => restore(ch)}>
<span className="sr-only">{fill(t.restore, { value: `${label(ch.field)} ${ch.from || t.empty}` })}</span>
<span aria-hidden>{fill(t.restore, { value: '' }).trim()}</span>
</Button>
) : (
<span className="max-sm:hidden lg:max-xl:hidden" />
)}
</li>
);
})}
</ul>
)}
{e.detail && <p className="px-4 py-3 text-pretty text-[13.5px] text-foreground">{e.detail}</p>}
</article>
</li>
);
})}
</ol>
</section>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* compact-feed */
/* ------------------------------------------------------------------ */
function CompactFeed(c: Ctx) {
const { t, locale, now, onAction, children } = c;
const [items, setItems] = useState(() => [...c.events].sort((a, b) => toMs(b.time) - toMs(a.time)));
const [rest, setRest] = useState(c.more ?? []);
const [tab, setTab] = useState<'all' | 'mine'>('all');
const [busy, setBusy] = useState(false);
const [seenAll, setSeenAll] = useState(false);
const [added, setAdded] = useState<string[]>([]);
const [live, setLive] = useState('');
const step = 4;
const mine = (e: ActivityEvent) => !!c.me && ((e.detail ?? '').includes('@' + c.person(c.me).name.split(' ')[0]) || e.kind === 'invite' || e.kind === 'share');
const shown = items.filter((e) => tab === 'all' || mine(e));
const unread = seenAll ? 0 : items.filter((e) => e.unread).length;
const firstOld = shown.findIndex((e) => !e.unread);
const load = async () => {
setBusy(true);
await run(onAction, 'load-more', { after: items[items.length - 1]?.id }, 800);
const next = rest.slice(0, step);
setItems((l) => [...l, ...next]);
setRest((r) => r.slice(step));
setAdded(next.map((e) => e.id));
setBusy(false);
setLive(fill(t.loaded, { n: next.length }));
};
const panel = (
<aside aria-label={t.activity} className="b5a-rise flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6 lg:max-h-[calc(100dvh-3rem)]">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
{t.activity}
{unread > 0 && <span className="b5a-pop grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground">{unread}</span>}
</h2>
<IconButton
icon="check-check"
size="sm"
label={t.markSeen}
disabled={!unread}
onClick={() => {
setSeenAll(true);
setLive(t.seen);
void run(onAction, 'seen', {});
}}
/>
</div>
<div className="shrink-0 px-3 pt-3">
<Segmented
label={t.activity}
value={tab}
onChange={setTab}
className="flex w-full"
options={[
{ value: 'all', label: t.all },
{ value: 'mine', label: t.mentions },
]}
/>
</div>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto px-2 py-2">
<ul>
{shown.map((e, i) => (
<li key={e.id} className={cn(added.includes(e.id) || i < 8 ? 'b5a-rise' : '')} style={{ '--b5a-d': `${added.includes(e.id) ? added.indexOf(e.id) * 60 : 80 + i * 40}ms` } as React.CSSProperties}>
{i === firstOld && firstOld > 0 && !seenAll && (
<p className="my-1.5 flex items-center gap-2 px-2 text-[11.5px] font-medium text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]">
<span className="h-px flex-1 bg-[color-mix(in_oklab,var(--primary)_40%,transparent)]" />
{t.newSince}
</p>
)}
<div className="group/it relative flex gap-2.5 rounded-lg px-2 py-2.5 transition-colors hover:bg-surface">
<span className="relative">
<Avatar name={c.person(e.actor).name} size="sm" alt="" />
<span className={cn('absolute -bottom-1 -end-1 grid size-[18px] place-items-center rounded-full ring-2 ring-card', KIND[e.kind].tone)}>
<Icon name={KIND[e.kind].icon} size={10} strokeWidth={2} />
</span>
</span>
<div className="min-w-0 flex-1">
<p className="text-pretty text-[13px] leading-snug text-muted-foreground">
<span className="font-semibold text-foreground">{c.who(e.actor)}</span> {e.action} {e.target && <span className="font-medium text-foreground">{e.target}</span>}
</p>
{e.detail && <p className="mt-1 line-clamp-2 text-pretty text-[12.5px] text-muted-foreground">„{e.detail}“</p>}
<time dateTime={e.time} title={stampFull(e.time, locale)} className="mt-1 block text-[11.5px] tabular-nums text-subtle-foreground">
{agoShort(e.time, now, locale)}
</time>
</div>
{e.unread && !seenAll && <span aria-hidden className="b5a-pop mt-1.5 size-2 shrink-0 rounded-full bg-primary" />}
</div>
</li>
))}
</ul>
<div className="px-2 pb-2 pt-1">
{rest.length ? (
<Button variant="outline" size="sm" className="w-full" iconLeft={busy ? 'loader' : 'arrow-down'} disabled={busy} onClick={load}>
{busy ? t.loading : t.loadMore}
</Button>
) : (
<p className="py-2 text-center text-[12px] text-subtle-foreground">{t.end}</p>
)}
</div>
</div>
</aside>
);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className={cn('mt-6 grid grid-cols-[minmax(0,1fr)] gap-6', children ? 'lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start' : 'mx-auto max-w-[24rem]')}>
{children && <div className="b5a-rise min-w-0 max-lg:hidden">{children}</div>}
<div className="mx-auto w-full max-w-[34rem] lg:max-w-none">{panel}</div>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* digest */
/* ------------------------------------------------------------------ */
function Delta({ now, before, t, locale }: { now: number; before: number; t: Texts; locale: Locale }) {
if (!before && !now) return null;
const pct = before ? Math.round(((now - before) / before) * 100) : 100;
const up = pct >= 0;
return (
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium tabular-nums', up ? 'text-success-foreground' : 'text-muted-foreground')}>
<Icon name={up ? 'trending-up' : 'trending-down'} size={13} />
{new Intl.NumberFormat(intl(locale), { signDisplay: 'exceptZero', style: 'percent' }).format(pct / 100)}
<span className="font-normal text-subtle-foreground">{t.vsPrev}</span>
</span>
);
}
function Digest(c: Ctx) {
const { t, locale, now, events } = c;
const [mode, setMode] = useState<'day' | 'week'>('week');
const [offset, setOffset] = useState(0);
useEffect(() => setOffset(0), [mode]);
const len = mode === 'day' ? 1 : 7;
// Periods end with the day of now
const endDay = toMs(dayKey(now)) + DAY - offset * len * DAY;
const startDay = endDay - len * DAY;
const inRange = (a: number, b: number) => events.filter((e) => toMs(e.time) >= a && toMs(e.time) < b);
const cur = inRange(startDay, endDay);
const prev = inRange(startDay - len * DAY, startDay);
const oldest = Math.min(...events.map((e) => toMs(e.time)));
const stats = (l: ActivityEvent[]) => ({
changes: l.filter((e) => e.kind === 'update' || e.kind === 'status' || e.kind === 'delete').length,
comments: l.filter((e) => e.kind === 'comment').length,
created: l.filter((e) => e.kind === 'create' || e.kind === 'upload').length,
people: new Set(l.map((e) => e.actor)).size,
});
const s = stats(cur);
const sp = stats(prev);
const fmtDay = (ms: number) => new Intl.DateTimeFormat(intl(locale), { day: 'numeric', month: 'long', timeZone: 'UTC' }).format(new Date(ms));
const label = mode === 'day' ? dayLabel(isoOf(startDay), now, locale, t) : `${fmtDay(startDay)} ${locale === 'de' ? 'bis' : 'to'} ${fmtDay(endDay - DAY)}`;
const data =
mode === 'week'
? Array.from({ length: 7 }, (_, i) => {
const a = startDay + i * DAY;
return { day: isoOf(a).slice(0, 10), n: inRange(a, a + DAY).length };
})
: Array.from({ length: 12 }, (_, i) => {
const a = startDay + (7 + i) * 60 * MIN;
return { day: new Intl.DateTimeFormat(intl(locale), { hour: 'numeric', timeZone: 'UTC' }).format(new Date(a)), n: inRange(a, a + 60 * MIN).length };
});
const top = Object.entries(cur.reduce<Record<string, number>>((m, e) => ((m[e.actor] = (m[e.actor] ?? 0) + 1), m), {}))
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
const hot = Object.entries(cur.filter((e) => e.target).reduce<Record<string, number>>((m, e) => ((m[e.target!] = (m[e.target!] ?? 0) + 1), m), {}))
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
const max = Math.max(1, ...top.map((x) => x[1]));
const tiles: { label: string; icon: IconName; n: number; p: number }[] = [
{ label: t.changes, icon: 'edit', n: s.changes, p: sp.changes },
{ label: t.comments, icon: 'message-circle', n: s.comments, p: sp.comments },
{ label: t.created, icon: 'plus-circle', n: s.created, p: sp.created },
{ label: t.activePeople, icon: 'users', n: s.people, p: sp.people },
];
const nf = new Intl.NumberFormat(intl(locale));
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Segmented
label={t.period}
value={mode}
onChange={setMode}
options={[
{ value: 'day', label: t.daily },
{ value: 'week', label: t.weekly },
]}
/>
</Header>
<div className="b5a-rise mt-5 flex items-center gap-2 [--b5a-d:60ms]">
<IconButton icon="chevron-left" size="sm" variant="outline" label={t.prevPeriod} disabled={startDay <= oldest} onClick={() => setOffset((o) => o + 1)} />
<IconButton icon="chevron-right" size="sm" variant="outline" label={t.nextPeriod} disabled={offset === 0} onClick={() => setOffset((o) => Math.max(0, o - 1))} />
<p key={label} className="b5a-rise ms-1 text-[15px] font-semibold text-foreground" aria-live="polite">
{label}
</p>
</div>
<ul className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)] lg:grid-cols-4">
{tiles.map((x, i) => (
<li key={x.label} className="bg-card p-4 sm:p-5">
<div className="b5a-rise" style={{ '--b5a-d': `${100 + i * 60}ms` } as React.CSSProperties}>
<p className="flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name={x.icon} size={15} />
{x.label}
</p>
<p key={`${label}-${x.n}`} className="b5a-pop mt-2 text-[clamp(1.6rem,1.3rem+1vw,2.1rem)] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums">
{nf.format(x.n)}
</p>
<p className="mt-2">
<Delta now={x.n} before={x.p} t={t} locale={locale} />
</p>
</div>
</li>
))}
</ul>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:200ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{mode === 'week' ? t.perDay : t.perHour}</h2>
<div className="mt-3">
{cur.length ? (
<BarChart key={`${mode}-${offset}`} data={data} xKey="day" series={[{ key: 'n', label: t.activity }]} height={220} label={mode === 'week' ? t.perDay : t.perHour} xFormat={mode === 'week' ? { weekday: 'short' } : undefined} valueLabels />
) : (
<p className="py-16 text-center text-[13.5px] text-muted-foreground">{t.noData}</p>
)}
</div>
</section>
<div className="grid content-start gap-6">
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:260ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.top}</h2>
{top.length ? (
<ol className="mt-3 grid gap-3">
{top.map(([pid, n], i) => (
<li key={`${label}-${pid}`} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3">
<Avatar name={c.person(pid).name} size="sm" alt="" />
<span className="min-w-0">
<span className="block truncate text-[13.5px] font-medium text-foreground">{c.who(pid)}</span>
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-primary motion-safe:animate-[b5a-grow_.9s_var(--ease-kit)_both]" style={{ width: `${Math.round((n / max) * 100)}%`, animationDelay: `${i * 70}ms` }} />
</span>
</span>
<span className="text-[13px] font-semibold tabular-nums text-foreground">{n}</span>
</li>
))}
</ol>
) : (
<p className="mt-3 text-[13px] text-muted-foreground">{t.noData}</p>
)}
<style>{'@keyframes b5a-grow{from{transform:scaleX(0)}}'}</style>
</section>
<section className="b5a-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5a-d:320ms] sm:p-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.hot}</h2>
{hot.length ? (
<ul className="mt-2">
{hot.map(([name, n]) => (
<li key={name} className="flex items-center justify-between gap-3 border-b border-border py-2.5 last:border-b-0">
<span className="truncate text-[13.5px] text-foreground">{name}</span>
<span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{fill(t.nEvents, { n })}</span>
</li>
))}
</ul>
) : (
<p className="mt-3 text-[13px] text-muted-foreground">{t.noData}</p>
)}
</section>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* ActivityLog */
/* ------------------------------------------------------------------ */
/**
* Activity screens for apps: a timeline by day with filters, an audit table
* with before and after, the history of one record field by field with
* restore, a narrow feed for side columns and a summary per day or week.
* <ActivityLog variant="timeline" title="Activity" people={[…]} events={[…]} />
*/
export function ActivityLog({ variant = 'timeline', sidebar, labels, className, now = '2026-10-23T14:30', ...props }: ActivityLogProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const person = (id: string): ActivityPerson => props.people.find((p) => p.id === id) ?? { id, name: id };
const who = (id: string) => (id === props.me ? t.you : person(id).name);
const c: Ctx = { ...props, t, locale, now, person, who };
const body = (() => {
switch (variant) {
case 'audit-table':
return <AuditTable {...c} />;
case 'record-history':
return <RecordHistory {...c} />;
case 'compact-feed':
return <CompactFeed {...c} />;
case 'digest':
return <Digest {...c} />;
default:
return <Timeline {...c} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Activity log?
With aiw add @ai-website/activity-log. The command puts components/ui/activity-log.tsx into your project, the code is yours from then on. It brings along avatar, bar-chart, button, icon, icon-button, motion, reveal and sidebar. It needs no further npm packages.
Which variants does Activity log have?
5 variants, Timeline, Audit table, Record history, Side feed and Summary. You pick one with the variant prop, for example variant="audit-table".
Can I translate the texts of Activity log?
Yes. Activity log carries 84 texts of its own in English and German, such as “Search activity”, “Person” and “Everyone”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Activity log?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Activity log with aiw_components and install it with aiw_add @ai-website/activity-log. Name the variant in your prompt, for example variant="timeline", and the assistant writes your content straight into the props.