// 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, isMultiDay, } from '../format.js'; import { computeStayLayout, renderWeekBands } from './stayBands.js'; import { makeChipDraggable, makeDayDropTarget } from './dragdrop.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); // id -> entry, for the drop handler to resolve the dragged chip. const entriesById = new Map(entries.map((e) => [e.id, e])); 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 (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++) { 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, entriesById, tctx, currency)); } weekEl.appendChild(daysRow); cal.appendChild(weekEl); } // A cancelled drag (released outside a cell) fires dragend on the source chip, // which bubbles up here — sweep any lingering drop highlight. cal.addEventListener('dragend', () => { for (const n of cal.querySelectorAll('.cal-day-drop')) n.classList.remove('cal-day-drop'); }); section.appendChild(cal); return section; } function dayCell(date, rangeStart, rangeEnd, byDate, contByDate, dropoffByDate, entriesById, 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); } }); } // Only in-range days accept dropped entries; new sort_order appends after the // day's current entries (byDate holds every entry, incl. stays, for that day). if (inRange) { makeDayDropTarget(cell, key, (byDate.get(key) || []).length, entriesById, tctx); } 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; const node = 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, ); // Regular chips are drag sources (move the entry to another day). The // "…continues" and "dropoff" ghost chips and stay bands stay non-draggable. makeChipDraggable(node, entry); return node; } // 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; }