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