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
+2
View File
@@ -23,6 +23,7 @@ import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
@@ -278,6 +279,7 @@ export function createApp(): express.Application {
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
+7
View File
@@ -128,4 +128,11 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
try {
const { backfillFlightEndpoints } = require('../services/airportService');
backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
+21
View File
@@ -1634,6 +1634,27 @@ function runMigrations(db: Database.Database): void {
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
code TEXT,
lat REAL NOT NULL,
lng REAL NOT NULL,
timezone TEXT,
local_time TEXT,
local_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)');
try { db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
];
if (currentVersion < migrations.length) {
+19
View File
@@ -0,0 +1,19 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { searchAirports, findByIata } from '../services/airportService';
const router = express.Router();
router.get('/search', authenticate, (req: Request, res: Response) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
if (!q) return res.json([]);
res.json(searchAirports(q));
});
router.get('/:iata', authenticate, (req: Request, res: Response) => {
const airport = findByIata(req.params.iata);
if (!airport) return res.status(404).json({ error: 'Airport not found' });
res.json(airport);
});
export default router;
+6 -4
View File
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -44,7 +44,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationCreated } = createReservation(tripId, {
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
});
if (accommodationCreated) {
@@ -101,7 +102,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -115,7 +116,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
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
}, current);
if (accommodationChanged) {
+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) {
+16
View File
@@ -139,6 +139,20 @@ export interface BudgetItemMember {
budget_item_id?: number;
}
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;
}
export interface Reservation {
id: number;
trip_id: number;
@@ -155,6 +169,8 @@ export interface Reservation {
type: string;
accommodation_id?: number | null;
metadata?: string | null;
needs_review?: number;
endpoints?: ReservationEndpoint[];
created_at?: string;
day_number?: number;
place_name?: string;