From 8cbbbc582b51833642de16c162f0e031ca4b8230 Mon Sep 17 00:00:00 2001 From: abccodes Date: Tue, 23 Jun 2026 00:03:03 -0700 Subject: [PATCH] [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input --- .../app/pages/Workflows/app/ScheduleCard.tsx | 111 ++++++++++++++---- 1 file changed, 86 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx index e015eeb2..eb516a16 100644 --- a/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx +++ b/frontend/src/app/pages/Workflows/app/ScheduleCard.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import type { CSSProperties } from 'react'; import type { Workflow, ScheduleConfig } from '@/shared/state/workflowsSlice'; import { describeSchedule } from '@/app/pages/Workflows/scheduleUtils'; -import { WC, FONT_SERIF, track, knob } from './uiKit'; +import { useWC, FONT_SERIF, track, knob } from './uiKit'; import { freqOf, patchForFreq, intervalMinutes, timeInputValue, parseTimeInput, ordinal, nextRunText, type Freq, } from './model'; @@ -13,6 +13,7 @@ const FREQS: Array<[Freq, string]> = [['daily', 'Daily'], ['weekly', 'Weekly'], const DAY_LABELS: Array<[string, number]> = [['S', 0], ['M', 1], ['T', 2], ['W', 3], ['T', 4], ['F', 5], ['S', 6]]; const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { + const WC = useWC(); const patch = useWorkflowPatch(); const sched = workflow.schedule; const freq = freqOf(sched); @@ -20,14 +21,42 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const patchSched = (p: Partial) => patch(workflow, { schedule: { ...sched, ...p } }); + // The "Run at" field is an uncontrolled native time input so React doesn't + // reset the segment's pending-digit state between keystrokes (a controlled + // value made typing 4 then 5 land 05 instead of 45). To still reflect edits + // from elsewhere (e.g. the agent reschedules), push the store time in + // imperatively, and only when it actually differs from what's shown. + const timeRef = useRef(null); + useEffect(() => { + const el = timeRef.current; + if (!el) return; + const want = timeInputValue(sched); + if (el.value !== want) el.value = want; + }, [sched.hour, sched.minute]); + + // Turning a weekly schedule on with no days picked is "unconfigured", so the + // backend silently forces it back off and the switch looks dead. Seed today's + // weekday so the default Weekly 9am toggles on (and stays on) in one click. + const toggleEnabled = () => { + if (!enabled && sched.repeat_unit === 'week' && sched.on_days.length === 0) { + patchSched({ enabled: true, on_days: [new Date().getDay()] }); + } else { + patchSched({ enabled: !enabled }); + } + }; + + // Local draft so the interval field can be cleared / mid-typed below the + // floor without snapping; we warn instead and commit a valid value. + const [intervalDraft, setIntervalDraft] = useState(null); + const freqBtn = (active: boolean): CSSProperties => ({ flex: 1, padding: '6px 2px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: active ? WC.paper : 'transparent', color: active ? WC.ink : WC.muted, - boxShadow: active ? '0 1px 3px rgba(33,30,27,0.10)' : 'none', + boxShadow: active ? WC.shadow.sm : 'none', }); const pillBtn = (active: boolean): CSSProperties => ({ - flex: 1, height: 30, borderRadius: 7, border: `1px solid ${active ? WC.accent : 'rgba(33,30,27,0.12)'}`, - cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: active ? WC.accent : '#FFFFFF', color: active ? '#fff' : WC.muted, + flex: 1, height: 30, borderRadius: 7, border: `1px solid ${active ? WC.accent : `rgba(${WC.inkRGB},0.12)`}`, + cursor: 'pointer', fontSize: 11.5, fontWeight: 600, background: active ? WC.accent : WC.raised, color: active ? '#fff' : WC.muted, }); const toggleDay = (d: number) => { @@ -39,19 +68,24 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { const intervalMins = intervalMinutes(sched); const intervalUnit: 'min' | 'hour' = sched.repeat_unit === 'hour' ? 'hour' : 'min'; const intervalValue = intervalUnit === 'hour' ? Math.max(1, Math.round(intervalMins / 60)) : intervalMins; + const minInterval = intervalUnit === 'hour' ? 1 : 15; + const shownInterval = intervalDraft ?? String(intervalValue); + const intervalWarn = intervalDraft != null && /^\d+$/.test(intervalDraft) && parseInt(intervalDraft, 10) < minInterval; const dom = sched.day_of_month ?? 1; + const lastDay = !!sched.last_day_of_month; + const stepBtn: CSSProperties = { width: 26, height: 26, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 7, color: WC.ink3, fontSize: 15, cursor: lastDay ? 'default' : 'pointer', opacity: lastDay ? 0.4 : 1 }; const maxRuns = sched.max_runs; // Picking a finite limit resets the lifetime counter so "run 3 times" always means 3 from now. const setMaxRuns = (n: number | null) => patchSched(n == null ? { max_runs: null } : { max_runs: n, runs_count: 0 }); return ( -
+
Schedule -
patchSched({ enabled: !enabled })} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer' }}> +
{enabled ? 'On' : 'Off'} -
+
@@ -70,14 +104,23 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { )} {freq === 'monthly' && ( -
- Day of month -
- - {ordinal(dom)} - + <> +
+ Day of month +
+ + {lastDay ? 'Last day' : ordinal(dom)} + + +
-
+ {!lastDay && dom >= 29 && ( +
+ + Short months fire on their last day. Pick "Last" to always hit month-end. +
+ )} + )} {freq !== 'interval' ? ( @@ -85,9 +128,10 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { Run at { const t = parseTimeInput(e.target.value); if (t) patchSched(t); }} - style={{ width: 134, boxSizing: 'border-box', height: 32, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '0 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink }} + style={{ width: 134, boxSizing: 'border-box', height: 32, background: WC.raised, border: `1px solid rgba(${WC.inkRGB},0.12)`, borderRadius: 8, padding: '0 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink }} />
) : ( @@ -95,20 +139,30 @@ const ScheduleCard: React.FC<{ workflow: Workflow }> = ({ workflow }) => { Run every
{ - const v = Math.max(1, parseInt(e.target.value, 10) || 1); - patchSched({ repeat_every: intervalUnit === 'hour' ? v : Math.max(15, v) }); + const raw = e.target.value.replace(/[^0-9]/g, ''); + setIntervalDraft(raw); + const v = parseInt(raw, 10); + if (raw !== '' && v >= minInterval) patchSched({ repeat_every: v }); }} - style={{ width: 56, background: '#FFFFFF', border: '1px solid rgba(33,30,27,0.12)', borderRadius: 8, padding: '6px 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink, textAlign: 'right' }} + onBlur={() => { + const v = parseInt(intervalDraft ?? '', 10); + if (intervalDraft != null && (intervalDraft === '' || Number.isNaN(v) || v < minInterval)) { + patchSched({ repeat_every: minInterval }); + } + setIntervalDraft(null); + }} + style={{ width: 56, background: WC.raised, border: `1px solid ${intervalWarn ? WC.warn : `rgba(${WC.inkRGB},0.12)`}`, borderRadius: 8, padding: '6px 9px', fontSize: 13, fontFamily: "'JetBrains Mono',monospace", color: WC.ink, textAlign: 'right' }} />