Zum Inhalt springen
UI Kit
Accent

Aktivitätsprotokoll für Apps

Aktivitäts-Screens für Apps. Zeitleiste nach Tagen mit Filtern, Prüftabelle mit vorher und nachher, Verlauf eines Datensatzes mit Wiederherstellen, Seitenfeed und Zusammenfassung.

5 VariantsEine von 4 Components in Team und Zusammenarbeit

Wer was wann geändert hat

Ein Aktivitätsprotokoll macht gemeinsame Arbeit nachvollziehbar. Wenn mehrere Menschen dieselben Datensätze bearbeiten, zeigt es, wer was wann geändert hat, und ein alter Wert lässt sich zurückholen, statt herumzufragen.

Die „Zeitleiste“ setzt alles nach Tagen auf eine Linie, gefiltert nach Person, Art, Zeitraum und Wörtern, Kommentare zitiert und Änderungen als von und nach. Die „Prüftabelle“ zeigt, wer was wo und wann getan hat, sortierbar und geblättert, jede Zeile öffnet vorher und nachher mit Gerät und Adresse, die Ansicht geht als CSV raus. Der „Verlauf eines Datensatzes“ stellt die aktuellen Werte neben jede Änderung Feld für Feld, ein alter Wert kommt mit einem Klick zurück. Der „Seitenfeed“ ist ein schmaler Feed neben der Arbeit mit Marke für Neues und Reiter für das, was dich betrifft. Die „Zusammenfassung“ vergleicht Änderungen, Kommentare, Neues und aktive Personen je Tag oder Woche mit Diagramm, den aktivsten Personen und meistbearbeiteten Dingen.

Schreib Einträge als einfache Sätze mit der Person vorn, Lena hat die Karte in die Prüfung verschoben. Gib die Prüftabelle den Admins und den Seitenfeed allen anderen. Zeig bei jeder Änderung von und nach, ein bloßes geändert sagt niemandem etwas.

Variants

ZeitleisteNeu in 1.2

Alles nach Tagen auf einer Linie, gefiltert nach Person, Art, Zeitraum und Wörtern, Kommentare zitiert und Änderungen als von und nach.

PrüftabelleNeu in 1.2

Wer was wo und wann getan hat, sortierbar und geblättert, jede Zeile öffnet vorher und nachher mit Gerät und Adresse, die Ansicht als CSV.

Verlauf eines DatensatzesNeu in 1.2

Die aktuellen Werte eines Datensatzes neben jeder Änderung Feld für Feld, nach Feld gefiltert, und ein alter Wert mit einem Klick zurück.

SeitenfeedNeu in 1.2

Ein schmaler Feed neben der Arbeit mit Marke für Neues, Reiter für das, was dich betrifft, kurzen Zeiten und mehr auf Wunsch.

ZusammenfassungNeu in 1.2

Änderungen, Kommentare, Neues und aktive Personen je Tag oder Woche im Vergleich, ein Diagramm, die aktivsten Personen und meistbearbeiteten Dinge.

Details

Was Aktivitätsprotokoll mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/activity-log.tsx
Bringt mit
utilskitavatarbar-chartbuttoniconicon-buttonmotionrevealsidebar
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 84 eigene Texte
Studio
Im Code anpassen
In Sammlungen
App-Dashboard

Props

Die Schnittstelle ActivityLogProps aus components/ui/activity-log.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ActivityLogVariant

    timelineaudit-tablerecord-historycompact-feeddigest

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

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

  • titlePflicht
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • meoptional
    string

    Your person id, written as "You"

  • peoplePflicht
    ActivityPerson[]
  • eventsPflicht
    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.

Eingebaute Texte

Diese Texte schreibt Aktivitätsprotokoll selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 84 Texte anzeigen
searchEnglisch Search activityDeutsch Aktivitäten durchsuchen
personEnglisch PersonDeutsch Person
everyoneEnglisch EveryoneDeutsch Alle Personen
kindEnglisch KindDeutsch Art
allKindsEnglisch Every kindDeutsch Alle Arten
periodEnglisch PeriodDeutsch Zeitraum
dayEnglisch 24 hoursDeutsch 24 Stunden
weekEnglisch 7 daysDeutsch 7 Tage
monthEnglisch 30 daysDeutsch 30 Tage
allEnglisch AllDeutsch Alle
createEnglisch CreatedDeutsch Angelegt
updateEnglisch ChangedDeutsch Geändert
deleteEnglisch DeletedDeutsch Gelöscht
commentEnglisch CommentsDeutsch Kommentare
shareEnglisch SharedDeutsch Geteilt
uploadEnglisch UploadsDeutsch Uploads
statusEnglisch StatusDeutsch Status
loginEnglisch Sign insDeutsch Anmeldungen
inviteEnglisch InvitesDeutsch Einladungen
clearEnglisch Clear filtersDeutsch Filter zurücksetzen
resultsEnglisch {n} activitiesDeutsch {n} Aktivitäten
oneResultEnglisch 1 activityDeutsch 1 Aktivität
noneEnglisch Nothing happened in this view. Widen the period or clear the filters.Deutsch In dieser Ansicht ist nichts passiert. Erweitere den Zeitraum oder setze die Filter zurück.
todayEnglisch TodayDeutsch Heute
yesterdayEnglisch YesterdayDeutsch Gestern
youEnglisch YouDeutsch Du
changedToEnglisch changed toDeutsch geändert auf
timeEnglisch TimeDeutsch Zeit
actorEnglisch WhoDeutsch Wer
actionEnglisch ActionDeutsch Aktion
objectEnglisch ObjectDeutsch Objekt
placeEnglisch WhereDeutsch Wo
sortByEnglisch Sort by {col}Deutsch Nach {col} sortieren
detailsEnglisch Details of {what}Deutsch Details zu {what}
beforeEnglisch BeforeDeutsch Vorher
afterEnglisch AfterDeutsch Nachher
fieldEnglisch FieldDeutsch Feld
emptyEnglisch emptyDeutsch leer
deviceEnglisch DeviceDeutsch Gerät
ipEnglisch IP addressDeutsch IP-Adresse
eventIdEnglisch EventDeutsch Ereignis
exportEnglisch Export CSVDeutsch Als CSV exportieren
exportedEnglisch {n} rows exported as CSV.Deutsch {n} Zeilen als CSV exportiert.
pageEnglisch Page {n} of {total}Deutsch Seite {n} von {total}
showingEnglisch {from} to {to} of {total}Deutsch {from} bis {to} von {total}
prevEnglisch Previous pageDeutsch Vorherige Seite
nextEnglisch Next pageDeutsch Nächste Seite
historyEnglisch HistoryDeutsch Verlauf
currentEnglisch Current valuesDeutsch Aktuelle Werte
fieldsEnglisch FieldsDeutsch Felder
allFieldsEnglisch All fieldsDeutsch Alle Felder
changedNEnglisch changed {n} fieldsDeutsch hat {n} Felder geändert
changedOneEnglisch changed {field}Deutsch hat {field} geändert
restoreEnglisch Restore {value}Deutsch {value} wiederherstellen
restoredEnglisch {field} is back to {value}.Deutsch {field} ist wieder {value}.
restoredActionEnglisch restoredDeutsch hat wiederhergestellt
showRecordEnglisch Show current valuesDeutsch Aktuelle Werte zeigen
hideRecordEnglisch Hide current valuesDeutsch Aktuelle Werte ausblenden
activityEnglisch ActivityDeutsch Aktivität
mentionsEnglisch For youDeutsch Für dich
markSeenEnglisch Mark all as seenDeutsch Alle als gesehen markieren
seenEnglisch Everything is seen.Deutsch Alles gesehen.
newSinceEnglisch New since your last visitDeutsch Neu seit deinem letzten Besuch
loadMoreEnglisch Load moreDeutsch Mehr laden
loadingEnglisch Loading…Deutsch Wird geladen…
loadedEnglisch {n} more activities.Deutsch {n} weitere Aktivitäten.
endEnglisch That is everything from the last weeks.Deutsch Das ist alles aus den letzten Wochen.
digestEnglisch SummaryDeutsch Zusammenfassung
dailyEnglisch DayDeutsch Tag
weeklyEnglisch WeekDeutsch Woche
prevPeriodEnglisch Earlier periodDeutsch Früherer Zeitraum
nextPeriodEnglisch Later periodDeutsch Späterer Zeitraum
changesEnglisch ChangesDeutsch Änderungen
commentsEnglisch CommentsDeutsch Kommentare
createdEnglisch New itemsDeutsch Neu angelegt
activePeopleEnglisch Active peopleDeutsch Aktive Personen
vsPrevEnglisch vs. period beforeDeutsch zum Zeitraum davor
perDayEnglisch Activity per dayDeutsch Aktivität je Tag
perHourEnglisch Activity per hourDeutsch Aktivität je Stunde
topEnglisch Most activeDeutsch Am aktivsten
hotEnglisch Most worked onDeutsch Am meisten bearbeitet
nEventsEnglisch {n} activitiesDeutsch {n} Aktivitäten
noDataEnglisch No activity in this period.Deutsch In diesem Zeitraum gab es keine Aktivität.
weekOfEnglisch Week of {date}Deutsch Woche ab {date}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Aktivitätsprotokoll reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed und aria-sort, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio, radiogroup und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.246 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Aktivitätsprotokoll?

Mit aiw add @ai-website/activity-log. Der Befehl legt die Datei components/ui/activity-log.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, bar-chart, button, icon, icon-button, motion, reveal und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Aktivitätsprotokoll?

5 Varianten, Zeitleiste, Prüftabelle, Verlauf eines Datensatzes, Seitenfeed und Zusammenfassung. Du wählst sie mit der Prop variant, zum Beispiel variant="audit-table".

Kann ich die Texte von Aktivitätsprotokoll übersetzen?

Ja. Aktivitätsprotokoll bringt 84 eigene Texte auf Deutsch und Englisch mit, etwa „Aktivitäten durchsuchen“, „Person“ und „Alle Personen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Aktivitätsprotokoll einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Aktivitätsprotokoll mit aiw_components und installieren sie mit aiw_add @ai-website/activity-log. Nenn im Prompt die Variante, etwa variant="timeline", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert