Files
trip-plan/public/js/views/segments.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

333 lines
11 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.
// 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}` : ''}`)),
);
}
}