Add multi-day entries and area stay blocks

Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
This commit is contained in:
2026-07-19 00:30:14 +07:00
parent 1d86c68665
commit 154f56a0a0
14 changed files with 416 additions and 26 deletions
+77 -15
View File
@@ -13,7 +13,9 @@ import {
flightChain,
hasSegments,
hasRental,
isMultiDay,
} from '../format.js';
import { computeStayLayout, renderWeekBands } from './stayBands.js';
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@@ -26,6 +28,9 @@ export function renderCalendar(tctx) {
// Derived "dropoff" chips: a rental whose dropoff day differs from its
// (pickup) entry date gets a secondary chip on the dropoff day.
const dropoffByDate = new Map();
// Continuation ghosts: a non-stay multi-day entry marks each following day
// through end_date with a "…continues" chip.
const contByDate = new Map();
for (const entry of entries) {
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
byDate.get(entry.date).push(entry);
@@ -36,8 +41,20 @@ export function renderCalendar(tctx) {
dropoffByDate.get(dropDate).push(entry);
}
}
if (entry.type !== 'stay' && isMultiDay(entry)) {
let d = addDays(parseYMD(entry.date), 1);
const end = parseYMD(entry.end_date);
while (d <= end) {
const k = ymd(d);
if (!contByDate.has(k)) contByDate.set(k, []);
contByDate.get(k).push(entry);
d = addDays(d, 1);
}
}
}
const stayLayout = computeStayLayout(entries);
const rangeStart = parseYMD(trip.start_date);
const rangeEnd = parseYMD(trip.end_date);
const gridStart = startOfWeekMon(rangeStart);
@@ -54,35 +71,54 @@ export function renderCalendar(tctx) {
legend(),
);
const grid = el('div', { class: 'calendar-grid' });
for (const label of WEEKDAYS) {
grid.appendChild(el('div', { class: 'cal-weekday' }, label));
}
// Walk whole weeks from gridStart until we've passed the range end.
// Collect whole weeks (MonSun) covering the range.
const weeks = [];
let cursor = gridStart;
let guard = 0;
while (cursor <= rangeEnd && guard < 400) {
const week = [];
for (let i = 0; i < 7; i++) {
grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency));
week.push(cursor);
cursor = addDays(cursor, 1);
}
weeks.push(week);
guard += 7;
}
section.appendChild(grid);
const cal = el('div', { class: 'calendar' });
const header = el('div', { class: 'cal-weekdays' });
for (const label of WEEKDAYS) header.appendChild(el('div', { class: 'cal-weekday' }, label));
cal.appendChild(header);
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' });
const bands = renderWeekBands(week.map(ymd), stayLayout, 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, tctx, currency));
}
weekEl.appendChild(daysRow);
cal.appendChild(weekEl);
}
section.appendChild(cal);
return section;
}
function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) {
function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, tctx, currency) {
const key = ymd(date);
const inRange = date >= rangeStart && date <= rangeEnd;
const dayEntries = byDate.get(key) || [];
// Stays render as bands, not chips, so exclude them from the day cell.
const dayEntries = (byDate.get(key) || []).filter((e) => e.type !== 'stay');
const conts = contByDate.get(key) || [];
const dropoffs = dropoffByDate.get(key) || [];
const hasAny = dayEntries.length || conts.length || dropoffs.length;
const isFirstOfMonth = date.getDate() === 1;
const cell = el('div', {
class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' cal-has' : ''}`,
class: `cal-day${inRange ? '' : ' cal-out'}${hasAny ? ' cal-has' : ''}`,
});
cell.appendChild(
@@ -93,7 +129,7 @@ function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, curren
isFirstOfMonth
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
: null,
!inRange && (dayEntries.length || dropoffs.length)
!inRange && hasAny
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
: null,
),
@@ -101,13 +137,15 @@ function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, curren
const chips = el('div', { class: 'cal-chips' });
for (const entry of dayEntries) chips.appendChild(chip(entry, currency));
// "…continues" ghosts for multi-day entries; click opens the start day.
for (const entry of conts) chips.appendChild(continuationChip(entry, tctx));
// Secondary dropoff chips: clicking opens the pickup day where the entry lives.
for (const entry of dropoffs) chips.appendChild(dropoffChip(entry, tctx));
cell.appendChild(chips);
// In-range days are always clickable; out-of-range days only when they
// hold entries or a derived dropoff chip.
if (inRange || dayEntries.length || dropoffs.length) {
// In-range days are always clickable; out-of-range days only when they hold
// something (a chip, continuation, or dropoff marker).
if (inRange || hasAny) {
cell.classList.add('clickable');
cell.tabIndex = 0;
cell.setAttribute('role', 'button');
@@ -144,6 +182,30 @@ function chip(entry, currency) {
);
}
// Ghost chip on the days a multi-day entry spans after its start. Clicking
// opens the START day's editor (where the entry lives).
function continuationChip(entry, tctx) {
const info = typeInfo(entry.type);
const node = el(
'div',
{
class: 'cal-chip cal-chip-cont',
style: { '--chip': info.color },
role: 'button',
tabindex: '0',
title: `${info.label}: ${entry.title} (continues) — opens the start day`,
},
el('span', { class: 'chip-icon' }, '⤷'),
el('span', { class: 'chip-text' }, 'continues'),
);
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
node.addEventListener('click', open);
node.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
return node;
}
// Secondary, outlined chip shown on a rental's dropoff day. Clicking opens the
// PICKUP day's editor (the day the entry actually lives on).
function dropoffChip(entry, tctx) {
+41 -2
View File
@@ -9,12 +9,15 @@ import {
ENTRY_TYPE_LIST,
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';
@@ -119,6 +122,7 @@ export function openDayEditor(tctx, date) {
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
),
isMultiDay(entry) ? el('div', { class: 'entry-span muted' }, spanText(entry)) : null,
flight ? renderSegmentLines(entry.segments) : null,
hasRental(entry) ? renderRentalLine(entry.rental) : null,
hasPrice
@@ -140,6 +144,16 @@ export function openDayEditor(tctx, date) {
);
}
// "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
@@ -152,6 +166,7 @@ export function openDayEditor(tctx, date) {
fields.details.value = entry.details || '';
fields.start.value = entry.start_time || '';
fields.end.value = entry.end_time || '';
fields.endDate.value = entry.end_date || '';
fields.cost.prefill(entry);
// Flight segments (load() triggers the flight-route onChange -> UI sync).
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
@@ -174,6 +189,11 @@ export function openDayEditor(tctx, date) {
const detailsInput = el('textarea', { class: 'input', rows: '2', placeholder: 'Details (optional)' });
const startInput = el('input', { class: 'input', type: 'time' });
const endInput = el('input', { class: 'input', type: 'time' });
// Optional end date for multi-day entries (all types). Relabeled "Until"
// for stays via syncTypeUI.
const endDateInput = el('input', { class: 'input', type: 'date', min: date });
const endDateLabel = el('span', { class: 'field-label' }, 'End date');
const endDateField = el('label', { class: 'field' }, endDateLabel, endDateInput);
const locWrap = el('div', { class: 'loc-field' });
const locInput = el('input', {
@@ -218,6 +238,9 @@ export function openDayEditor(tctx, date) {
flightSection.style.display = type === 'flight' ? '' : 'none';
rentalSection.style.display = type === 'rental' ? '' : 'none';
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
// Stays emphasise an end date ("until"); other types call it "End date".
endDateLabel.textContent = type === 'stay' ? 'Until' : 'End date';
locationField.classList.toggle('field-emphasis', type === 'stay');
}
typeSelect.addEventListener('change', syncTypeUI);
typeUIReady = true;
@@ -228,6 +251,7 @@ export function openDayEditor(tctx, date) {
fields = {
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
endDate: endDateInput,
locInput, locResults, locSelected,
cost: costForm, flightRoute, rentalDetails,
};
@@ -240,11 +264,19 @@ export function openDayEditor(tctx, date) {
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const title = titleInput.value.trim();
if (!title) return (errorEl.textContent = 'Title is required.');
const isStay = typeSelect.value === 'stay';
let title = titleInput.value.trim();
if (!title) {
// Stays may leave the title blank — default to the location's short name.
if (isStay && loc) title = loc.name.split(',')[0].trim();
else return (errorEl.textContent = isStay
? 'Give the stay a title or pick a location.'
: 'Title is required.');
}
const payload = {
date,
end_date: endDateInput.value || null,
type: typeSelect.value,
title,
details: detailsInput.value.trim(),
@@ -286,6 +318,12 @@ export function openDayEditor(tctx, date) {
payload.rental = null;
}
// end_date must be on/after the effective start date (rental may have
// moved payload.date to the pickup date above).
if (payload.end_date && payload.end_date < payload.date) {
return (errorEl.textContent = 'End date must be on or after the start date.');
}
// Cost fields: price is the toggle. When set, send the full cost set;
// when blank, send price:null (clears any prior cost) and omit the rest.
const costRes = costForm.read();
@@ -337,6 +375,7 @@ export function openDayEditor(tctx, date) {
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start time'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End time'), endInput),
endDateField,
),
flightSection,
rentalSection,
+80
View File
@@ -0,0 +1,80 @@
// Stay "area" bands for the calendar. Stay entries render as continuous
// coloured bars spanning date..end_date across each week row (all-day-event
// style) instead of day chips. Lanes are assigned globally (interval
// partitioning) so a stay keeps the same vertical position across weeks.
import { el } from '../dom.js';
import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.js';
// Returns { stays: [{entry,start,end,name,days,lane}], laneCount }.
export function computeStayLayout(entries) {
const stays = entries
.filter((e) => e.type === 'stay')
.map((e) => ({
entry: e,
start: e.date,
end: e.end_date && e.end_date >= e.date ? e.end_date : e.date,
name: stayShortName(e),
days: entrySpanDays(e),
lane: 0,
}))
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.entry.id - b.entry.id));
const laneEnds = []; // last end date (ymd) occupying each lane
for (const s of stays) {
// A lane is free if its last stay ended strictly before this one starts.
let lane = laneEnds.findIndex((end) => end < s.start);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(s.end);
} else {
laneEnds[lane] = s.end;
}
s.lane = lane;
}
return { stays, laneCount: laneEnds.length };
}
// Bands strip for one week (7 ymd strings). Returns null if no stay intersects.
export function renderWeekBands(weekYmd, layout, tctx) {
const weekStart = weekYmd[0];
const weekEnd = weekYmd[6];
const inWeek = layout.stays.filter((s) => s.start <= weekEnd && s.end >= weekStart);
if (!inWeek.length) return null;
const maxLane = inWeek.reduce((m, s) => Math.max(m, s.lane), 0);
const strip = el('div', {
class: 'cal-week-bands',
style: { gridTemplateRows: `repeat(${maxLane + 1}, var(--band-h))` },
});
for (const s of inWeek) {
const segStart = s.start > weekStart ? s.start : weekStart;
const segEnd = s.end < weekEnd ? s.end : weekEnd;
const col = dayIndex(weekStart, segStart); // 0..6
const span = daysBetweenInclusive(segStart, segEnd);
const roundLeft = segStart === s.start;
const roundRight = segEnd === s.end;
const info = typeInfo('stay');
const band = el('div', {
class: `cal-band${roundLeft ? ' round-l' : ''}${roundRight ? ' round-r' : ''}`,
style: { gridColumn: `${col + 1} / span ${span}`, gridRow: String(s.lane + 1), '--chip': info.color },
role: 'button',
tabindex: '0',
title: `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`,
},
el('span', { class: 'cal-band-label' }, `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`));
const open = (e) => { e.stopPropagation(); tctx.openDay(s.entry.date); };
band.addEventListener('click', open);
band.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
strip.appendChild(band);
}
return strip;
}
function dayIndex(weekStart, day) {
return Math.round((parseYMD(day) - parseYMD(weekStart)) / 86400000);
}
+12
View File
@@ -34,6 +34,7 @@ export function renderSummary(tctx) {
tile('🚗', s.travelLegs, 'Travel legs'),
tile('📍', s.activities, 'Activities'),
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
s.stays > 0 ? tile('🏙️', s.stays, s.stays === 1 ? 'Stay' : 'Stays') : null,
);
section.appendChild(tiles);
@@ -71,6 +72,17 @@ export function renderSummary(tctx) {
if (breakdown.childElementCount) kmBlock.appendChild(breakdown);
section.appendChild(kmBlock);
const areas = s.areas || [];
if (areas.length) {
const areaBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Areas'));
const ul = el('ul', { class: 'area-list' });
for (const a of areas) {
ul.appendChild(el('li', {}, `🏙️ ${a.name}${a.days} ${a.days === 1 ? 'day' : 'days'}`));
}
areaBlock.appendChild(ul);
section.appendChild(areaBlock);
}
const locations = s.locations || [];
const locBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Locations in order'));
if (!locations.length) {