diff --git a/docs/API.md b/docs/API.md index c4238c3..150326c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -405,3 +405,26 @@ Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept - Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load. - Expenses UI lives in `public/js/views/expenses.js` (+ `public/css/expenses.css` — styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching from `GET .../expenses` (never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a **sort control** (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../expenses/export.csv` (cookie auth makes a plain link work). - Checklist UI lives in `public/js/views/checklist.js`, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (`dragdrop.js` `enableReorder`, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure. + +### Hourly day timeline & "now" indicator (frontend-only — no API surface) + +Pure client feature over existing entry fields (`start_time`/`end_time` `HH:MM`, `date`/`end_date`). No new endpoints, no schema changes. + +**Hourly day timeline** — when a day holds **4 or more** entries (all entries whose `date` equals the opened day, stays included), the day-editor slideover (`dayEditor.js`) renders an hour-grid timeline between the header and the entry list. Days with 1–3 entries are unchanged. Implementation is split to respect the 500-line cap (dayEditor is at 488): + +- `public/js/timelineLayout.js` (new) — **pure, DOM-free** helpers, imported by the view AND unit-tested directly from node: + - `timedSlots(entries)` → entries with a valid `start_time`, each as `{entry, startMin, endMin}` (minutes since midnight; missing/invalid `end_time` → `endMin = startMin + 60`; `end_time` ≤ `start_time` (or spanning into `end_date`) clamps to `24*60`). Invalid `start_time` (not `HH:MM`) counts as untimed. + - `layoutColumns(slots)` → same slots + `{col, cols}` per slot: overlapping slots split the 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` = column count of its overlap cluster). + - `gridWindow(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. +- `public/js/views/dayTimeline.js` (new) — `renderDayTimeline(tctx, date, dayEntries)` → a DOM node (or `null` below the threshold, so dayEditor can just append the result): + - **All-day strip** on top: entries with no valid `start_time` (plus stays regardless of time) as compact type-coloured chips, icon + title. + - **Hour grid** below (only when ≥1 timed slot): one row per hour of `gridWindow`, `HH:00` labels left, timed entries as absolutely positioned blocks (top/height from start/end minutes, ≥ 30 min visual height; overlapping blocks share the width per `layoutColumns`). Blocks show icon + title (+ time range in the `title` tooltip), coloured via the entry type's `--chip` var like everywhere else. + - Clicking an all-day chip or a block loads that entry into the day editor's edit form (same handler as clicking ✎ in the list). + - The timeline re-renders with the rest of the panel on `tctx._onModalRefresh` — no self-fetching, it reads the already-loaded `tctx.trip.entries`. +- `public/css/timeline.css` (new, linked in `index.html`) — all styles for the strip, grid, and both now-markers below (styles.css is at 450/500 — do not grow it). + +**Red "now" indicator** — both views, only while "now" is actually inside what they show; updates every minute (`setInterval` 60 s, cleared when the view unmounts — calendar re-render or slideover close): + +- **Month grid** (`calendar.js`): today's `.cal-day` cell gets a vertical red line whose horizontal position within the cell = the fraction of the day elapsed (00:00 left edge → 24:00 right edge), with a small dot at the top — the "slider" that moves through the cell over the day. Rendered only when today is inside the rendered week rows. The cell also gets a `cal-today` class (subtle red day-number accent) so today is findable at a glance. +- **Hourly timeline** (`dayTimeline.js`): when the opened `date` is today and the current time falls inside the grid window, a horizontal red line with a left dot across the grid at the current-time position (the classic Google-Calendar line). +- One shared colour token for both (`--now`, red), defined in `timeline.css`. diff --git a/public/css/timeline.css b/public/css/timeline.css new file mode 100644 index 0000000..b03cd04 --- /dev/null +++ b/public/css/timeline.css @@ -0,0 +1,58 @@ +/* Hourly day-timeline (dayTimeline.js) and the "now" indicators in both the + month calendar and the timeline (see docs/API.md "Hourly day timeline & + 'now' indicator"). Split out of styles.css — which is at its 500-line cap + — following the same per-feature stylesheet convention as checklist.css. */ + +:root { + --now: #ef4444; +} + +/* ---------- Day timeline (day-editor slideover) ---------- */ +.day-timeline { display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 0.6rem; border-bottom: 1px solid var(--border); } + +.dt-allday { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.dt-chip { display: inline-flex; align-items: center; gap: 0.3rem; max-width: 100%; border: none; border-left: 3px solid var(--chip); border-radius: 6px; background: color-mix(in srgb, var(--chip) 16%, white); color: #334155; font-size: 0.78rem; font-weight: 600; padding: 0.2rem 0.5rem; cursor: pointer; } +.dt-chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 11rem; } + +.dt-grid-wrap { position: relative; display: flex; } +.dt-hours { flex: 1; display: flex; flex-direction: column; } +.dt-hour-row { flex-shrink: 0; box-sizing: border-box; border-top: 1px solid var(--border); padding-left: 3rem; position: relative; } +.dt-hour-row:first-child { border-top: none; } +.dt-hour-label { position: absolute; left: 0; top: -0.55em; width: 2.7rem; font-size: 0.68rem; font-weight: 700; color: var(--text-faint); text-align: right; padding-right: 0.4rem; } + +.dt-events { position: absolute; inset: 0; left: 3rem; } +.dt-block { + position: absolute; + box-sizing: border-box; + overflow: hidden; + border: none; + border-left: 3px solid var(--chip); + border-radius: 5px; + background: color-mix(in srgb, var(--chip) 22%, white); + color: #334155; + font-size: 0.74rem; + font-weight: 600; + text-align: left; + padding: 0.15rem 0.4rem; + margin-right: 3px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.25rem; +} +.dt-block-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.dt-now-line { position: absolute; left: 0; right: 0; height: 2px; background: var(--now); z-index: 3; pointer-events: none; } +.dt-now-dot { position: absolute; left: -0.3rem; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--now); box-shadow: 0 0 0 2px var(--surface); } + +/* ---------- Month calendar "now" slider (calendar.js) ---------- */ +.cal-day.cal-today { position: relative; } +.cal-today .cal-daynum { color: var(--now); font-weight: 800; } +.cal-now-slider { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--now); pointer-events: none; z-index: 2; } +.cal-now-dot { position: absolute; top: -3px; left: 50%; transform: translateX(-50%); width: 7px; height: 7px; border-radius: 50%; background: var(--now); box-shadow: 0 0 0 2px var(--surface); } + +@media (max-width: 480px) { + .dt-hour-row { padding-left: 2.4rem; } + .dt-hour-label { width: 2.1rem; } + .dt-events { left: 2.4rem; } +} diff --git a/public/index.html b/public/index.html index 9df74d2..0c6564c 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,7 @@ +
diff --git a/public/js/timelineLayout.js b/public/js/timelineLayout.js new file mode 100644 index 0000000..f55e593 --- /dev/null +++ b/public/js/timelineLayout.js @@ -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 }; +} diff --git a/public/js/views/calendar.js b/public/js/views/calendar.js index c8fec53..6aeb8b5 100644 --- a/public/js/views/calendar.js +++ b/public/js/views/calendar.js @@ -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; diff --git a/public/js/views/dayEditor.js b/public/js/views/dayEditor.js index 034b4ec..5509699 100644 --- a/public/js/views/dayEditor.js +++ b/public/js/views/dayEditor.js @@ -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 diff --git a/public/js/views/dayTimeline.js b/public/js/views/dayTimeline.js new file mode 100644 index 0000000..c341cbf --- /dev/null +++ b/public/js/views/dayTimeline.js @@ -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); +} diff --git a/public/js/views/entryRow.js b/public/js/views/entryRow.js new file mode 100644 index 0000000..41248ec --- /dev/null +++ b/public/js/views/entryRow.js @@ -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}`; +} diff --git a/tests/index.js b/tests/index.js index dcece75..6ad7b93 100644 --- a/tests/index.js +++ b/tests/index.js @@ -16,4 +16,5 @@ import './expenses-export.test.js'; import './flights.test.js'; import './rental.test.js'; import './stays.test.js'; +import './timeline-layout.test.js'; import './transport.test.js'; diff --git a/tests/timeline-layout.test.js b/tests/timeline-layout.test.js new file mode 100644 index 0000000..5d6d070 --- /dev/null +++ b/tests/timeline-layout.test.js @@ -0,0 +1,249 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { timedSlots, layoutColumns, gridWindow } from '../public/js/timelineLayout.js'; + +// Unit tests for the pure, DOM-free timeline layout helpers backing the hourly +// day timeline (docs/API.md "Hourly day timeline"). The DOM rendering in +// dayTimeline.js is not covered here — these pin the contract of the maths. + +let nextId = 1; +function entry(over = {}) { + return { + id: nextId++, type: 'activity', title: 'Thing', date: '2026-08-01', + start_time: '09:00', end_time: '10:00', ...over, + }; +} + +// A raw slot as layoutColumns consumes it (what timedSlots produces). +function slot(title, startMin, endMin) { + return { entry: entry({ title, start_time: null }), startMin, endMin }; +} + +// Column results keyed by entry title, so assertions are independent of the +// order layoutColumns returns the slots in. +function colsByTitle(slots) { + const out = {}; + for (const s of layoutColumns(slots)) out[s.entry.title] = { col: s.col, cols: s.cols }; + return out; +} + +// --- timedSlots --- + +test('timedSlots: parses valid HH:MM times into minutes since midnight', () => { + const e = entry({ start_time: '09:30', end_time: '11:45' }); + const slots = timedSlots([e]); + assert.equal(slots.length, 1); + assert.equal(slots[0].startMin, 9 * 60 + 30); + assert.equal(slots[0].endMin, 11 * 60 + 45); + assert.equal(slots[0].entry, e, 'the slot carries the original entry'); +}); + +test('timedSlots: missing end_time defaults to one hour after the start', () => { + const slots = timedSlots([ + entry({ start_time: '14:15', end_time: null }), + entry({ start_time: '14:15', end_time: undefined }), + entry({ start_time: '14:15', end_time: '' }), + ]); + assert.equal(slots.length, 3); + for (const s of slots) { + assert.equal(s.startMin, 14 * 60 + 15); + assert.equal(s.endMin, 15 * 60 + 15); + } +}); + +test('timedSlots: an invalid end_time format is treated as missing (+60)', () => { + const slots = timedSlots([ + entry({ start_time: '10:00', end_time: 'later' }), + entry({ start_time: '10:00', end_time: '10.30' }), + ]); + assert.equal(slots.length, 2); + for (const s of slots) assert.equal(s.endMin, 11 * 60); +}); + +test('timedSlots: end_time at or before start_time clamps endMin to midnight (1440)', () => { + const slots = timedSlots([ + entry({ start_time: '22:00', end_time: '01:30' }), // crosses midnight + entry({ start_time: '12:00', end_time: '12:00' }), // equal counts too + ]); + assert.equal(slots.length, 2); + assert.equal(slots[0].startMin, 22 * 60); + assert.equal(slots[0].endMin, 1440); + assert.equal(slots[1].endMin, 1440); +}); + +test('timedSlots: an entry spanning into end_date clamps endMin to 1440', () => { + const slots = timedSlots([ + entry({ date: '2026-08-01', end_date: '2026-08-02', start_time: '10:00', end_time: '11:00' }), + ]); + assert.equal(slots.length, 1); + assert.equal(slots[0].startMin, 10 * 60); + assert.equal(slots[0].endMin, 1440); +}); + +test('timedSlots: entries with an invalid or absent start_time are excluded', () => { + const timed = entry({ title: 'timed', start_time: '08:00', end_time: '09:00' }); + const slots = timedSlots([ + entry({ start_time: null }), + entry({ start_time: undefined }), + entry({ start_time: '' }), + entry({ start_time: 'noon' }), + timed, + ]); + assert.equal(slots.length, 1); + assert.equal(slots[0].entry, timed); +}); + +// --- layoutColumns --- + +test('layoutColumns: non-overlapping slots all sit in column 0 of a 1-column row', () => { + const out = colsByTitle([ + slot('a', 9 * 60, 10 * 60), + slot('b', 11 * 60, 12 * 60), + slot('c', 13 * 60, 14 * 60), + ]); + assert.deepEqual(out, { + a: { col: 0, cols: 1 }, + b: { col: 0, cols: 1 }, + c: { col: 0, cols: 1 }, + }); +}); + +test('layoutColumns: two overlapping slots get distinct columns in a 2-column row', () => { + const out = colsByTitle([ + slot('a', 9 * 60, 11 * 60), + slot('b', 10 * 60, 12 * 60), + ]); + assert.equal(out.a.cols, 2); + assert.equal(out.b.cols, 2); + assert.notEqual(out.a.col, out.b.col); + assert.deepEqual([out.a.col, out.b.col].sort(), [0, 1]); +}); + +test('layoutColumns: an overlap chain shares one cluster, with column reuse', () => { + // a overlaps b, b overlaps c, but a and c do not touch: one connected + // cluster of width 2, and c reuses column 0 because a has ended by then. + const out = colsByTitle([ + slot('a', 0, 100), + slot('b', 50, 150), + slot('c', 120, 200), + ]); + assert.deepEqual(out, { + a: { col: 0, cols: 2 }, + b: { col: 1, cols: 2 }, + c: { col: 0, cols: 2 }, + }); +}); + +test('layoutColumns: back-to-back slots (end == next start) do not overlap', () => { + const out = colsByTitle([ + slot('a', 60, 120), + slot('b', 120, 180), + ]); + assert.deepEqual(out, { + a: { col: 0, cols: 1 }, + b: { col: 0, cols: 1 }, + }); +}); + +test('layoutColumns: cols is per overlap cluster, not a global maximum', () => { + // Three separate clusters in one call: a 2-wide pair, an isolated slot, and + // a 3-wide pile-up. Each slot's cols must reflect only its own cluster. + const out = colsByTitle([ + slot('a1', 9 * 60, 10 * 60), + slot('a2', 9 * 60 + 30, 10 * 60 + 30), + slot('lone', 11 * 60, 12 * 60), + slot('b1', 13 * 60, 16 * 60), + slot('b2', 13 * 60 + 30, 15 * 60), + slot('b3', 14 * 60, 14 * 60 + 30), + ]); + assert.equal(out.a1.cols, 2); + assert.equal(out.a2.cols, 2); + assert.deepEqual(out.lone, { col: 0, cols: 1 }, + 'an isolated slot is full-width even when other clusters split'); + assert.deepEqual([out.b1.cols, out.b2.cols, out.b3.cols], [3, 3, 3]); +}); + +test('layoutColumns: three-way overlap widens the whole cluster to 3 columns', () => { + const out = colsByTitle([ + slot('a', 9 * 60, 12 * 60), + slot('b', 10 * 60, 11 * 60), + slot('c', 10 * 60 + 30, 11 * 60 + 30), + ]); + assert.deepEqual([out.a.cols, out.b.cols, out.c.cols], [3, 3, 3]); + assert.deepEqual([out.a.col, out.b.col, out.c.col].sort(), [0, 1, 2]); +}); + +// --- gridWindow --- + +test('gridWindow: empty slots fall back to the 08:00–21:00 default window', () => { + assert.deepEqual(gridWindow([]), { startHour: 8, endHour: 21 }); +}); + +test('gridWindow: pads one full hour either side of the slots', () => { + const win = gridWindow([slot('a', 9 * 60 + 30, 17 * 60 + 45)]); + assert.deepEqual(win, { startHour: 8, endHour: 19 }); +}); + +test('gridWindow: exact-hour boundaries still get a full hour of padding', () => { + const win = gridWindow([slot('a', 9 * 60, 17 * 60)]); + assert.deepEqual(win, { startHour: 8, endHour: 18 }); +}); + +test('gridWindow: clamps to 0 and 24 at the edges of the day', () => { + assert.deepEqual(gridWindow([slot('a', 15, 23 * 60 + 40)]), { startHour: 0, endHour: 24 }); + assert.deepEqual(gridWindow([slot('a', 0, 1440)]), { startHour: 0, endHour: 24 }); +}); + +test('gridWindow: spans from the earliest start to the latest end across slots', () => { + const win = gridWindow([ + slot('a', 11 * 60, 12 * 60), + slot('b', 7 * 60 + 10, 8 * 60), + slot('c', 13 * 60, 20 * 60 + 5), + ]); + assert.deepEqual(win, { startHour: 6, endHour: 22 }); +}); + +// --- determinism & purity --- + +test('timedSlots: repeat calls agree and the input entries are untouched', () => { + const make = () => [ + entry({ id: 1, title: 'x', start_time: '10:00', end_time: 'bogus' }), + entry({ id: 2, title: 'y', start_time: null }), + entry({ id: 3, title: 'z', start_time: '08:00', end_time: '07:00' }), + ]; + const input = make(); + const snapshot = JSON.stringify(input); + const a = timedSlots(input); + const b = timedSlots(input); + assert.deepEqual( + a.map((s) => [s.entry.id, s.startMin, s.endMin]), + b.map((s) => [s.entry.id, s.startMin, s.endMin]) + ); + assert.equal(JSON.stringify(input), snapshot, 'entries are not mutated'); +}); + +test('layoutColumns: deliberately unsorted input yields the sorted-input layout', () => { + // Same chain as the cluster test, fed out of order: the greedy pass sorts + // by startMin then endMin internally, so the assignments must not change. + const unsorted = [ + slot('c', 120, 200), + slot('a', 0, 100), + slot('b', 50, 150), + ]; + const before = [...unsorted]; + const out = colsByTitle(unsorted); + assert.deepEqual(out, { + a: { col: 0, cols: 2 }, + b: { col: 1, cols: 2 }, + c: { col: 0, cols: 2 }, + }); + assert.deepEqual(unsorted, before, 'the input array order is not disturbed'); + + // And a second identical call agrees exactly. + const again = colsByTitle([ + slot('c', 120, 200), + slot('a', 0, 100), + slot('b', 50, 150), + ]); + assert.deepEqual(again, out); +});