diff --git a/frontend/src/app/pages/Workflows/app/CalendarView.tsx b/frontend/src/app/pages/Workflows/app/CalendarView.tsx index e2e7f8e8..caa91227 100644 --- a/frontend/src/app/pages/Workflows/app/CalendarView.tsx +++ b/frontend/src/app/pages/Workflows/app/CalendarView.tsx @@ -1,13 +1,17 @@ -import React, { useMemo, useRef, useEffect } from 'react'; +import React, { useMemo, useRef, useEffect, useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { CSSProperties } from 'react'; import { useAppSelector } from '@/shared/hooks'; import { fireTimesWithin, startOfWeek, startOfMonthGrid, addDays, sameDay } from '@/app/pages/Workflows/scheduleUtils'; -import { colorForId, WC } from './uiKit'; +import { colorForWorkflow, useWC, type WCPalette } from './uiKit'; import type { AppNav } from './types'; -interface Occ { wfId: string; title: string; at: Date; } +interface Occ { wfId: string; title: string; at: Date; color: string; } +interface DayPop { title: string; runs: Occ[]; x: number; y: number; } +type OpenDayPop = (title: string, runs: Occ[], e: React.MouseEvent) => void; const DOW = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; +const POP_W = 240; function miniTime(d: Date): string { let h = d.getHours(); @@ -23,13 +27,14 @@ function hourLabel(h: number): string { return `${h - 12} PM`; } -const tabBtn = (active: boolean): CSSProperties => ({ +const tabBtn = (active: boolean, WC: WCPalette): CSSProperties => ({ padding: '5px 13px', borderRadius: 7, border: 'none', cursor: 'pointer', fontSize: 12.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 CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { + const WC = useWC(); const items = useAppSelector((s) => s.workflows.items); const now = new Date(); const ref = nav.refDate; @@ -41,8 +46,9 @@ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { const out: Occ[] = []; for (const wf of Object.values(items)) { if (wf.unsaved) continue; + const color = colorForWorkflow(wf); for (const at of fireTimesWithin(wf, from, to, 200)) { - out.push({ wfId: wf.id, title: wf.title || 'Untitled', at }); + out.push({ wfId: wf.id, title: wf.title || 'Untitled', at, color }); } } return out.sort((a, b) => a.at.getTime() - b.at.getTime()); @@ -67,25 +73,57 @@ const CalendarView: React.FC<{ nav: AppNav }> = ({ nav }) => { else nav.setRefDate(new Date(ref.getFullYear(), ref.getMonth() + dir, 1)); }; + // Click "+N more" to peek a day's/hour's full run list. position:fixed via a + // body portal so it isn't reparented by the zoomed/panned canvas transform. + const [dayPop, setDayPop] = useState(null); + const openDayPop: OpenDayPop = (popTitle, runs, e) => { + e.stopPropagation(); + const r = e.currentTarget.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + const x = Math.min(r.left, vw - POP_W - 12); + let y = r.bottom + 6; + if (y > vh - 220) y = Math.max(12, r.top - 8 - 300); + setDayPop({ title: popTitle, runs, x, y }); + }; + const selectFromPop = (id: string) => { setDayPop(null); nav.selectWorkflow(id); }; + return (
- +
- - + +

{title}

- - + +
{nav.calView === 'month' - ? - : } + ? + : } + + {dayPop && createPortal( +
setDayPop(null)} style={{ position: 'fixed', inset: 0, zIndex: 2147483600 }}> +
e.stopPropagation()} style={{ position: 'fixed', left: dayPop.x, top: dayPop.y, width: POP_W, maxHeight: 320, overflowY: 'auto', background: WC.paper, border: `1px solid ${WC.line2}`, borderRadius: WC.radius.lg, boxShadow: WC.shadow.lg, padding: 12 }}> +
{dayPop.title}
+
+ {dayPop.runs.map((r, i) => ( +
selectFromPop(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 7px', borderRadius: 8, cursor: 'pointer' }}> +
+ {miniTime(r.at)} + {r.title} +
+ ))} +
+
+
, + document.body, + )}
); }; @@ -95,40 +133,94 @@ interface GridProps { occByDay: Map; dayKey: (d: Date) => string; onSelect: (id: string) => void; + openDayPop: OpenDayPop; } -const MonthGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect }) => { +const moreStyle = (WC: WCPalette): CSSProperties => ({ + fontSize: 10.5, fontWeight: 600, color: WC.muted, padding: '2px 3px', borderRadius: 5, + cursor: 'pointer', alignSelf: 'flex-start', border: 'none', background: 'transparent', +}); + +const MonthGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect, openDayPop }) => { + const WC = useWC(); const start = startOfMonthGrid(ref0); - const cells = Array.from({ length: 42 }, (_, i) => addDays(start, i)); + // Only as many weeks as the month actually spans (5 or 6), like the design, + // so rows aren't squashed by a dangling extra week of next-month days. + const monthEnd = new Date(ref0.getFullYear(), ref0.getMonth() + 1, 0); + const weeks = Math.ceil((Math.round((monthEnd.getTime() - start.getTime()) / 86400000) + 1) / 7); + const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i)); const month = ref0.getMonth(); + + // Cells shrink with the window, so a fixed cap clips. Measure the real row and + // "+more" heights off hidden probes (font metrics vary), then fit only events + // that fully fit, the rest roll into "+N more". No guessed pixel constants. + const gridRef = useRef(null); + const probeEventRef = useRef(null); + const probeMoreRef = useRef(null); + const [rowH, setRowH] = useState(0); + const [eventH, setEventH] = useState(18); + const [moreH, setMoreH] = useState(18); + useLayoutEffect(() => { + const el = gridRef.current; + if (!el) return; + const measure = () => { + setRowH(el.clientHeight / weeks); + if (probeEventRef.current) setEventH(probeEventRef.current.offsetHeight); + if (probeMoreRef.current) setMoreH(probeMoreRef.current.offsetHeight); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [weeks]); + const GAP = 2; // events-container row gap + const CELL_OVERHEAD = 39; // number row (27) + vertical padding (12), our own fixed pixels + const contentH = rowH - CELL_OVERHEAD; + const fitNoMore = Math.max(0, Math.floor((contentH + GAP) / (eventH + GAP))); + const fitWithMore = Math.max(0, Math.floor((contentH + 1 - moreH) / (eventH + GAP))); + return (
+
+
+
+ 12:00am + Sample +
+ +0 more +
{DOW.map((d) =>
{d}
)}
-
+
{cells.map((d, i) => { const inMonth = d.getMonth() === month; const isToday = sameDay(d, now); const runs = occByDay.get(dayKey(d)) || []; - const shown = runs.slice(0, 4); + const shown = runs.length <= fitNoMore ? runs : runs.slice(0, fitWithMore); + const moreCount = runs.length - shown.length; return ( -
+
{d.getDate()}
-
+
{shown.map((r, ri) => (
onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', borderRadius: 4, padding: '1px 3px' }}> -
- {miniTime(r.at)} +
+ {miniTime(r.at)} {r.title}
))} - {runs.length > 4 && +{runs.length - 4} more}
+ {moreCount > 0 && ( + openDayPop(d.toLocaleDateString([], { month: 'long', day: 'numeric' }), runs, e)} + style={{ ...moreStyle(WC), flex: 'none', marginTop: 1 }} + >+{moreCount} more + )}
); })} @@ -137,16 +229,23 @@ const MonthGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect ); }; -const WeekGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect }) => { +const WeekGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect, openDayPop }) => { + const WC = useWC(); const start = startOfWeek(ref0); + const startMs = start.getTime(); const days = Array.from({ length: 7 }, (_, i) => addDays(start, i)); const scrollRef = useRef(null); - const ROW_H = 54; + const ROW_H = 44; useEffect(() => { + // Scroll to ~2h before now once per mount / week change. Keying this on the + // fresh `now`/`start` objects re-ran it on every render, so any background + // re-render (a live run streaming, ongoing-runs updating) yanked the scroll + // back up, you could never sit at the bottom. Key off the stable week ms. const el = scrollRef.current; - if (el) el.scrollTop = Math.max(0, (now.getHours() - 2) * ROW_H); - }, [now, start]); + if (el) el.scrollTop = Math.max(0, (new Date().getHours() - 2) * ROW_H); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startMs]); const nowFrac = (now.getHours() * 60 + now.getMinutes()) / 60 % 1; return ( @@ -156,7 +255,7 @@ const WeekGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect } {days.map((d, i) => { const isToday = sameDay(d, now); return ( -
+
{DOW[d.getDay()]}
{d.getDate()} @@ -167,24 +266,29 @@ const WeekGrid: React.FC = ({ ref0, now, occByDay, dayKey, onSelect }
{Array.from({ length: 24 }, (_, h) => ( -
+
{hourLabel(h)}
{days.map((d, di) => { const runs = (occByDay.get(dayKey(d)) || []).filter((r) => r.at.getHours() === h); const isNow = sameDay(d, now) && now.getHours() === h; return ( -
+
{isNow && <>
} {runs.slice(0, 3).map((r, ri) => ( -
onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', background: colorForId(r.wfId), color: '#fff', borderRadius: 999, padding: '2px 9px', fontSize: 10.5, fontWeight: 600, cursor: 'pointer', lineHeight: 1.35, overflow: 'hidden' }}> +
onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', background: r.color, color: '#fff', borderRadius: 999, padding: '1px 7px', fontSize: 10, fontWeight: 600, cursor: 'pointer', lineHeight: 1.2, overflow: 'hidden' }}> {r.title} {miniTime(r.at)}
))} - {runs.length > 3 && {runs.length - 3} more} + {runs.length > 3 && ( + openDayPop(`${DOW[d.getDay()]} ${d.getDate()} ยท ${hourLabel(h)}`, runs, e)} + style={{ ...moreStyle(WC), fontSize: 10 }} + >{runs.length - 3} more + )}
); })}