Automationsregeln
Automations-Screens. Wenn-dann-Regeln mit Schaltern, ein Editor, der die Regel als Satz vorliest, Vorlagen, ein Laufprotokoll mit Wiederholen und ein Zeitplan mit den nächsten Läufen.
Eine von 3 Components in Automation und Builder
Variants
Details
Was Automationsregeln mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Automation und Builder
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/automation-rules.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 82 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AutomationRulesProps aus components/ui/automation-rules.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAutomationRulesVariantrule-listrule-editortemplatesrun-logschedule
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
triggersoptionalRuleOption[]fieldsoptionalRuleOption[]actionsoptionalRuleOption[]rulesoptionalRule[]ruleoptionalRulerule-editor: the rule on screen
templatesoptionalRuleTemplate[]runsoptionalRuleRun[]timezoneoptionalstringschedule
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, toggle, retry and use. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Automationsregeln selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 82 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| rules | Englisch Rules | Deutsch Regeln |
| when | Englisch When | Deutsch Wenn |
| sentenceIf | Englisch if | Deutsch und |
| then | Englisch Then | Deutsch Dann |
| and | Englisch and | Deutsch und |
| or | Englisch or | Deutsch oder |
| all | Englisch All | Deutsch Alle |
| active | Englisch Active | Deutsch Aktiv |
| paused | Englisch Paused | Deutsch Pausiert |
| failing | Englisch Needs attention | Deutsch Braucht Aufmerksamkeit |
| runs | Englisch {n} runs | Deutsch {n} Läufe |
| lastRun | Englisch Last run {when} | Deutsch Zuletzt {when} |
| never | Englisch Not run yet | Deutsch Noch nicht gelaufen |
| toggle | Englisch Run {name} | Deutsch {name} ausführen |
| newRule | Englisch New rule | Deutsch Neue Regel |
| search | Englisch Search rules | Deutsch Regeln suchen |
| noMatch | Englisch No rule matches. | Deutsch Keine Regel passt. |
| trigger | Englisch Trigger | Deutsch Auslöser |
| conditions | Englisch Conditions | Deutsch Bedingungen |
| oneCondition | Englisch + 1 condition | Deutsch + 1 Bedingung |
| nConditions | Englisch + {n} conditions | Deutsch + {n} Bedingungen |
| actions | Englisch Actions | Deutsch Aktionen |
| matchAll | Englisch All conditions | Deutsch Alle Bedingungen |
| matchAny | Englisch Any condition | Deutsch Eine Bedingung |
| field | Englisch Field | Deutsch Feld |
| op | Englisch Condition | Deutsch Bedingung |
| value | Englisch Value | Deutsch Wert |
| is | Englisch is | Deutsch ist |
| is-not | Englisch is not | Deutsch ist nicht |
| greater | Englisch is more than | Deutsch ist größer als |
| less | Englisch is less than | Deutsch ist kleiner als |
| contains | Englisch contains | Deutsch enthält |
| addCondition | Englisch Add condition | Deutsch Bedingung hinzufügen |
| addAction | Englisch Add action | Deutsch Aktion hinzufügen |
| remove | Englisch Remove {what} | Deutsch {what} entfernen |
| moveUp | Englisch Move action {n} up | Deutsch Aktion {n} nach oben |
| target | Englisch With | Deutsch Mit |
| sentence | Englisch In one sentence | Deutsch In einem Satz |
| name | Englisch Rule name | Deutsch Name der Regel |
| save | Englisch Save rule | Deutsch Regel speichern |
| saving | Englisch Saving… | Deutsch Wird gespeichert… |
| saved | Englisch Rule saved and active. | Deutsch Regel gespeichert und aktiv. |
| test | Englisch Test with the last event | Deutsch Mit dem letzten Ereignis testen |
| testing | Englisch Testing… | Deutsch Wird getestet… |
| tested | Englisch Every condition held, {n} actions would run. | Deutsch Alle Bedingungen erfüllt, {n} Aktionen würden laufen. |
| noActions | Englisch Add at least one action. | Deutsch Füge mindestens eine Aktion hinzu. |
| templates | Englisch Templates | Deutsch Vorlagen |
| allCategories | Englisch All | Deutsch Alle |
| use | Englisch Use template | Deutsch Vorlage nutzen |
| using | Englisch Setting up… | Deutsch Wird eingerichtet… |
| used | Englisch {name} is set up. Check the details and turn it on. | Deutsch {name} ist eingerichtet. Prüfe die Details und schalte sie ein. |
| usesN | Englisch Used by {n} teams | Deutsch Von {n} Teams genutzt |
| steps | Englisch Steps | Deutsch Schritte |
| runLog | Englisch Runs | Deutsch Läufe |
| success | Englisch Succeeded | Deutsch Erfolgreich |
| failed | Englisch Failed | Deutsch Fehlgeschlagen |
| skipped | Englisch Skipped | Deutsch Übersprungen |
| retry | Englisch Run again | Deutsch Erneut ausführen |
| retrying | Englisch Running… | Deutsch Läuft… |
| retried | Englisch Run again, it succeeded this time. | Deutsch Erneut ausgeführt, diesmal erfolgreich. |
| duration | Englisch {ms} ms | Deutsch {ms} ms |
| showSteps | Englisch Show steps of the run for {subject} | Deutsch Schritte des Laufs für {subject} zeigen |
| schedule | Englisch Schedule | Deutsch Zeitplan |
| scheduleText | Englisch Runs the rule on its own at the times you pick. | Deutsch Führt die Regel zu den gewählten Zeiten von selbst aus. |
| every | Englisch Repeat | Deutsch Wiederholen |
| hourly | Englisch Hourly | Deutsch Stündlich |
| daily | Englisch Daily | Deutsch Täglich |
| weekly | Englisch Weekly | Deutsch Wöchentlich |
| monthly | Englisch Monthly | Deutsch Monatlich |
| on | Englisch On | Deutsch Am |
| at | Englisch At | Deutsch Um |
| atMinute | Englisch At minute | Deutsch Zur Minute |
| dayOfMonth | Englisch Day of the month | Deutsch Tag im Monat |
| timezone | Englisch Time zone | Deutsch Zeitzone |
| next | Englisch Next runs | Deutsch Nächste Läufe |
| pickDay | Englisch Pick at least one day. | Deutsch Wähle mindestens einen Tag. |
| saveSchedule | Englisch Save schedule | Deutsch Zeitplan speichern |
| scheduleSaved | Englisch Schedule saved. Next run {when}. | Deutsch Zeitplan gespeichert. Nächster Lauf {when}. |
| summaryHourly | Englisch Every hour at minute {m} | Deutsch Jede Stunde zur Minute {m} |
| summaryDaily | Englisch Every day at {t} | Deutsch Jeden Tag um {t} |
| summaryWeekly | Englisch Every {days} at {t} | Deutsch Jeden {days} um {t} |
| summaryMonthly | Englisch On day {d} of every month at {t} | Deutsch Am {d}. jeden Monats um {t} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Automationsregeln 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-live und aria-pressed, 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
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.181 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Automationsregeln?
Mit aiw add @ai-website/automation-rules. Der Befehl legt die Datei components/ui/automation-rules.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, switch und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Automationsregeln?
5 Varianten, Regelliste, Regeleditor, Regelvorlagen, Laufprotokoll und Zeitplan. Du wählst sie mit der Prop variant, zum Beispiel variant="rule-editor".
Kann ich die Texte von Automationsregeln übersetzen?
Ja. Automationsregeln bringt 82 eigene Texte auf Deutsch und Englisch mit, etwa „Regeln“, „Wenn“ und „und“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Automationsregeln einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Automationsregeln mit aiw_components und installieren sie mit aiw_add @ai-website/automation-rules. Nenn im Prompt die Variante, etwa variant="rule-list", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Automationsregeln ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AutomationRules-Code zum Kopieren.