feat(bookings): show transport routes on map (#384, #587)

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:
Maurice
2026-04-17 14:04:40 +02:00
parent 21511c2f68
commit 8defc90e95
26 changed files with 1437 additions and 81 deletions
+109
View File
@@ -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`);
}
+80 -9
View File
@@ -1,10 +1,59 @@
import { db, canAccessTrip } from '../db/database';
import { Reservation } from '../types';
export interface ReservationEndpoint {
id?: number;
reservation_id?: number;
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
const rows = db.prepare(`
SELECT e.* FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id = ?
ORDER BY e.reservation_id, e.sequence
`).all(tripId) as ReservationEndpoint[];
const map = new Map<number, ReservationEndpoint[]>();
for (const r of rows) {
const list = map.get(r.reservation_id!) ?? [];
list.push(r);
map.set(r.reservation_id!, list);
}
return map;
}
function loadEndpoints(reservationId: number): ReservationEndpoint[] {
return db.prepare(
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence'
).all(reservationId) as ReservationEndpoint[];
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
endpoints.forEach((e, i) => {
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
});
export function listReservations(tripId: string | number) {
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
@@ -18,7 +67,6 @@ export function listReservations(tripId: string | number) {
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId) as any[];
// Attach per-day positions for multi-day reservations
const dayPositions = db.prepare(`
SELECT rdp.reservation_id, rdp.day_id, rdp.position
FROM reservation_day_positions rdp
@@ -32,15 +80,18 @@ export function listReservations(tripId: string | number) {
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
}
const endpointsMap = loadEndpointsByTrip(tripId);
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
r.endpoints = endpointsMap.get(r.id) || [];
}
return reservations;
}
export function getReservationWithJoins(id: string | number) {
return db.prepare(`
const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
@@ -49,7 +100,10 @@ export function getReservationWithJoins(id: string | number) {
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.id = ?
`).get(id);
`).get(id) as any;
if (!row) return undefined;
row.endpoints = loadEndpoints(row.id);
return row;
}
interface CreateAccommodation {
@@ -76,13 +130,16 @@ interface CreateReservationData {
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
endpoints?: EndpointInput[];
needs_review?: boolean;
}
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
} = data;
let accommodationCreated = false;
@@ -101,8 +158,8 @@ export function createReservation(tripId: string | number, data: CreateReservati
}
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
@@ -117,9 +174,14 @@ export function createReservation(tripId: string | number, data: CreateReservati
status || 'pending',
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
);
if (endpoints && endpoints.length > 0) {
saveEndpoints(Number(result.lastInsertRowid), endpoints);
}
// Sync check-in/out to accommodation if linked
if (accommodation_id && metadata) {
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
@@ -187,13 +249,16 @@ interface UpdateReservationData {
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
endpoints?: EndpointInput[];
needs_review?: boolean;
}
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
} = data;
let accommodationChanged = false;
@@ -234,7 +299,8 @@ export function updateReservation(id: string | number, tripId: string | number,
status = COALESCE(?, status),
type = COALESCE(?, type),
accommodation_id = ?,
metadata = ?
metadata = ?,
needs_review = COALESCE(?, needs_review)
WHERE id = ?
`).run(
title || null,
@@ -250,9 +316,14 @@ export function updateReservation(id: string | number, tripId: string | number,
type || null,
resolvedAccId,
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
needs_review === undefined ? null : (needs_review ? 1 : 0),
id
);
if (endpoints !== undefined) {
saveEndpoints(Number(id), endpoints);
}
// Sync check-in/out to accommodation if linked
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
if (resolvedAccId && resolvedMeta) {