// Calendar grid for the trip's date range. Real weeks as rows (Mon–Sun // 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, } from '../format.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(); 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); } } } 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(), ); 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. let cursor = gridStart; let guard = 0; while (cursor <= rangeEnd && guard < 400) { for (let i = 0; i < 7; i++) { grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency)); cursor = addDays(cursor, 1); } guard += 7; } section.appendChild(grid); return section; } function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) { const key = ymd(date); const inRange = date >= rangeStart && date <= rangeEnd; const dayEntries = byDate.get(key) || []; const dropoffs = dropoffByDate.get(key) || []; const isFirstOfMonth = date.getDate() === 1; const cell = el('div', { class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' 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 && (dayEntries.length || dropoffs.length) ? 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)); // 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) { 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, ); } // 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; }