Files
trip-plan/public/js/views/calendar.js
T
grabowski fe89bb2b1c Initial release: collaborative trip planner
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.
2026-07-18 23:15:29 +07:00

186 lines
6.0 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,
} 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;
}