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:
@@ -0,0 +1,88 @@
|
||||
// Pure, DOM-free layout helpers for the hourly day timeline (dayTimeline.js).
|
||||
// No imports from DOM-touching modules — unit-tested directly under node:test.
|
||||
// See docs/API.md "Hourly day timeline & 'now' indicator" for the binding spec.
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
// "HH:MM" -> minutes since midnight, or null if missing/malformed.
|
||||
function parseHHMM(str) {
|
||||
if (typeof str !== 'string') return null;
|
||||
const m = TIME_RE.exec(str);
|
||||
if (!m) return null;
|
||||
return Number(m[1]) * 60 + Number(m[2]);
|
||||
}
|
||||
|
||||
// entries with a valid start_time -> [{entry, startMin, endMin}] (minutes
|
||||
// since midnight). Missing/invalid end_time -> endMin = startMin + 60.
|
||||
// end_time <= start_time, or the entry spanning into end_date, clamps
|
||||
// endMin to 24*60 (overnight / multi-day entries run to the end of the day).
|
||||
// An invalid start_time (not HH:MM) counts as untimed and is skipped.
|
||||
export function timedSlots(entries) {
|
||||
const slots = [];
|
||||
for (const entry of entries || []) {
|
||||
const startMin = parseHHMM(entry.start_time);
|
||||
if (startMin == null) continue;
|
||||
const spansPastToday = !!(entry.end_date && entry.end_date > entry.date);
|
||||
let endMin = parseHHMM(entry.end_time);
|
||||
if (endMin == null) endMin = startMin + 60;
|
||||
else if (endMin <= startMin || spansPastToday) endMin = 24 * 60;
|
||||
slots.push({ entry, startMin, endMin });
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// slots -> same slots + {col, cols}: overlapping slots split their shared row
|
||||
// into side-by-side columns. Greedy: sort by startMin then endMin; place each
|
||||
// slot in the first column whose last slot ends <= its start. `cols` is the
|
||||
// column count of the slot's overlap cluster (a maximal run of mutually-
|
||||
// reachable overlapping slots), not a single global maximum.
|
||||
export function layoutColumns(slots) {
|
||||
const sorted = [...slots].sort((a, b) => a.startMin - b.startMin || a.endMin - b.endMin);
|
||||
const out = sorted.map((s) => ({ ...s, col: 0, cols: 1 }));
|
||||
|
||||
let cluster = [];
|
||||
let columnEnds = []; // columnEnds[i] = endMin of the last slot placed in column i
|
||||
let clusterEnd = -Infinity;
|
||||
|
||||
const finalizeCluster = () => {
|
||||
for (const item of cluster) item.cols = columnEnds.length;
|
||||
cluster = [];
|
||||
columnEnds = [];
|
||||
};
|
||||
|
||||
for (const item of out) {
|
||||
if (item.startMin >= clusterEnd) {
|
||||
finalizeCluster();
|
||||
clusterEnd = -Infinity;
|
||||
}
|
||||
let col = columnEnds.findIndex((end) => end <= item.startMin);
|
||||
if (col === -1) {
|
||||
col = columnEnds.length;
|
||||
columnEnds.push(item.endMin);
|
||||
} else {
|
||||
columnEnds[col] = item.endMin;
|
||||
}
|
||||
item.col = col;
|
||||
cluster.push(item);
|
||||
clusterEnd = Math.max(clusterEnd, item.endMin);
|
||||
}
|
||||
finalizeCluster();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// slots -> {startHour, endHour}: one full hour before the earliest startMin to
|
||||
// one full hour after the latest endMin, clamped to [0, 24].
|
||||
// {startHour: 8, endHour: 21} when slots is empty.
|
||||
export function gridWindow(slots) {
|
||||
if (!slots || !slots.length) return { startHour: 8, endHour: 21 };
|
||||
let minStart = Infinity;
|
||||
let maxEnd = -Infinity;
|
||||
for (const s of slots) {
|
||||
if (s.startMin < minStart) minStart = s.startMin;
|
||||
if (s.endMin > maxEnd) maxEnd = s.endMin;
|
||||
}
|
||||
const startHour = Math.max(0, Math.floor(minStart / 60) - 1);
|
||||
const endHour = Math.min(24, Math.ceil(maxEnd / 60) + 1);
|
||||
return { startHour, endHour };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Hourly timeline for a crowded day, shown inside the day-editor slideover
|
||||
// between its header and entry list (see dayEditor.js). Layout math lives in
|
||||
// timelineLayout.js (pure, unit-tested); this module is the DOM rendering +
|
||||
// the red "now" line. See docs/API.md "Hourly day timeline & 'now' indicator".
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo, entryIcon, formatTimeRange, ymd } from '../format.js';
|
||||
import { timedSlots, layoutColumns, gridWindow } from '../timelineLayout.js';
|
||||
|
||||
const HOUR_REM = 3; // must match --dt-hour-h in timeline.css
|
||||
const THRESHOLD = 4;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
// Exported so dayEditor.js can stop the "now" line's interval on close —
|
||||
// draws that happen after this call each start (and self-clear) their own.
|
||||
export function stopDayTimelineClock() {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// (tctx, date, dayEntries, {onEdit}) -> a DOM node, or null below the
|
||||
// 4-entry threshold. `onEdit(entry)` loads the clicked entry into the day
|
||||
// editor's edit form — same handler as clicking the list's ✎ button.
|
||||
export function renderDayTimeline(tctx, date, dayEntries, { onEdit } = {}) {
|
||||
stopDayTimelineClock();
|
||||
if (!dayEntries || dayEntries.length < THRESHOLD) return null;
|
||||
|
||||
const stays = dayEntries.filter((e) => e.type === 'stay');
|
||||
const rest = dayEntries.filter((e) => e.type !== 'stay');
|
||||
const slots = layoutColumns(timedSlots(rest));
|
||||
const timedIds = new Set(slots.map((s) => s.entry.id));
|
||||
const allDay = [...stays, ...rest.filter((e) => !timedIds.has(e.id))];
|
||||
|
||||
const node = el('div', { class: 'day-timeline' });
|
||||
if (allDay.length) node.appendChild(allDayStrip(allDay, onEdit));
|
||||
if (slots.length) node.appendChild(hourGrid(slots, date, onEdit));
|
||||
return node;
|
||||
}
|
||||
|
||||
function chip(entry, onEdit) {
|
||||
const info = typeInfo(entry.type);
|
||||
return el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'dt-chip',
|
||||
style: { '--chip': info.color },
|
||||
title: entry.title,
|
||||
onClick: () => onEdit && onEdit(entry),
|
||||
},
|
||||
el('span', { class: 'dt-chip-icon' }, entryIcon(entry)),
|
||||
el('span', { class: 'dt-chip-text' }, entry.title),
|
||||
);
|
||||
}
|
||||
|
||||
function allDayStrip(entries, onEdit) {
|
||||
return el('div', { class: 'dt-allday' }, ...entries.map((e) => chip(e, onEdit)));
|
||||
}
|
||||
|
||||
function hourGrid(slots, date, onEdit) {
|
||||
const { startHour, endHour } = gridWindow(slots);
|
||||
const hours = Math.max(1, endHour - startHour);
|
||||
const totalRem = hours * HOUR_REM;
|
||||
|
||||
const rows = el('div', { class: 'dt-hours' });
|
||||
for (let h = startHour; h < endHour; h++) {
|
||||
rows.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'dt-hour-row', style: { height: `${HOUR_REM}rem` } },
|
||||
el('span', { class: 'dt-hour-label' }, `${String(h).padStart(2, '0')}:00`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const events = el('div', { class: 'dt-events' });
|
||||
for (const slot of slots) {
|
||||
const info = typeInfo(slot.entry.type);
|
||||
const top = ((slot.startMin - startHour * 60) / 60) * HOUR_REM;
|
||||
const height = Math.max(slot.endMin - slot.startMin, 30) / 60 * HOUR_REM;
|
||||
const time = formatTimeRange(slot.entry.start_time, slot.entry.end_time);
|
||||
events.appendChild(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'dt-block',
|
||||
style: {
|
||||
'--chip': info.color,
|
||||
top: `${top}rem`,
|
||||
height: `${height}rem`,
|
||||
left: `${(slot.col / slot.cols) * 100}%`,
|
||||
width: `${100 / slot.cols}%`,
|
||||
},
|
||||
title: time ? `${slot.entry.title} · ${time}` : slot.entry.title,
|
||||
onClick: () => onEdit && onEdit(slot.entry),
|
||||
},
|
||||
el('span', { class: 'dt-block-icon' }, entryIcon(slot.entry)),
|
||||
el('span', { class: 'dt-block-text' }, slot.entry.title),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const wrap = el(
|
||||
'div',
|
||||
{ class: 'dt-grid-wrap', style: { height: `${totalRem}rem` } },
|
||||
rows,
|
||||
events,
|
||||
);
|
||||
|
||||
if (date === ymd(new Date())) wireNowLine(wrap, events, startHour, endHour);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function wireNowLine(wrap, events, startHour, endHour) {
|
||||
const line = el('div', { class: 'dt-now-line' }, el('span', { class: 'dt-now-dot' }));
|
||||
let mounted = false;
|
||||
|
||||
function position() {
|
||||
const now = new Date();
|
||||
const min = now.getHours() * 60 + now.getMinutes();
|
||||
const inWindow = min >= startHour * 60 && min < endHour * 60;
|
||||
if (!inWindow) {
|
||||
if (mounted) { line.remove(); mounted = false; }
|
||||
return;
|
||||
}
|
||||
line.style.top = `${((min - startHour * 60) / 60) * HOUR_REM}rem`;
|
||||
if (!mounted) { events.appendChild(line); mounted = true; }
|
||||
}
|
||||
|
||||
// The isConnected self-heal only applies to interval ticks: the initial
|
||||
// position() runs while the timeline node is still detached (dayEditor
|
||||
// mounts it after renderDayTimeline returns), and gating it would hide the
|
||||
// now-line until the first 60s tick.
|
||||
function tick() {
|
||||
if (!wrap.isConnected) {
|
||||
stopDayTimelineClock();
|
||||
return;
|
||||
}
|
||||
position();
|
||||
}
|
||||
|
||||
position();
|
||||
activeTimer = setInterval(tick, 60000);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// A single entry's read-only row in the day editor's list: icon, title,
|
||||
// price, sub-line (type/time/flight-chain/location), span text for multi-day
|
||||
// entries, flight-segment/rental detail lines, cost line, and edit/delete
|
||||
// actions. Split out of dayEditor.js to keep it under the repo's 500-line cap.
|
||||
import { el } from '../dom.js';
|
||||
import {
|
||||
typeInfo,
|
||||
formatDate,
|
||||
formatTimeRange,
|
||||
formatMoney,
|
||||
flightChain,
|
||||
hasSegments,
|
||||
hasRental,
|
||||
isMultiDay,
|
||||
entrySpanDays,
|
||||
splitModeLabel,
|
||||
} from '../format.js';
|
||||
import { renderSegmentLines } from './segments.js';
|
||||
import { renderRentalLine } from './rental.js';
|
||||
|
||||
// Returns { node, handle } — handle is the drag grip (null unless reorderable).
|
||||
export function renderEntryRow(entry, { reorderable, currency, memberName, onEdit, onDelete }) {
|
||||
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: () => onEdit(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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user