mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +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`);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user