mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Adds from/to endpoints to flight/train/cruise/car reservations with live map rendering. Flights use geodesic arcs and a curved duration + distance badge; train/car/cruise render as straight or geodesic lines with endpoint markers. Airports come from an embedded OurAirports database (~3200 airports, offline-capable); train/cruise/car locations via Nominatim. Per-trip connection toggle sits in the day plan sidebar, persisted in localStorage. Clicking a map endpoint opens the existing transport detail popup. New display setting toggles endpoint labels on the map. Migration 105 adds the reservation_endpoints table plus needs_review flag; existing flights are backfilled from their IATA metadata on server startup.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { db } from '../db/database';
|
||||
|
||||
export interface Airport {
|
||||
iata: string;
|
||||
icao: string | null;
|
||||
name: string;
|
||||
city: string;
|
||||
country: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
tz: string;
|
||||
}
|
||||
|
||||
let cache: Airport[] | null = null;
|
||||
let byIata: Map<string, Airport> | null = null;
|
||||
|
||||
function load(): Airport[] {
|
||||
if (cache) return cache;
|
||||
const file = path.join(__dirname, '..', '..', 'data', 'airports.json');
|
||||
if (!fs.existsSync(file)) {
|
||||
console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`');
|
||||
cache = [];
|
||||
byIata = new Map();
|
||||
return cache;
|
||||
}
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
cache = JSON.parse(raw) as Airport[];
|
||||
byIata = new Map(cache.map(a => [a.iata, a]));
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function findByIata(code: string): Airport | null {
|
||||
load();
|
||||
return byIata!.get(code.toUpperCase()) ?? null;
|
||||
}
|
||||
|
||||
export function searchAirports(query: string, limit = 12): Airport[] {
|
||||
const all = load();
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
|
||||
const upper = q.toUpperCase();
|
||||
if (q.length === 3) {
|
||||
const exact = byIata!.get(upper);
|
||||
if (exact) return [exact];
|
||||
}
|
||||
|
||||
const matches: Array<{ a: Airport; score: number }> = [];
|
||||
for (const a of all) {
|
||||
let score = 0;
|
||||
if (a.iata === upper) score = 100;
|
||||
else if (a.icao === upper) score = 90;
|
||||
else if (a.iata.startsWith(upper)) score = 70;
|
||||
else if (a.city.toLowerCase().startsWith(q)) score = 60;
|
||||
else if (a.name.toLowerCase().startsWith(q)) score = 50;
|
||||
else if (a.city.toLowerCase().includes(q)) score = 30;
|
||||
else if (a.name.toLowerCase().includes(q)) score = 20;
|
||||
if (score > 0) matches.push({ a, score });
|
||||
}
|
||||
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
|
||||
return matches.slice(0, limit).map(m => m.a);
|
||||
}
|
||||
|
||||
export function backfillFlightEndpoints(): void {
|
||||
const pending = db.prepare(`
|
||||
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
|
||||
FROM reservations r
|
||||
WHERE r.type = 'flight'
|
||||
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
|
||||
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
load();
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const markReview = db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
|
||||
|
||||
let filled = 0;
|
||||
let flagged = 0;
|
||||
for (const r of pending) {
|
||||
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
|
||||
let meta: any;
|
||||
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
|
||||
|
||||
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
|
||||
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
|
||||
|
||||
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
|
||||
|
||||
const split = (iso: string | null) => {
|
||||
if (!iso) return { date: null as string | null, time: null as string | null };
|
||||
const [date, time] = iso.split('T');
|
||||
return { date: date || null, time: time ? time.slice(0, 5) : null };
|
||||
};
|
||||
const depParts = split(r.reservation_time);
|
||||
const arrParts = split(r.reservation_end_time);
|
||||
|
||||
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
|
||||
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
|
||||
filled++;
|
||||
}
|
||||
|
||||
console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`);
|
||||
}
|
||||
Reference in New Issue
Block a user