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.
This commit is contained in:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
// Orchestrates the trip page: header + calendar, then map + summary.
// Owns the shared trip context passed to the calendar, map, summary and
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
// derived /route data so all three panels stay in sync.
import { api } from '../api.js';
import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
import { renderCalendar } from './calendar.js';
import { renderMap } from './map.js';
import { renderSummary } from './summary.js';
import { renderCosts } from './costs.js';
import { renderCountdown } from './flipclock.js';
import { openDayEditor } from './dayEditor.js';
export function renderTripDetail(container, ctx, id) {
const tctx = {
...ctx,
tripId: id,
trip: null,
route: null,
costs: null,
_onModalRefresh: null,
};
mount(container, loading('Loading trip…'));
init();
async function load() {
const [trip, route, costs] = await Promise.all([
api.trips.get(id),
api.trips.route(id),
api.trips.costs(id),
]);
tctx.trip = trip;
tctx.route = route;
tctx.costs = costs;
}
tctx.refreshTrip = async () => {
try {
await load();
draw();
if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh();
} catch (err) {
toast(err.message);
}
};
tctx.openDay = (date) => openDayEditor(tctx, date);
async function init() {
try {
await load();
draw();
} catch (err) {
clear(container);
if (err.status === 404) {
toast('Trip not found (or you are not a member).');
ctx.navigate('#/trips');
return;
}
mount(container, errorBox(err.message, init));
}
}
function draw() {
const page = el('div', { class: 'page' });
page.appendChild(renderHeader());
page.appendChild(renderCountdown(tctx));
page.appendChild(renderCalendar(tctx));
page.appendChild(
el(
'div',
{ class: 'detail-grid' },
renderMap(tctx),
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx)),
),
);
mount(container, page);
}
function renderHeader() {
const { trip, members } = tctx.trip;
const isOwner = trip.owner_id === tctx.state.user.id;
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
const header = el('div', { class: 'trip-header card' });
const titleRow = el(
'div',
{ class: 'trip-header-top' },
el(
'div',
{},
el(
'a',
{ class: 'back-link', href: '#/trips' },
'← All trips',
),
el('h1', { class: 'trip-title' }, trip.name),
el(
'p',
{ class: 'trip-subtitle muted' },
'📅 ', formatRange(trip.start_date, trip.end_date),
' · ', pluralize(dayCount, 'day', 'days'),
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
),
),
el(
'div',
{ class: 'trip-header-actions' },
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
isOwner
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
: null,
),
);
const membersRow = el(
'div',
{ class: 'members-row' },
el('span', { class: 'members-label' }, 'Members:'),
...members.map((m) =>
el(
'span',
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
m.display_name,
isOwner && m.role !== 'owner'
? el('button', {
class: 'member-remove',
title: `Remove ${m.display_name}`,
onClick: () => onRemoveMember(m),
}, '×')
: null,
),
),
);
header.appendChild(titleRow);
header.appendChild(joinCodeRow(trip, isOwner));
header.appendChild(membersRow);
return header;
}
// Share code for inviting others — display only, not a credential.
function joinCodeRow(trip, isOwner) {
const grouped = groupCode(trip.join_code, 4);
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(grouped);
toast('Join code copied', 'success');
} catch {
toast('Copy failed — select and copy it manually');
}
});
async function onRegenerate() {
if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return;
try {
await api.trips.regenerateJoinCode(tctx.tripId);
toast('Join code regenerated', 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
return el(
'div',
{ class: 'joincode-row' },
el('span', { class: 'members-label' }, 'Join code:'),
el('span', { class: 'joincode-chip' }, grouped),
copyBtn,
isOwner
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
: null,
);
}
function toggleEdit(header) {
const existing = header.querySelector('.trip-edit');
if (existing) {
existing.remove();
return;
}
const { trip } = tctx.trip;
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name });
const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date });
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' });
const errorEl = el('p', { class: 'form-error' });
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes');
async function onSubmit(e) {
e.preventDefault();
errorEl.textContent = '';
const name = nameInput.value.trim();
const start_date = startInput.value;
const end_date = endInput.value;
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
if (!name) return (errorEl.textContent = 'Name cannot be empty.');
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving…';
try {
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
toast('Trip updated', 'success');
await tctx.refreshTrip();
} catch (err) {
errorEl.textContent = err.message;
saveBtn.disabled = false;
saveBtn.textContent = 'Save changes';
}
}
const editBox = el(
'form',
{ class: 'trip-edit', onSubmit },
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
),
el(
'div',
{ class: 'form-row' },
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput),
),
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
errorEl,
el('div', { class: 'form-actions' }, saveBtn),
);
header.appendChild(editBox);
}
async function onRemoveMember(member) {
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
try {
await api.trips.removeMember(tctx.tripId, member.id);
toast(`${member.display_name} removed`, 'success');
await tctx.refreshTrip();
} catch (err) {
toast(err.message);
}
}
async function onDelete() {
const { trip } = tctx.trip;
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
try {
await api.trips.remove(tctx.tripId);
toast('Trip deleted', 'success');
ctx.navigate('#/trips');
} catch (err) {
toast(err.message);
}
}
}