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:
@@ -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: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}` : ''}`)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user