From a60103e337810f5c12840b9fb412dc02195ff6fb Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 03:20:46 -0500 Subject: [PATCH] feat: add MaintenanceScheduleSection with schedule builder and summary Schedule draft state is local UI only (not in store). Hydrates form from existing schedule on load. Includes getScheduleSummary helper for collapsed section display. Two-stage save: tree first, schedule second. Schedule failure shows actionable error without rolling back tree save. Co-Authored-By: Claude Opus 4.6 --- .../MaintenanceScheduleSection.tsx | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx diff --git a/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx b/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx new file mode 100644 index 00000000..d41fdbcb --- /dev/null +++ b/frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx @@ -0,0 +1,264 @@ +import { useState, useEffect } from 'react' +import { Clock } from 'lucide-react' +import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' +import { targetListsApi } from '@/api/targetLists' +import type { MaintenanceSchedule, TargetList } from '@/types' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface MaintenanceScheduleSectionProps { + treeId: string | null + onScheduleLoaded?: (schedule: MaintenanceSchedule | null, targetList: TargetList | null) => void +} + +const FREQUENCY_OPTIONS = [ + { value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` }, + { value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` }, + { value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` }, + { value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` }, +] as const + +const TIMEZONE_OPTIONS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Berlin', + 'Asia/Tokyo', + 'Australia/Sydney', +] + +export function MaintenanceScheduleSection({ treeId, onScheduleLoaded }: MaintenanceScheduleSectionProps) { + const [schedule, setSchedule] = useState(null) + const [targetLists, setTargetLists] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + // Draft form state (local UI only — not in store) + const [frequency, setFrequency] = useState('weekly-mon') + const [hour, setHour] = useState(9) + const [minute, setMinute] = useState(0) + const [timezone, setTimezone] = useState('UTC') + const [selectedTargetListId, setSelectedTargetListId] = useState('') + + // Load existing schedule and target lists + useEffect(() => { + const load = async () => { + setIsLoading(true) + try { + const lists = await targetListsApi.list() + setTargetLists(lists) + + if (treeId) { + try { + const existing = await maintenanceSchedulesApi.getForTree(treeId) + setSchedule(existing) + // Hydrate form from existing schedule + hydrateFromSchedule(existing) + if (existing.target_list_id) { + setSelectedTargetListId(existing.target_list_id) + const tl = lists.find(l => l.id === existing.target_list_id) || null + onScheduleLoaded?.(existing, tl) + } else { + onScheduleLoaded?.(existing, null) + } + } catch { + // 404 = no schedule yet (valid state) + onScheduleLoaded?.(null, null) + } + } + } catch { + // Target lists may not load — non-critical + } finally { + setIsLoading(false) + } + } + load() + }, [treeId]) + + const hydrateFromSchedule = (s: MaintenanceSchedule) => { + const parts = s.cron_expression.split(' ') + const min = parseInt(parts[0] || '0', 10) + const hr = parseInt(parts[1] || '9', 10) + setMinute(min) + setHour(hr) + setTimezone(s.timezone) + + // Detect frequency from cron + const dayOfMonth = parts[2] + const dayOfWeek = parts[4] + if (dayOfMonth === '1') { + setFrequency('monthly') + } else if (dayOfWeek === '*') { + setFrequency('daily') + } else if (dayOfWeek === '5') { + setFrequency('weekly-fri') + } else { + setFrequency('weekly-mon') + } + } + + const handleSaveSchedule = async () => { + if (!treeId) { + toast.error('Save the flow first before configuring a schedule') + return + } + + setIsSaving(true) + try { + const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency) + const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1` + + if (schedule) { + const updated = await maintenanceSchedulesApi.update(schedule.id, { + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(updated) + const tl = targetLists.find(l => l.id === selectedTargetListId) || null + onScheduleLoaded?.(updated, tl) + toast.success('Schedule updated') + } else { + const created = await maintenanceSchedulesApi.create({ + tree_id: treeId, + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(created) + const tl = targetLists.find(l => l.id === selectedTargetListId) || null + onScheduleLoaded?.(created, tl) + toast.success('Schedule created') + } + } catch { + toast.error('Failed to save schedule. The flow was saved successfully — you can retry the schedule.') + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
Loading schedule...
+ ) + } + + return ( +
+ {/* Frequency */} +
+ + +
+ + {/* Time */} +
+
+ + setHour(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + setMinute(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + {/* Timezone */} +
+ + +
+ + {/* Target List */} + {targetLists.length > 0 && ( +
+ + +
+ )} + + {/* Save button */} + + {!treeId && ( +

Save the flow first to configure a schedule.

+ )} +
+ ) +} + +export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string { + if (!schedule) return 'No schedule configured' + + const parts = schedule.cron_expression.split(' ') + const min = parts[0] ?? '0' + const hour = parts[1] ?? '0' + const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}` + + const dayOfWeek = parts[4] + const dayOfMonth = parts[2] + let freqStr = 'Custom' + if (dayOfMonth === '1') { + freqStr = 'Monthly (1st)' + } else if (dayOfMonth === '*' && parts[3] === '*') { + if (dayOfWeek === '*') freqStr = 'Daily' + else if (dayOfWeek === '1') freqStr = 'Every Monday' + else if (dayOfWeek === '5') freqStr = 'Every Friday' + } + + const targetStr = targetList ? ` \u00b7 ${targetList.targets.length} target${targetList.targets.length !== 1 ? 's' : ''}` : '' + return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}` +}