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
+11 -86
View File
@@ -5,26 +5,14 @@
// this panel re-renders itself from the freshly fetched trip data too.
import { api } from '../api.js';
import { el, clear, mount, toast } from '../dom.js';
import {
ENTRY_TYPE_LIST,
TRANSPORT_MODES,
splitModeLabel,
typeInfo,
formatDate,
formatFullDate,
formatTimeRange,
formatMoney,
flightChain,
hasSegments,
hasRental,
isMultiDay,
entrySpanDays,
} from '../format.js';
import { createFlightRoute, renderSegmentLines } from './segments.js';
import { createRentalDetails, renderRentalLine } from './rental.js';
import { ENTRY_TYPE_LIST, TRANSPORT_MODES, formatFullDate } from '../format.js';
import { createFlightRoute } from './segments.js';
import { createRentalDetails } from './rental.js';
import { createWaypoints } from './waypoints.js';
import { createCostForm } from './costForm.js';
import { enableRowReorder } from './dragdrop.js';
import { renderDayTimeline, stopDayTimelineClock } from './dayTimeline.js';
import { renderEntryRow } from './entryRow.js';
export function openDayEditor(tctx, date) {
// A form-state object for the entry currently being added/edited.
@@ -46,6 +34,7 @@ export function openDayEditor(tctx, date) {
function close() {
tctx._onModalRefresh = null;
stopDayTimelineClock();
document.body.classList.remove('no-scroll');
overlay.remove();
document.removeEventListener('keydown', onKey);
@@ -95,84 +84,20 @@ export function openDayEditor(tctx, date) {
const reorderable = dayEntries.length > 1;
const rows = [];
for (const entry of dayEntries) {
const { node, handle } = entryRow(entry, reorderable);
const { node, handle } = renderEntryRow(entry, {
reorderable, currency: currency(), memberName, onEdit: startEdit, onDelete,
});
rows.push({ node, handle, entry });
list.appendChild(node);
}
if (reorderable) enableRowReorder(rows, () => tctx.refreshTrip());
}
mount(panel, header, list, formSection(dayEntries));
const timeline = renderDayTimeline(tctx, date, dayEntries, { onEdit: startEdit });
mount(panel, header, timeline, list, formSection(dayEntries));
panel.scrollTop = 0;
}
// Returns { node, handle } — handle is the drag grip (null unless reorderable).
function entryRow(entry, reorderable) {
const info = typeInfo(entry.type);
const time = formatTimeRange(entry.start_time, entry.end_time);
const hasPrice = entry.price != null;
const flight = hasSegments(entry);
const handle = reorderable
? el('span', { class: 'entry-drag-handle', title: 'Drag to reorder', 'aria-hidden': 'true' }, '⋮⋮')
: null;
const node = el(
'div',
{ class: 'entry-row', style: { '--chip': info.color } },
handle,
el('span', { class: 'entry-icon' }, info.icon),
el(
'div',
{ class: 'entry-body' },
el(
'div',
{ class: 'entry-title-row' },
el('span', { class: 'entry-title' }, entry.title),
hasPrice
? el('span', { class: 'entry-price' }, formatMoney(entry.price, currency(), { compact: true }))
: null,
),
el(
'div',
{ class: 'entry-sub muted' },
info.label,
time ? ` · ${time}` : '',
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
entry.auto_ref ? ' · ↻ auto' : '',
),
isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null,
flight ? renderSegmentLines(entry.segments) : null,
hasRental(entry) ? renderRentalLine(entry.rental) : null,
hasPrice
? el(
'div',
{ class: 'entry-cost muted' },
`💰 ${splitModeLabel(entry.split_mode)}`,
entry.paid_by != null ? ` · paid by ${memberName(entry.paid_by)}` : ' · no payer set',
)
: null,
entry.details ? el('div', { class: 'entry-details' }, entry.details) : null,
),
el(
'div',
{ class: 'entry-actions' },
el('button', { class: 'icon-btn', title: 'Edit', onClick: () => startEdit(entry) }, '✎'),
el('button', { class: 'icon-btn danger', title: 'Delete', onClick: () => onDelete(entry) }, '🗑'),
),
);
return { node, handle };
}
// "until 8 Aug · 3 days" for stays; "5 Aug 20:50 → 7 Aug 06:30" otherwise.
function spanText(entry) {
if (entry.type === 'stay') {
return `until ${formatDate(entry.end_date)} · ${entrySpanDays(entry)} days`;
}
const a = `${formatDate(entry.date)}${entry.start_time ? ` ${entry.start_time}` : ''}`;
const b = `${formatDate(entry.end_date)}${entry.end_time ? ` ${entry.end_time}` : ''}`;
return `${a}${b}`;
}
function startEdit(entry) {
editing = entry.id;
loc = entry.lat != null && entry.lng != null