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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user