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
+332
View File
@@ -0,0 +1,332 @@
// Flight-route builder used inside the day editor for flight entries.
// Provides a quick "CNX-BKK-DXB-FRA" expander plus editable per-leg rows with
// airport-code autocomplete backed by GET /api/airports. Kept as its own
// module so dayEditor.js stays small.
import { el, clear, toast } from '../dom.js';
import { api } from '../api.js';
import { formatTimeRange } from '../format.js';
const CODE_RE = /^[A-Z0-9]{2,4}$/;
// Per-segment display lines for an entry row: "TG103 CNX→BKK 10:3011:45".
export function renderSegmentLines(segments) {
return el(
'div',
{ class: 'entry-segments' },
...segments.map((s) => {
const legTime = formatTimeRange(s.dep_time, s.arr_time);
const codes = `${s.from?.code || '?'}${s.to?.code || '?'}`;
return el(
'div',
{ class: 'entry-seg muted' },
s.flight_no ? el('span', { class: 'seg-flight' }, s.flight_no) : null,
el('span', {}, codes),
legTime ? el('span', {}, legTime) : null,
);
}),
);
}
// createFlightRoute({ initialSegments, onChange }) ->
// { node, read(), hasSegments() }
// read() returns { segments: [...] | null } or { error: 'message' }.
export function createFlightRoute({ initialSegments = null, onChange = () => {} } = {}) {
const rows = []; // { state:{flight_no,dep_time,arr_time,from,to}, node, refs }
const listEl = el('div', { class: 'seg-list' });
const quickInput = el('input', {
class: 'input seg-quick-input',
type: 'text',
placeholder: 'CNX-BKK-DXB-FRA',
autocomplete: 'off',
spellcheck: 'false',
'aria-label': 'Airport code chain',
});
const buildBtn = el('button', { class: 'btn btn-sm', type: 'button' }, 'Build route');
buildBtn.addEventListener('click', () => buildFromChain(quickInput.value));
const addBtn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '+ Add leg');
addBtn.addEventListener('click', () => {
const prev = rows[rows.length - 1];
addRow(prev ? { from: { ...prev.state.to } } : {});
renumber();
onChange();
});
const node = el(
'div',
{ class: 'flight-route' },
el('div', { class: 'seg-quick' },
el('label', { class: 'field field-grow' },
el('span', { class: 'field-label' }, 'Quick route (airport codes)'),
quickInput),
buildBtn),
listEl,
addBtn,
);
function emptyAirport() {
return { code: '', name: '', lat: null, lng: null };
}
function addRow(seg = {}) {
const state = {
flight_no: seg.flight_no || '',
dep_time: seg.dep_time || '',
arr_time: seg.arr_time || '',
from: { ...emptyAirport(), ...(seg.from || {}) },
to: { ...emptyAirport(), ...(seg.to || {}) },
};
const { rowNode, refs } = buildRowDom(state);
const row = { state, node: rowNode, refs };
rows.push(row);
listEl.appendChild(rowNode);
refreshRowLabels(row);
return row;
}
function removeRow(row) {
const i = rows.indexOf(row);
if (i === -1) return;
rows.splice(i, 1);
row.node.remove();
renumber();
onChange();
}
function renumber() {
rows.forEach((r, i) => { r.refs.num.textContent = String(i + 1); });
}
function buildRowDom(state) {
const num = el('span', { class: 'seg-num' }, '1');
const flightNo = el('input', { class: 'input input-sm seg-flightno', type: 'text', maxlength: '12', placeholder: 'Flight no.', value: state.flight_no });
flightNo.addEventListener('input', () => { state.flight_no = flightNo.value; });
const from = buildAirportField(state.from, 'From');
const to = buildAirportField(state.to, 'To');
const dep = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Departure time' });
dep.value = state.dep_time || '';
dep.addEventListener('input', () => { state.dep_time = dep.value; });
const arr = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Arrival time' });
arr.value = state.arr_time || '';
arr.addEventListener('input', () => { state.arr_time = arr.value; });
const removeBtn = el('button', { class: 'icon-btn danger', type: 'button', title: 'Remove leg' }, '×');
const rowNode = el(
'div',
{ class: 'seg-row' },
el('div', { class: 'seg-row-head' }, num, flightNo, removeBtn),
el('div', { class: 'seg-airports' }, from.field, el('span', { class: 'seg-arrow' }, '→'), to.field),
el('div', { class: 'seg-times' },
el('label', { class: 'seg-time' }, el('span', {}, 'Dep'), dep),
el('label', { class: 'seg-time' }, el('span', {}, 'Arr'), arr)),
);
const refs = { num, from, to };
removeBtn.addEventListener('click', () => {
const row = rows.find((r) => r.node === rowNode);
if (row) removeRow(row);
});
return { rowNode, refs };
}
// One airport code input + autocomplete dropdown + resolved-name line.
function buildAirportField(airportState, label) {
const input = el('input', {
class: 'input input-sm seg-code',
type: 'text',
maxlength: '4',
placeholder: label === 'From' ? 'From (e.g. CNX)' : 'To (e.g. BKK)',
autocomplete: 'off',
spellcheck: 'false',
'aria-label': `${label} airport code`,
});
input.value = airportState.code || '';
const results = el('div', { class: 'ap-results' });
const nameLine = el('span', { class: 'ap-name' });
const field = el('div', { class: 'seg-ap' }, input, results, nameLine);
let timer = null;
let seq = 0;
input.addEventListener('input', () => {
const val = input.value.toUpperCase();
// Manual edit clears any previously resolved coordinates.
airportState.code = val;
airportState.name = '';
airportState.lat = null;
airportState.lng = null;
setNameLine(nameLine, airportState);
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 2) { clear(results); return; }
timer = setTimeout(async () => {
const mine = ++seq;
try {
const data = await api.airports(q);
if (mine !== seq) return;
showAirportResults(results, data.results || [], (ap) => {
applyAirport(airportState, ap);
input.value = ap.code;
clear(results);
setNameLine(nameLine, airportState);
onChange();
});
} catch (err) {
if (mine !== seq) return;
clear(results);
}
}, 300);
});
return { field, input, nameLine, state: airportState };
}
function refreshRowLabels(row) {
setNameLine(row.refs.from.nameLine, row.state.from);
setNameLine(row.refs.to.nameLine, row.state.to);
}
// Quick-build: split codes, make N-1 legs, resolve each code to coords.
async function buildFromChain(raw) {
const codes = String(raw || '')
.split(/[\s,>\-]+/)
.map((s) => s.trim().toUpperCase())
.filter(Boolean);
if (codes.length < 2) {
toast('Enter at least two airport codes, e.g. CNX-BKK');
return;
}
if (codes.length - 1 > 8) {
toast('A flight can have at most 8 legs.');
return;
}
// Replace existing rows.
rows.slice().forEach((r) => { r.node.remove(); });
rows.length = 0;
for (let i = 0; i < codes.length - 1; i++) {
addRow({ from: { code: codes[i] }, to: { code: codes[i + 1] } });
}
renumber();
onChange();
// Resolve unique codes once, then apply to every matching field.
const unique = [...new Set(codes)];
const resolved = {};
await Promise.all(unique.map(async (code) => {
resolved[code] = await resolveCode(code);
}));
for (const row of rows) {
applyResolved(row.state.from, resolved[row.state.from.code], row.refs.from);
applyResolved(row.state.to, resolved[row.state.to.code], row.refs.to);
}
onChange();
}
function applyResolved(airportState, ap, ref) {
if (ap) {
applyAirport(airportState, ap);
ref.input.value = ap.code;
ref.field.classList.remove('ap-unresolved');
} else {
// Keep the typed code but flag that it has no coordinates.
ref.field.classList.add('ap-unresolved');
}
setNameLine(ref.nameLine, airportState);
}
async function resolveCode(code) {
try {
const data = await api.airports(code);
const list = data.results || [];
return list.find((r) => (r.code || '').toUpperCase() === code) || null;
} catch {
return null;
}
}
function read() {
if (rows.length === 0) return { segments: null };
if (rows.length > 8) return { error: 'A flight can have at most 8 legs.' };
const segments = [];
for (const r of rows) {
const fromCode = (r.state.from.code || '').trim().toUpperCase();
const toCode = (r.state.to.code || '').trim().toUpperCase();
if (!CODE_RE.test(fromCode) || !CODE_RE.test(toCode)) {
return { error: 'Every leg needs a valid from and to airport code (24 characters).' };
}
const seg = { from: airportOut(r.state.from, fromCode), to: airportOut(r.state.to, toCode) };
if (r.state.flight_no.trim()) seg.flight_no = r.state.flight_no.trim();
if (r.state.dep_time) seg.dep_time = r.state.dep_time;
if (r.state.arr_time) seg.arr_time = r.state.arr_time;
segments.push(seg);
}
return { segments };
}
// Replace all rows from a segments array (used when editing an entry).
function load(segments) {
rows.slice().forEach((r) => { r.node.remove(); });
rows.length = 0;
if (Array.isArray(segments)) {
for (const seg of segments) addRow(seg);
}
renumber();
onChange();
}
// Prefill from an existing entry's segments (edit).
if (Array.isArray(initialSegments)) {
for (const seg of initialSegments) addRow(seg);
renumber();
}
return { node, read, load, hasSegments: () => rows.length > 0 };
}
function applyAirport(airportState, ap) {
airportState.code = (ap.code || '').toUpperCase();
airportState.name = ap.name || '';
airportState.lat = ap.lat != null ? ap.lat : null;
airportState.lng = ap.lng != null ? ap.lng : null;
}
function airportOut(airportState, code) {
const out = { code };
if (airportState.name) out.name = airportState.name;
if (airportState.lat != null && airportState.lng != null) {
out.lat = airportState.lat;
out.lng = airportState.lng;
}
return out;
}
function setNameLine(nameLine, airportState) {
if (airportState.name) {
nameLine.textContent = airportState.name;
nameLine.classList.remove('muted');
} else if (airportState.code) {
nameLine.textContent = 'no coordinates — pick from the list';
nameLine.classList.add('muted');
} else {
nameLine.textContent = '';
}
}
function showAirportResults(box, results, onPick) {
clear(box);
if (!results.length) {
box.appendChild(el('div', { class: 'ap-empty muted' }, 'No airports found'));
return;
}
for (const r of results) {
const meta = [r.city, r.country].filter(Boolean).join(', ');
box.appendChild(
el('button', { class: 'ap-result', type: 'button', onClick: () => onPick(r) },
el('span', { class: 'ap-code' }, r.code),
el('span', { class: 'ap-desc' }, `${r.name}${meta ? `${meta}` : ''}`)),
);
}
}