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:
+23
@@ -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.
|
- 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).
|
- 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.
|
- 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`.
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||||
<link rel="stylesheet" href="./css/checklist.css" />
|
<link rel="stylesheet" href="./css/checklist.css" />
|
||||||
<link rel="stylesheet" href="./css/expenses.css" />
|
<link rel="stylesheet" href="./css/expenses.css" />
|
||||||
|
<link rel="stylesheet" href="./css/timeline.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -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'];
|
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) {
|
export function renderCalendar(tctx) {
|
||||||
|
stopNowTicker();
|
||||||
const { trip, entries } = tctx.trip;
|
const { trip, entries } = tctx.trip;
|
||||||
const currency = trip.currency || 'USD';
|
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));
|
for (const label of WEEKDAYS) header.appendChild(el('div', { class: 'cal-weekday' }, label));
|
||||||
cal.appendChild(header);
|
cal.appendChild(header);
|
||||||
|
|
||||||
|
const todayKey = ymd(new Date());
|
||||||
|
let todayCell = null;
|
||||||
for (const week of weeks) {
|
for (const week of weeks) {
|
||||||
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
|
// Each week is a stay-bands strip (all-day bars) above a row of day cells.
|
||||||
const weekEl = el('div', { class: 'cal-week' });
|
const weekEl = el('div', { class: 'cal-week' });
|
||||||
@@ -107,11 +129,16 @@ export function renderCalendar(tctx) {
|
|||||||
if (bands) weekEl.appendChild(bands);
|
if (bands) weekEl.appendChild(bands);
|
||||||
const daysRow = el('div', { class: 'cal-week-days' });
|
const daysRow = el('div', { class: 'cal-week-days' });
|
||||||
for (const date of week) {
|
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);
|
weekEl.appendChild(daysRow);
|
||||||
cal.appendChild(weekEl);
|
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,
|
// A cancelled drag (released outside a cell) fires dragend on the source chip,
|
||||||
// which bubbles up here — sweep any lingering drop highlight.
|
// which bubbles up here — sweep any lingering drop highlight.
|
||||||
@@ -150,6 +177,38 @@ function syncTransportsButton(tctx) {
|
|||||||
return btn;
|
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) {
|
function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, tctx, currency) {
|
||||||
const key = ymd(date);
|
const key = ymd(date);
|
||||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||||
|
|||||||
@@ -5,26 +5,14 @@
|
|||||||
// this panel re-renders itself from the freshly fetched trip data too.
|
// this panel re-renders itself from the freshly fetched trip data too.
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { el, clear, mount, toast } from '../dom.js';
|
import { el, clear, mount, toast } from '../dom.js';
|
||||||
import {
|
import { ENTRY_TYPE_LIST, TRANSPORT_MODES, formatFullDate } from '../format.js';
|
||||||
ENTRY_TYPE_LIST,
|
import { createFlightRoute } from './segments.js';
|
||||||
TRANSPORT_MODES,
|
import { createRentalDetails } from './rental.js';
|
||||||
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 { createWaypoints } from './waypoints.js';
|
import { createWaypoints } from './waypoints.js';
|
||||||
import { createCostForm } from './costForm.js';
|
import { createCostForm } from './costForm.js';
|
||||||
import { enableRowReorder } from './dragdrop.js';
|
import { enableRowReorder } from './dragdrop.js';
|
||||||
|
import { renderDayTimeline, stopDayTimelineClock } from './dayTimeline.js';
|
||||||
|
import { renderEntryRow } from './entryRow.js';
|
||||||
|
|
||||||
export function openDayEditor(tctx, date) {
|
export function openDayEditor(tctx, date) {
|
||||||
// A form-state object for the entry currently being added/edited.
|
// A form-state object for the entry currently being added/edited.
|
||||||
@@ -46,6 +34,7 @@ export function openDayEditor(tctx, date) {
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
tctx._onModalRefresh = null;
|
tctx._onModalRefresh = null;
|
||||||
|
stopDayTimelineClock();
|
||||||
document.body.classList.remove('no-scroll');
|
document.body.classList.remove('no-scroll');
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
@@ -95,84 +84,20 @@ export function openDayEditor(tctx, date) {
|
|||||||
const reorderable = dayEntries.length > 1;
|
const reorderable = dayEntries.length > 1;
|
||||||
const rows = [];
|
const rows = [];
|
||||||
for (const entry of dayEntries) {
|
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 });
|
rows.push({ node, handle, entry });
|
||||||
list.appendChild(node);
|
list.appendChild(node);
|
||||||
}
|
}
|
||||||
if (reorderable) enableRowReorder(rows, () => tctx.refreshTrip());
|
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;
|
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) {
|
function startEdit(entry) {
|
||||||
editing = entry.id;
|
editing = entry.id;
|
||||||
loc = entry.lat != null && entry.lng != null
|
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}`;
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ import './expenses-export.test.js';
|
|||||||
import './flights.test.js';
|
import './flights.test.js';
|
||||||
import './rental.test.js';
|
import './rental.test.js';
|
||||||
import './stays.test.js';
|
import './stays.test.js';
|
||||||
|
import './timeline-layout.test.js';
|
||||||
import './transport.test.js';
|
import './transport.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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user