[aidan] ui/calendar: overflow popover, condensed week view, scroll fix

This commit is contained in:
abccodes
2026-06-23 00:02:21 -07:00
parent b8b0e3ec9e
commit 4d815bcf86
@@ -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<DayPop | null>(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 (
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: WC.paper }}>
<div style={{ flex: 'none', padding: '14px 26px', borderBottom: `1px solid ${WC.line}`, display: 'flex', alignItems: 'center', gap: 14 }}>
<button onClick={() => nav.setRefDate(new Date())} style={{ background: WC.paper, border: '1px solid rgba(33,30,27,0.14)', borderRadius: 8, padding: '6px 14px', fontSize: 13, fontWeight: 600, color: WC.ink, cursor: 'pointer' }}>Today</button>
<button onClick={() => nav.setRefDate(new Date())} style={{ background: WC.paper, border: `1px solid rgba(${WC.inkRGB},0.14)`, borderRadius: 8, padding: '6px 14px', fontSize: 13, fontWeight: 600, color: WC.ink, cursor: 'pointer' }}>Today</button>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => step(-1)} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid rgba(33,30,27,0.12)', background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 6l-6 6 6 6" /></svg></button>
<button onClick={() => step(1)} style={{ width: 30, height: 30, borderRadius: 8, border: '1px solid rgba(33,30,27,0.12)', background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 6l6 6-6 6" /></svg></button>
<button onClick={() => step(-1)} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid rgba(${WC.inkRGB},0.12)`, background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 6l-6 6 6 6" /></svg></button>
<button onClick={() => step(1)} style={{ width: 30, height: 30, borderRadius: 8, border: `1px solid rgba(${WC.inkRGB},0.12)`, background: WC.paper, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.ink3 }}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 6l6 6-6 6" /></svg></button>
</div>
<h1 style={{ margin: 0, fontFamily: "'Newsreader',serif", fontSize: 22, fontWeight: 600, color: WC.ink, letterSpacing: '-0.01em' }}>{title}</h1>
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', background: WC.inset, border: `1px solid ${WC.line}`, borderRadius: 9, padding: 3, gap: 2 }}>
<button onClick={() => nav.setCalView('week')} style={tabBtn(nav.calView === 'week')}>Week</button>
<button onClick={() => nav.setCalView('month')} style={tabBtn(nav.calView === 'month')}>Month</button>
<button onClick={() => nav.setCalView('week')} style={tabBtn(nav.calView === 'week', WC)}>Week</button>
<button onClick={() => nav.setCalView('month')} style={tabBtn(nav.calView === 'month', WC)}>Month</button>
</div>
</div>
{nav.calView === 'month'
? <MonthGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} />
: <WeekGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} />}
? <MonthGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} openDayPop={openDayPop} />
: <WeekGrid ref0={ref} now={now} occByDay={occByDay} dayKey={dayKey} onSelect={nav.selectWorkflow} openDayPop={openDayPop} />}
{dayPop && createPortal(
<div onClick={() => setDayPop(null)} style={{ position: 'fixed', inset: 0, zIndex: 2147483600 }}>
<div onClick={(e) => 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 }}>
<div style={{ fontFamily: "'Newsreader',serif", fontSize: 15, fontWeight: 500, color: WC.ink, marginBottom: 10, padding: '0 2px' }}>{dayPop.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{dayPop.runs.map((r, i) => (
<div key={i} onClick={() => selectFromPop(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '8px 7px', borderRadius: 8, cursor: 'pointer' }}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: r.color, flex: 'none' }} />
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 11, color: WC.ink4, flex: 'none', minWidth: 52 }}>{miniTime(r.at)}</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, color: WC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
</div>
))}
</div>
</div>
</div>,
document.body,
)}
</div>
);
};
@@ -95,40 +133,94 @@ interface GridProps {
occByDay: Map<string, Occ[]>;
dayKey: (d: Date) => string;
onSelect: (id: string) => void;
openDayPop: OpenDayPop;
}
const MonthGrid: React.FC<GridProps> = ({ 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<GridProps> = ({ 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<HTMLDivElement | null>(null);
const probeEventRef = useRef<HTMLDivElement | null>(null);
const probeMoreRef = useRef<HTMLSpanElement | null>(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 (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div aria-hidden style={{ position: 'absolute', top: -9999, left: -9999, visibility: 'hidden', pointerEvents: 'none' }}>
<div ref={probeEventRef} style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '1px 3px' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%' }} />
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10 }}>12:00am</span>
<span style={{ fontSize: 11 }}>Sample</span>
</div>
<span ref={probeMoreRef} style={moreStyle(WC)}>+0 more</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', flex: 'none', borderBottom: `1px solid ${WC.line}` }}>
{DOW.map((d) => <div key={d} style={{ textAlign: 'center', padding: '9px 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, letterSpacing: '0.06em', color: WC.muted2 }}>{d}</div>)}
</div>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gridAutoRows: '1fr', minHeight: 0 }}>
<div ref={gridRef} style={{ flex: 1, display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gridAutoRows: '1fr', minHeight: 0 }}>
{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 (
<div key={i} style={{ borderRight: '1px solid rgba(33,30,27,0.06)', borderBottom: '1px solid rgba(33,30,27,0.06)', padding: '6px 8px', background: isToday ? 'rgba(194,90,54,0.05)' : (inMonth ? WC.paper : '#F1EFE9'), display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<div key={i} style={{ borderRight: `1px solid rgba(${WC.inkRGB},0.06)`, borderBottom: `1px solid rgba(${WC.inkRGB},0.06)`, padding: '6px 8px', background: isToday ? 'rgba(194,90,54,0.05)' : (inMonth ? WC.paper : WC.inset), display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
<div style={{ display: 'flex', marginBottom: 3 }}>
<div style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 12, fontWeight: isToday ? 700 : 500, lineHeight: 1, color: isToday ? '#fff' : (inMonth ? WC.ink3 : WC.faint) }}>{d.getDate()}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden' }}>
{shown.map((r, ri) => (
<div key={ri} onClick={() => onSelect(r.wfId)} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', borderRadius: 4, padding: '1px 3px' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: colorForId(r.wfId), flex: 'none' }} />
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: '#6B655C', flex: 'none' }}>{miniTime(r.at)}</span>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: r.color, flex: 'none' }} />
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.ink4, flex: 'none' }}>{miniTime(r.at)}</span>
<span style={{ fontSize: 11, color: WC.ink2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
</div>
))}
{runs.length > 4 && <span style={{ fontSize: 10.5, color: WC.muted2, paddingLeft: 3 }}>+{runs.length - 4} more</span>}
</div>
{moreCount > 0 && (
<span
onClick={(e) => openDayPop(d.toLocaleDateString([], { month: 'long', day: 'numeric' }), runs, e)}
style={{ ...moreStyle(WC), flex: 'none', marginTop: 1 }}
>+{moreCount} more</span>
)}
</div>
);
})}
@@ -137,16 +229,23 @@ const MonthGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect
);
};
const WeekGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect }) => {
const WeekGrid: React.FC<GridProps> = ({ 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<HTMLDivElement | null>(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<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect }
{days.map((d, i) => {
const isToday = sameDay(d, now);
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 7px', borderLeft: '1px solid rgba(33,30,27,0.06)' }}>
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 7px', borderLeft: `1px solid rgba(${WC.inkRGB},0.06)` }}>
<span style={{ fontFamily: "'JetBrains Mono',monospace", fontSize: 10, letterSpacing: '0.04em', color: WC.muted2 }}>{DOW[d.getDay()]}</span>
<div style={{ width: 30, height: 30, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 3, flex: 'none', background: isToday ? WC.accent : 'transparent' }}>
<span style={{ fontFamily: "'Newsreader',serif", fontSize: 18, fontWeight: 500, lineHeight: 1, color: isToday ? '#fff' : WC.ink }}>{d.getDate()}</span>
@@ -167,24 +266,29 @@ const WeekGrid: React.FC<GridProps> = ({ ref0, now, occByDay, dayKey, onSelect }
</div>
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{Array.from({ length: 24 }, (_, h) => (
<div key={h} style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', borderBottom: '1px solid rgba(33,30,27,0.05)', minHeight: ROW_H }}>
<div key={h} style={{ display: 'grid', gridTemplateColumns: '56px repeat(7,1fr)', borderBottom: `1px solid rgba(${WC.inkRGB},0.05)`, minHeight: ROW_H }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '5px 8px 0 0', fontFamily: "'JetBrains Mono',monospace", fontSize: 10, color: WC.muted2 }}>{hourLabel(h)}</div>
{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 (
<div key={di} style={{ position: 'relative', borderLeft: '1px solid rgba(33,30,27,0.06)', padding: '3px 4px', display: 'flex', flexDirection: 'column', gap: 3 }}>
<div key={di} style={{ position: 'relative', borderLeft: `1px solid rgba(${WC.inkRGB},0.06)`, padding: '2px 4px', display: 'flex', flexDirection: 'column', gap: 2 }}>
{isNow && <>
<div style={{ position: 'absolute', left: -4, top: nowFrac * ROW_H - 4, width: 8, height: 8, borderRadius: '50%', background: WC.accent, zIndex: 4 }} />
<div style={{ position: 'absolute', left: 0, right: 0, top: nowFrac * ROW_H, height: 2, background: WC.accent, zIndex: 3 }} />
</>}
{runs.slice(0, 3).map((r, ri) => (
<div key={ri} onClick={() => 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' }}>
<div key={ri} onClick={() => 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' }}>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.85, flex: 'none' }}>{miniTime(r.at)}</span>
</div>
))}
{runs.length > 3 && <span style={{ fontSize: 10, color: WC.muted2, paddingLeft: 2 }}>{runs.length - 3} more</span>}
{runs.length > 3 && (
<span
onClick={(e) => openDayPop(`${DOW[d.getDay()]} ${d.getDate()} · ${hourLabel(h)}`, runs, e)}
style={{ ...moreStyle(WC), fontSize: 10 }}
>{runs.length - 3} more</span>
)}
</div>
);
})}