// Hourly timeline for a crowded day, shown inside the day-editor slideover // between its header and entry list (see dayEditor.js). Layout math lives in // timelineLayout.js (pure, unit-tested); this module is the DOM rendering + // the red "now" line. See docs/API.md "Hourly day timeline & 'now' indicator". import { el } from '../dom.js'; import { typeInfo, entryIcon, formatTimeRange, ymd } from '../format.js'; import { timedSlots, layoutColumns, gridWindow } from '../timelineLayout.js'; const HOUR_REM = 3; // must match --dt-hour-h in timeline.css const THRESHOLD = 4; let activeTimer = null; // Exported so dayEditor.js can stop the "now" line's interval on close โ€” // draws that happen after this call each start (and self-clear) their own. export function stopDayTimelineClock() { if (activeTimer) { clearInterval(activeTimer); activeTimer = null; } } // (tctx, date, dayEntries, {onEdit}) -> a DOM node, or null below the // 4-entry threshold. `onEdit(entry)` loads the clicked entry into the day // editor's edit form โ€” same handler as clicking the list's โœŽ button. export function renderDayTimeline(tctx, date, dayEntries, { onEdit } = {}) { stopDayTimelineClock(); if (!dayEntries || dayEntries.length < THRESHOLD) return null; const stays = dayEntries.filter((e) => e.type === 'stay'); const rest = dayEntries.filter((e) => e.type !== 'stay'); const slots = layoutColumns(timedSlots(rest)); const timedIds = new Set(slots.map((s) => s.entry.id)); const allDay = [...stays, ...rest.filter((e) => !timedIds.has(e.id))]; const node = el('div', { class: 'day-timeline' }); if (allDay.length) node.appendChild(allDayStrip(allDay, onEdit)); if (slots.length) node.appendChild(hourGrid(slots, date, onEdit)); return node; } function chip(entry, onEdit) { const info = typeInfo(entry.type); return el( 'button', { type: 'button', class: 'dt-chip', style: { '--chip': info.color }, title: entry.title, onClick: () => onEdit && onEdit(entry), }, el('span', { class: 'dt-chip-icon' }, entryIcon(entry)), el('span', { class: 'dt-chip-text' }, entry.title), ); } function allDayStrip(entries, onEdit) { return el('div', { class: 'dt-allday' }, ...entries.map((e) => chip(e, onEdit))); } function hourGrid(slots, date, onEdit) { const { startHour, endHour } = gridWindow(slots); const hours = Math.max(1, endHour - startHour); const totalRem = hours * HOUR_REM; const rows = el('div', { class: 'dt-hours' }); for (let h = startHour; h < endHour; h++) { rows.appendChild( el( 'div', { class: 'dt-hour-row', style: { height: `${HOUR_REM}rem` } }, el('span', { class: 'dt-hour-label' }, `${String(h).padStart(2, '0')}:00`), ), ); } const events = el('div', { class: 'dt-events' }); for (const slot of slots) { const info = typeInfo(slot.entry.type); const top = ((slot.startMin - startHour * 60) / 60) * HOUR_REM; const height = Math.max(slot.endMin - slot.startMin, 30) / 60 * HOUR_REM; const time = formatTimeRange(slot.entry.start_time, slot.entry.end_time); events.appendChild( el( 'button', { type: 'button', class: 'dt-block', style: { '--chip': info.color, top: `${top}rem`, height: `${height}rem`, left: `${(slot.col / slot.cols) * 100}%`, width: `${100 / slot.cols}%`, }, title: time ? `${slot.entry.title} ยท ${time}` : slot.entry.title, onClick: () => onEdit && onEdit(slot.entry), }, el('span', { class: 'dt-block-icon' }, entryIcon(slot.entry)), el('span', { class: 'dt-block-text' }, slot.entry.title), ), ); } const wrap = el( 'div', { class: 'dt-grid-wrap', style: { height: `${totalRem}rem` } }, rows, events, ); if (date === ymd(new Date())) wireNowLine(wrap, events, startHour, endHour); return wrap; } function wireNowLine(wrap, events, startHour, endHour) { const line = el('div', { class: 'dt-now-line' }, el('span', { class: 'dt-now-dot' })); let mounted = false; function position() { const now = new Date(); const min = now.getHours() * 60 + now.getMinutes(); const inWindow = min >= startHour * 60 && min < endHour * 60; if (!inWindow) { if (mounted) { line.remove(); mounted = false; } return; } line.style.top = `${((min - startHour * 60) / 60) * HOUR_REM}rem`; if (!mounted) { events.appendChild(line); mounted = true; } } // The isConnected self-heal only applies to interval ticks: the initial // position() runs while the timeline node is still detached (dayEditor // mounts it after renderDayTimeline returns), and gating it would hide the // now-line until the first 60s tick. function tick() { if (!wrap.isConnected) { stopDayTimelineClock(); return; } position(); } position(); activeTimer = setInterval(tick, 60000); }