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:
+77
-15
@@ -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 (Mon–Sun) 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) {
|
||||
|
||||
Reference in New Issue
Block a user