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