feat(planner): public transit routing via Transitous (#1065)

Each day header gets a transit button (replacing the rename pencil, which
moved next to the day name in the day detail panel). It opens a route search
backed by Transitous/MOTIS — free, open data, no paid provider: from/to stop
search with the day's own places as quick picks, depart/arrive time, mode
filters (train, subway, tram, bus, ferry, cable car) and ranking by best
route, fewer transfers or less walking. Results show local times, duration,
transfers, walking time and line badges in their official colors, with a
stop-by-stop breakdown per connection.

Adding a connection saves it as a regular transport reservation — typed by
its dominant leg, timed from the itinerary's wall-clock departure/arrival
converted to station-local time (tz-lookup), with the origin, transfer stops
and destination as endpoints and the compact legs in metadata.transit. It
slots into the day timeline by time and inherits editing, deletion and
drag-reordering from the existing transport machinery; the transport detail
view renders the full itinerary. Re-saving a transit transport through the
edit modal preserves the stored itinerary while the route is unchanged.

The server proxies the Transitous API (JWT-guarded, rate-limited, identifying
User-Agent, short response cache, strict mode whitelist); TRANSIT_API_URL
lets self-hosters use their own MOTIS instance. New i18n keys in every
locale, wiki page updated.

Discussion #1065
This commit is contained in:
Maurice
2026-07-02 17:29:12 +02:00
committed by Maurice
parent 268ed419b2
commit 45f1ff7799
42 changed files with 2227 additions and 55 deletions
+2 -1
View File
@@ -31,6 +31,7 @@ import { JourneyModule } from './journey/journey.module';
import { CollectionsModule } from './collections/collections.module';
import { ShareModule } from './share/share.module';
import { TripInviteModule } from './trip-invite/trip-invite.module';
import { TransitModule } from './transit/transit.module';
import { FeedsModule } from './feeds/feeds.module';
import { SettingsModule } from './settings/settings.module';
import { BackupModule } from './backup/backup.module';
@@ -50,7 +51,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -0,0 +1,67 @@
import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RateLimitService } from '../auth/rate-limit.service';
import * as transit from '../../services/transitService';
const RL_WINDOW = 15 * 60 * 1000;
/**
* /api/transit — public transit routing (#1065) proxied through Transitous
* (or a self-hosted MOTIS via TRANSIT_API_URL). JWT-guarded and rate-limited:
* the Transitous usage policy asks integrators to keep expensive routing
* traffic reasonable, so planning gets a tighter bucket than geocoding.
*/
@Controller('api/transit')
@UseGuards(JwtAuthGuard)
export class TransitController {
constructor(private readonly rl: RateLimitService) {}
private limit(bucket: string, req: Request, max: number): void {
if (!this.rl.check(bucket, req.ip || 'unknown', max, RL_WINDOW, Date.now())) {
throw new HttpException({ error: 'Too many requests. Please try again later.' }, 429);
}
}
private rethrow(err: unknown): never {
const status = (err as { status?: number }).status || 502;
const message = err instanceof Error ? err.message : 'Transit provider error';
throw new HttpException({ error: message }, status);
}
@Get('geocode')
async geocode(
@Query('q') q: string | undefined,
@Query('lang') lang: string | undefined,
@Query('near') near: string | undefined,
@Req() req: Request,
) {
this.limit('transit_geocode', req, 300);
try {
return await transit.geocode(q || '', lang, near);
} catch (err) { this.rethrow(err); }
}
@Get('plan')
async plan(
@Query('from') from: string | undefined,
@Query('to') to: string | undefined,
@Query('time') time: string | undefined,
@Query('arriveBy') arriveBy: string | undefined,
@Query('modes') modes: string | undefined,
@Query('maxTransfers') maxTransfers: string | undefined,
@Req() req: Request,
) {
this.limit('transit_plan', req, 60);
try {
return await transit.plan({
from: from || '',
to: to || '',
time,
arriveBy: arriveBy === 'true' || arriveBy === '1',
modes,
maxTransfers: maxTransfers !== undefined && maxTransfers !== '' ? Number(maxTransfers) : undefined,
});
} catch (err) { this.rethrow(err); }
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TransitController } from './transit.controller';
import { RateLimitService } from '../auth/rate-limit.service';
@Module({
controllers: [TransitController],
providers: [RateLimitService],
})
export class TransitModule {}
+254
View File
@@ -0,0 +1,254 @@
import { getAppUrl } from './notifications';
import { buildUserAgent } from './mapsService';
/**
* Public transit routing (#1065) backed by Transitous (api.transitous.org), the
* community-run MOTIS instance over public GTFS feeds — free, no API key, fits
* TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their
* own MOTIS instance instead.
*
* This service is a thin, validating proxy: the browser never talks to
* Transitous directly (their usage policy wants an identifying User-Agent with
* contact info, which we send once from the server), and responses are mapped
* to a compact shape so the client isn't coupled to the MOTIS schema.
*/
const TRANSIT_API_BASE = (process.env.TRANSIT_API_URL || 'https://api.transitous.org').replace(/\/+$/, '');
const UA = buildUserAgent(getAppUrl());
// Modes the client may request — a strict whitelist so the proxy can't be used
// to smuggle arbitrary query values upstream. TRANSIT covers everything; the
// others let the user filter (RAIL already includes subway/suburban etc.).
const ALLOWED_MODES = new Set([
'TRANSIT', 'BUS', 'COACH', 'TRAM', 'SUBWAY', 'RAIL', 'FERRY', 'FUNICULAR', 'AERIAL_LIFT',
// Fine-grained rail modes so "train without subway" is expressible (RAIL
// itself includes SUBWAY per the MOTIS mode taxonomy).
'HIGHSPEED_RAIL', 'LONG_DISTANCE', 'NIGHT_RAIL', 'REGIONAL_RAIL', 'SUBURBAN',
]);
// Short-lived response cache: planning is the expensive call per the Transitous
// usage policy, and a user toggling filters re-requests identical plans.
const CACHE_TTL = 60 * 1000;
const CACHE_MAX = 200;
const cache = new Map<string, { at: number; data: unknown }>();
function cacheGet(key: string): unknown | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL) { cache.delete(key); return null; }
return hit.data;
}
function cacheSet(key: string, data: unknown): void {
if (cache.size >= CACHE_MAX) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, { at: Date.now(), data });
}
const COORD_RE = /^-?\d{1,3}(\.\d+)?,-?\d{1,3}(\.\d+)?$/;
function isCoord(v: string): boolean {
if (!COORD_RE.test(v)) return false;
const [lat, lng] = v.split(',').map(Number);
return Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
}
async function upstream(path: string, params: URLSearchParams): Promise<unknown> {
const url = `${TRANSIT_API_BASE}${path}?${params}`;
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) {
const err = new Error(`Transit provider error (HTTP ${res.status})`) as Error & { status: number };
err.status = res.status === 429 ? 429 : 502;
throw err;
}
return res.json();
}
// ── Geocode ──────────────────────────────────────────────────────────────────
export interface TransitPlace {
name: string;
lat: number;
lng: number;
type: string;
area: string | null;
}
/** Station/place search for the from/to pickers. `near` biases results. */
export async function geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> {
const text = (query || '').trim();
if (text.length < 2) return { results: [] };
if (text.length > 200) { const e = new Error('Query too long') as Error & { status: number }; e.status = 400; throw e; }
const params = new URLSearchParams({ text });
if (language) params.set('language', language.slice(0, 5));
if (near && isCoord(near)) params.set('place', near);
const key = `geo:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { results: TransitPlace[] };
const raw = (await upstream('/api/v1/geocode', params)) as Array<{
name?: string; lat?: number; lon?: number; type?: string;
areas?: Array<{ name?: string; matched?: boolean; default?: boolean }>;
}>;
const results: TransitPlace[] = (Array.isArray(raw) ? raw : []).slice(0, 8).flatMap((m) => {
if (typeof m.lat !== 'number' || typeof m.lon !== 'number' || !m.name) return [];
const area = m.areas?.find((a) => a.default)?.name || m.areas?.[0]?.name || null;
return [{ name: m.name, lat: m.lat, lng: m.lon, type: m.type || 'PLACE', area }];
});
const data = { results };
cacheSet(key, data);
return data;
}
// ── Plan ─────────────────────────────────────────────────────────────────────
export interface TransitLegStop {
name: string;
lat: number;
lng: number;
time: string | null;
scheduledTime: string | null;
track: string | null;
}
export interface TransitLeg {
mode: string;
from: TransitLegStop;
to: TransitLegStop;
duration: number;
distance: number | null;
headsign: string | null;
line: string | null;
lineColor: string | null;
lineTextColor: string | null;
agency: string | null;
intermediateStops: number;
}
export interface TransitItinerary {
startTime: string;
endTime: string;
duration: number;
transfers: number;
walkSeconds: number;
legs: TransitLeg[];
}
export interface PlanQuery {
from: string;
to: string;
time?: string;
arriveBy?: boolean;
modes?: string;
maxTransfers?: number;
}
// GTFS colors come as bare hex ("FF0000"), with hash, or empty — normalise to
// a #-prefixed value or null so the client can use them in CSS directly.
function safeColor(v: unknown): string | null {
if (typeof v !== 'string') return null;
const hex = v.trim().replace(/^#/, '');
return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null;
}
interface MotisPlaceRaw {
name?: string; lat?: number; lon?: number;
departure?: string; arrival?: string;
scheduledDeparture?: string; scheduledArrival?: string;
track?: string; scheduledTrack?: string;
}
function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): TransitLegStop {
return {
name: p?.name || '',
lat: typeof p?.lat === 'number' ? p.lat : 0,
lng: typeof p?.lon === 'number' ? p.lon : 0,
time: (kind === 'departure' ? p?.departure : p?.arrival) || null,
scheduledTime: (kind === 'departure' ? p?.scheduledDeparture : p?.scheduledArrival) || null,
track: p?.track || p?.scheduledTrack || null,
};
}
/** Route search between two coordinates. Returns compact itineraries for the picker. */
export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> {
const bad = (msg: string) => { const e = new Error(msg) as Error & { status: number }; e.status = 400; throw e; };
if (!q.from || !isCoord(q.from)) bad('from must be "lat,lng"');
if (!q.to || !isCoord(q.to)) bad('to must be "lat,lng"');
const params = new URLSearchParams({ fromPlace: q.from, toPlace: q.to, numItineraries: '8' });
if (q.time) {
const parsed = new Date(q.time);
if (isNaN(parsed.getTime())) bad('time must be an ISO date-time');
params.set('time', parsed.toISOString());
}
if (q.arriveBy) params.set('arriveBy', 'true');
if (q.modes) {
const modes = q.modes.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean);
if (modes.some((m) => !ALLOWED_MODES.has(m))) bad('unsupported transit mode');
if (modes.length > 0) params.set('transitModes', modes.join(','));
}
if (q.maxTransfers !== undefined && q.maxTransfers !== null) {
const n = Number(q.maxTransfers);
if (!Number.isInteger(n) || n < 0 || n > 10) bad('maxTransfers must be 0-10');
params.set('maxTransfers', String(n));
}
// We only want scheduled transit journeys in the results — a pure-walk
// "direct" connection is what the existing OSRM footpath routing is for.
params.set('directModes', 'WALK');
const key = `plan:${params.toString()}`;
const cached = cacheGet(key);
if (cached) return cached as { itineraries: TransitItinerary[] };
const raw = (await upstream('/api/v6/plan', params)) as {
itineraries?: Array<{
duration?: number; startTime?: string; endTime?: string; transfers?: number;
legs?: Array<{
mode?: string; duration?: number; distance?: number; headsign?: string;
routeShortName?: string; displayName?: string; routeColor?: string; routeTextColor?: string;
agencyName?: string; from?: MotisPlaceRaw; to?: MotisPlaceRaw;
intermediateStops?: unknown[];
}>;
}>;
};
const itineraries: TransitItinerary[] = (raw.itineraries || []).flatMap((it) => {
if (!it.startTime || !it.endTime || !Array.isArray(it.legs)) return [];
const legs: TransitLeg[] = it.legs.map((leg) => ({
mode: (leg.mode || 'WALK').toUpperCase(),
from: mapStop(leg.from, 'departure'),
to: mapStop(leg.to, 'arrival'),
duration: typeof leg.duration === 'number' ? leg.duration : 0,
distance: typeof leg.distance === 'number' ? Math.round(leg.distance) : null,
headsign: leg.headsign || null,
line: leg.routeShortName || leg.displayName || null,
lineColor: safeColor(leg.routeColor),
lineTextColor: safeColor(leg.routeTextColor),
agency: leg.agencyName || null,
intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0,
}));
const walkSeconds = legs.filter((l) => l.mode === 'WALK').reduce((a, l) => a + l.duration, 0);
return [{
startTime: it.startTime,
endTime: it.endTime,
// Wall-clock duration (start→end) so waits/transfers count — summing leg
// run-times would understate the journey and mis-slot it in the timeline.
duration: Math.max(0, Math.round((new Date(it.endTime).getTime() - new Date(it.startTime).getTime()) / 1000)),
transfers: typeof it.transfers === 'number' ? it.transfers : Math.max(0, legs.filter((l) => l.mode !== 'WALK').length - 1),
walkSeconds,
legs,
}];
});
const data = { itineraries };
cacheSet(key, data);
return data;
}