mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-27 12:04:49 +02:00
480 lines
24 KiB
TypeScript
480 lines
24 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import Typography from '@mui/material/Typography';
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import Popover from '@mui/material/Popover';
|
|
import Menu from '@mui/material/Menu';
|
|
import MenuItem from '@mui/material/MenuItem';
|
|
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
|
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
|
import { API_BASE } from '@/shared/config';
|
|
import type { Workflow } from '@/shared/state/workflowsSlice';
|
|
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
|
|
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
|
|
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel } from './scheduleUtils';
|
|
|
|
interface Props {
|
|
view: 'Week' | 'Month' | 'List';
|
|
density: 'compact' | 'roomy';
|
|
onSelectWorkflow?: (id: string) => void;
|
|
refDate?: Date;
|
|
}
|
|
|
|
// Both compact (popover) and roomy (hub) show the full 24 hours scrollable —
|
|
// the user explicitly wants midnight visible at the top, not "9am" as the
|
|
// starting hour. The scroll container caps the visible window.
|
|
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
|
|
|
|
interface CalendarEvent {
|
|
workflow_id: string;
|
|
fire_at: string;
|
|
}
|
|
|
|
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
|
|
const c = useClaudeTokens();
|
|
const dispatch = useAppDispatch();
|
|
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
|
|
// Right-click menu: pinned position + the workflow whose pill was
|
|
// clicked. Same anchor pattern as MUI's menu examples.
|
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
|
const closeMenu = () => setCtxMenu(null);
|
|
const onRunNow = () => {
|
|
if (!ctxMenu) return;
|
|
dispatch(runWorkflowNow(ctxMenu.workflow.id));
|
|
closeMenu();
|
|
};
|
|
const onPauseToggle = () => {
|
|
if (!ctxMenu) return;
|
|
const wf = ctxMenu.workflow;
|
|
dispatch(updateWorkflow({
|
|
id: wf.id,
|
|
patch: { schedule: { ...wf.schedule, enabled: !wf.schedule.enabled } as any },
|
|
ifMatch: wf.updated_at || null,
|
|
}));
|
|
closeMenu();
|
|
};
|
|
const onEdit = () => {
|
|
if (!ctxMenu) return;
|
|
dispatch(addWorkflowCard({ workflowId: ctxMenu.workflow.id }));
|
|
// Right-click "Edit" on a calendar entry opens the new Edit Agent
|
|
// chat view, matching the post-revamp design (Image #38).
|
|
dispatch(openWorkflowCard({ workflowId: ctxMenu.workflow.id, view: 'edit_agent' }));
|
|
closeMenu();
|
|
};
|
|
const onDelete = () => {
|
|
if (!ctxMenu) return;
|
|
const ok = window.confirm(`Delete "${ctxMenu.workflow.title}"? Scheduled runs will stop.`);
|
|
if (!ok) { closeMenu(); return; }
|
|
dispatch(deleteWorkflow(ctxMenu.workflow.id));
|
|
closeMenu();
|
|
};
|
|
const ctxMenuEl = (
|
|
<Menu
|
|
open={Boolean(ctxMenu)}
|
|
onClose={closeMenu}
|
|
anchorReference="anchorPosition"
|
|
anchorPosition={ctxMenu ? { top: ctxMenu.y, left: ctxMenu.x } : undefined}>
|
|
<MenuItem onClick={onRunNow}>Run now</MenuItem>
|
|
<MenuItem onClick={onPauseToggle}>{ctxMenu?.workflow.schedule.enabled ? 'Pause schedule' : 'Resume schedule'}</MenuItem>
|
|
<MenuItem onClick={onEdit}>Edit…</MenuItem>
|
|
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
|
|
</Menu>
|
|
);
|
|
// refDate is recreated on every render unless the caller memoizes it.
|
|
// Pin the calendar to a day-precision key so occurrence fetches only
|
|
// change when the visible day, view, or schedule set changes.
|
|
const today = refDate || new Date();
|
|
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
|
const compact = density === 'compact';
|
|
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
|
|
const rangeStart = useMemo(
|
|
() => view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : new Date(today.getFullYear(), today.getMonth(), today.getDate()),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[view, dayKey],
|
|
);
|
|
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
|
|
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
|
const [calendarFetchKey, setCalendarFetchKey] = useState('');
|
|
const workflowScheduleKey = workflows
|
|
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
|
|
.sort()
|
|
.join('|');
|
|
const fromIso = rangeStart.toISOString();
|
|
const toIso = rangeEndExclusive.toISOString();
|
|
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const ctrl = new AbortController();
|
|
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
|
|
return res.json();
|
|
})
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
setCalendarEvents((data.events || []) as CalendarEvent[]);
|
|
setCalendarFetchKey(calendarRequestKey);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setCalendarEvents([]);
|
|
setCalendarFetchKey(calendarRequestKey);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
ctrl.abort();
|
|
};
|
|
}, [fromIso, toIso, calendarRequestKey]);
|
|
|
|
const eventsByDay = useMemo(() => {
|
|
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
|
|
if (calendarFetchKey !== calendarRequestKey) {
|
|
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
|
}
|
|
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
|
|
for (const event of calendarEvents) {
|
|
const wf = workflowById.get(event.workflow_id);
|
|
if (!wf) continue;
|
|
const d = new Date(event.fire_at);
|
|
if (Number.isNaN(d.getTime())) continue;
|
|
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
const arr = map.get(key) || [];
|
|
arr.push({ workflow: wf, date: d });
|
|
map.set(key, arr);
|
|
}
|
|
for (const arr of map.values()) {
|
|
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
}
|
|
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
|
|
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
|
|
|
|
const SLOT_H = compact ? 32 : 44;
|
|
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
|
|
const DAY_NUM = compact ? '0.95rem' : '1.15rem';
|
|
const DAY_LABEL = compact ? '0.66rem' : '0.72rem';
|
|
const EVENT_FS = compact ? '0.7rem' : '0.78rem';
|
|
|
|
if (view === 'Week') {
|
|
const start = startOfWeek(today);
|
|
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
|
const HOURS = HOURS_24;
|
|
// Prefer the short zone name ("PDT", "EST", "JST") so the label
|
|
// reads in plain English instead of "GMT-7". formatToParts is wide-
|
|
// supported; if it ever fails we degrade silently rather than show
|
|
// a confusing fallback.
|
|
const TZ_LABEL = (() => {
|
|
try {
|
|
const parts = new Intl.DateTimeFormat('en', { timeZoneName: 'short' }).formatToParts(new Date());
|
|
return parts.find((p) => p.type === 'timeZoneName')?.value || '';
|
|
} catch { return ''; }
|
|
})();
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', color: c.text.secondary }}>
|
|
{/* Day headers: muted weekday caps; today's date gets the filled circle */}
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', gap: 0, position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, pb: 0.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'flex-end', pr: 1, pb: 0.5 }}>
|
|
{!compact && (
|
|
<Typography sx={{ fontSize: '0.62rem', color: c.text.ghost, fontWeight: 500 }}>{TZ_LABEL}</Typography>
|
|
)}
|
|
</Box>
|
|
{days.map((d) => {
|
|
const isToday = sameDay(d, today);
|
|
return (
|
|
<Box key={d.toISOString()} sx={{ textAlign: 'center', pb: 0.5 }}>
|
|
<Typography sx={{ fontSize: DAY_LABEL, color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', lineHeight: 1.3, textTransform: 'uppercase' }}>
|
|
{WEEKDAY_LABEL_SHORT[d.getDay()]}
|
|
</Typography>
|
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: compact ? 30 : 38, height: compact ? 30 : 38, borderRadius: '50%', bgcolor: isToday ? c.accent.primary : 'transparent', color: isToday ? '#fff' : c.text.primary, fontWeight: isToday ? 700 : 500, fontSize: DAY_NUM, mt: 0.25 }}>{d.getDate()}</Box>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
|
|
{HOURS.map((hour, hourIdx) => (
|
|
<React.Fragment key={hour}>
|
|
{/* Hour label sits inside its row (top-aligned) rather than
|
|
straddling the line above it; that way the first row
|
|
doesn't clip "12 AM" and the labels never drift when the
|
|
body scrolls. Apple Calendar does the same. */}
|
|
<Box sx={{
|
|
height: SLOT_H, fontSize: ROW_LABEL,
|
|
color: c.text.ghost, fontWeight: 500,
|
|
textAlign: 'right', pr: 1, pt: 0.25,
|
|
borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
|
|
}}>
|
|
{formatHourLabel(hour)}
|
|
</Box>
|
|
{days.map((d) => {
|
|
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
const evs = (eventsByDay.map.get(key) || []).filter((e) => e.date.getHours() === hour);
|
|
const targetWeekday = d.getDay();
|
|
return (
|
|
<Box
|
|
key={`${d.toISOString()}-${hour}`}
|
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const wid = e.dataTransfer.getData('application/x-workflow-id');
|
|
if (!wid) return;
|
|
const wf = workflows.find((w) => w.id === wid);
|
|
if (!wf) return;
|
|
// Build the patched schedule: new hour, and for
|
|
// weekly schedules swap on_days to just the target
|
|
// weekday. Daily/monthly only get the new hour.
|
|
const sched = { ...wf.schedule, hour } as typeof wf.schedule;
|
|
if (sched.repeat_unit === 'week') sched.on_days = [targetWeekday];
|
|
dispatch(updateWorkflow({
|
|
id: wf.id,
|
|
patch: { schedule: sched as any },
|
|
ifMatch: wf.updated_at || null,
|
|
}));
|
|
}}
|
|
sx={{ height: SLOT_H, borderLeft: `1px solid ${c.border.subtle}`, borderTop: hourIdx === 0 ? 'none' : `1px solid ${c.border.subtle}`, position: 'relative' }}>
|
|
<EventStack
|
|
events={evs}
|
|
onSelectWorkflow={onSelectWorkflow}
|
|
eventFontSize={EVENT_FS}
|
|
onContextWorkflow={(wf, ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: wf }); }}
|
|
/>
|
|
</Box>
|
|
);
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</Box>
|
|
{ctxMenuEl}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (view === 'Month') {
|
|
const start = startOfMonthGrid(today);
|
|
const cells = Array.from({ length: 35 }, (_, i) => addDays(start, i));
|
|
const accent = c.accent.primary;
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
{/* Sticky weekday header so it stays visible even when the
|
|
calendar body scrolls. Slightly bigger + tinted bg so it
|
|
reads cleanly in both light and dark themes. */}
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
|
|
{WEEKDAY_LABEL_SHORT.map((l, i) => (
|
|
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
|
))}
|
|
</Box>
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
|
|
{cells.map((d) => {
|
|
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
const evs = eventsByDay.map.get(key) || [];
|
|
const isToday = sameDay(d, today);
|
|
const inMonth = d.getMonth() === today.getMonth();
|
|
return (
|
|
<Box key={d.toISOString()} sx={{ minHeight: compact ? 70 : 96, borderRight: `1px solid ${c.border.subtle}`, borderBottom: `1px solid ${c.border.subtle}`, p: 0.5, position: 'relative', overflow: 'hidden', bgcolor: inMonth ? 'transparent' : c.bg.elevated }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
|
|
{/* Out-of-month dates still need to be legible (Apple
|
|
Calendar shows them in a muted shade, not invisible).
|
|
Color tweak instead of opacity so dark themes stay
|
|
readable. */}
|
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: '50%', bgcolor: isToday ? accent : 'transparent', color: isToday ? '#fff' : inMonth ? c.text.primary : c.text.ghost, fontWeight: isToday ? 700 : 500, fontSize: '0.82rem', px: 0.5 }}>{d.getDate()}</Box>
|
|
</Box>
|
|
{evs.slice(0, compact ? 3 : 4).map((e, idx) => (
|
|
<Box
|
|
key={`${e.workflow.id}-${idx}`}
|
|
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
|
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
|
sx={{ mt: 0.3, display: 'flex', alignItems: 'center', gap: 0.5, fontSize: EVENT_FS, color: c.text.primary, cursor: 'pointer', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', '&:hover': { color: accent } }}>
|
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: accent, flexShrink: 0 }} />
|
|
<span style={{ color: c.text.muted, flexShrink: 0 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</span>
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, fontWeight: 500 }}>{e.workflow.title}</span>
|
|
</Box>
|
|
))}
|
|
{evs.length > (compact ? 3 : 4) && (
|
|
<Typography sx={{ fontSize: EVENT_FS, color: c.text.muted, mt: 0.3, pl: 1.4 }}>+{evs.length - (compact ? 3 : 4)} more</Typography>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
{ctxMenuEl}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Apple-Calendar-style list: big day number + weekday on the left, a
|
|
// vertical colored bar separating it from events on the right. Today
|
|
// renders even with no events (shows a "No events today" placeholder)
|
|
// so the list doesn't feel empty for new users.
|
|
const upcoming: { date: Date; events: { workflow: Workflow; date: Date }[]; isToday: boolean }[] = [];
|
|
for (let i = 0; i < 14; i += 1) {
|
|
const day = addDays(today, i);
|
|
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
|
|
const arr = eventsByDay.map.get(key) || [];
|
|
const isToday = sameDay(day, today);
|
|
if (arr.length || isToday) upcoming.push({ date: day, events: arr, isToday });
|
|
}
|
|
const accent = c.accent.primary;
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.lg}px`, overflow: 'hidden', bgcolor: c.bg.surface }}>
|
|
{upcoming.length === 0 && (
|
|
<Typography sx={{ fontSize: '0.85rem', color: c.text.muted, textAlign: 'center', py: 3 }}>No scheduled</Typography>
|
|
)}
|
|
{upcoming.map(({ date, events, isToday }, rowIdx) => (
|
|
<Box
|
|
key={date.toISOString()}
|
|
sx={{
|
|
display: 'flex', alignItems: 'stretch',
|
|
borderTop: rowIdx === 0 ? 'none' : `1px dashed ${c.border.subtle}`,
|
|
minHeight: 64,
|
|
}}>
|
|
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.75, pl: 2, pr: 1.25 }}>
|
|
<Typography sx={{ fontSize: '1.55rem', fontWeight: 600, color: isToday ? accent : c.text.primary, lineHeight: 1, letterSpacing: '-0.01em' }}>
|
|
{date.getDate()}
|
|
</Typography>
|
|
<Box>
|
|
<Typography sx={{ fontSize: '0.78rem', color: isToday ? accent : c.text.secondary, fontWeight: 500, lineHeight: 1.2 }}>
|
|
{date.toLocaleString('en', { month: 'short' })}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.2 }}>{WEEKDAY_FULL[date.getDay()]}</Typography>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', py: 1, pr: 2 }}>
|
|
{events.length === 0 && (
|
|
<Typography sx={{ fontSize: '0.85rem', color: c.text.ghost }}>No events today</Typography>
|
|
)}
|
|
{events.map((e, idx) => (
|
|
<Tooltip key={`${e.workflow.id}-${idx}`} title={<EventTooltipBody event={e} />} placement="right" arrow>
|
|
<Box
|
|
onClick={() => onSelectWorkflow?.(e.workflow.id)}
|
|
onContextMenu={(ev) => { ev.preventDefault(); setCtxMenu({ x: ev.clientX, y: ev.clientY, workflow: e.workflow }); }}
|
|
sx={{
|
|
display: 'flex', alignItems: 'center', gap: 1.25,
|
|
py: 0.4,
|
|
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
|
'&:hover .ev-title': { color: accent },
|
|
}}>
|
|
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
|
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
|
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Tooltip>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
{ctxMenuEl}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Apple Calendar style event chip: 3px colored left-bar + faintly-tinted
|
|
// background + readable text. One chip per cell with a "+N" badge for
|
|
// overflow; clicking it opens a popover listing all events that hour.
|
|
function EventStack({ events, onSelectWorkflow, eventFontSize, onContextWorkflow }: {
|
|
events: { workflow: Workflow; date: Date }[];
|
|
onSelectWorkflow?: (id: string) => void;
|
|
eventFontSize: string;
|
|
onContextWorkflow?: (workflow: Workflow, e: React.MouseEvent) => void;
|
|
}) {
|
|
const c = useClaudeTokens();
|
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
if (events.length === 0) return null;
|
|
const first = events[0];
|
|
const rest = events.slice(1);
|
|
const accent = c.accent.primary;
|
|
|
|
// Time string is part of the chip so a glance tells you both what and
|
|
// when, matching Apple's "Title, 1pm" pattern. Chip is slim (height ~22)
|
|
// not slot-stretching, since OpenSwarm events fire at a single instant.
|
|
const timeLabel = formatTime(first.date.getHours(), first.date.getMinutes());
|
|
return (
|
|
<>
|
|
<Tooltip title={<EventTooltipBody event={first} />} placement="top" arrow>
|
|
<Box
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.dataTransfer.setData('application/x-workflow-id', first.workflow.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}}
|
|
onClick={() => onSelectWorkflow?.(first.workflow.id)}
|
|
onContextMenu={(e) => onContextWorkflow?.(first.workflow, e)}
|
|
sx={{
|
|
position: 'absolute',
|
|
left: 2, right: rest.length > 0 ? 24 : 2, top: 2,
|
|
height: 22,
|
|
bgcolor: accent + '14',
|
|
color: c.text.primary,
|
|
borderLeft: `3px solid ${accent}`,
|
|
borderRadius: '4px',
|
|
px: 0.65, py: 0,
|
|
fontSize: eventFontSize, fontWeight: 500,
|
|
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 0.5,
|
|
'&:hover': { bgcolor: accent + '22' },
|
|
}}>
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{first.workflow.title}</span>
|
|
<span style={{ color: 'inherit', opacity: 0.7, flexShrink: 0 }}>{timeLabel}</span>
|
|
</Box>
|
|
</Tooltip>
|
|
{rest.length > 0 && (
|
|
<Box
|
|
onClick={(e) => setAnchor(e.currentTarget)}
|
|
role="button"
|
|
sx={{
|
|
position: 'absolute',
|
|
right: 2, top: 2,
|
|
height: 22,
|
|
minWidth: 20, px: 0.4,
|
|
bgcolor: accent + '22',
|
|
color: accent,
|
|
borderRadius: '4px',
|
|
fontSize: eventFontSize, fontWeight: 700,
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
'&:hover': { bgcolor: accent + '33' },
|
|
}}>
|
|
+{rest.length}
|
|
</Box>
|
|
)}
|
|
<Popover
|
|
open={Boolean(anchor)}
|
|
anchorEl={anchor}
|
|
onClose={() => setAnchor(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
|
<Box sx={{ minWidth: 220, p: 1 }}>
|
|
<Typography sx={{ fontSize: '0.7rem', fontWeight: 700, color: c.text.muted, letterSpacing: '0.06em', mb: 0.5 }}>
|
|
{events.length} runs at this hour
|
|
</Typography>
|
|
{events.map((e, idx) => (
|
|
<Box
|
|
key={`${e.workflow.id}-${idx}`}
|
|
onClick={() => { setAnchor(null); onSelectWorkflow?.(e.workflow.id); }}
|
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 0.5, py: 0.5, borderRadius: `${c.radius.md}px`, cursor: 'pointer', '&:hover': { bgcolor: c.bg.elevated } }}>
|
|
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: c.accent.primary }} />
|
|
<Typography sx={{ flex: 1, fontSize: '0.82rem', color: c.text.primary, fontWeight: 600 }}>{e.workflow.title}</Typography>
|
|
<Typography sx={{ fontSize: '0.74rem', color: c.text.muted }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Popover>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function EventTooltipBody({ event }: { event: { workflow: Workflow; date: Date } }) {
|
|
const wf = event.workflow;
|
|
const status = wf.last_run_status;
|
|
const cost = wf.cost_estimate?.last_run_usd;
|
|
const monthly = wf.cost_estimate?.monthly_usd;
|
|
return (
|
|
<Box sx={{ fontSize: '0.72rem', lineHeight: 1.5 }}>
|
|
<div style={{ fontWeight: 700 }}>{wf.title}</div>
|
|
<div>{`Fires at ${formatTime(event.date.getHours(), event.date.getMinutes())}`}</div>
|
|
{status && <div>{`Last run: ${status}`}</div>}
|
|
{typeof cost === 'number' && cost > 0 && <div>{`Last run cost: $${cost.toFixed(4)}`}</div>}
|
|
{typeof monthly === 'number' && monthly > 0 && <div>{`Est. monthly: $${monthly.toFixed(2)}`}</div>}
|
|
</Box>
|
|
);
|
|
}
|