// Timeline (Gantt): bars from a timeline column (or a date column as single days), grouped // by board group. Drag a bar to move it, drag its right edge to change the end date. import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs'; import { api } from './api.js'; import { navigate, boardPath, mutateBoard } from './store.js'; import { Icon, ymd, parseDate, todayStr, Empty, AvatarStack } from './ui.js'; import * as A from './actions.js'; const DAY = 86400000; const SCALES = { day: 40, week: 18, month: 6 }; export function TimelineView({ board, view, items }) { const boardId = board.board.id; const cols = board.columns.filter((c) => c.type === 'timeline' || c.type === 'date'); const col = cols.find((c) => c.id === view.settings?.column) || cols.find((c) => c.type === 'timeline') || cols[0]; const people = board.columns.find((c) => c.type === 'people'); const [scale, setScale] = useState(view.settings?.scale || 'week'); const scroller = useRef(); const [drag, setDrag] = useState(null); const px = SCALES[scale]; const today = parseDate(todayStr()); const range = (it) => { const v = it.vals[col?.id]; if (!v) return null; if (col.type === 'date') { const d = parseDate(String(v).slice(0, 10)); return { from: d, to: d }; } return { from: parseDate(v.from), to: parseDate(v.to || v.from) }; }; const ranges = items.map(range).filter(Boolean); const min = new Date(Math.min(today - 14 * DAY, ...ranges.map((r) => r.from))); min.setDate(min.getDate() - 7); const max = new Date(Math.max(today.getTime() + 60 * DAY, ...ranges.map((r) => r.to.getTime()))); max.setDate(max.getDate() + 14); const days = Math.round((max - min) / DAY); const x = (d) => Math.round((d - min) / DAY) * px; useEffect(() => { if (scroller.current) scroller.current.scrollLeft = Math.max(0, x(today) - 200); }, [scale, col?.id]); if (!col) return html`<${Empty} icon="timeline" title="Timeline needs a timeline or date column">/>`; const setting = (patch) => { mutateBoard(boardId, () => { view.settings = { ...view.settings, ...patch }; }); api.patch(`/api/views/${view.id}`, { settings: { ...view.settings, ...patch } }); }; const startDrag = (e, it, mode) => { e.preventDefault(); e.stopPropagation(); const r = range(it); const x0 = e.clientX; let delta = 0; const move = (ev) => { delta = Math.round((ev.clientX - x0) / px); setDrag({ id: it.id, mode, delta }); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); setDrag(null); if (!delta) { if (mode === 'move') navigate(boardPath(boardId, { viewId: view.id, itemId: it.id })); return; } const shift = (d, n) => { const z = new Date(d); z.setDate(z.getDate() + n); return ymd(z); }; if (col.type === 'date') A.setValue(boardId, it.id, col.id, shift(r.from, delta)); else if (mode === 'move') A.setValue(boardId, it.id, col.id, { from: shift(r.from, delta), to: shift(r.to, delta) }); else { const to = new Date(r.to); to.setDate(to.getDate() + delta); A.setValue(boardId, it.id, col.id, { from: ymd(r.from), to: ymd(to < r.from ? r.from : to) }); } }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; // Header ticks const ticks = []; for (let i = 0; i <= days; i++) { const d = new Date(min); d.setDate(d.getDate() + i); if (scale === 'day' || (scale === 'week' && d.getDay() === 1) || (scale === 'month' && d.getDate() === 1)) ticks.push(d); } const groups = [...board.groups].sort((a, b) => a.position - b.position); return html`