Skip to content
UI Kit
Accent color

Automation rules

Automation screens. When and then rules with switches, an editor that reads the rule back as a sentence, templates, a run log with retry and a schedule with the next runs.

One of 3 components in Automation and builders

5 variants

Variants

Rule list

Every rule as a chain from trigger to actions, runs and last run, a switch to pause and a mark when the last run failed.

Open in Studio

Rule editor

When, if and then as three connected cards, all or any conditions, ordered actions, the rule as one sentence and a test with the last event.

Open in Studio

Rule templates

Templates by category with their steps as a small flow, how many teams use them and a one click setup.

Open in Studio

Run log

Every run with result, subject and duration, the steps on a line with the reason a step failed, and a retry.

Open in Studio

Schedule

Hourly, daily, weekly or monthly, days as toggles, the rule in plain words and the next five runs worked out live.

Open in Studio

Details

What Automation rules brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/automation-rules.tsx
Brings along
utilskitbuttoniconswitchsidebar
npm packages
None
Languages
English and German, 82 texts of its own

Props

The AutomationRulesProps interface from components/ui/automation-rules.tsx, with the comments from the code.

  • variantoptional
    AutomationRulesVariant

    rule-listrule-editortemplatesrun-logschedule

  • 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

  • triggersoptional
    RuleOption[]
  • fieldsoptional
    RuleOption[]
  • actionsoptional
    RuleOption[]
  • rulesoptional
    Rule[]
  • ruleoptional
    Rule

    rule-editor: the rule on screen

  • templatesoptional
    RuleTemplate[]
  • runsoptional
    RuleRun[]
  • timezoneoptional
    string

    schedule

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on save, toggle, retry and use. Without it the block simulates the request.

Built-in texts

Automation rules writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 82 texts
rulesEnglish RulesGerman Regeln
whenEnglish WhenGerman Wenn
sentenceIfEnglish ifGerman und
thenEnglish ThenGerman Dann
andEnglish andGerman und
orEnglish orGerman oder
allEnglish AllGerman Alle
activeEnglish ActiveGerman Aktiv
pausedEnglish PausedGerman Pausiert
failingEnglish Needs attentionGerman Braucht Aufmerksamkeit
runsEnglish {n} runsGerman {n} Läufe
lastRunEnglish Last run {when}German Zuletzt {when}
neverEnglish Not run yetGerman Noch nicht gelaufen
toggleEnglish Run {name}German {name} ausführen
newRuleEnglish New ruleGerman Neue Regel
searchEnglish Search rulesGerman Regeln suchen
noMatchEnglish No rule matches.German Keine Regel passt.
triggerEnglish TriggerGerman Auslöser
conditionsEnglish ConditionsGerman Bedingungen
oneConditionEnglish + 1 conditionGerman + 1 Bedingung
nConditionsEnglish + {n} conditionsGerman + {n} Bedingungen
actionsEnglish ActionsGerman Aktionen
matchAllEnglish All conditionsGerman Alle Bedingungen
matchAnyEnglish Any conditionGerman Eine Bedingung
fieldEnglish FieldGerman Feld
opEnglish ConditionGerman Bedingung
valueEnglish ValueGerman Wert
isEnglish isGerman ist
is-notEnglish is notGerman ist nicht
greaterEnglish is more thanGerman ist größer als
lessEnglish is less thanGerman ist kleiner als
containsEnglish containsGerman enthält
addConditionEnglish Add conditionGerman Bedingung hinzufügen
addActionEnglish Add actionGerman Aktion hinzufügen
removeEnglish Remove {what}German {what} entfernen
moveUpEnglish Move action {n} upGerman Aktion {n} nach oben
targetEnglish WithGerman Mit
sentenceEnglish In one sentenceGerman In einem Satz
nameEnglish Rule nameGerman Name der Regel
saveEnglish Save ruleGerman Regel speichern
savingEnglish Saving…German Wird gespeichert…
savedEnglish Rule saved and active.German Regel gespeichert und aktiv.
testEnglish Test with the last eventGerman Mit dem letzten Ereignis testen
testingEnglish Testing…German Wird getestet…
testedEnglish Every condition held, {n} actions would run.German Alle Bedingungen erfüllt, {n} Aktionen würden laufen.
noActionsEnglish Add at least one action.German Füge mindestens eine Aktion hinzu.
templatesEnglish TemplatesGerman Vorlagen
allCategoriesEnglish AllGerman Alle
useEnglish Use templateGerman Vorlage nutzen
usingEnglish Setting up…German Wird eingerichtet…
usedEnglish {name} is set up. Check the details and turn it on.German {name} ist eingerichtet. Prüfe die Details und schalte sie ein.
usesNEnglish Used by {n} teamsGerman Von {n} Teams genutzt
stepsEnglish StepsGerman Schritte
runLogEnglish RunsGerman Läufe
successEnglish SucceededGerman Erfolgreich
failedEnglish FailedGerman Fehlgeschlagen
skippedEnglish SkippedGerman Übersprungen
retryEnglish Run againGerman Erneut ausführen
retryingEnglish Running…German Läuft…
retriedEnglish Run again, it succeeded this time.German Erneut ausgeführt, diesmal erfolgreich.
durationEnglish {ms} msGerman {ms} ms
showStepsEnglish Show steps of the run for {subject}German Schritte des Laufs für {subject} zeigen
scheduleEnglish ScheduleGerman Zeitplan
scheduleTextEnglish Runs the rule on its own at the times you pick.German Führt die Regel zu den gewählten Zeiten von selbst aus.
everyEnglish RepeatGerman Wiederholen
hourlyEnglish HourlyGerman Stündlich
dailyEnglish DailyGerman Täglich
weeklyEnglish WeeklyGerman Wöchentlich
monthlyEnglish MonthlyGerman Monatlich
onEnglish OnGerman Am
atEnglish AtGerman Um
atMinuteEnglish At minuteGerman Zur Minute
dayOfMonthEnglish Day of the monthGerman Tag im Monat
timezoneEnglish Time zoneGerman Zeitzone
nextEnglish Next runsGerman Nächste Läufe
pickDayEnglish Pick at least one day.German Wähle mindestens einen Tag.
saveScheduleEnglish Save scheduleGerman Zeitplan speichern
scheduleSavedEnglish Schedule saved. Next run {when}.German Zeitplan gespeichert. Nächster Lauf {when}.
summaryHourlyEnglish Every hour at minute {m}German Jede Stunde zur Minute {m}
summaryDailyEnglish Every day at {t}German Jeden Tag um {t}
summaryWeeklyEnglish Every {days} at {t}German Jeden {days} um {t}
summaryMonthlyEnglish On day {d} of every month at {t}German Am {d}. jeden Monats um {t}

Accessibility

Read from the source of the component.

  • Automation rules handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-controls, aria-expanded, aria-label, aria-live and aria-pressed, 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

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,181 lines
components/ui/automation-rules.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';

export type AutomationRulesVariant = 'rule-list' | 'rule-editor' | 'templates' | 'run-log' | 'schedule';

export interface RuleOption {
  id: string;
  label: string;
  icon?: IconName;
}

export interface RuleCondition {
  id: string;
  field: string;
  op: 'is' | 'is-not' | 'greater' | 'less' | 'contains';
  value: string;
}

export interface RuleAction {
  id: string;
  /** Option id from actions */
  type: string;
  /** What the action works with, e.g. a channel or a person */
  target: string;
}

export interface Rule {
  id: string;
  name: string;
  /** Option id from triggers */
  trigger: string;
  conditions: RuleCondition[];
  /** all: every condition must hold, any: one is enough */
  match?: 'all' | 'any';
  actions: RuleAction[];
  enabled: boolean;
  runs?: number;
  /** ISO date and time */
  lastRun?: string;
  /** The last run failed */
  failing?: boolean;
}

export interface RuleTemplate {
  id: string;
  name: string;
  description: string;
  category: string;
  /** Short steps, e.g. ["New form entry", "Create contact", "Send welcome email"] */
  steps: string[];
  icon?: IconName;
  uses?: number;
}

export interface RuleRun {
  id: string;
  rule: string;
  /** ISO date and time */
  time: string;
  status: 'success' | 'failed' | 'skipped';
  /** What started it, e.g. Deal #482 */
  subject: string;
  ms: number;
  steps: { label: string; status: 'success' | 'failed' | 'skipped'; detail?: string }[];
}

export interface AutomationRulesProps {
  variant?: AutomationRulesVariant;
  /** 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;
  triggers?: RuleOption[];
  fields?: RuleOption[];
  actions?: RuleOption[];
  rules?: Rule[];
  /** rule-editor: the rule on screen */
  rule?: Rule;
  templates?: RuleTemplate[];
  runs?: RuleRun[];
  /** schedule */
  timezone?: string;
  /** Called on save, toggle, retry and use. 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: {
    rules: 'Rules',
    when: 'When',
    sentenceIf: 'if',
    then: 'Then',
    and: 'and',
    or: 'or',
    all: 'All',
    active: 'Active',
    paused: 'Paused',
    failing: 'Needs attention',
    runs: '{n} runs',
    lastRun: 'Last run {when}',
    never: 'Not run yet',
    toggle: 'Run {name}',
    newRule: 'New rule',
    search: 'Search rules',
    noMatch: 'No rule matches.',
    trigger: 'Trigger',
    conditions: 'Conditions',
    oneCondition: '+ 1 condition',
    nConditions: '+ {n} conditions',
    actions: 'Actions',
    matchAll: 'All conditions',
    matchAny: 'Any condition',
    field: 'Field',
    op: 'Condition',
    value: 'Value',
    is: 'is',
    'is-not': 'is not',
    greater: 'is more than',
    less: 'is less than',
    contains: 'contains',
    addCondition: 'Add condition',
    addAction: 'Add action',
    remove: 'Remove {what}',
    moveUp: 'Move action {n} up',
    target: 'With',
    sentence: 'In one sentence',
    name: 'Rule name',
    save: 'Save rule',
    saving: 'Saving…',
    saved: 'Rule saved and active.',
    test: 'Test with the last event',
    testing: 'Testing…',
    tested: 'Every condition held, {n} actions would run.',
    noActions: 'Add at least one action.',
    templates: 'Templates',
    allCategories: 'All',
    use: 'Use template',
    using: 'Setting up…',
    used: '{name} is set up. Check the details and turn it on.',
    usesN: 'Used by {n} teams',
    steps: 'Steps',
    runLog: 'Runs',
    success: 'Succeeded',
    failed: 'Failed',
    skipped: 'Skipped',
    retry: 'Run again',
    retrying: 'Running…',
    retried: 'Run again, it succeeded this time.',
    duration: '{ms} ms',
    showSteps: 'Show steps of the run for {subject}',
    schedule: 'Schedule',
    scheduleText: 'Runs the rule on its own at the times you pick.',
    every: 'Repeat',
    hourly: 'Hourly',
    daily: 'Daily',
    weekly: 'Weekly',
    monthly: 'Monthly',
    on: 'On',
    at: 'At',
    atMinute: 'At minute',
    dayOfMonth: 'Day of the month',
    timezone: 'Time zone',
    next: 'Next runs',
    pickDay: 'Pick at least one day.',
    saveSchedule: 'Save schedule',
    scheduleSaved: 'Schedule saved. Next run {when}.',
    summaryHourly: 'Every hour at minute {m}',
    summaryDaily: 'Every day at {t}',
    summaryWeekly: 'Every {days} at {t}',
    summaryMonthly: 'On day {d} of every month at {t}',
  },
  de: {
    rules: 'Regeln',
    when: 'Wenn',
    sentenceIf: 'und',
    then: 'Dann',
    and: 'und',
    or: 'oder',
    all: 'Alle',
    active: 'Aktiv',
    paused: 'Pausiert',
    failing: 'Braucht Aufmerksamkeit',
    runs: '{n} Läufe',
    lastRun: 'Zuletzt {when}',
    never: 'Noch nicht gelaufen',
    toggle: '{name} ausführen',
    newRule: 'Neue Regel',
    search: 'Regeln suchen',
    noMatch: 'Keine Regel passt.',
    trigger: 'Auslöser',
    conditions: 'Bedingungen',
    oneCondition: '+ 1 Bedingung',
    nConditions: '+ {n} Bedingungen',
    actions: 'Aktionen',
    matchAll: 'Alle Bedingungen',
    matchAny: 'Eine Bedingung',
    field: 'Feld',
    op: 'Bedingung',
    value: 'Wert',
    is: 'ist',
    'is-not': 'ist nicht',
    greater: 'ist größer als',
    less: 'ist kleiner als',
    contains: 'enthält',
    addCondition: 'Bedingung hinzufügen',
    addAction: 'Aktion hinzufügen',
    remove: '{what} entfernen',
    moveUp: 'Aktion {n} nach oben',
    target: 'Mit',
    sentence: 'In einem Satz',
    name: 'Name der Regel',
    save: 'Regel speichern',
    saving: 'Wird gespeichert…',
    saved: 'Regel gespeichert und aktiv.',
    test: 'Mit dem letzten Ereignis testen',
    testing: 'Wird getestet…',
    tested: 'Alle Bedingungen erfüllt, {n} Aktionen würden laufen.',
    noActions: 'Füge mindestens eine Aktion hinzu.',
    templates: 'Vorlagen',
    allCategories: 'Alle',
    use: 'Vorlage nutzen',
    using: 'Wird eingerichtet…',
    used: '{name} ist eingerichtet. Prüfe die Details und schalte sie ein.',
    usesN: 'Von {n} Teams genutzt',
    steps: 'Schritte',
    runLog: 'Läufe',
    success: 'Erfolgreich',
    failed: 'Fehlgeschlagen',
    skipped: 'Übersprungen',
    retry: 'Erneut ausführen',
    retrying: 'Läuft…',
    retried: 'Erneut ausgeführt, diesmal erfolgreich.',
    duration: '{ms} ms',
    showSteps: 'Schritte des Laufs für {subject} zeigen',
    schedule: 'Zeitplan',
    scheduleText: 'Führt die Regel zu den gewählten Zeiten von selbst aus.',
    every: 'Wiederholen',
    hourly: 'Stündlich',
    daily: 'Täglich',
    weekly: 'Wöchentlich',
    monthly: 'Monatlich',
    on: 'Am',
    at: 'Um',
    atMinute: 'Zur Minute',
    dayOfMonth: 'Tag im Monat',
    timezone: 'Zeitzone',
    next: 'Nächste Läufe',
    pickDay: 'Wähle mindestens einen Tag.',
    saveSchedule: 'Zeitplan speichern',
    scheduleSaved: 'Zeitplan gespeichert. Nächster Lauf {when}.',
    summaryHourly: 'Jede Stunde zur Minute {m}',
    summaryDaily: 'Jeden Tag um {t}',
    summaryWeekly: 'Jeden {days} um {t}',
    summaryMonthly: 'Am {d}. jeden Monats um {t}',
  },
};

type Texts = (typeof TEXTS)['en'];

const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);

type P = Omit<AutomationRulesProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale };

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: AutomationRulesProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
  if (onAction) await onAction(action, data);
  else await wait(prefersReducedMotion() ? 0 : ms);
}

function ago(locale: Locale, iso: string, now: string) {
  const rtf = new Intl.RelativeTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { numeric: 'auto' });
  const d = toMin(iso) - toMin(now);
  const a = Math.abs(d);
  if (a < 60) return rtf.format(Math.round(d), 'minute');
  if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
  return rtf.format(Math.round(d / 1440), 'day');
}

function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
  return (
    <div className="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 Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
  return (
    <section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
      {(title || action) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
          <div className="min-w-0">
            {title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
            {description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
          </div>
          {action}
        </div>
      )}
      <div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
    </section>
  );
}

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 = useGlide<HTMLDivElement>(value);
  const onKey = (e: React.KeyboardEvent, i: number) => {
    const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = (i + dir + options.length) % options.length;
    onChange(options[next].value);
    ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
  };
  return (
    <div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 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) => onKey(e, i)}
          className={cn(
            'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
            value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
          <span className="relative inline-flex items-center gap-1.5">{o.label}</span>
        </button>
      ))}
    </div>
  );
}

function Live({ text }: { text: string }) {
  return (
    <p role="status" aria-live="polite" className="sr-only">
      {text}
    </p>
  );
}

/** The browser select in the look of the kit fields */
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
  return (
    <span className={cn('relative block min-w-0', className)}>
      <select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-8 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]">
        {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" />
    </span>
  );
}

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 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]';

/** A trigger or an action as a small tile with its icon */
function Node({ icon, label, tone = 'muted' }: { icon?: IconName; label: string; tone?: 'muted' | 'trigger' | 'action' }) {
  return (
    <span className={cn('inline-flex h-7 max-w-full items-center gap-1.5 rounded-md border px-2 text-[12.5px] font-medium', tone === 'trigger' ? 'border-[color-mix(in_oklab,var(--warning)_40%,var(--border))] bg-[color-mix(in_oklab,var(--warning)_12%,var(--card))] text-foreground' : tone === 'action' ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] text-foreground' : 'border-border bg-surface text-muted-foreground')}>
      {icon && <Icon name={icon} size={13} className={cn('shrink-0', tone === 'trigger' ? 'text-warning-foreground' : tone === 'action' ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : '')} />}
      <span className="truncate">{label}</span>
    </span>
  );
}

const STATUS: Record<RuleRun['status'], { icon: IconName; tone: string; dot: string }> = {
  success: { icon: 'check', tone: 'text-success-foreground', dot: 'bg-success' },
  failed: { icon: 'x', tone: 'text-destructive', dot: 'bg-destructive' },
  skipped: { icon: 'minus', tone: 'text-muted-foreground', dot: 'bg-subtle-foreground' },
};

/* ------------------------------------------------------------------ */
/* rule-list                                                           */
/* ------------------------------------------------------------------ */

function RuleList({ t, title, subtitle, rules = [], triggers = [], actions = [], now, locale, onAction }: P) {
  const [items, setItems] = useState(rules);
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState<'all' | 'active' | 'paused'>('all');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tr = (id: string) => triggers.find((x) => x.id === id);
  const ac = (id: string) => actions.find((x) => x.id === id);
  const shown = items.filter((r) => (filter === 'all' || (filter === 'active' ? r.enabled : !r.enabled)) && (!q || r.name.toLowerCase().includes(q.toLowerCase())));
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Button size="sm" iconLeft="plus" magnetic onClick={() => void onAction?.('new', {})}>
          {t.newRule}
        </Button>
      </Header>
      <div ref={scope} {...enter} className="mt-6 grid gap-4">
        <div className="kitm-up flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <Segmented
            label={t.rules}
            value={filter}
            onChange={setFilter}
            className="w-full sm:w-auto"
            options={[
              { value: 'all', label: t.all },
              { value: 'active', label: <>{t.active}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => r.enabled).length}</span></> },
              { value: 'paused', label: <>{t.paused}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => !r.enabled).length}</span></> },
            ]}
          />
          <label className="relative block sm:w-64">
            <span className="sr-only">{t.search}</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={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
          </label>
        </div>
        <ul className="grid gap-3">
          {shown.map((r, i) => (
            <li key={r.id} className={cn('kitm-up rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-opacity duration-300 sm:p-5', r.failing && r.enabled ? 'border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))]' : 'border-border', !r.enabled && 'opacity-75')} style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
              <div className="flex items-start gap-4">
                <div className="min-w-0 flex-1">
                  <p className="flex flex-wrap items-center gap-2">
                    <span className="text-[15px] font-semibold text-foreground">{r.name}</span>
                    {r.failing && r.enabled && (
                      <span className="inline-flex items-center gap-1 rounded bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] px-1.5 py-px text-[11px] font-semibold text-destructive">
                        <Icon name="alert-circle" size={11} />
                        {t.failing}
                      </span>
                    )}
                  </p>
                  {/* The rule as a chain: trigger, then each action */}
                  <div className="mt-3 flex flex-wrap items-center gap-1.5">
                    <span className="text-[12px] font-medium text-muted-foreground">{t.when}</span>
                    <Node icon={tr(r.trigger)?.icon} label={tr(r.trigger)?.label ?? r.trigger} tone="trigger" />
                    {r.conditions.length > 0 && <span className="text-[12px] text-subtle-foreground">{r.conditions.length === 1 ? t.oneCondition : fill(t.nConditions, { n: r.conditions.length })}</span>}
                    {r.actions.map((a, k) => (
                      <span key={a.id} className="inline-flex max-w-full items-center gap-1.5">
                        {k === 0 ? <Icon name="arrow-right" size={14} className="mx-0.5 shrink-0 text-subtle-foreground" /> : <span className="text-[12px] text-subtle-foreground">{t.and}</span>}
                        <Node icon={ac(a.type)?.icon} label={ac(a.type)?.label ?? a.type} tone="action" />
                      </span>
                    ))}
                  </div>
                  <p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] tabular-nums text-subtle-foreground">
                    <span>{fill(t.runs, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(r.runs ?? 0) })}</span>
                    <span>{r.lastRun ? fill(t.lastRun, { when: ago(locale, r.lastRun, now) }) : t.never}</span>
                  </p>
                </div>
                <Switch
                  checked={r.enabled}
                  aria-label={fill(t.toggle, { name: r.name })}
                  onCheckedChange={(v) => {
                    setItems((l) => l.map((x) => (x.id === r.id ? { ...x, enabled: v } : x)));
                    setMsg(`${r.name}: ${v ? t.active : t.paused}`);
                    void run(onAction, 'toggle', { id: r.id, enabled: v });
                  }}
                />
              </div>
            </li>
          ))}
        </ul>
        {!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>}
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* rule-editor                                                         */
/* ------------------------------------------------------------------ */

const OPS: RuleCondition['op'][] = ['is', 'is-not', 'greater', 'less', 'contains'];

function RuleEditor({ t, title, subtitle, rule, triggers = [], fields = [], actions = [], locale, onAction }: P) {
  // Inside a sentence English labels go lower case, German nouns keep their capital
  const low = (s: string) => (locale === 'de' ? s : s.charAt(0).toLowerCase() + s.slice(1));
  const id = useId();
  const start = rule ?? { id: 'new', name: '', trigger: triggers[0]?.id ?? '', conditions: [], actions: [], enabled: false, match: 'all' as const };
  const [r, setR] = useState<Rule>({ match: 'all', ...start });
  const [state, setState] = useState<'idle' | 'busy' | 'saved' | 'testing' | 'tested'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tr = triggers.find((x) => x.id === r.trigger);
  const fieldLabel = (fid: string) => fields.find((x) => x.id === fid)?.label ?? fid;
  const acLabel = (aid: string) => actions.find((x) => x.id === aid)?.label ?? aid;
  const set = (patch: Partial<Rule>) => {
    setR((x) => ({ ...x, ...patch }));
    setState('idle');
  };
  const sentence = (
    <>
      <b className="font-semibold text-foreground">{t.when}</b> {tr ? low(tr.label) : '…'}
      {r.conditions.map((c, i) => (
        <span key={c.id}>
          {i === 0 ? ` ${t.sentenceIf} ` : ` ${r.match === 'any' ? t.or : t.and} `}
          <span className="text-foreground">{fieldLabel(c.field)}</span> {t[c.op]} <span className="rounded bg-muted px-1 text-foreground">{c.value || '…'}</span>
        </span>
      ))}
      {', '}
      <b className="font-semibold text-foreground">{t.then.toLowerCase()}</b>{' '}
      {r.actions.length
        ? r.actions.map((a, i) => (
            <span key={a.id}>
              {i > 0 ? (i === r.actions.length - 1 ? ` ${t.and} ` : ', ') : ''}
              {low(acLabel(a.type))} <span className="text-foreground">{a.target || '…'}</span>
            </span>
          ))
        : '…'}
      {r.actions.length > 0 && r.actions[r.actions.length - 1].target ? '.' : ''}
    </>
  );
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start">
        <div className="grid min-w-0 gap-0">
          {/* When */}
          <section className="kitm-up relative rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
              <span className="grid size-6 place-items-center rounded-md bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground">
                <Icon name="bolt" size={13} />
              </span>
              {t.when}
            </h2>
            <div className="mt-3 grid gap-3 sm:grid-cols-2">
              <label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground" htmlFor={`${id}-name`}>
                {t.name}
                <input id={`${id}-name`} value={r.name} onChange={(e) => set({ name: e.target.value })} className={field} />
              </label>
              <div className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
                {t.trigger}
                <Pick label={t.trigger} value={r.trigger} onChange={(v) => set({ trigger: v })}>
                  {triggers.map((x) => (
                    <option key={x.id} value={x.id}>
                      {x.label}
                    </option>
                  ))}
                </Pick>
              </div>
            </div>
          </section>
          <span aria-hidden className="mx-auto block h-6 w-px bg-border" />
          {/* If */}
          <section className="kitm-up [--kitm-d:80ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <div className="flex flex-wrap items-center justify-between gap-3">
              <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
                <span className="grid size-6 place-items-center rounded-md bg-muted text-muted-foreground">
                  <Icon name="filter" size={13} />
                </span>
                {t.conditions}
              </h2>
              {r.conditions.length > 1 && (
                <Segmented
                  label={t.conditions}
                  value={r.match ?? 'all'}
                  onChange={(v) => set({ match: v })}
                  options={[
                    { value: 'all', label: t.matchAll },
                    { value: 'any', label: t.matchAny },
                  ]}
                />
              )}
            </div>
            <ul className="mt-3 grid gap-2">
              {r.conditions.map((c, i) => (
                <li key={c.id} className="kitm-in">
                  {i > 0 && <p className="mb-2 ps-1 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{r.match === 'any' ? t.or : t.and}</p>}
                  <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1fr)_auto]">
                    <Pick label={t.field} value={c.field} onChange={(v) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, field: v } : x)) })} className="max-sm:col-span-1">
                      {fields.map((x) => (
                        <option key={x.id} value={x.id}>
                          {x.label}
                        </option>
                      ))}
                    </Pick>
                    <button type="button" aria-label={fill(t.remove, { what: fieldLabel(c.field) })} onClick={() => set({ conditions: r.conditions.filter((x) => x.id !== c.id) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:order-last sm:size-9">
                      <Icon name="x" size={15} />
                    </button>
                    <Pick className="max-sm:col-span-2" label={t.op} value={c.op} onChange={(v) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, op: v as RuleCondition['op'] } : x)) })}>
                      {OPS.map((o) => (
                        <option key={o} value={o}>
                          {t[o]}
                        </option>
                      ))}
                    </Pick>
                    <input aria-label={t.value} value={c.value} onChange={(e) => set({ conditions: r.conditions.map((x) => (x.id === c.id ? { ...x, value: e.target.value } : x)) })} className={cn(field, 'max-sm:col-span-2 sm:col-span-1')} />
                  </div>
                </li>
              ))}
            </ul>
            <Button variant="ghost" size="sm" iconLeft="plus" className="mt-2" onClick={() => set({ conditions: [...r.conditions, { id: `c${Date.now()}`, field: fields[0]?.id ?? '', op: 'is', value: '' }] })}>
              {t.addCondition}
            </Button>
          </section>
          <span aria-hidden className="mx-auto block h-6 w-px bg-border" />
          {/* Then */}
          <section className="kitm-up [--kitm-d:160ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
            <h2 className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
              <span className="grid size-6 place-items-center rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                <Icon name="play" size={12} />
              </span>
              {t.then}
            </h2>
            <ol className="mt-3 grid gap-2">
              {r.actions.map((a, i) => (
                <li key={a.id} className="kitm-in grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-border bg-surface p-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)_auto]">
                  <span className="grid size-7 place-items-center rounded-full bg-card text-[12px] font-semibold tabular-nums text-foreground ring-1 ring-border">{i + 1}</span>
                  <Pick label={t.actions} value={a.type} className="col-span-2 sm:col-span-1" onChange={(v) => set({ actions: r.actions.map((x) => (x.id === a.id ? { ...x, type: v } : x)) })}>
                    {actions.map((x) => (
                      <option key={x.id} value={x.id}>
                        {x.label}
                      </option>
                    ))}
                  </Pick>
                  <span className="order-last flex items-center">
                    <button type="button" aria-label={fill(t.moveUp, { n: i + 1 })} disabled={i === 0} onClick={() => set({ actions: r.actions.map((x, k) => (k === i - 1 ? r.actions[i] : k === i ? r.actions[i - 1] : x)) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-9">
                      <Icon name="arrow-up" size={15} />
                    </button>
                    <button type="button" aria-label={fill(t.remove, { what: acLabel(a.type) })} onClick={() => set({ actions: r.actions.filter((x) => x.id !== a.id) })} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-9">
                      <Icon name="trash" size={15} />
                    </button>
                  </span>
                  <input aria-label={t.target} placeholder={t.target} value={a.target} onChange={(e) => set({ actions: r.actions.map((x) => (x.id === a.id ? { ...x, target: e.target.value } : x)) })} className={cn(field, 'col-span-2 sm:col-span-1')} />
                </li>
              ))}
            </ol>
            {!r.actions.length && <p className="mt-3 text-[13px] text-warning-foreground">{t.noActions}</p>}
            <Button variant="ghost" size="sm" iconLeft="plus" className="mt-2" onClick={() => set({ actions: [...r.actions, { id: `a${Date.now()}`, type: actions[0]?.id ?? '', target: '' }] })}>
              {t.addAction}
            </Button>
          </section>
        </div>
        {/* Desktop: the rule in plain words next to the editor. Tablets and phones: under it, before the buttons. */}
        <aside className="kitm-up [--kitm-d:220ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
          <h2 className="text-[12.5px] font-medium text-muted-foreground">{t.sentence}</h2>
          <p className="mt-2 text-pretty text-[15px] leading-relaxed text-muted-foreground" aria-live="polite">
            {sentence}
          </p>
          {state === 'tested' && (
            <p className="kitm-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
              <Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
              {fill(t.tested, { n: r.actions.length })}
            </p>
          )}
          {state === 'saved' && (
            <p className="kitm-in mt-4 flex items-start gap-2 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] text-foreground">
              <Icon name="check-circle" size={15} className="mt-px shrink-0 text-success-foreground" />
              {t.saved}
            </p>
          )}
          <div className="mt-5 grid gap-2">
            <Button
              disabled={!r.actions.length || state === 'busy'}
              iconLeft={state === 'busy' ? 'loader' : 'check'}
              onClick={async () => {
                setState('busy');
                await run(onAction, 'save', { rule: r });
                setState('saved');
              }}
            >
              {state === 'busy' ? t.saving : t.save}
            </Button>
            <Button
              variant="outline"
              disabled={!r.actions.length || state === 'testing'}
              iconLeft={state === 'testing' ? 'loader' : 'play'}
              onClick={async () => {
                setState('testing');
                await run(onAction, 'test', { rule: r }, 900);
                setState('tested');
              }}
            >
              {state === 'testing' ? t.testing : t.test}
            </Button>
          </div>
        </aside>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* templates                                                           */
/* ------------------------------------------------------------------ */

function Templates({ t, title, subtitle, templates = [], locale, onAction }: P) {
  const cats = useMemo(() => [...new Set(templates.map((x) => x.category))], [templates]);
  const [cat, setCat] = useState('all');
  const [busy, setBusy] = useState('');
  const [done, setDone] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const listRef = useRef<HTMLUListElement>(null);
  const [rowRef, fade] = useScrollFade<HTMLDivElement>();
  const shown = templates.filter((x) => cat === 'all' || x.category === cat);
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid gap-5">
        <div ref={rowRef} role="group" aria-label={t.templates} className={cn('kitm-up kit-no-scrollbar -mx-4 -my-1 flex gap-2 overflow-x-auto px-4 py-1 sm:mx-0 sm:flex-wrap sm:px-1', fade)}>
          {['all', ...cats].map((c) => (
            <button key={c} type="button" aria-pressed={cat === c} onClick={() => setCat(c)} className={cn('h-11 shrink-0 rounded-lg border px-3.5 text-[13px] font-medium transition-colors sm:h-9', cat === c ? 'border-transparent bg-foreground text-background' : 'border-border text-muted-foreground hover:text-foreground')}>
              {c === 'all' ? t.allCategories : c}
            </button>
          ))}
        </div>
        <ul ref={listRef} key={cat} className="grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 xl:grid-cols-3">
          {shown.map((x, i) => (
            <li key={x.id} className="kitm-in group flex flex-col rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5" style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
              <div className="flex items-start gap-3">
                <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface-2))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                  <Icon name={x.icon ?? 'bolt'} size={18} />
                </span>
                <div className="min-w-0">
                  <h3 className="text-[15px] font-semibold text-foreground">{x.name}</h3>
                  <p className="text-[12px] text-subtle-foreground">{x.category}</p>
                </div>
              </div>
              <p className="mt-3 text-pretty text-[13px] leading-snug text-muted-foreground">{x.description}</p>
              {/* The steps as a small flow */}
              <ol aria-label={t.steps} className="mt-4 grid gap-1.5 border-t border-border pt-4">
                {x.steps.map((s, k) => (
                  <li key={s} className="flex items-center gap-2.5 text-[12.5px] text-foreground">
                    <span className={cn('grid size-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold', k === 0 ? 'bg-[color-mix(in_oklab,var(--warning)_22%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>{k === 0 ? <Icon name="bolt" size={10} /> : k}</span>
                    <span className="truncate">{s}</span>
                  </li>
                ))}
              </ol>
              <div className="mt-auto flex items-center justify-between gap-3 pt-5">
                <span className="text-[12px] tabular-nums text-subtle-foreground">{x.uses ? fill(t.usesN, { n: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(x.uses) }) : ''}</span>
                {done === x.id ? (
                  <span className="kitm-in inline-flex h-9 items-center gap-1.5 text-[13px] font-medium text-success-foreground">
                    <Icon name="check" size={15} />
                    {t.active}
                  </span>
                ) : (
                  <Button
                    size="sm"
                    variant="outline"
                    disabled={busy === x.id}
                    iconLeft={busy === x.id ? 'loader' : undefined}
                    onClick={async () => {
                      setBusy(x.id);
                      await run(onAction, 'use-template', { id: x.id });
                      setBusy('');
                      setDone(x.id);
                    }}
                  >
                    {busy === x.id ? t.using : t.use}
                  </Button>
                )}
              </div>
            </li>
          ))}
        </ul>
      </div>
      <Live text={done ? fill(t.used, { name: templates.find((x) => x.id === done)?.name ?? '' }) : ''} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* run-log                                                             */
/* ------------------------------------------------------------------ */

function RunLog({ t, title, subtitle, runs = [], rules = [], now, locale, onAction }: P) {
  const id = useId();
  const [items, setItems] = useState(runs);
  const [filter, setFilter] = useState<'all' | RuleRun['status']>('all');
  const [open, setOpen] = useState<string | null>(runs.find((r) => r.status === 'failed')?.id ?? null);
  const [busy, setBusy] = useState('');
  const [msg, setMsg] = useState('');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const shown = items.filter((r) => filter === 'all' || r.status === filter);
  const ruleName = (rid: string) => rules.find((r) => r.id === rid)?.name ?? rid;
  const label: Record<RuleRun['status'], string> = { success: t.success, failed: t.failed, skipped: t.skipped };
  const time = (iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(toMin(iso) * MIN));
  const retry = async (r: RuleRun) => {
    setBusy(r.id);
    await run(onAction, 'retry', { id: r.id }, 1000);
    setItems((l) => l.map((x) => (x.id === r.id ? { ...x, status: 'success', steps: x.steps.map((s) => ({ ...s, status: s.status === 'failed' ? 'success' : s.status, detail: s.status === 'failed' ? undefined : s.detail })) } : x)));
    setBusy('');
    setMsg(t.retried);
  };
  return (
    <Main>
      <Header title={title} subtitle={subtitle}>
        <Segmented
          label={t.runLog}
          value={filter}
          onChange={setFilter}
          className="grid w-full grid-cols-2 gap-1 sm:flex sm:w-auto"
          options={[
            { value: 'all', label: t.all },
            { value: 'success', label: t.success },
            { value: 'failed', label: <>{t.failed}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((r) => r.status === 'failed').length}</span></> },
            { value: 'skipped', label: t.skipped },
          ]}
        />
      </Header>
      <div ref={scope} {...enter} className="mt-6">
        <ul className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
          {shown.map((r) => {
            const s = STATUS[r.status];
            const isOpen = open === r.id;
            return (
              <li key={r.id} className="border-b border-border last:border-b-0">
                <button type="button" aria-expanded={isOpen} aria-controls={`${id}-${r.id}`} aria-label={fill(t.showSteps, { subject: r.subject })} onClick={() => setOpen(isOpen ? null : r.id)} className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] [outline-offset:-2px] items-center gap-x-3.5 gap-y-1 px-4 py-3.5 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)] sm:grid-cols-[auto_minmax(0,1.2fr)_minmax(0,1fr)_7rem_auto] sm:px-5">
                  <span className={cn('grid size-8 place-items-center rounded-full', r.status === 'success' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)]' : r.status === 'failed' ? 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)]' : 'bg-muted', s.tone)}>
                    <Icon key={r.status} name={s.icon} size={15} strokeWidth={2.2} className="kitm-pop" />
                  </span>
                  <span className="min-w-0">
                    <span className="block truncate text-[14px] font-medium text-foreground">{ruleName(r.rule)}</span>
                    <span className="block truncate text-[12.5px] text-muted-foreground sm:hidden">
                      {r.subject} · {time(r.time)}
                    </span>
                  </span>
                  <span className="hidden truncate text-[13px] text-muted-foreground sm:block">{r.subject}</span>
                  <span className="hidden text-right text-[12.5px] tabular-nums text-subtle-foreground sm:block">
                    {ago(locale, r.time, now)}
                    <span className="block">{fill(t.duration, { ms: new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(r.ms) })}</span>
                  </span>
                  <Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', isOpen && 'rotate-180')} />
                </button>
                <div id={`${id}-${r.id}`} className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!isOpen}>
                  <div className="min-h-0 overflow-hidden">
                    <div className="border-t border-border bg-surface px-4 py-4 sm:ps-[4.25rem] sm:pe-5">
                      <ol className="relative grid gap-3">
                        <span aria-hidden className="absolute bottom-2 start-[9px] top-2 w-px bg-border" />
                        {r.steps.map((st, k) => (
                          <li key={k} className="relative flex items-start gap-3">
                            <span className={cn('relative z-[1] mt-0.5 grid size-[19px] shrink-0 place-items-center rounded-full border-2 border-surface', STATUS[st.status].dot)}>
                              <Icon name={STATUS[st.status].icon} size={10} strokeWidth={3} className="text-white" />
                            </span>
                            <span className="min-w-0">
                              <span className="block text-[13.5px] text-foreground">{st.label}</span>
                              {st.detail && <span className={cn('mt-0.5 block text-pretty text-[12.5px]', st.status === 'failed' ? 'text-destructive' : 'text-muted-foreground')}>{st.detail}</span>}
                            </span>
                          </li>
                        ))}
                      </ol>
                      {r.status === 'failed' && (
                        <Button size="sm" className="mt-4" iconLeft={busy === r.id ? 'loader' : 'rotate-cw'} disabled={busy === r.id} onClick={() => retry(r)}>
                          {busy === r.id ? t.retrying : t.retry}
                        </Button>
                      )}
                    </div>
                  </div>
                </div>
              </li>
            );
          })}
        </ul>
      </div>
      <Live text={msg} />
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* schedule                                                            */
/* ------------------------------------------------------------------ */

type Freq = 'hourly' | 'daily' | 'weekly' | 'monthly';

function Schedule({ t, title, subtitle, now, timezone = 'Europe/Berlin', locale, onAction }: P) {
  const id = useId();
  const [freq, setFreq] = useState<Freq>('weekly');
  const [days, setDays] = useState<number[]>([0, 2, 4]);
  const [time, setTime] = useState('08:30');
  const [minute, setMinute] = useState(15);
  const [dom, setDom] = useState(1);
  const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
  const [scope, enter] = useEnter<HTMLDivElement>();
  const tag = locale === 'de' ? 'de-DE' : 'en-US';
  const dayNames = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
  const longDays = useMemo(() => Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(tag, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i)))), [tag]);
  // The next five runs, worked out in UTC so server and browser agree
  const next = useMemo(() => {
    const [h, m] = time.split(':').map(Number);
    const start = toMin(now);
    const out: number[] = [];
    if (freq === 'hourly') {
      let x = Math.floor(start / 60) * 60 + minute;
      while (out.length < 5) {
        if (x > start) out.push(x);
        x += 60;
      }
      return out;
    }
    let day = Math.floor(start / 1440);
    for (let k = 0; k < 400 && out.length < 5; k++, day++) {
      const at = day * 1440 + h * 60 + m;
      if (at <= start) continue;
      const d = new Date(day * 1440 * MIN);
      const wd = (d.getUTCDay() + 6) % 7;
      if (freq === 'daily' || (freq === 'weekly' && days.includes(wd)) || (freq === 'monthly' && d.getUTCDate() === dom)) out.push(at);
    }
    return out;
  }, [freq, days, time, minute, dom, now]);
  const fmt = (min: number) => new Intl.DateTimeFormat(tag, { weekday: 'short', day: 'numeric', month: 'short', hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(min * MIN));
  const clock = (v: string) => {
    const [hh, mm] = v.split(':').map(Number);
    if (Number.isNaN(hh) || Number.isNaN(mm)) return v;
    return new Intl.DateTimeFormat(tag, { hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 0, 1, hh, mm)));
  };
  const join = (xs: string[]) => (xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} ${t.and} ${xs.at(-1)}`);
  const summary = freq === 'hourly' ? fill(t.summaryHourly, { m: String(minute).padStart(2, '0') }) : freq === 'daily' ? fill(t.summaryDaily, { t: clock(time) }) : freq === 'weekly' ? fill(t.summaryWeekly, { days: join(days.map((d) => longDays[d])), t: clock(time) }) : fill(t.summaryMonthly, { d: dom, t: clock(time) });
  const bad = freq === 'weekly' && !days.length;
  return (
    <Main>
      <Header title={title} subtitle={subtitle} />
      <div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
        <Card title={t.schedule} description={t.scheduleText} className="kitm-up">
          <div className="grid gap-6">
            <div>
              <p className="mb-2 text-[13px] font-medium text-foreground">{t.every}</p>
              <Segmented
                label={t.every}
                value={freq}
                onChange={(v) => {
                  setFreq(v);
                  setState('idle');
                }}
                className="grid w-full grid-cols-2 gap-1 sm:flex"
                options={[
                  { value: 'hourly', label: t.hourly },
                  { value: 'daily', label: t.daily },
                  { value: 'weekly', label: t.weekly },
                  { value: 'monthly', label: t.monthly },
                ]}
              />
            </div>
            <div key={freq} className="kitm-in grid gap-5">
              {freq === 'weekly' && (
                <fieldset>
                  <legend className="mb-2 text-[13px] font-medium text-foreground">{t.on}</legend>
                  <div className="grid grid-cols-7 gap-1 sm:gap-1.5">
                    {dayNames.map((d, i) => {
                      const on = days.includes(i);
                      return (
                        <button key={i} type="button" aria-pressed={on} aria-label={longDays[i]} onClick={() => setDays((xs) => (on ? xs.filter((x) => x !== i) : [...xs, i].sort()))} className={cn('h-11 rounded-lg border text-[13px] font-medium transition-[background-color,border-color,color,transform] duration-200 active:scale-95', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                          {d.replace('.', '')}
                        </button>
                      );
                    })}
                  </div>
                  {bad && <p className="kitm-in mt-2 text-[13px] font-medium text-destructive">{t.pickDay}</p>}
                </fieldset>
              )}
              {freq === 'monthly' && (
                <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-dom`}>
                  {t.dayOfMonth}
                  <input id={`${id}-dom`} type="number" min={1} max={28} value={dom} onChange={(e) => setDom(Math.max(1, Math.min(28, Number(e.target.value) || 1)))} className={cn(field, 'max-w-[8rem] tabular-nums')} />
                </label>
              )}
              <div className="grid gap-4 sm:grid-cols-2">
                {freq === 'hourly' ? (
                  <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-min`}>
                    {t.atMinute}
                    <input id={`${id}-min`} type="number" min={0} max={59} value={minute} onChange={(e) => setMinute(Math.max(0, Math.min(59, Number(e.target.value) || 0)))} className={cn(field, 'tabular-nums')} />
                  </label>
                ) : (
                  <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-t`}>
                    {t.at}
                    <input id={`${id}-t`} type="time" value={time} onChange={(e) => setTime(e.target.value || '00:00')} className={cn(field, 'tabular-nums')} />
                  </label>
                )}
                <label className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-tz`}>
                  {t.timezone}
                  <input id={`${id}-tz`} readOnly value={timezone} className={cn(field, 'bg-surface text-muted-foreground')} />
                </label>
              </div>
            </div>
            <p className="flex items-start gap-2.5 rounded-lg bg-surface p-3.5 text-[14px] text-foreground">
              <Icon name="repeat" size={16} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
              <span key={summary} className="kitm-in">
                {summary}
              </span>
            </p>
          </div>
        </Card>
        <Card title={t.next} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6" bodyClassName="p-0 sm:p-0">
          <ol className="relative">
            {next.map((n, i) => (
              <li key={`${freq}-${n}`} className="kitm-fly flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
                <span className={cn('size-2 shrink-0 rounded-full', i === 0 ? 'bg-primary' : 'bg-border-strong')} />
                <span className={cn('text-[13.5px] tabular-nums', i === 0 ? 'font-medium text-foreground' : 'text-muted-foreground')}>{fmt(n)}</span>
              </li>
            ))}
          </ol>
          <div className="border-t border-border p-4 sm:p-5">
            {state === 'saved' && (
              <p className="kitm-in mb-3 flex items-start gap-2 text-[13px] text-success-foreground">
                <Icon name="check-circle" size={15} className="mt-px shrink-0" />
                {fill(t.scheduleSaved, { when: next[0] ? fmt(next[0]) : '' })}
              </p>
            )}
            <Button
              className="w-full"
              disabled={bad || state === 'busy'}
              iconLeft={state === 'busy' ? 'loader' : undefined}
              onClick={async () => {
                setState('busy');
                await run(onAction, 'schedule', { freq, days, time, minute, dom });
                setState('saved');
              }}
            >
              {state === 'busy' ? t.saving : t.saveSchedule}
            </Button>
          </div>
        </Card>
      </div>
    </Main>
  );
}

/* ------------------------------------------------------------------ */
/* AutomationRules                                                     */
/* ------------------------------------------------------------------ */

/**
 * Automation screens for apps: a list of when and then rules with switches,
 * an editor that reads the rule back as a sentence, templates to start from,
 * a run log with steps and retry, and a schedule with the next runs.
 *   <AutomationRules variant="rule-list" title="Automations" rules={…} triggers={…} actions={…} />
 */
export function AutomationRules({ variant = 'rule-list', sidebar, labels, className, now = '2026-10-19T14:30', ...props }: AutomationRulesProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const p: P = { ...props, t, now, locale };
  const body = (() => {
    switch (variant) {
      case 'rule-editor':
        return <RuleEditor {...p} />;
      case 'templates':
        return <Templates {...p} />;
      case 'run-log':
        return <RunLog {...p} />;
      case 'schedule':
        return <Schedule {...p} />;
      default:
        return <RuleList {...p} />;
    }
  })();
  if (!sidebar)
    return (
      <div className={cn('min-h-dvh bg-background', className)}>
        <KitmStyle />
        {body}
      </div>
    );
  return (
    <Sidebar mobile="drawer" {...sidebar} className={className}>
      <KitmStyle />
      {body}
    </Sidebar>
  );
}

Frequent questions

How do I install Automation rules?

With aiw add @ai-website/automation-rules. The command puts components/ui/automation-rules.tsx into your project, the code is yours from then on. It brings along button, icon, switch and sidebar. It needs no further npm packages.

Which variants does Automation rules have?

5 variants, Rule list, Rule editor, Rule templates, Run log and Schedule. You pick one with the variant prop, for example variant="rule-editor".

Can I translate the texts of Automation rules?

Yes. Automation rules carries 82 texts of its own in English and German, such as “Rules”, “When” and “if”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Automation rules?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Automation rules with aiw_components and install it with aiw_add @ai-website/automation-rules. Name the variant in your prompt, for example variant="rule-list", and the assistant writes your content straight into the props.

Can I adjust Automation rules without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching AutomationRules code to copy.

Often searched

Jump to

Copied to the clipboard