diff --git a/dashboard/src/v2/SchedulerPage.tsx b/dashboard/src/v2/SchedulerPage.tsx index 7048d64f4d..676140cb48 100644 --- a/dashboard/src/v2/SchedulerPage.tsx +++ b/dashboard/src/v2/SchedulerPage.tsx @@ -1,5 +1,5 @@ import type { FunctionComponent, JSX } from "preact"; -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { CalendarDays, BellRing, @@ -24,6 +24,15 @@ import { PageHeader } from "./components/layout/PageHeader.js"; import { AvantgardeSelect } from "./components/ui/AvantgardeSelect.js"; import { Button } from "./components/ui/Button.js"; import { useProjectData } from "./context/project-data.js"; +import { + translateDashboardMessage, + translateDashboardPlural, + type DashboardLocale, + type DashboardMessageVariables, + type DashboardTextMessageKey, +} from "./i18n/locales.js"; +import { schedulerMessages } from "./i18n/messages/scheduler.js"; +import { useDashboardI18n } from "./i18n/context.js"; import { subscribeToDashboardRealtime } from "../lib/realtime/dashboard-realtime-client.js"; import { fetchSprints } from "./lib/project-api.js"; import { fetchNodeFlows } from "./lib/node-flow-api.js"; @@ -32,6 +41,7 @@ import { createSchedulerEntry, deleteSchedulerEntry, fetchProjectSchedule, + formatSchedulerDateValueInTimeZone, updateSchedulerEntry, } from "./lib/scheduler-api.js"; import type { @@ -61,7 +71,6 @@ const SCHEDULER_COMPACT_FIELD_CLASS = `${SCHEDULER_FIELD_CLASS} min-h-[40px]`; const TARGET_OPTIONS: Array<{ value: ScheduleTargetType; - label: string; icon: typeof Zap; tone: string; activeClassName: string; @@ -69,7 +78,6 @@ const TARGET_OPTIONS: Array<{ }> = [ { value: "sprint", - label: "Sprint", icon: Zap, tone: "text-ember-500", activeClassName: "border-ember-500/35 bg-ember-500/10 shadow-[0_12px_34px_rgba(255,184,0,0.13)]", @@ -77,7 +85,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "quicksprint", - label: "Quicksprint", icon: RefreshCw, tone: "text-sky-500", activeClassName: "border-sky-500/35 bg-sky-500/10 shadow-[0_12px_34px_rgba(14,165,233,0.13)]", @@ -85,7 +92,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "chat", - label: "Chat message", icon: MessageCircle, tone: "text-violet-500", activeClassName: "border-violet-500/35 bg-violet-500/10 shadow-[0_12px_34px_rgba(139,92,246,0.13)]", @@ -93,7 +99,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "memory_remediation", - label: "Memory", icon: Brain, tone: "text-signal-500", activeClassName: "border-signal-500/35 bg-signal-500/10 shadow-[0_12px_34px_rgba(0,224,160,0.13)]", @@ -101,7 +106,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "node_flow", - label: "Node flow", icon: Workflow, tone: "text-rose-500", activeClassName: "border-rose-500/35 bg-rose-500/10 shadow-[0_12px_34px_rgba(244,63,94,0.13)]", @@ -109,7 +113,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "agent_wakeup", - label: "Agent wakeup", icon: BellRing, tone: "text-fuchsia-500", activeClassName: "border-fuchsia-500/35 bg-fuchsia-500/10 shadow-[0_12px_34px_rgba(217,70,239,0.13)]", @@ -117,7 +120,6 @@ const TARGET_OPTIONS: Array<{ }, { value: "task", - label: "Task", icon: ListTodo, tone: "text-cyan-500", activeClassName: "border-cyan-500/35 bg-cyan-500/10 shadow-[0_12px_34px_rgba(6,182,212,0.13)]", @@ -153,17 +155,57 @@ const startOfWeek = (date: Date): Date => { return addDays(next, -offset); }; -const formatDayLabel = (date: Date): string => ( - date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) +type SchedulerTextKey = DashboardTextMessageKey; + +const schedulerText = ( + locale: DashboardLocale, + key: SchedulerTextKey, + variables?: DashboardMessageVariables, +): string => translateDashboardMessage(schedulerMessages, locale, key, variables); + +export const formatSchedulerDayLabel = (date: Date, locale: DashboardLocale): string => ( + formatSchedulerDateValueInTimeZone(date, locale, { weekday: "short", month: "short", day: "numeric" }) +); + +export const formatSchedulerTimeLabel = ( + value: string | Date, + locale: DashboardLocale, + timeZone?: string, +): string => formatSchedulerDateValueInTimeZone( + typeof value === "string" ? new Date(value) : value, + locale, + { hour: "2-digit", minute: "2-digit" }, + timeZone, ); -const formatTimeLabel = (iso: string): string => ( - new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) +const formatSchedulerDateTime = ( + value: string | Date, + locale: DashboardLocale, + timeZone?: string, +): string => formatSchedulerDateValueInTimeZone( + typeof value === "string" ? new Date(value) : value, + locale, + { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }, + timeZone, ); -const targetLabel = (targetType: ScheduleTargetType): string => { - const option = targetOptionByType.get(targetType); - return option?.label || "Schedule"; +const targetLabel = (targetType: ScheduleTargetType, locale: DashboardLocale): string => { + const key = { + sprint: "targetSprint", + quicksprint: "targetQuicksprint", + chat: "targetChat", + memory_remediation: "targetMemory", + node_flow: "targetNodeFlow", + agent_wakeup: "targetAgentWakeup", + task: "targetTask", + } as const; + return schedulerText(locale, key[targetType] ?? "targetFallback"); }; function isOperatorTargetType(targetType: ScheduleTargetType): targetType is OperatorScheduleTargetType { @@ -174,49 +216,101 @@ const isOperatorEditableTarget = (targetType: ScheduleTargetType): targetType is isOperatorTargetType(targetType) ); -const unsupportedEditReason = (targetType: ScheduleTargetType): string => ( - `${targetLabel(targetType)} schedules are created by the secured MCP scheduler tool and cannot be edited in the dashboard form. Pause, resume, or delete remains available.` +const unsupportedEditReason = (targetType: ScheduleTargetType, locale: DashboardLocale): string => ( + schedulerText(locale, "unsupportedEdit", { target: targetLabel(targetType, locale) }) ); -const scheduleStatusLabel = (status: SchedulerEntryRecord["status"] | SchedulerOccurrence["status"] | undefined): string => ( - status ? status.replaceAll("_", " ") : "scheduled" -); +const scheduleStatusLabel = ( + status: SchedulerEntryRecord["status"] | SchedulerOccurrence["status"] | undefined, + locale: DashboardLocale, +): string => { + const key = { + scheduled: "statusScheduled", + paused: "statusPaused", + completed: "statusCompleted", + failed: "statusFailed", + cancelled: "statusCancelled", + } as const; + return schedulerText(locale, key[status ?? "scheduled"]); +}; -const schedulerViewLabel = (view: SchedulerView): string => view === "calendar" ? "Calendar" : "24 Hours"; +export const formatSchedulerSprintStatus = ( + status: SprintRecord["status"], + locale: DashboardLocale, +): string => { + const key = { + running: "sprintStatusRunning", + paused: "sprintStatusPaused", + completed: "sprintStatusCompleted", + failed: "sprintStatusFailed", + cancelled: "sprintStatusCancelled", + idle: "sprintStatusIdle", + } as const; + return schedulerText(locale, key[status]); +}; -const recurrenceFrequencyLabel = (frequency: ScheduleRecurrenceRule["frequency"], interval: number): string => { +const schedulerViewLabel = (view: SchedulerView, locale: DashboardLocale): string => ( + schedulerText(locale, view === "calendar" ? "calendar" : "dayView") +); + +const recurrenceFrequencyLabel = ( + frequency: ScheduleRecurrenceRule["frequency"], + interval: number, + locale: DashboardLocale, +): string => { if (frequency === "minutely") { - return interval === 1 ? "minute" : `${interval} minutes`; + return schedulerText(locale, interval === 1 ? "frequencyMinute" : "frequencyMinutes", { count: interval }); } if (interval === 1) { - return frequency; + const key = { + hourly: "frequencyHourly", + daily: "frequencyDaily", + weekly: "frequencyWeekly", + monthly: "frequencyMonthly", + none: "recurrenceOneTime", + } as const; + return schedulerText(locale, key[frequency]); } - return `${interval} ${frequency}`; + const key = { + hourly: "frequencyHours", + daily: "frequencyDays", + weekly: "frequencyWeeks", + monthly: "frequencyMonths", + none: "recurrenceOneTime", + } as const; + return schedulerText(locale, key[frequency], { count: interval }); }; -const recurrenceSummary = (recurrence: ScheduleRecurrenceRule): string => { +export const summarizeSchedulerRecurrence = ( + recurrence: ScheduleRecurrenceRule, + locale: DashboardLocale, + timeZone?: string, +): string => { if (recurrence.frequency === "none") { - return "One time"; + return schedulerText(locale, "recurrenceOneTime"); } - const every = recurrenceFrequencyLabel(recurrence.frequency, recurrence.interval); + const every = recurrenceFrequencyLabel(recurrence.frequency, recurrence.interval, locale); if (recurrence.endMode === "after_count" && recurrence.count) { - return `Every ${every}, ${recurrence.count} runs`; + return schedulerText(locale, "recurrenceEveryRuns", { frequency: every, count: recurrence.count }); } if (recurrence.endMode === "on_date" && recurrence.until) { - return `Every ${every} until ${new Date(recurrence.until).toLocaleString()}`; + return schedulerText(locale, "recurrenceEveryUntil", { + frequency: every, + until: formatSchedulerDateTime(recurrence.until, locale, timeZone), + }); } - return `Every ${every}`; + return schedulerText(locale, "recurrenceEvery", { frequency: every }); }; -const scheduleAnchorOffsetLabel = (offsetMinutes?: number): string => { +const scheduleAnchorOffsetLabel = (offsetMinutes: number | undefined, locale: DashboardLocale): string => { const offset = Math.max(0, Math.floor(Number(offsetMinutes ?? 0))); if (offset === 0) { return ""; } if (offset === 1) { - return " + 1 minute"; + return schedulerText(locale, "offsetOneMinute"); } - return ` + ${offset} minutes`; + return schedulerText(locale, "offsetManyMinutes", { count: offset }); }; const sprintDisplayName = (sprints: SprintRecord[], sprintId: string): string => ( @@ -247,26 +341,33 @@ const isNodeFlowJsonObject = (value: unknown): value is NodeFlowJsonObject => ( && Object.values(value as Record).every(isNodeFlowJsonValue) ); -const parseNodeFlowInputJson = (rawInput: string): { input?: NodeFlowJsonObject; error?: string } => { +const parseNodeFlowInputJson = (rawInput: string, locale: DashboardLocale): { input?: NodeFlowJsonObject; error?: string } => { if (!rawInput.trim()) { return {}; } try { const parsed: unknown = JSON.parse(rawInput); if (!isNodeFlowJsonObject(parsed)) { - return { error: "Node flow input must be a JSON object." }; + return { error: schedulerText(locale, "validationNodeFlowObject") }; } return { input: parsed }; } catch { - return { error: "Node flow input must be valid JSON." }; + return { error: schedulerText(locale, "validationNodeFlowJson") }; } }; -const scheduleTimingSummary = (entry: SchedulerEntryRecord, sprints: SprintRecord[]): string => { +const scheduleTimingSummary = (entry: SchedulerEntryRecord, sprints: SprintRecord[], locale: DashboardLocale): string => { if (entry.scheduleAnchor?.mode === "after_sprint_end") { - return `After ${sprintDisplayName(sprints, entry.scheduleAnchor.sourceSprintId)} ends${scheduleAnchorOffsetLabel(entry.scheduleAnchor.offsetMinutes)}`; + return schedulerText(locale, "anchorAfterSprint", { + sprint: sprintDisplayName(sprints, entry.scheduleAnchor.sourceSprintId), + offset: scheduleAnchorOffsetLabel(entry.scheduleAnchor.offsetMinutes, locale), + }); } - return `Next run: ${entry.nextRunAt ? new Date(entry.nextRunAt).toLocaleString() : "none"}`; + return schedulerText(locale, "nextRunAt", { + date: entry.nextRunAt + ? `${formatSchedulerDateTime(entry.nextRunAt, locale, entry.timezone)} (${entry.timezone})` + : schedulerText(locale, "none"), + }); }; const scheduleTargetSummary = ( @@ -274,30 +375,45 @@ const scheduleTargetSummary = ( sprints: SprintRecord[], templates: QuicksprintTemplateRecord[], nodeFlows: NodeFlowRecord[], + locale: DashboardLocale, ): string => { if (entry.targetType === "sprint") { - return entry.sprintTarget ? `Sprint: ${sprintDisplayName(sprints, entry.sprintTarget.sprintId)}` : "Sprint"; + return entry.sprintTarget + ? schedulerText(locale, "sprintSummary", { name: sprintDisplayName(sprints, entry.sprintTarget.sprintId) }) + : schedulerText(locale, "targetSprint"); } if (entry.targetType === "quicksprint") { const templateId = entry.quicksprintTarget?.templateId; - const templateName = templateId ? templates.find((template) => template.id === templateId)?.name || templateId : "template"; - return `Quicksprint: ${templateName}`; + const templateName = templateId + ? templates.find((template) => template.id === templateId)?.name || templateId + : schedulerText(locale, "templateFallback"); + return schedulerText(locale, "quicksprintSummary", { name: templateName }); } if (entry.targetType === "chat") { - return entry.chatTarget?.threadId ? `Chat thread: ${entry.chatTarget.threadId}` : "Project chat message"; + return entry.chatTarget?.threadId + ? schedulerText(locale, "chatThreadSummary", { threadId: entry.chatTarget.threadId }) + : schedulerText(locale, "projectChatMessage"); } if (entry.targetType === "memory_remediation") { - return `Memory remediation: ${entry.memoryRemediationTarget?.mode === "ai" ? "AI review" : "deterministic cleanup"}`; + return schedulerText(locale, "remediationSummary", { + mode: schedulerText(locale, entry.memoryRemediationTarget?.mode === "ai" ? "aiReview" : "deterministicCleanup"), + }); } if (entry.targetType === "node_flow") { const flowId = entry.nodeFlowTarget?.flowId; - return flowId ? `Node flow: ${nodeFlowDisplayName(nodeFlows, flowId)}` : "Node flow"; + return flowId + ? schedulerText(locale, "nodeFlowSummary", { name: nodeFlowDisplayName(nodeFlows, flowId) }) + : schedulerText(locale, "targetNodeFlow"); } if (entry.targetType === "agent_wakeup") { - return entry.agentWakeupTarget?.threadId ? `Agent wakeup: ${entry.agentWakeupTarget.threadId}` : "Agent wakeup message"; + return entry.agentWakeupTarget?.threadId + ? schedulerText(locale, "agentWakeupSummary", { threadId: entry.agentWakeupTarget.threadId }) + : schedulerText(locale, "agentWakeupMessage"); } const provider = entry.taskTarget?.provider ? ` · ${entry.taskTarget.provider}` : ""; - return entry.taskTarget?.taskId ? `Task rerun: ${entry.taskTarget.taskId}${provider}` : "Task rerun"; + return entry.taskTarget?.taskId + ? schedulerText(locale, "taskRerunSummary", { taskId: entry.taskTarget.taskId, provider }) + : schedulerText(locale, "taskRerun"); }; const ProjectPlaceholder: FunctionComponent = () => ( @@ -305,15 +421,27 @@ const ProjectPlaceholder: FunctionComponent = () => (
-

Select a project to schedule work.

-

- Scheduler entries are project-scoped so sprints, quicksprints, node flows, and chat messages run against the right workspace. -

+ ); +const LocalizedProjectPlaceholderCopy: FunctionComponent = () => { + const { translate } = useDashboardI18n(); + return ( + <> +

{translate(schedulerMessages, "selectProjectTitle")}

+

+ {translate(schedulerMessages, "selectProjectDescription")} +

+ + ); +}; + export const SchedulerPage: FunctionComponent = () => { const { selectedProject } = useProjectData(); + const { locale, translate, translatePlural, formatNumber, formatTime } = useDashboardI18n(); + const refreshSequence = useRef(0); + const submitInFlight = useRef(false); const [view, setView] = useState("calendar"); const [selectedDate, setSelectedDate] = useState(() => startOfDay(new Date())); const [schedule, setSchedule] = useState(null); @@ -347,6 +475,7 @@ export const SchedulerPage: FunctionComponent = () => { }; }, []); const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); const [feedback, setFeedback] = useState({ tone: "idle", message: null }); const [editingEntry, setEditingEntry] = useState(null); @@ -373,6 +502,7 @@ export const SchedulerPage: FunctionComponent = () => { const [endMode, setEndMode] = useState("never"); const [count, setCount] = useState(6); const [until, setUntil] = useState(() => toDateInputValue(addDays(new Date(), 30))); + const selectedTimezone = editingEntry?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const range = useMemo(() => { const from = startOfWeek(selectedDate); @@ -385,6 +515,7 @@ export const SchedulerPage: FunctionComponent = () => { if (!selectedProject) { return; } + const requestId = ++refreshSequence.current; setLoading(true); try { const [nextSchedule, sprintResponse, quicksprintTemplates, nodeFlowResponse] = await Promise.all([ @@ -393,6 +524,9 @@ export const SchedulerPage: FunctionComponent = () => { fetchQuicksprintTemplates(selectedProject.id), fetchNodeFlows(selectedProject.id, signal), ]); + if (signal?.aborted || requestId !== refreshSequence.current) { + return; + } setSchedule(nextSchedule); setSprints(sprintResponse.sprints); setTemplates(quicksprintTemplates); @@ -406,15 +540,15 @@ export const SchedulerPage: FunctionComponent = () => { : nodeFlowResponse.flows[0]?.id || "" )); } catch (error) { - if (!signal?.aborted) { - setFeedback({ tone: "error", message: error instanceof Error ? error.message : "Failed to load scheduler." }); + if (!signal?.aborted && requestId === refreshSequence.current) { + setFeedback({ tone: "error", message: error instanceof Error ? error.message : translate(schedulerMessages, "failedLoad") }); } } finally { - if (!signal?.aborted) { + if (!signal?.aborted && requestId === refreshSequence.current) { setLoading(false); } } - }, [range.from, range.to, selectedProject]); + }, [range.from, range.to, selectedProject, translate]); useEffect(() => { const controller = new AbortController(); @@ -483,9 +617,19 @@ export const SchedulerPage: FunctionComponent = () => { }; }, [schedule?.entries, schedule?.occurrences]); - const scheduleRangeStatus = loading && schedule - ? `Updating schedule. Showing cached ${schedulerStats.visibleCount} visible occurrences from ${formatDayLabel(range.from)} to ${formatDayLabel(range.to)}.` - : `${schedulerStats.visibleCount} visible occurrences · ${formatDayLabel(range.from)} to ${formatDayLabel(range.to)}`; + const scheduleRangeStatus = loading && !schedule + ? translate(schedulerMessages, "loadingSchedule") + : loading + ? translate(schedulerMessages, "updatingSchedule", { + count: formatNumber(schedulerStats.visibleCount), + from: formatSchedulerDayLabel(range.from, locale), + to: formatSchedulerDayLabel(range.to, locale), + }) + : translate(schedulerMessages, "visibleScheduleRange", { + count: formatNumber(schedulerStats.visibleCount), + from: formatSchedulerDayLabel(range.from, locale), + to: formatSchedulerDayLabel(range.to, locale), + }); const focusSchedulerView = (nextView: SchedulerView) => { setView(nextView); @@ -515,7 +659,7 @@ export const SchedulerPage: FunctionComponent = () => { const startEdit = (entry: SchedulerEntryRecord) => { if (!isOperatorEditableTarget(entry.targetType)) { - setFeedback({ tone: "error", message: unsupportedEditReason(entry.targetType) }); + setFeedback({ tone: "error", message: unsupportedEditReason(entry.targetType, locale) }); return; } setEditingEntry(entry); @@ -561,7 +705,7 @@ export const SchedulerPage: FunctionComponent = () => { } }; - const cancelEdit = () => { + const cancelEdit = (clearFeedback = true) => { setEditingEntry(null); setEntryTitle(""); setTargetType("sprint"); @@ -584,7 +728,9 @@ export const SchedulerPage: FunctionComponent = () => { setEndMode("never"); setCount(6); setUntil(toDateInputValue(addDays(new Date(), 30))); - setFeedback({ tone: "idle", message: null }); + if (clearFeedback) { + setFeedback({ tone: "idle", message: null }); + } }; const editOccurrence = (occurrence: SchedulerOccurrence) => { @@ -592,26 +738,56 @@ export const SchedulerPage: FunctionComponent = () => { if (entry) { startEdit(entry); } else { - setFeedback({ tone: "error", message: "Parent schedule entry not found." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "parentEntryNotFound") }); } }; const submitSchedule = async () => { - if (!selectedProject) { + if (!selectedProject || submitInFlight.current) { return; } setFeedback({ tone: "idle", message: null }); if (!isOperatorEditableTarget(targetType)) { - setFeedback({ tone: "error", message: unsupportedEditReason(targetType) }); + setFeedback({ tone: "error", message: unsupportedEditReason(targetType, locale) }); + return; + } + + const scheduledDate = isAnchoredTiming ? null : new Date(scheduledFor); + if (scheduledDate && !Number.isFinite(scheduledDate.getTime())) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationDateTime") }); return; } + + let recurrenceUntil: Date | null = null; + if (repeatEnabled && !isAnchoredTiming) { + if (!Number.isInteger(interval) || interval < 1) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationInterval") }); + return; + } + if (endMode === "after_count" && (!Number.isInteger(count) || count < 1)) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationCount") }); + return; + } + if (endMode === "on_date") { + recurrenceUntil = new Date(until); + if (!Number.isFinite(recurrenceUntil.getTime())) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationEndDate") }); + return; + } + if (scheduledDate && recurrenceUntil.getTime() <= scheduledDate.getTime()) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationEndAfterStart") }); + return; + } + } + } + const recurrence: Partial = repeatEnabled && !isAnchoredTiming ? { frequency, interval, endMode, count: endMode === "after_count" ? count : null, - until: endMode === "on_date" ? new Date(until).toISOString() : null, + until: recurrenceUntil?.toISOString() ?? null, } : { frequency: "none", interval: 1, endMode: "never" }; @@ -637,32 +813,36 @@ export const SchedulerPage: FunctionComponent = () => { if (targetType === "sprint") { if (!selectedSprintId) { - setFeedback({ tone: "error", message: "Choose a sprint that is not completed." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChooseSprint") }); return; } const sprint = sprints.find((item) => item.id === selectedSprintId); if (!sprint || (sprint.status === "completed" && (!editingEntry || editingEntry.sprintTarget?.sprintId !== selectedSprintId))) { - setFeedback({ tone: "error", message: "Choose a sprint that is not completed." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChooseSprint") }); return; } } else if (targetType === "quicksprint") { if (!selectedTemplateId) { - setFeedback({ tone: "error", message: "Choose a quicksprint template." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChooseTemplate") }); + return; + } + if (!Number.isInteger(taskCount) || taskCount < 1 || taskCount > 50) { + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationTaskCount") }); return; } } else if (targetType === "chat") { if (!chatMessage.trim()) { - setFeedback({ tone: "error", message: "Write the chat message to schedule." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChatMessage") }); return; } } else if (targetType === "node_flow") { if (!selectedNodeFlowId) { - setFeedback({ tone: "error", message: "Choose a node flow." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChooseNodeFlow") }); return; } } - const parsedNodeFlowInput = targetType === "node_flow" ? parseNodeFlowInputJson(nodeFlowInputJson) : {}; + const parsedNodeFlowInput = targetType === "node_flow" ? parseNodeFlowInputJson(nodeFlowInputJson, locale) : {}; if (parsedNodeFlowInput.error) { setFeedback({ tone: "error", message: parsedNodeFlowInput.error }); return; @@ -671,16 +851,16 @@ export const SchedulerPage: FunctionComponent = () => { let scheduleAnchor: ScheduleAnchor | undefined; if (isAnchoredTiming) { if (!anchorSourceSprintId) { - setFeedback({ tone: "error", message: "Choose the sprint this schedule should wait for." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationChooseAnchorSprint") }); return; } const offsetMinutes = Math.floor(Number(anchorOffsetMinutes || 0)); if (!Number.isFinite(offsetMinutes) || offsetMinutes < 0) { - setFeedback({ tone: "error", message: "Offset minutes must be zero or greater." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationOffset") }); return; } if (targetType === "sprint" && selectedSprintId === anchorSourceSprintId) { - setFeedback({ tone: "error", message: "A sprint cannot be scheduled after its own completion." }); + setFeedback({ tone: "error", message: translate(schedulerMessages, "validationOwnSprint") }); return; } scheduleAnchor = { @@ -693,13 +873,13 @@ export const SchedulerPage: FunctionComponent = () => { const input: SchedulerFormInput = { title: finalTitle, targetType, - timezone: editingEntry ? editingEntry.timezone : (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), + timezone: selectedTimezone, recurrence, }; if (scheduleAnchor) { input.scheduleAnchor = scheduleAnchor; } else { - input.scheduledFor = new Date(scheduledFor).toISOString(); + input.scheduledFor = scheduledDate!.toISOString(); if (editingEntry?.scheduleAnchor) { input.scheduleAnchor = null; } @@ -729,24 +909,29 @@ export const SchedulerPage: FunctionComponent = () => { }; } + submitInFlight.current = true; + setSubmitting(true); try { if (editingEntry) { await updateSchedulerEntry(editingEntry.id, input); - setFeedback({ tone: "success", message: "Schedule entry updated." }); - cancelEdit(); + cancelEdit(false); + setFeedback({ tone: "success", message: translate(schedulerMessages, "scheduleUpdated") }); } else { const createInput: CreateSchedulerEntryInput = { ...input, scheduleAnchor: input.scheduleAnchor ?? undefined, }; await createSchedulerEntry(selectedProject.id, createInput); - setFeedback({ tone: "success", message: "Schedule entry created." }); + setFeedback({ tone: "success", message: translate(schedulerMessages, "scheduleCreated") }); setChatMessage(""); setEntryTitle(""); } await refresh(); } catch (error) { - setFeedback({ tone: "error", message: error instanceof Error ? error.message : "Failed to save schedule entry." }); + setFeedback({ tone: "error", message: error instanceof Error ? error.message : translate(schedulerMessages, "failedSave") }); + } finally { + submitInFlight.current = false; + setSubmitting(false); } }; @@ -755,35 +940,38 @@ export const SchedulerPage: FunctionComponent = () => { await updateSchedulerEntry(entryId, { status: paused ? "scheduled" : "paused" }); await refresh(); } catch (error) { - setFeedback({ tone: "error", message: error instanceof Error ? error.message : "Failed to update schedule entry." }); + setFeedback({ tone: "error", message: error instanceof Error ? error.message : translate(schedulerMessages, "failedUpdate") }); } }; const removeEntry = async (entryId: string) => { + if (!window.confirm(translate(schedulerMessages, "deleteConfirmation"))) { + return; + } try { await deleteSchedulerEntry(entryId); await refresh(); } catch (error) { - setFeedback({ tone: "error", message: error instanceof Error ? error.message : "Failed to delete schedule entry." }); + setFeedback({ tone: "error", message: error instanceof Error ? error.message : translate(schedulerMessages, "failedDelete") }); } }; if (!selectedProject) { return ( - + ); } return ( - +
{(["calendar", "day"] as SchedulerView[]).map((item) => ( @@ -827,7 +1015,7 @@ export const SchedulerPage: FunctionComponent = () => { view === item ? "bg-signal-500 text-white dark:text-void-900 shadow-[0_2px_8px_rgba(0,224,160,0.2)]" : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white" }`} > - {schedulerViewLabel(item)} + {schedulerViewLabel(item, locale)} ))}
@@ -837,13 +1025,13 @@ export const SchedulerPage: FunctionComponent = () => {
{[ - { id: "active", label: "Active entries", value: schedulerStats.activeCount, detail: "ready to fire", icon: Play, tone: "text-signal-500" }, - { id: "repeating", label: "Repeating", value: schedulerStats.repeatingCount, detail: "recurrence rules", icon: Repeat, tone: "text-signal-500" }, + { id: "active", label: translate(schedulerMessages, "activeEntries"), value: formatNumber(schedulerStats.activeCount), detail: translate(schedulerMessages, "readyToFire"), icon: Play, tone: "text-signal-500" }, + { id: "repeating", label: translate(schedulerMessages, "repeating"), value: formatNumber(schedulerStats.repeatingCount), detail: translate(schedulerMessages, "recurrenceRules"), icon: Repeat, tone: "text-signal-500" }, { id: "next-run", - label: "Next run", - value: schedulerStats.nextOccurrence ? formatTimeLabel(schedulerStats.nextOccurrence.startsAt) : "None", - detail: schedulerStats.nextOccurrence ? schedulerStats.nextOccurrence.title : "no upcoming work", + label: translate(schedulerMessages, "nextRun"), + value: schedulerStats.nextOccurrence ? formatSchedulerTimeLabel(schedulerStats.nextOccurrence.startsAt, locale) : translate(schedulerMessages, "none"), + detail: schedulerStats.nextOccurrence ? schedulerStats.nextOccurrence.title : translate(schedulerMessages, "noUpcomingWork"), icon: Clock3, tone: "text-ember-500", }, @@ -869,10 +1057,10 @@ export const SchedulerPage: FunctionComponent = () => {

- {editingEntry ? "Edit entry" : "Add entry"} + {translate(schedulerMessages, editingEntry ? "editEntry" : "addEntry")}

- {editingEntry ? "Modify the title, work, time, and recurrence." : "Choose the work, time, and recurrence."} + {translate(schedulerMessages, editingEntry ? "editEntryDescription" : "addEntryDescription")}

@@ -880,9 +1068,9 @@ export const SchedulerPage: FunctionComponent = () => {
-
+
- Selected schedule target: {targetLabel(targetType)}. + {translate(schedulerMessages, "selectedScheduleTarget", { target: targetLabel(targetType, locale) })} {FORM_TARGET_OPTIONS.map((option) => ( ))}
{targetType === "sprint" && (