Files
trip-plan/public/js/views/calendar.js
T
grabowski b0bdd2570c 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.
2026-08-30 12:49:17 +02:00

361 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Calendar grid for the trip's date range. Real weeks as rows (MonSun
// columns); days outside the range are greyed. Each in-range day shows its
// entries as compact, type-coloured chips. Clicking a day opens the editor.
import { el, toast } from '../dom.js';
import { api } from '../api.js';
import {
ENTRY_TYPES,
typeInfo,
entryIcon,
parseYMD,
ymd,
addDays,
startOfWeekMon,
formatMoney,
flightChain,
hasSegments,
hasRental,
isMultiDay,
} from '../format.js';
import { computeStayLayout, renderWeekBands } from './stayBands.js';
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';
// Group entries by date for quick per-cell lookup.
const byDate = new Map();
// Derived "dropoff" chips: a rental whose dropoff day differs from its
// (pickup) entry date gets a secondary chip on the dropoff day.
const dropoffByDate = new Map();
// Continuation ghosts: a non-stay multi-day entry marks each following day
// through end_date with a "…continues" chip.
const contByDate = new Map();
for (const entry of entries) {
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
byDate.get(entry.date).push(entry);
if (hasRental(entry)) {
const dropDate = entry.rental.dropoff && entry.rental.dropoff.date;
if (dropDate && dropDate !== entry.date) {
if (!dropoffByDate.has(dropDate)) dropoffByDate.set(dropDate, []);
dropoffByDate.get(dropDate).push(entry);
}
}
if (entry.type !== 'stay' && isMultiDay(entry)) {
let d = addDays(parseYMD(entry.date), 1);
const end = parseYMD(entry.end_date);
while (d <= end) {
const k = ymd(d);
if (!contByDate.has(k)) contByDate.set(k, []);
contByDate.get(k).push(entry);
d = addDays(d, 1);
}
}
}
const stayLayout = computeStayLayout(entries);
// id -> entry, for the drop handler to resolve the dragged chip.
const entriesById = new Map(entries.map((e) => [e.id, e]));
const rangeStart = parseYMD(trip.start_date);
const rangeEnd = parseYMD(trip.end_date);
const gridStart = startOfWeekMon(rangeStart);
const section = el(
'section',
{ class: 'card calendar-section' },
el(
'div',
{ class: 'section-head cal-section-head' },
el(
'div',
{},
el('h2', {}, 'Calendar'),
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
),
syncTransportsButton(tctx),
),
legend(),
);
// Collect whole weeks (MonSun) covering the range.
const weeks = [];
let cursor = gridStart;
let guard = 0;
while (cursor <= rangeEnd && guard < 400) {
const week = [];
for (let i = 0; i < 7; i++) {
week.push(cursor);
cursor = addDays(cursor, 1);
}
weeks.push(week);
guard += 7;
}
const cal = el('div', { class: 'calendar' });
const header = el('div', { class: 'cal-weekdays' });
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' });
const bands = renderWeekBands(week.map(ymd), stayLayout, entriesById, tctx);
if (bands) weekEl.appendChild(bands);
const daysRow = el('div', { class: 'cal-week-days' });
for (const date of week) {
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.
cal.addEventListener('dragend', () => {
for (const n of cal.querySelectorAll('.cal-day-drop')) n.classList.remove('cal-day-drop');
for (const n of cal.querySelectorAll('.cal-bands-drop')) n.classList.remove('cal-bands-drop');
});
section.appendChild(cal);
return section;
}
// "↻ Sync transports" — reconciles auto-created transport entries after stays
// have been dragged/reshuffled (see docs/API.md, "Regenerating auto-transports").
function syncTransportsButton(tctx) {
const btn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '↻ Sync transports');
btn.addEventListener('click', async () => {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Syncing…';
try {
const res = await api.trips.regenerateTransports(tctx.tripId);
const parts = [];
if (res.created) parts.push(`${res.created} added`);
if (res.updated) parts.push(`${res.updated} updated`);
if (res.deleted) parts.push(`${res.deleted} removed`);
toast(parts.length ? `Transports synced: ${parts.join(', ')}` : 'Transports already in sync', 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
} finally {
btn.disabled = false;
btn.textContent = original;
}
});
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;
// Stays render as bands, not chips, so exclude them from the day cell.
const dayEntries = (byDate.get(key) || []).filter((e) => e.type !== 'stay');
const conts = contByDate.get(key) || [];
const dropoffs = dropoffByDate.get(key) || [];
const hasAny = dayEntries.length || conts.length || dropoffs.length;
const isFirstOfMonth = date.getDate() === 1;
const cell = el('div', {
class: `cal-day${inRange ? '' : ' cal-out'}${hasAny ? ' cal-has' : ''}`,
});
cell.appendChild(
el(
'div',
{ class: 'cal-day-head' },
el('span', { class: 'cal-daynum' }, String(date.getDate())),
isFirstOfMonth
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
: null,
!inRange && hasAny
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
: null,
),
);
const chips = el('div', { class: 'cal-chips' });
for (const entry of dayEntries) chips.appendChild(chip(entry, currency));
// "…continues" ghosts for multi-day entries; click opens the start day.
for (const entry of conts) chips.appendChild(continuationChip(entry, tctx));
// Secondary dropoff chips: clicking opens the pickup day where the entry lives.
for (const entry of dropoffs) chips.appendChild(dropoffChip(entry, tctx));
cell.appendChild(chips);
// In-range days are always clickable; out-of-range days only when they hold
// something (a chip, continuation, or dropoff marker).
if (inRange || hasAny) {
cell.classList.add('clickable');
cell.tabIndex = 0;
cell.setAttribute('role', 'button');
cell.addEventListener('click', () => tctx.openDay(key));
cell.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tctx.openDay(key);
}
});
}
// Only in-range days accept dropped entries; new sort_order appends after the
// day's current entries (byDate holds every entry, incl. stays, for that day).
if (inRange) {
makeDayDropTarget(cell, key, (byDate.get(key) || []).length, entriesById, tctx);
}
return cell;
}
function chip(entry, currency) {
const info = typeInfo(entry.type);
const hasPrice = entry.price != null;
const chain = hasSegments(entry) ? flightChain(entry.segments) : '';
const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : '';
const label = chain || car || entry.title;
const node = el(
'div',
{
class: 'cal-chip',
style: { '--chip': info.color },
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
},
el('span', { class: 'chip-icon' }, entryIcon(entry)),
el('span', { class: 'chip-text' }, label),
hasPrice
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
: null,
);
// Regular chips are drag sources (move the entry to another day), same as
// stay area bands (see stayBands.js). The "…continues" and "dropoff" ghost
// chips stay non-draggable — they're derived markers, not the entry itself.
makeChipDraggable(node, entry);
return node;
}
// Ghost chip on the days a multi-day entry spans after its start. Clicking
// opens the START day's editor (where the entry lives).
function continuationChip(entry, tctx) {
const info = typeInfo(entry.type);
const node = el(
'div',
{
class: 'cal-chip cal-chip-cont',
style: { '--chip': info.color },
role: 'button',
tabindex: '0',
title: `${info.label}: ${entry.title} (continues) — opens the start day`,
},
el('span', { class: 'chip-icon' }, '⤷'),
el('span', { class: 'chip-text' }, 'continues'),
);
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
node.addEventListener('click', open);
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
return node;
}
// Secondary, outlined chip shown on a rental's dropoff day. Clicking opens the
// PICKUP day's editor (the day the entry actually lives on).
function dropoffChip(entry, tctx) {
const info = typeInfo(entry.type);
const car = [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ');
const node = el(
'div',
{
class: 'cal-chip cal-chip-dropoff',
style: { '--chip': info.color },
role: 'button',
tabindex: '0',
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
},
el('span', { class: 'chip-icon' }, entryIcon(entry)),
el('span', { class: 'chip-text' }, 'dropoff'),
);
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
node.addEventListener('click', open);
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
return node;
}
function legend() {
const wrap = el('div', { class: 'legend' });
for (const [type, info] of Object.entries(ENTRY_TYPES)) {
wrap.appendChild(
el(
'span',
{ class: 'legend-item', 'data-type': type },
el('span', { class: 'legend-dot', style: { background: info.color } }),
el('span', {}, `${info.icon} ${info.label}`),
),
);
}
return wrap;
}