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:
2026-08-30 12:49:17 +02:00
parent f272e74b84
commit b0bdd2570c
10 changed files with 725 additions and 87 deletions
+60 -1
View File
@@ -22,7 +22,27 @@ import { makeChipDraggable, makeDayDropTarget } from './dragdrop.js';
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// The red "now" slider's 60s interval lives in module state (only one
// calendar is ever mounted) — same lifecycle convention as flipclock.js's
// countdown: stopNowTicker() runs at the top of every renderCalendar so a
// re-render (refreshTrip) never leaks or double-ticks, and a hashchange
// handler stops it on navigation away from the trip page.
let activeTimer = null;
let activeHashHandler = null;
function stopNowTicker() {
if (activeTimer) {
clearInterval(activeTimer);
activeTimer = null;
}
if (activeHashHandler) {
window.removeEventListener('hashchange', activeHashHandler);
activeHashHandler = null;
}
}
export function renderCalendar(tctx) {
stopNowTicker();
const { trip, entries } = tctx.trip;
const currency = trip.currency || 'USD';
@@ -100,6 +120,8 @@ export function renderCalendar(tctx) {
for (const label of WEEKDAYS) header.appendChild(el('div', { class: 'cal-weekday' }, label));
cal.appendChild(header);
const todayKey = ymd(new Date());
let todayCell = null;
for (const week of weeks) {
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
const weekEl = el('div', { class: 'cal-week' });
@@ -107,11 +129,16 @@ export function renderCalendar(tctx) {
if (bands) weekEl.appendChild(bands);
const daysRow = el('div', { class: 'cal-week-days' });
for (const date of week) {
daysRow.appendChild(dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency));
const cell = dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency);
if (ymd(date) === todayKey) todayCell = cell;
daysRow.appendChild(cell);
}
weekEl.appendChild(daysRow);
cal.appendChild(weekEl);
}
// Red "now" slider: only when today falls inside the rendered week rows
// (a past/future trip's grid may not include it at all).
if (todayCell) wireNowSlider(todayCell);
// A cancelled drag (released outside a cell) fires dragend on the source chip,
// which bubbles up here — sweep any lingering drop highlight.
@@ -150,6 +177,38 @@ function syncTransportsButton(tctx) {
return btn;
}
// A vertical red line through today's cell, positioned by the fraction of the
// day elapsed (00:00 left edge -> 24:00 right edge), with a dot on top — the
// "slider" that moves through the cell over the day. Updates every 60s.
function wireNowSlider(cell) {
cell.classList.add('cal-today');
const slider = el('div', { class: 'cal-now-slider' }, el('span', { class: 'cal-now-dot' }));
cell.appendChild(slider);
function position() {
const now = new Date();
const frac = (now.getHours() * 60 + now.getMinutes()) / 1440;
slider.style.left = `${frac * 100}%`;
}
// The isConnected self-heal only applies to interval ticks: the initial
// position() runs while the calendar section is still detached (tripDetail
// mounts it after renderCalendar returns), and gating it would leave the
// slider unpositioned until the first 60s tick.
function tick() {
if (!slider.isConnected) {
stopNowTicker();
return;
}
position();
}
position();
activeTimer = setInterval(tick, 60000);
activeHashHandler = stopNowTicker;
window.addEventListener('hashchange', activeHashHandler);
}
function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency) {
const key = ymd(date);
const inRange = date >= rangeStart && date <= rangeEnd;