- Type set is now activity/stay/transport/flight/rental/note; hotel, travel and immigration are removed with idempotent startup data migrations (hotel->stay, travel->transport, immigration->activity with flag prefix) - Transport entries carry an optional mode (train/bus/ferry/taxi/drive/other) that drives the chip/map icon; route stops expose transport_mode - Creating a stay auto-creates a bridging transport to its neighbouring stays unless a transport/flight already covers the gap (one-shot) - Summary: transports count replaces hotels/travelLegs
268 lines
9.3 KiB
JavaScript
268 lines
9.3 KiB
JavaScript
// 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,
|
||
entryIcon,
|
||
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' }, entryIcon(entry)),
|
||
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' }, entryIcon(entry)),
|
||
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;
|
||
}
|