fix: schedule node to be more user friendly

This commit is contained in:
Ida 2026-05-14 11:52:17 +02:00
parent dd26ea132d
commit 7e2ffb42fe
8 changed files with 1157 additions and 476 deletions

View file

@ -79,6 +79,26 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange]
);
const patchParams = useCallback(
(patch: Record<string, unknown>) => {
setParams((prev) => {
const next = { ...prev, ...patch };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
}
notifyParentTimeoutRef.current = setTimeout(() => {
notifyParentTimeoutRef.current = null;
onParametersChange(id, next);
}, 0);
}
return next;
});
},
[onParametersChange]
);
const dataFlow = useAutomation2DataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
@ -239,7 +259,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
@ -287,6 +307,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
);

View file

@ -23,6 +23,8 @@ export interface FieldRendererProps {
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
/** Atomically merge several parameter keys (e.g. cron + schedule). */
onPatchParams?: (patch: Record<string, unknown>) => void;
}
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
@ -32,6 +34,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// ---------------------------------------------------------------------------
import React from 'react';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
buildCronFromSpec,
scheduleSpecFromParams,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../runtime/scheduleCron';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
@ -773,19 +782,33 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
);
};
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
const spec = React.useMemo(
() =>
scheduleSpecFromParams({
...(allParams ?? {}),
cron:
(typeof value === 'string' && value
? value
: typeof allParams?.cron === 'string'
? allParams.cron
: '') as string,
} as Record<string, unknown>),
[allParams, value]
);
const handlePlanner = React.useCallback(
(next: ScheduleSpec) => {
const cron = buildCronFromSpec(next);
const schedule = scheduleSpecToPersistentJson(next);
if (onPatchParams) onPatchParams({ cron, schedule });
else onChange(cron);
},
[onChange, onPatchParams]
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
<SchedulePlanner value={spec} onChange={handlePlanner} />
</div>
);
};

View file

@ -4,31 +4,47 @@
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
*/
export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
/** Primary planner modes (+ legacy aliases still read/written). */
export type ScheduleMode =
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| 'custom'
| 'daily'
| 'weekdays'
| 'weekly'
| 'calendar'
| 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
/** sek, min, h, T (Tage), a (Jahre) — legacy interval mode */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06, cron DOW; nur bei mode === 'weekly' */
/** 06 cron DOW; für weeks / weekly / weekdays */
weekdays: number[];
/** Monatlich: Tag 131; Jährlich: Tag im gewählten Monat */
/** Tag des Monats 131 (Planner months: 128 empfohlen) */
monthDay: number;
/** 112, nur bei calendar + yearly */
/** 112, nur bei calendar + yearly (Legacy) */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
/** mode === 'custom': Roh-Cron (5 oder 6 Felder) */
customCron: string;
/** mode === 'weeks': alle W Wochen (Phase 1: Cron entspricht W===1; W>1 nur in schedule persistiert) */
weeksInterval: number;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cronDow wie oben) */
/** Anzeige MoSo (cron DOW) */
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 1, label: 'Mo' },
{ cronDow: 2, label: 'Di' },
@ -39,17 +55,27 @@ export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 0, label: 'So' },
];
/** Minuten-Optionen für Dropdowns (wie Prototyp + feinere Schritte bis 59) */
export const MINUTE_SELECT_OPTIONS: readonly number[] = (() => {
const base = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
const set = new Set(base);
for (let i = 0; i < 60; i++) if (!set.has(i)) set.add(i);
return [...set].sort((a, b) => a - b);
})();
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'daily',
hour: 8,
mode: 'days',
hour: 9,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 15,
intervalValue: 1,
intervalUnit: 'minutes',
customCron: '0 9 * * 1-5',
weeksInterval: 1,
};
}
@ -57,26 +83,119 @@ function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Normalisiert Legacy-Modi für Planner-UI (ohne Cron neu zu bauen). */
export function normalizeSpecForPlanner(spec: ScheduleSpec): ScheduleSpec {
const s = { ...spec };
switch (s.mode) {
case 'daily':
return { ...s, mode: 'days', intervalValue: 1 };
case 'weekdays':
return {
...s,
mode: 'weeks',
weeksInterval: 1,
weekdays: [1, 2, 3, 4, 5],
};
case 'weekly':
return { ...s, mode: 'weeks', weeksInterval: s.weeksInterval || 1 };
case 'calendar':
if (s.calendarPeriod === 'yearly') {
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'calendar' }),
};
}
return {
...s,
mode: 'months',
intervalValue: 1,
monthDay: clamp(s.monthDay, 1, 28),
};
case 'interval': {
const u = s.intervalUnit;
if (u === 'minutes')
return { ...s, mode: 'minutes', intervalValue: Math.max(1, s.intervalValue) };
if (u === 'hours')
return {
...s,
mode: 'hours',
intervalValue: Math.max(1, s.intervalValue),
minute: 0,
};
if (u === 'days')
return {
...s,
mode: 'days',
intervalValue: Math.max(1, s.intervalValue),
hour: 0,
minute: 0,
};
if (u === 'seconds')
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
}
default:
return s;
}
}
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
export function buildCronFromSpec(spec: ScheduleSpec): string {
const m = clamp(Math.floor(spec.minute), 0, 59);
const h = clamp(Math.floor(spec.hour), 0, 23);
switch (spec.mode) {
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'minutes': {
const mm = clamp(Math.floor(spec.intervalValue), 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(Math.floor(spec.intervalValue), 1, 23);
return `${m} */${hh} * * *`;
}
case 'days': {
const d = Math.max(1, Math.floor(spec.intervalValue));
if (d <= 1) return `${m} ${h} * * *`;
const step = clamp(d, 2, 31);
return `${m} ${h} */${step} * *`;
}
case 'weeks':
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((d) => d >= 0 && d <= 6)
.filter((x) => x >= 0 && x <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
// Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'months': {
const dom = clamp(Math.floor(spec.monthDay), 1, 28);
const monIv = Math.max(1, Math.floor(spec.intervalValue));
if (monIv <= 1) return `${m} ${h} ${dom} * *`;
const step = clamp(monIv, 2, 12);
return `${m} ${h} ${dom} */${step} *`;
}
case 'custom': {
const c = (spec.customCron || '').trim();
if (!c) return '0 9 * * *';
return c;
}
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'calendar': {
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
if (spec.calendarPeriod === 'monthly') {
@ -89,8 +208,8 @@ export function buildCronFromSpec(spec: ScheduleSpec): string {
const v = Math.max(1, Math.floor(spec.intervalValue));
switch (spec.intervalUnit) {
case 'seconds': {
const s = clamp(v, 1, 59);
return `*/${s} * * * * *`;
const sec = clamp(v, 1, 59);
return `*/${sec} * * * * *`;
}
case 'minutes': {
const mm = clamp(v, 1, 59);
@ -101,13 +220,12 @@ export function buildCronFromSpec(spec: ScheduleSpec): string {
return `0 */${hh} * * *`;
}
case 'days': {
if (v <= 1) return `0 0 * * *`;
if (v <= 1) return `${m} ${h} * * *`;
const d = clamp(v, 2, 31);
return `0 0 */${d} * *`;
return `${m} ${h} */${d} * *`;
}
case 'years':
default:
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
return `0 0 1 1 *`;
}
}
@ -116,10 +234,17 @@ export function buildCronFromSpec(spec: ScheduleSpec): string {
}
}
/** Validiert 5- oder 6-Feld-Cron (Whitespace-getrennt). */
export function isValidCronFieldCount(cron: string): boolean {
const n = cron.trim().split(/\s+/).length;
return n === 5 || n === 6;
}
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
if (!cron || typeof cron !== 'string') return null;
const p = cron.trim().split(/\s+/);
const trimmed = cron.trim();
const p = trimmed.split(/\s+/);
if (p.length === 6) {
const [secS, minS, hourS, domS, monthS, dowS] = p;
@ -143,63 +268,132 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
};
}
}
return null;
const base = { ...defaultScheduleSpec(), mode: 'custom' as const, customCron: trimmed };
return base;
}
if (p.length < 5) return null;
const [minS, hourS, domS, monthS, dowS] = p;
const minute = parseInt(minS, 10);
const hour = parseInt(hourS, 10);
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
let [minS, hourS, domS, monthS, dowS] = p;
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
if (minS.startsWith('*/') && hourS === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
mode: 'minutes',
intervalValue: iv,
intervalUnit: 'minutes',
minute: 0,
hour: 0,
};
}
}
const minNum = parseInt(minS, 10);
const hourNum = parseInt(hourS, 10);
if (
!minS.startsWith('*/') &&
!Number.isNaN(minNum) &&
hourS.startsWith('*/') &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: minNum,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
mode: 'hours',
intervalValue: iv,
intervalUnit: 'hours',
minute: 0,
hour: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
domS.startsWith('*/') &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: hourNum,
minute: minNum,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
mode: 'days',
intervalValue: iv,
intervalUnit: 'days',
minute: 0,
hour: 0,
minute: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
!domS.startsWith('*/') &&
monthS.startsWith('*/') &&
(dowS === '*' || dowS === '?')
) {
const dom = parseInt(domS, 10);
const iv = parseInt(monthS.slice(2), 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && !Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'months',
intervalValue: iv,
monthDay: dom,
hour: hourNum,
minute: minNum,
};
}
}
const minute = minNum;
const hour = hourNum;
if (Number.isNaN(minute) || Number.isNaN(hour)) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
return {
...defaultScheduleSpec(),
mode: 'weekdays',
hour,
minute,
weekdays: [1, 2, 3, 4, 5],
};
}
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
@ -213,6 +407,7 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
hour,
minute,
weekdays: norm,
weeksInterval: 1,
};
}
}
@ -220,6 +415,13 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (
(domS.includes(',') || domS.includes('-') || domS.includes('/')) &&
!domS.startsWith('*/')
) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
return {
...defaultScheduleSpec(),
@ -251,10 +453,22 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
};
}
return null;
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
const VALID_MODES: ScheduleMode[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
'daily',
'weekdays',
'weekly',
'calendar',
'interval',
];
function normalizeIntervalUnit(u: unknown): IntervalUnit {
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
@ -276,21 +490,42 @@ export function scheduleSpecFromParams(params: Record<string, unknown>): Schedul
if (mode === 'calendar') {
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
}
return {
const spec: ScheduleSpec = {
mode: mode as ScheduleMode,
hour: clamp(Number(o.hour) || base.hour, 0, 23),
minute: clamp(Number(o.minute) || base.minute, 0, 59),
hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour,
minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute,
weekdays: Array.isArray(o.weekdays)
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
: base.weekdays,
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
monthDay: clamp(Number(o.monthDay) ?? base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) ?? base.monthIndex, 1, 12),
calendarPeriod,
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
customCron: typeof o.customCron === 'string' ? o.customCron : base.customCron,
weeksInterval: Math.max(1, Number(o.weeksInterval) || base.weeksInterval),
};
return normalizeSpecForPlanner(spec);
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
return parseCronToSpec(cron) ?? defaultScheduleSpec();
const parsed = parseCronToSpec(cron);
return normalizeSpecForPlanner(parsed ?? defaultScheduleSpec());
}
/** Serialisiert Spec für parameters.schedule (persistiert). */
export function scheduleSpecToPersistentJson(spec: ScheduleSpec): Record<string, unknown> {
return {
mode: spec.mode,
hour: spec.hour,
minute: spec.minute,
weekdays: spec.weekdays,
monthDay: spec.monthDay,
monthIndex: spec.monthIndex,
calendarPeriod: spec.calendarPeriod,
intervalValue: spec.intervalValue,
intervalUnit: spec.intervalUnit,
customCron: spec.customCron,
weeksInterval: spec.weeksInterval,
};
}

View file

@ -1,438 +1,33 @@
/**
* Start node (Zeitplan) Karten-UI mit Konfiguration unter der gewählten Option.
* Start node (Zeitplan) Accordion planner; gespeichert werden cron + schedule.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
import React, { useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
import type { NodeConfigRendererProps } from '../shared/types';
import { SchedulePlanner } from '../../../SchedulePlanner';
import {
type ScheduleSpec,
type ScheduleMode,
type IntervalUnit,
type CalendarPeriod,
buildCronFromSpec,
scheduleSpecFromParams,
WEEKDAYS_MO_SO,
scheduleSpecToPersistentJson,
type ScheduleSpec,
} from '../runtime/scheduleCron';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
return [
{ value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
{ value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
{ value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
{ value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
{ value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
];
}
function _monthNames(t: (k: string) => string): string[] {
return [
t('Januar'), t('Februar'), t('März'), t('April'),
t('Mai'), t('Juni'), t('Juli'), t('August'),
t('September'), t('Oktober'), t('November'), t('Dezember'),
];
}
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
return [
{ value: 'seconds', label: t('Sekunde'), title: t('Sekunden') },
{ value: 'minutes', label: t('Minute'), title: t('Minuten') },
{ value: 'hours', label: t('Stunde'), title: t('Stunden') },
{ value: 'days', label: t('Tag'), title: t('Tage') },
{ value: 'years', label: t('Jahr'), title: t('Jahre') },
];
}
function timeString(hour: number, minute: number): string {
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
}
function commitSpec(next: ScheduleSpec, updateParam: (key: string, value: unknown) => void) {
updateParam('schedule', next);
updateParam('cron', buildCronFromSpec(next));
}
function clampInterval(value: number, unit: IntervalUnit): number {
const v = Math.max(1, Math.floor(value) || 1);
switch (unit) {
case 'seconds':
return Math.min(59, v);
case 'minutes':
return Math.min(59, v);
case 'hours':
return Math.min(23, v);
case 'days':
return Math.min(31, v);
case 'years':
return Math.min(99, v);
default:
return v;
}
}
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const modeOptions = _getModeOptions(t);
const intervalUnits = _getIntervalUnits(t);
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
const prefersReducedMotion = useReducedMotion();
const specModeRef = useRef(spec.mode);
specModeRef.current = spec.mode;
useEffect(() => {
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
console.log('[ScheduleStartNode] useEffect params → setSpec', {
paramsCron: params.cron,
paramsSchedule: params.schedule,
derivedMode: derived.mode,
previousSpecMode: specModeRef.current,
});
setSpec(derived);
}, [params.cron, params.schedule]);
useEffect(() => {
console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', {
specMode: spec.mode,
cssBlockBase: styles.scheduleModeBlock,
cssBlockActive: styles.scheduleModeBlockActive,
});
}, [spec.mode]);
const spec = useMemo(
() => scheduleSpecFromParams(params as Record<string, unknown>),
[params.cron, params.schedule]
);
const push = useCallback(
(next: ScheduleSpec) => {
setSpec(next);
commitSpec(next, updateParam);
const sched = scheduleSpecToPersistentJson(next);
const cron = buildCronFromSpec(next);
flushSync(() => {
updateParam('schedule', sched);
});
updateParam('cron', cron);
},
[updateParam]
);
const setMode = (mode: ScheduleMode) => {
console.log('[ScheduleStartNode] setMode', {
from: spec.mode,
to: mode,
refMode: specModeRef.current,
});
const base: ScheduleSpec = { ...spec, mode };
if (mode === 'weekly' && base.weekdays.length === 0) {
base.weekdays = [1, 2, 3, 4, 5];
}
if (mode === 'calendar') {
base.calendarPeriod = base.calendarPeriod ?? 'monthly';
}
push(base);
};
const onModeCardPointerEvent = (
phase: 'pointerdown' | 'click',
e: React.PointerEvent | React.MouseEvent,
o: { value: ScheduleMode; title: string; subtitle: string }
) => {
const el = e.target as HTMLElement;
const cur = e.currentTarget as HTMLElement;
const isLast = o.value === 'interval';
if (!isLast) return;
const cx = 'clientX' in e ? e.clientX : 0;
const cy = 'clientY' in e ? e.clientY : 0;
const hit = typeof document !== 'undefined' ? document.elementFromPoint(cx, cy) : null;
const hitH = hit as HTMLElement | null;
console.log(`[ScheduleStartNode] ${phase} — unterstes Element (Intervall)`, {
intendedMode: o.value,
title: o.title,
specModeClosure: spec.mode,
specModeRef: specModeRef.current,
eventPhase: e.nativeEvent.eventPhase,
target: { tag: el?.tagName, className: el?.className, id: el?.id },
currentTarget: { tag: cur?.tagName, className: cur?.className },
clientX: cx,
clientY: cy,
pointerId: 'pointerId' in e ? (e as React.PointerEvent).pointerId : undefined,
isTrusted: e.nativeEvent.isTrusted,
elementFromPoint: hitH
? {
tag: hitH.tagName,
className: hitH.className,
dataScheduleMode: hitH.closest?.('[data-schedule-mode]')?.getAttribute('data-schedule-mode'),
textSlice: (hitH.textContent ?? '').slice(0, 60),
}
: null,
});
};
const onTimeChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const v = ev.target.value;
if (!v) return;
const [hs, ms] = v.split(':');
const hour = Number(hs);
const minute = Number(ms);
if (Number.isNaN(hour) || Number.isNaN(minute)) return;
push({ ...spec, hour, minute });
};
const toggleWeekday = (cronDow: number) => {
const set = new Set(spec.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let weekdays = [...set];
if (weekdays.length === 0) weekdays = [cronDow];
push({ ...spec, weekdays });
};
const setCalendarPeriod = (calendarPeriod: CalendarPeriod) => {
push({ ...spec, calendarPeriod });
};
const setIntervalUnit = (intervalUnit: IntervalUnit) => {
const next = { ...spec, intervalUnit };
next.intervalValue = clampInterval(next.intervalValue, intervalUnit);
push(next);
};
const panelTransition = prefersReducedMotion
? { duration: 0 }
: {
height: { duration: 0.44, ease: EASE_SMOOTH },
opacity: { duration: 0.3, ease: 'easeOut' as const },
};
return (
<div className={styles.schedulePanel}>
<p className={styles.startNodeDocIntro}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
)}
</p>
<LayoutGroup>
<div className={styles.scheduleModeStack}>
{modeOptions.map((o) => (
<motion.div
key={o.value}
data-schedule-mode={o.value}
data-active={spec.mode === o.value ? 'true' : 'false'}
className={
spec.mode === o.value
? `${styles.scheduleModeBlock} ${styles.scheduleModeBlockActive}`
: styles.scheduleModeBlock
}
>
<motion.button
type="button"
className={styles.scheduleModeCard}
onPointerDown={(e) => onModeCardPointerEvent('pointerdown', e, o)}
onClick={(e) => {
onModeCardPointerEvent('click', e, o);
setMode(o.value);
}}
whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }}
transition={{ type: 'spring', stiffness: 520, damping: 32 }}
>
<span className={styles.scheduleModeCardTitle}>{o.title}</span>
<span className={styles.scheduleModeCardSubtitle}>{o.subtitle}</span>
</motion.button>
<AnimatePresence initial={false}>
{spec.mode === o.value && (
<motion.div
key={`panel-${o.value}`}
className={styles.scheduleModeConfigShell}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={panelTransition}
style={{ overflow: 'hidden' }}
>
<div className={styles.scheduleModeConfig}>
{o.value === 'daily' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekdays' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
)}
{o.value === 'weekly' && (
<>
<div className={styles.scheduleFieldCol}>
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
<div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
className={
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
}
onClick={() => toggleWeekday(cronDow)}
>
{cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
</button>
))}
</div>
</div>
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'calendar' && (
<>
<div className={styles.scheduleSubModes}>
<button
type="button"
className={
spec.calendarPeriod === 'monthly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('monthly')}
>
{t('Monatlich')}
</button>
<button
type="button"
className={
spec.calendarPeriod === 'yearly'
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
: styles.scheduleSubModeBtn
}
onClick={() => setCalendarPeriod('yearly')}
>
{t('Jährlich')}
</button>
</div>
{spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
)}
{spec.calendarPeriod === 'yearly' && (
<div className={styles.scheduleYearlyRow}>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthIndex}
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
>
{_monthNames(t).map((name, i) => (
<option key={i + 1} value={i + 1}>
{name}
</option>
))}
</select>
</label>
<label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
<select
className={styles.scheduleSelect}
value={spec.monthDay}
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>
{d}.
</option>
))}
</select>
</label>
</div>
)}
<label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input
type="time"
step={60}
className={styles.scheduleTimeInput}
value={timeString(spec.hour, spec.minute)}
onChange={onTimeChange}
/>
</label>
</>
)}
{o.value === 'interval' && (
<div className={styles.scheduleIntervalRow}>
<span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
className={styles.scheduleNumberInput}
value={spec.intervalValue}
onChange={(e) =>
push({
...spec,
intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit),
})
}
/>
<select
className={styles.scheduleUnitSelect}
value={spec.intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
>
{intervalUnits.map((u) => (
<option key={u.value} value={u.value} title={u.title}>
{u.label}
</option>
))}
</select>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</LayoutGroup>
</div>
);
return <SchedulePlanner value={spec} onChange={push} />;
};

View file

@ -0,0 +1,254 @@
/**
* Schedule planner accordion tokens aligned with FlowEditor / app theme.
*/
.wrap {
max-width: 560px;
font-size: 14px;
color: var(--text-primary, #1a1a1a);
}
.intro {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary, #666);
}
.item {
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
border-radius: var(--radius-md, 8px);
margin-bottom: 6px;
overflow: hidden;
background: var(--bg-primary, #fff);
}
/** Gespeicherte / gültige Option — nur Dezent, Indikator ist der Punkt links */
.itemSelected {
border-color: var(--border-color, rgba(0, 0, 0, 0.14));
}
.header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
margin: 0;
border: none;
cursor: pointer;
user-select: none;
text-align: left;
background: transparent;
color: inherit;
font: inherit;
outline: none;
}
.dotRail {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
}
.modeDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: transparent;
box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22));
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.modeDotOn {
background: var(--text-primary, #1a1a1a);
box-shadow: none;
}
.title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
}
.chevron {
flex-shrink: 0;
margin-left: auto;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--text-tertiary, #888);
transition: transform 0.2s ease;
transform-origin: 50% 40%;
}
.itemExpanded .chevron {
transform: rotate(180deg);
}
/*
* Höhe: 0fr 1fr interpoliert gegen die echte Inhaltshöhe (kein max-height-Ruckeln).
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#interpolation
*/
.bodyCollapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1);
pointer-events: none;
}
.bodyCollapseOpen {
grid-template-rows: 1fr;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.bodyCollapse {
transition: none;
}
}
.bodyInner {
min-height: 0;
overflow: hidden;
}
.bodyPad {
padding: 0 12px 12px;
}
.fields {
display: grid;
gap: 10px;
padding-top: 8px;
}
.field {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fieldStart {
align-items: flex-start;
}
.fieldLabel {
font-size: 13px;
color: var(--text-secondary, #666);
width: 120px;
flex-shrink: 0;
}
.fieldLabelTop {
padding-top: 4px;
}
.unit {
font-size: 13px;
color: var(--text-secondary, #666);
}
.timeRow {
display: flex;
align-items: center;
gap: 6px;
}
.timeSep {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary, #666);
}
.daysRow {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dayBtn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.15));
background: var(--bg-secondary, rgba(0, 0, 0, 0.05));
color: var(--text-secondary, #555);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.dayBtn:hover {
border-color: var(--text-secondary, #888);
}
.dayBtnSel {
background: var(--text-primary, #1a1a1a);
color: var(--bg-primary, #fff);
border-color: var(--text-primary, #1a1a1a);
}
.numberInput {
width: 72px;
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.select {
font-size: 14px;
padding: 6px 8px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
min-width: 4.5rem;
}
.cronInput {
font-family: ui-monospace, monospace;
font-size: 13px;
width: 100%;
max-width: 320px;
padding: 8px 10px;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--radius-md, 6px);
background: var(--bg-primary, #fff);
color: inherit;
}
.cronHint {
font-size: 11px;
color: var(--text-tertiary, #888);
margin-top: 8px;
line-height: 1.7;
max-width: 320px;
}
.warn {
font-size: 11px;
color: var(--warning-color, #b45309);
margin-top: 8px;
line-height: 1.5;
}
.disabled {
opacity: 0.55;
pointer-events: none;
}

View file

@ -0,0 +1,550 @@
/**
* Accordion schedule planner updates ScheduleSpec; parent derives cron via buildCronFromSpec.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
MINUTE_SELECT_OPTIONS,
WEEKDAYS_MO_SO,
type ScheduleSpec,
} from '../FlowEditor/nodes/runtime/scheduleCron';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './SchedulePlanner.module.css';
export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom';
const PLANNER_MODE_IDS: PlannerModeId[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
];
function plannerModeFromSpec(spec: ScheduleSpec): PlannerModeId {
const m = spec.mode;
if (m === 'interval') {
if (spec.intervalUnit === 'hours') return 'hours';
if (spec.intervalUnit === 'days') return 'days';
if (spec.intervalUnit === 'minutes') return 'minutes';
return 'custom';
}
if (m === 'minutes') return 'minutes';
if (m === 'hours') return 'hours';
if (m === 'days' || m === 'daily') return 'days';
if (m === 'weeks' || m === 'weekly' || m === 'weekdays') return 'weeks';
if (m === 'months' || (m === 'calendar' && spec.calendarPeriod === 'monthly')) return 'months';
return 'custom';
}
function withPlannerMode(spec: ScheduleSpec, id: PlannerModeId): ScheduleSpec {
const base = { ...spec };
switch (id) {
case 'minutes':
return {
...base,
mode: 'minutes',
intervalValue: Math.max(1, base.intervalValue || 15),
};
case 'hours':
return {
...base,
mode: 'hours',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'days':
return {
...base,
mode: 'days',
intervalValue: Math.max(1, base.intervalValue || 1),
};
case 'weeks':
return {
...base,
mode: 'weeks',
weeksInterval: Math.max(1, base.weeksInterval || 1),
weekdays:
base.weekdays.length > 0 ? [...base.weekdays] : [1, 2, 3, 4, 5],
};
case 'months':
return {
...base,
mode: 'months',
intervalValue: Math.max(1, base.intervalValue || 1),
monthDay: Math.min(28, Math.max(1, base.monthDay || 1)),
};
case 'custom':
return {
...base,
mode: 'custom',
customCron: (base.customCron || '0 9 * * *').trim(),
};
default:
return base;
}
}
export interface SchedulePlannerProps {
value: ScheduleSpec;
onChange: (next: ScheduleSpec) => void;
className?: string;
disabled?: boolean;
}
export const SchedulePlanner: React.FC<SchedulePlannerProps> = ({
value,
onChange,
className = '',
disabled = false,
}) => {
const { t } = useLanguage();
const activeMode = useMemo(() => plannerModeFromSpec(value), [value]);
const [openId, setOpenId] = useState<PlannerModeId | null>(null);
const modeMeta = useMemo(
() =>
({
minutes: { label: t('Alle X Minuten') },
hours: { label: t('Alle X Stunden') },
days: { label: t('Täglich / alle X Tage') },
weeks: { label: t('Wöchentlich') },
months: { label: t('Monatlich') },
custom: { label: t('Custom (Cron)') },
}) as const,
[t]
);
const push = useCallback(
(next: ScheduleSpec) => {
if (!disabled) onChange(next);
},
[disabled, onChange]
);
const toggle = (id: PlannerModeId) => {
const nextOpen: PlannerModeId | null = openId === id ? null : id;
setOpenId(nextOpen);
if (nextOpen != null) {
push(withPlannerMode(value, nextOpen));
}
};
const hourOptions = useMemo(
() => Array.from({ length: 24 }, (_, i) => i),
[]
);
const renderBody = (id: PlannerModeId) => {
const v = plannerModeFromSpec(value) === id ? value : withPlannerMode(value, id);
switch (id) {
case 'minutes':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={59}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'minutes',
intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Minuten')}</span>
</div>
</div>
);
case 'hours':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={23}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'hours',
intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Stunden')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Bei Minute')}</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'hours',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
);
case 'days':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={31}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'days',
intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Tage')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'days',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'days',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
<p className={styles.cronHint}>
{t(
'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.'
)}
</p>
</div>
);
case 'weeks':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={52}
className={styles.numberInput}
value={v.weeksInterval ?? 1}
onChange={(e) =>
push({
...v,
mode: 'weeks',
weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Wochen')}</span>
</div>
{(v.weeksInterval ?? 1) > 1 && (
<p className={styles.warn}>
{t(
'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).'
)}
</p>
)}
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Wochentage')}
</span>
<div className={styles.daysRow}>
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button
key={cronDow}
type="button"
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
onClick={() => {
const next = { ...v, mode: 'weeks' as const };
const set = new Set(next.weekdays);
if (set.has(cronDow)) set.delete(cronDow);
else set.add(cronDow);
let wd = [...set];
if (wd.length === 0) wd = [cronDow];
wd.sort((a, b) => {
const o = (x: number) => (x === 0 ? 7 : x);
return o(a) - o(b);
});
push({ ...next, weekdays: wd });
}}
>
{cronDow === 1
? t('Mo')
: cronDow === 2
? t('Di')
: cronDow === 3
? t('Mi')
: cronDow === 4
? t('Do')
: cronDow === 5
? t('Fr')
: cronDow === 6
? t('Sa')
: t('So')}
</button>
))}
</div>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'weeks',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'weeks',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'months':
return (
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Alle')}</span>
<input
type="number"
min={1}
max={12}
className={styles.numberInput}
value={v.intervalValue}
onChange={(e) =>
push({
...v,
mode: 'months',
intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
<span className={styles.unit}>{t('Monate')}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Tag des Monats')}</span>
<input
type="number"
min={1}
max={28}
className={styles.numberInput}
value={Math.min(28, v.monthDay)}
onChange={(e) =>
push({
...v,
mode: 'months',
monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
<div className={styles.timeRow}>
<select
className={styles.select}
value={v.hour}
onChange={(e) =>
push({
...v,
mode: 'months',
hour: Number(e.target.value),
})
}
>
{hourOptions.map((h) => (
<option key={h} value={h}>
{String(h).padStart(2, '0')}
</option>
))}
</select>
<span className={styles.timeSep}>:</span>
<select
className={styles.select}
value={v.minute}
onChange={(e) =>
push({
...v,
mode: 'months',
minute: Number(e.target.value),
})
}
>
{MINUTE_SELECT_OPTIONS.map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, '0')}
</option>
))}
</select>
</div>
</div>
</div>
);
case 'custom':
return (
<div className={styles.fields}>
<div className={`${styles.field} ${styles.fieldStart}`}>
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
{t('Ausdruck')}
</span>
<div>
<input
type="text"
className={styles.cronInput}
value={v.customCron}
onChange={(e) =>
push({
...v,
mode: 'custom',
customCron: e.target.value,
})
}
spellCheck={false}
/>
<div className={styles.cronHint}>
{t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '}
{t('Wochentag')}
{' · '}[{t('Sekunde')} {t('optional')}]
<br />
{t('z.B.')}{' '}
<code>0 9 * * 3,5</code>
</div>
</div>
</div>
</div>
);
default:
return null;
}
};
return (
<div className={`${styles.wrap} ${className} ${disabled ? styles.disabled : ''}`}>
<p className={styles.intro}>
{t(
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
)}
</p>
<div role="list">
{PLANNER_MODE_IDS.map((mid) => {
const open = openId === mid;
const isSelected = activeMode === mid;
const meta = modeMeta[mid];
return (
<div
key={mid}
role="listitem"
className={`${styles.item} ${open ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
>
<button
type="button"
className={styles.header}
aria-expanded={open}
onClick={() => toggle(mid)}
>
<span className={styles.dotRail} aria-hidden>
<span
className={`${styles.modeDot} ${isSelected ? styles.modeDotOn : ''}`}
/>
</span>
<span className={styles.title}>{meta.label}</span>
<span className={styles.chevron} aria-hidden />
</button>
<div
className={`${styles.bodyCollapse} ${open ? styles.bodyCollapseOpen : ''}`}
aria-hidden={!open}
>
<div className={styles.bodyInner}>
<div className={styles.bodyPad}>{renderBody(mid)}</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1 @@
export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner';

2
src/test/setup.ts Normal file
View file

@ -0,0 +1,2 @@
// Vitest / jsdom setup (minimal).
import '@testing-library/jest-dom/vitest';