Skip to content
UI Kit
Accent color

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.

5 variantsOne of 4 components in Team and collaboration

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

TimelineNew in 1.2

Everything by day on a line, filtered by person, kind, period and words, with comments quoted and changes as from and to.

Audit tableNew in 1.2

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

Side feedNew in 1.2

A narrow feed beside the work with a mark for what is new, a tab for what concerns you, short times and more on request.

SummaryNew in 1.2

Changes, comments, new items and active people per day or week compared with before, a chart, the most active people and the busiest items.

Details

What Activity log brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/activity-log.tsx
Brings along
utilskitavatarbar-chartbuttoniconicon-buttonmotionrevealsidebar
npm packages
None
Languages
English and German, 84 texts of its own
Studio
Adjust in code
In collections
App dashboard

Props

The ActivityLogProps interface from components/ui/activity-log.tsx, with the comments from the code.

  • variantoptional
    ActivityLogVariant

    timelineaudit-tablerecord-historycompact-feeddigest

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • meoptional
    string

    Your person id, written as "You"

  • peoplerequired
    ActivityPerson[]
  • eventsrequired
    ActivityEvent[]
  • moreoptional
    ActivityEvent[]

    compact-feed: older events that "Load more" brings in, in pages

  • recordoptional
    ActivityRecord

    record-history: the record the changes belong to

  • pageSizeoptional
    number

    audit-table: rows per page

  • childrenoptional
    React.ReactNode

    compact-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
searchEnglish Search activityGerman Aktivitäten durchsuchen
personEnglish PersonGerman Person
everyoneEnglish EveryoneGerman Alle Personen
kindEnglish KindGerman Art
allKindsEnglish Every kindGerman Alle Arten
periodEnglish PeriodGerman Zeitraum
dayEnglish 24 hoursGerman 24 Stunden
weekEnglish 7 daysGerman 7 Tage
monthEnglish 30 daysGerman 30 Tage
allEnglish AllGerman Alle
createEnglish CreatedGerman Angelegt
updateEnglish ChangedGerman Geändert
deleteEnglish DeletedGerman Gelöscht
commentEnglish CommentsGerman Kommentare
shareEnglish SharedGerman Geteilt
uploadEnglish UploadsGerman Uploads
statusEnglish StatusGerman Status
loginEnglish Sign insGerman Anmeldungen
inviteEnglish InvitesGerman Einladungen
clearEnglish Clear filtersGerman Filter zurücksetzen
resultsEnglish {n} activitiesGerman {n} Aktivitäten
oneResultEnglish 1 activityGerman 1 Aktivität
noneEnglish 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.
todayEnglish TodayGerman Heute
yesterdayEnglish YesterdayGerman Gestern
youEnglish YouGerman Du
changedToEnglish changed toGerman geändert auf
timeEnglish TimeGerman Zeit
actorEnglish WhoGerman Wer
actionEnglish ActionGerman Aktion
objectEnglish ObjectGerman Objekt
placeEnglish WhereGerman Wo
sortByEnglish Sort by {col}German Nach {col} sortieren
detailsEnglish Details of {what}German Details zu {what}
beforeEnglish BeforeGerman Vorher
afterEnglish AfterGerman Nachher
fieldEnglish FieldGerman Feld
emptyEnglish emptyGerman leer
deviceEnglish DeviceGerman Gerät
ipEnglish IP addressGerman IP-Adresse
eventIdEnglish EventGerman Ereignis
exportEnglish Export CSVGerman Als CSV exportieren
exportedEnglish {n} rows exported as CSV.German {n} Zeilen als CSV exportiert.
pageEnglish Page {n} of {total}German Seite {n} von {total}
showingEnglish {from} to {to} of {total}German {from} bis {to} von {total}
prevEnglish Previous pageGerman Vorherige Seite
nextEnglish Next pageGerman Nächste Seite
historyEnglish HistoryGerman Verlauf
currentEnglish Current valuesGerman Aktuelle Werte
fieldsEnglish FieldsGerman Felder
allFieldsEnglish All fieldsGerman Alle Felder
changedNEnglish changed {n} fieldsGerman hat {n} Felder geändert
changedOneEnglish changed {field}German hat {field} geändert
restoreEnglish Restore {value}German {value} wiederherstellen
restoredEnglish {field} is back to {value}.German {field} ist wieder {value}.
restoredActionEnglish restoredGerman hat wiederhergestellt
showRecordEnglish Show current valuesGerman Aktuelle Werte zeigen
hideRecordEnglish Hide current valuesGerman Aktuelle Werte ausblenden
activityEnglish ActivityGerman Aktivität
mentionsEnglish For youGerman Für dich
markSeenEnglish Mark all as seenGerman Alle als gesehen markieren
seenEnglish Everything is seen.German Alles gesehen.
newSinceEnglish New since your last visitGerman Neu seit deinem letzten Besuch
loadMoreEnglish Load moreGerman Mehr laden
loadingEnglish Loading…German Wird geladen…
loadedEnglish {n} more activities.German {n} weitere Aktivitäten.
endEnglish That is everything from the last weeks.German Das ist alles aus den letzten Wochen.
digestEnglish SummaryGerman Zusammenfassung
dailyEnglish DayGerman Tag
weeklyEnglish WeekGerman Woche
prevPeriodEnglish Earlier periodGerman Früherer Zeitraum
nextPeriodEnglish Later periodGerman Späterer Zeitraum
changesEnglish ChangesGerman Änderungen
commentsEnglish CommentsGerman Kommentare
createdEnglish New itemsGerman Neu angelegt
activePeopleEnglish Active peopleGerman Aktive Personen
vsPrevEnglish vs. period beforeGerman zum Zeitraum davor
perDayEnglish Activity per dayGerman Aktivität je Tag
perHourEnglish Activity per hourGerman Aktivität je Stunde
topEnglish Most activeGerman Am aktivsten
hotEnglish Most worked onGerman Am meisten bearbeitet
nEventsEnglish {n} activitiesGerman {n} Aktivitäten
noDataEnglish No activity in this period.German In diesem Zeitraum gab es keine Aktivität.
weekOfEnglish 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
components/ui/activity-log.tsx
'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.

Often searched

Jump to

Copied to the clipboard