Files
waters-boards/server/public/js/timeline.js
T

97 lines
6.6 KiB
JavaScript
Raw Normal View History

// 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"><button class="btn primary" onClick=${() => A.addColumn(boardId, 'timeline')}>Add a timeline column</button><//>`;
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`<div class="timeline">
<div class="view-options">
<div class="seg">${Object.keys(SCALES).map((s) => html`<button key=${s} class=${scale === s ? 'on' : ''} onClick=${() => { setScale(s); setting({ scale: s }); }}>${s[0].toUpperCase() + s.slice(1)}s</button>`)}</div>
<button class="btn ghost sm" onClick=${() => { scroller.current.scrollLeft = Math.max(0, x(today) - 200); }}>Today</button>
${cols.length > 1 ? html`<select class="input sm" value=${col.id} onChange=${(e) => setting({ column: e.target.value })}>${cols.map((c) => html`<option value=${c.id}>${c.title}</option>`)}</select>` : null}
</div>
<div class="tl-body">
<div class="tl-names">
<div class="tl-corner" />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group" style=${{ color: g.color }}>${g.title}</div>
${gi.map((it) => html`<div key=${it.id} class="tl-name" onClick=${() => navigate(boardPath(boardId, { viewId: view.id, itemId: it.id }))}>${it.name}</div>`)}</div>` : null; })}
</div>
<div class="tl-scroll" ref=${scroller}>
<div class="tl-canvas" style=${{ width: days * px }}>
<div class="tl-ticks">${ticks.map((d) => html`<span key=${d.getTime()} style=${{ left: x(d) }}>${scale === 'month' ? d.toLocaleString([], { month: 'short', year: '2-digit' }) : `${d.toLocaleString([], { month: 'short' })} ${d.getDate()}`}</span>`)}</div>
<div class="tl-today" style=${{ left: x(today) + px / 2 }} />
${groups.map((g) => { const gi = items.filter((i) => i.group_id === g.id); return gi.length ? html`<div key=${g.id}>
<div class="tl-group-row" />
${gi.map((it) => {
const r = range(it);
let left = r ? x(r.from) : 0; let width = r ? Math.max(px, x(r.to) - x(r.from) + px) : 0;
if (drag?.id === it.id) { if (drag.mode === 'move') left += drag.delta * px; else width = Math.max(px, width + drag.delta * px); }
return html`<div key=${it.id} class="tl-row">
${r ? html`<div class="tl-item" style=${{ left, width, background: g.color }} onPointerDown=${(e) => startDrag(e, it, 'move')} title=${it.name}>
<span class="tl-label">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${18} max=${2} />` : null}
${col.type === 'timeline' ? html`<span class="tl-resize" onPointerDown=${(e) => startDrag(e, it, 'resize')} />` : null}
</div>` : html`<button class="tl-set muted small" style=${{ left: x(today) }} onClick=${() => A.setValue(boardId, it.id, col.id, col.type === 'date' ? todayStr() : { from: todayStr(), to: ymd(new Date(today.getTime() + 6 * DAY)) })}>+ Set dates</button>`}
</div>`;
})}</div>` : null; })}
</div>
</div>
</div>
</div>`;
}