Files
trip-plan/public/js/views/calendar.js
T
grabowski 154f56a0a0 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.
2026-07-19 00:30:14 +07:00

248 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Calendar grid for the trip's date range. Real weeks as rows (MonSun
// columns); days outside the range are greyed. Each in-range day shows its
// entries as compact, type-coloured chips. Clicking a day opens the editor.
import { el } from '../dom.js';
import {
ENTRY_TYPES,
typeInfo,
parseYMD,
ymd,
addDays,
startOfWeekMon,
formatMoney,
flightChain,
hasSegments,
hasRental,
isMultiDay,
} from '../format.js';
import { computeStayLayout, renderWeekBands } from './stayBands.js';
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function renderCalendar(tctx) {
const { trip, entries } = tctx.trip;
const currency = trip.currency || 'USD';
// Group entries by date for quick per-cell lookup.
const byDate = new Map();
// 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);
if (hasRental(entry)) {
const dropDate = entry.rental.dropoff && entry.rental.dropoff.date;
if (dropDate && dropDate !== entry.date) {
if (!dropoffByDate.has(dropDate)) dropoffByDate.set(dropDate, []);
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);
const section = el(
'section',
{ class: 'card calendar-section' },
el(
'div',
{ class: 'section-head' },
el('h2', {}, 'Calendar'),
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
),
legend(),
);
// 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++) {
week.push(cursor);
cursor = addDays(cursor, 1);
}
weeks.push(week);
guard += 7;
}
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, contByDate, dropoffByDate, tctx, currency) {
const key = ymd(date);
const inRange = date >= rangeStart && date <= rangeEnd;
// 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'}${hasAny ? ' cal-has' : ''}`,
});
cell.appendChild(
el(
'div',
{ class: 'cal-day-head' },
el('span', { class: 'cal-daynum' }, String(date.getDate())),
isFirstOfMonth
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
: null,
!inRange && hasAny
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
: null,
),
);
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
// something (a chip, continuation, or dropoff marker).
if (inRange || hasAny) {
cell.classList.add('clickable');
cell.tabIndex = 0;
cell.setAttribute('role', 'button');
cell.addEventListener('click', () => tctx.openDay(key));
cell.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
tctx.openDay(key);
}
});
}
return cell;
}
function chip(entry, currency) {
const info = typeInfo(entry.type);
const hasPrice = entry.price != null;
const chain = hasSegments(entry) ? flightChain(entry.segments) : '';
const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : '';
const label = chain || car || entry.title;
return el(
'div',
{
class: 'cal-chip',
style: { '--chip': info.color },
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
},
el('span', { class: 'chip-icon' }, info.icon),
el('span', { class: 'chip-text' }, label),
hasPrice
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
: null,
);
}
// 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) {
const info = typeInfo(entry.type);
const car = [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ');
const node = el(
'div',
{
class: 'cal-chip cal-chip-dropoff',
style: { '--chip': info.color },
role: 'button',
tabindex: '0',
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
},
el('span', { class: 'chip-icon' }, info.icon),
el('span', { class: 'chip-text' }, 'dropoff'),
);
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;
}
function legend() {
const wrap = el('div', { class: 'legend' });
for (const [type, info] of Object.entries(ENTRY_TYPES)) {
wrap.appendChild(
el(
'span',
{ class: 'legend-item', 'data-type': type },
el('span', { class: 'legend-dot', style: { background: info.color } }),
el('span', {}, `${info.icon} ${info.label}`),
),
);
}
return wrap;
}