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.
333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
// 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:30–11: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 (2–4 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}` : ''}`)),
|
||
);
|
||
}
|
||
}
|