Files
openchamber/packages/ui/src/components/session/ScheduledTasksDialog.tsx
T
Bohdan Triapitsyn 874dc15263 fix: respect time format preference in UI
Add shared time formatting helpers that apply the Appearance time format preference consistently. Update visible time labels across chat, quota usage, scheduled tasks, tunnels, context details, git views, PR metadata, and passkey settings to use the selected 12-hour, 24-hour, or automatic format. Leave date-only and non-UI formatting untouched so unrelated behavior does not change.
2026-06-03 18:20:35 +03:00

637 lines
25 KiB
TypeScript

import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { useUIStore } from '@/stores/useUIStore';
import { formatTimeForPreference } from '@/lib/timeFormat';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import type { ProjectEntry } from '@/lib/api/types';
import {
deleteScheduledTask,
fetchScheduledTasks,
runScheduledTaskNow,
upsertScheduledTask,
type ScheduledTask,
type ScheduledTaskStatus,
} from '@/lib/scheduledTasksApi';
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
const scheduleTimes = (task: ScheduledTask): string[] => {
const raw = Array.isArray(task.schedule.times)
? task.schedule.times
: (task.schedule.time ? [task.schedule.time] : []);
const valid = raw.filter((value) => typeof value === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(value));
return Array.from(new Set(valid)).sort((a, b) => a.localeCompare(b));
};
const formatSchedule = (task: ScheduledTask, t: ReturnType<typeof useI18n>['t']): string => {
const timesLabel = scheduleTimes(task).join(', ') || '--:--';
const formatWeekday = (value: number) => {
if (value === 0) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sun');
if (value === 1) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.mon');
if (value === 2) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.tue');
if (value === 3) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.wed');
if (value === 4) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.thu');
if (value === 5) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.fri');
if (value === 6) return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.sat');
return t('sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown');
};
if (task.schedule.kind === 'daily') {
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.dailyWithTimezone', {
time: timesLabel,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.daily', { time: timesLabel });
}
if (task.schedule.kind === 'weekly') {
const days = Array.isArray(task.schedule.weekdays)
? task.schedule.weekdays.map((value) => formatWeekday(value)).join(', ')
: '';
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone', {
days,
time: timesLabel,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.weekly', { days, time: timesLabel });
}
if (task.schedule.kind === 'once') {
const date = typeof task.schedule.date === 'string' && task.schedule.date.trim().length > 0
? task.schedule.date
: t('sessions.scheduledTasks.dialog.schedule.unknownDate');
const time = typeof task.schedule.time === 'string' && task.schedule.time.trim().length > 0
? task.schedule.time
: '--:--';
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.onceWithTimezone', {
date,
time,
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.once', { date, time });
}
if (task.schedule.timezone) {
return t('sessions.scheduledTasks.dialog.schedule.cronWithTimezone', {
cron: task.schedule.cron || '',
timezone: task.schedule.timezone,
});
}
return t('sessions.scheduledTasks.dialog.schedule.cron', { cron: task.schedule.cron || '' });
};
const formatClockTime = (value: number | undefined, timeFormatPreference: TimeFormatPreference): string => {
if (!value || !Number.isFinite(value)) {
return '';
}
return formatTimeForPreference(value, timeFormatPreference);
};
const formatRelativeTime = (value: number | undefined, t: ReturnType<typeof useI18n>['t']): string => {
if (!value || !Number.isFinite(value)) {
return '';
}
const diff = value - Date.now();
const abs = Math.abs(diff);
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
const future = diff >= 0;
if (abs < minute) {
return future ? t('sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute') : t('sessions.scheduledTasks.dialog.relativeTime.justNow');
}
if (abs < hour) {
const m = Math.round(abs / minute);
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inMinutes', { count: m })
: t('sessions.scheduledTasks.dialog.relativeTime.minutesAgo', { count: m });
}
if (abs < day) {
const h = Math.floor(abs / hour);
const m = Math.round((abs % hour) / minute);
const body = m > 0 ? `${h}h ${m}m` : `${h}h`;
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
}
const d = Math.floor(abs / day);
const h = Math.round((abs % day) / hour);
const body = h > 0 ? `${d}d ${h}h` : `${d}d`;
return future
? t('sessions.scheduledTasks.dialog.relativeTime.inDuration', { duration: body })
: t('sessions.scheduledTasks.dialog.relativeTime.durationAgo', { duration: body });
};
type StatusTone = 'success' | 'error' | 'warning' | 'muted';
const STATUS_META: Record<
ScheduledTaskStatus,
{
tone: StatusTone;
Icon: IconName;
spin?: boolean;
}
> = {
success: { tone: 'success', Icon: 'checkbox-circle' },
error: { tone: 'error', Icon: 'error-warning' },
running: { tone: 'warning', Icon: 'loader-4', spin: true },
idle: { tone: 'muted', Icon: 'pulse' },
};
const toneStyle = (tone: StatusTone): React.CSSProperties => {
if (tone === 'muted') {
return {};
}
return {
color: `var(--status-${tone})`,
backgroundColor: `var(--status-${tone}-background)`,
borderColor: `var(--status-${tone}-border)`,
};
};
export function ScheduledTasksDialog() {
const { t } = useI18n();
const open = useUIStore((state) => state.isScheduledTasksDialogOpen);
const setOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
const isMobile = useUIStore((state) => state.isMobile);
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
const projects = useProjectsStore((state) => state.projects);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { currentTheme } = useThemeSystem();
const [selectedProjectID, setSelectedProjectID] = React.useState<string>('');
const [tasks, setTasks] = React.useState<ScheduledTask[]>([]);
// Start in loading state so the first frame after open shows the spinner,
// not an empty/select-project flash before the fetch effect runs.
const [loading, setLoading] = React.useState(true);
const [editorOpen, setEditorOpen] = React.useState(false);
const [editorTask, setEditorTask] = React.useState<ScheduledTask | null>(null);
const [mutatingTaskID, setMutatingTaskID] = React.useState<string | null>(null);
const selectedProject = React.useMemo(
() => projects.find((project) => project.id === selectedProjectID) || null,
[projects, selectedProjectID],
);
const renderProjectLabel = React.useCallback((project: ProjectEntry) => {
const displayLabel = project.label?.trim() || formatDirectoryName(project.path, homeDirectory || undefined);
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = project.color ? PROJECT_COLOR_MAP[project.color] : undefined;
const fallbackIcon = projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined}/>
);
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{project.iconImage ? (
<span
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
>
<ProjectIconImage
project={{ id: project.id, iconImage: project.iconImage ?? null }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={fallbackIcon}
/>
</span>
) : fallbackIcon}
<span className="truncate">{displayLabel}</span>
</span>
);
}, [homeDirectory, currentTheme.metadata.variant, currentTheme.colors.surface.foreground]);
const reloadTasks = React.useCallback(async (projectID: string, options?: { silent?: boolean }) => {
if (!projectID) {
setTasks([]);
return;
}
if (!options?.silent) {
setLoading(true);
}
try {
const nextTasks = await fetchScheduledTasks(projectID);
nextTasks.sort((a, b) => {
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1;
}
const byName = a.name.localeCompare(b.name);
if (byName !== 0) {
return byName;
}
return (a.state?.nextRunAt || Number.MAX_SAFE_INTEGER) - (b.state?.nextRunAt || Number.MAX_SAFE_INTEGER);
});
setTasks(nextTasks);
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.loadFailed'));
if (!options?.silent) {
setTasks([]);
}
} finally {
if (!options?.silent) {
setLoading(false);
}
}
}, [t]);
React.useEffect(() => {
if (!open) {
return;
}
const preferredProjectID = activeProject?.id || projects[0]?.id || '';
setSelectedProjectID(preferredProjectID);
if (preferredProjectID) {
void reloadTasks(preferredProjectID);
} else {
setTasks([]);
setLoading(false);
}
}, [open, activeProject, projects, reloadTasks]);
React.useEffect(() => {
if (!open) {
return;
}
let timeoutID: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = subscribeOpenchamberEvents((event) => {
if (event.type !== 'scheduled-task-ran') {
return;
}
if (event.projectId !== selectedProjectID) {
return;
}
if (timeoutID) {
clearTimeout(timeoutID);
}
timeoutID = setTimeout(() => {
void reloadTasks(selectedProjectID, { silent: true });
}, 400);
});
return () => {
if (timeoutID) {
clearTimeout(timeoutID);
}
unsubscribe();
};
}, [open, selectedProjectID, reloadTasks]);
const handleSaveTask = React.useCallback(async (taskDraft: Partial<ScheduledTask>) => {
if (!selectedProjectID) {
throw new Error(t('sessions.scheduledTasks.dialog.error.chooseProjectFirst'));
}
await upsertScheduledTask(selectedProjectID, taskDraft);
await reloadTasks(selectedProjectID);
toast.success(t('sessions.scheduledTasks.dialog.toast.saved'));
}, [selectedProjectID, reloadTasks, t]);
const handleToggleEnabled = React.useCallback(async (task: ScheduledTask, enabled: boolean) => {
if (!selectedProjectID) {
return;
}
setMutatingTaskID(task.id);
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
try {
await upsertScheduledTask(selectedProjectID, {
...task,
enabled,
});
await reloadTasks(selectedProjectID, { silent: true });
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
await reloadTasks(selectedProjectID, { silent: true });
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks, t]);
const handleDeleteTask = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
return;
}
const confirmed = window.confirm(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
if (!confirmed) {
return;
}
setMutatingTaskID(task.id);
try {
await deleteScheduledTask(selectedProjectID, task.id);
await reloadTasks(selectedProjectID, { silent: true });
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.deleteFailed'));
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks, t]);
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
return;
}
setMutatingTaskID(task.id);
try {
await runScheduledTaskNow(selectedProjectID, task.id);
await Promise.all([
reloadTasks(selectedProjectID, { silent: true }),
refreshGlobalSessions(),
]);
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
} finally {
setMutatingTaskID(null);
}
}, [selectedProjectID, reloadTasks, t]);
const projectSelector = (
<div className="flex flex-col items-start gap-1">
<span className="typography-meta text-muted-foreground">{t('sessions.scheduledTasks.dialog.project.label')}</span>
<Select
value={selectedProjectID || '__none'}
onValueChange={(value) => {
const nextProjectID = value === '__none' ? '' : value;
setSelectedProjectID(nextProjectID);
if (nextProjectID) {
void reloadTasks(nextProjectID);
} else {
setTasks([]);
}
}}
>
<SelectTrigger className={isMobile ? 'w-full' : undefined}>
{selectedProject ? (
<SelectValue>{renderProjectLabel(selectedProject)}</SelectValue>
) : (
<SelectValue placeholder={t('sessions.scheduledTasks.dialog.project.placeholder')} />
)}
</SelectTrigger>
<SelectContent>
{projects.length === 0 ? <SelectItem value="__none">{t('sessions.scheduledTasks.dialog.project.empty')}</SelectItem> : null}
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{renderProjectLabel(project)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const openNewTaskEditor = () => {
setEditorTask(null);
setEditorOpen(true);
};
const tasksContent = (
<div className="space-y-4">
{!isMobile ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{projectSelector}
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
</Button>
</div>
) : (
projectSelector
)}
<div className="min-h-[280px]">
{loading ? (
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
<Icon name="loader-4" className="h-4 w-4 animate-spin" /> {t('sessions.scheduledTasks.dialog.loading')}
</div>
) : tasks.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-4 typography-meta text-muted-foreground">
{selectedProjectID ? t('sessions.scheduledTasks.dialog.empty.noTasks') : t('sessions.scheduledTasks.dialog.empty.selectProject')}
</div>
) : (
<div className="space-y-2.5">
{tasks.map((task) => {
const isBusy = mutatingTaskID === task.id;
const status = (task.state?.lastStatus || 'idle') as ScheduledTaskStatus;
const meta = STATUS_META[status];
const statusLabel = status === 'success'
? t('sessions.scheduledTasks.dialog.status.success')
: status === 'error'
? t('sessions.scheduledTasks.dialog.status.error')
: status === 'running'
? t('sessions.scheduledTasks.dialog.status.running')
: t('sessions.scheduledTasks.dialog.status.idle');
const nextAt = task.state?.nextRunAt;
const lastAt = task.state?.lastRunAt;
return (
<div
key={task.id}
className={cn(
'rounded-lg border border-border p-4 transition-opacity',
!task.enabled && 'opacity-60',
)}
>
<div className="min-w-0">
<div className="typography-ui-header truncate font-semibold text-foreground">
{task.name}
</div>
<div className="typography-micro truncate text-muted-foreground">
{formatSchedule(task, t)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Icon name="timer" className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.nextRun.label')}</span>
{nextAt ? (
<>
<span className="text-foreground">{formatRelativeTime(nextAt, t)}</span>
<span className="text-muted-foreground/50">·</span>
<span>{formatClockTime(nextAt, timeFormatPreference)}</span>
</>
) : (
<span></span>
)}
</span>
<span className="inline-flex items-center gap-1.5">
<Icon name="history" className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">{t('sessions.scheduledTasks.dialog.lastRun.label')}</span>
{status === 'running' ? (
<span
className="inline-flex items-center gap-1"
style={{ color: 'var(--status-warning)' }}
>
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
{t('sessions.scheduledTasks.dialog.lastRun.runningNow')}
</span>
) : lastAt ? (
<>
{meta.tone !== 'muted' ? (
<span
className="inline-flex items-center gap-1"
style={{ color: `var(--status-${meta.tone})` }}
>
<Icon name={meta.Icon} className="h-3.5 w-3.5" />
{statusLabel}
</span>
) : null}
<span className="text-muted-foreground/50">·</span>
<span>{formatRelativeTime(lastAt, t)}</span>
</>
) : (
<span>{t('sessions.scheduledTasks.dialog.lastRun.never')}</span>
)}
</span>
</div>
{task.state?.lastError ? (
<div
className="mt-3 flex items-start gap-2 rounded-md border p-2 typography-micro"
style={toneStyle('error')}
>
<Icon name="error-warning" className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{task.state.lastError}</span>
</div>
) : null}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
<label
className={cn(
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
task.enabled ? 'text-foreground' : 'text-muted-foreground',
isBusy && 'cursor-not-allowed opacity-50',
)}
>
<Checkbox
checked={task.enabled}
onChange={(enabled) => void handleToggleEnabled(task, enabled)}
ariaLabel={task.enabled
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
disabled={isBusy}
/>
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
</label>
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => void handleRunNow(task)}
disabled={isBusy}
>
<Icon name="play" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.runNow')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditorTask(task);
setEditorOpen(true);
}}
disabled={isBusy}
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
>
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => void handleDeleteTask(task)}
disabled={isBusy}
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
>
<Icon name="delete-bin" className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
return (
<>
{isMobile ? (
<MobileOverlayPanel
open={open}
title={t('sessions.scheduledTasks.dialog.title')}
onClose={() => setOpen(false)}
contentMaxHeightClassName="max-h-[min(80vh,640px)]"
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1 border-b border-border/40 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">{t('sessions.scheduledTasks.dialog.title')}</h2>
{closeButton}
</div>
<p className="typography-micro text-muted-foreground">
{t('sessions.scheduledTasks.dialog.description')}
</p>
</div>
)}
footer={(
<Button
className="w-full"
onClick={openNewTaskEditor}
disabled={!selectedProjectID}
>
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
</Button>
)}
>
{tasksContent}
</MobileOverlayPanel>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('sessions.scheduledTasks.dialog.title')}</DialogTitle>
<DialogDescription>{t('sessions.scheduledTasks.dialog.description')}</DialogDescription>
</DialogHeader>
{tasksContent}
</DialogContent>
</Dialog>
)}
<ScheduledTaskEditorDialog
open={editorOpen}
task={editorTask}
onOpenChange={setEditorOpen}
onSave={handleSaveTask}
/>
</>
);
}