Add hourly day timeline and red now indicators to the calendar
Days with 4+ entries open with an hour-grid timeline in the day editor: untimed entries in an all-day strip, timed ones positioned by start/end with side-by-side columns for overlaps, click-to-edit. Layout math lives in a pure, unit-tested module (timelineLayout.js). Today's month-grid cell gets a vertical red slider positioned by the fraction of the day elapsed; the timeline gets the classic horizontal red current-time line. Both tick every minute with cleanup on unmount, and the initial positioning runs unconditionally while the node is still detached (the isConnected self-heal gates only interval ticks). Also splits entryRow rendering out of dayEditor.js to stay under the 500-line cap.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user