Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
186 lines
6.0 KiB
JavaScript
186 lines
6.0 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,
|
||
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;
|
||
}
|