From b3edf2c47bd31f5f3dd0e52118176e26815c5b75 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 18 May 2026 12:43:04 +0200 Subject: [PATCH] Support multiple API base URLs with fallback Support Multiple API Base URLs With Fallback Make ApiClient accept multiple base URLs and try the next when responses indicate unavailability (408, 429, 502, 503, 504 or any >=500) or on network errors. Add fetchApi helper and selection logic, and use a Tailscale dev server as a fallback in the mobile service. Also update EventHeader to show a live leave-by countdown. --- apps/mobile/src/components/EventHeader.tsx | 35 +++++++---- apps/mobile/src/services/api.ts | 7 ++- packages/api-client/src/client.ts | 68 +++++++++++++++------- 3 files changed, 75 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/components/EventHeader.tsx b/apps/mobile/src/components/EventHeader.tsx index a01d09c..01cc925 100644 --- a/apps/mobile/src/components/EventHeader.tsx +++ b/apps/mobile/src/components/EventHeader.tsx @@ -1,4 +1,6 @@ +import { useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; +import { calculateCountdown } from '@timetoleave/core'; import type { Event } from '@timetoleave/core'; import type { AppColors } from '../hooks/useColors'; @@ -11,25 +13,34 @@ interface Props { } /** - * Renders the event title, destination, formatted date/time, data source, - * and a three-column grid with leave-by time, arrive-by time, and buffer. + * Renders the event title, destination, leave countdown, data source, and a + * three-column grid with leave-by time, arrive-by time, and buffer. */ export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) { + const [, setTick] = useState(0); const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000); + const leaveCountdown = leaveByTime ? calculateCountdown(leaveByTime) : null; + + useEffect(() => { + const interval = setInterval(() => setTick((tick) => tick + 1), 30_000); + return () => clearInterval(interval); + }, []); return ( {event.title} {event.destination} - - {event.eventTime.toLocaleString('de-AT', { - weekday: 'long', - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} + + {leaveCountdown + ? leaveCountdown.label === 'Now' + ? 'Jetzt losgehen' + : `Noch ${leaveCountdown.label} bis Losgehen` + : 'Countdown wird berechnet'} Quelle: {event.source} @@ -61,7 +72,7 @@ const styles = StyleSheet.create({ container: { padding: 20, marginBottom: 12 }, title: { fontSize: 22, fontWeight: '700' }, destination: { fontSize: 16, marginTop: 4 }, - time: { fontSize: 14, marginTop: 8 }, + countdown: { fontSize: 18, fontWeight: '700', marginTop: 10 }, source: { fontSize: 12, marginTop: 4 }, infoGrid: { flexDirection: 'row', diff --git a/apps/mobile/src/services/api.ts b/apps/mobile/src/services/api.ts index 8607cbf..280a076 100644 --- a/apps/mobile/src/services/api.ts +++ b/apps/mobile/src/services/api.ts @@ -2,7 +2,10 @@ import { ApiClient } from '@timetoleave/api-client'; /** * Thin wrapper around the shared `ApiClient` class. - * Reads the base URL from the Expo environment variable `EXPO_PUBLIC_API_BASE_URL`. + * Reads the primary base URL from `EXPO_PUBLIC_API_BASE_URL` and falls back + * to the Tailscale dev server when the primary server is unreachable. */ const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? ''; -export const api = new ApiClient(baseUrl); +const fallbackBaseUrl = 'http://100.103.83.12:3030'; + +export const api = new ApiClient([baseUrl, fallbackBaseUrl]); diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 69170d6..67abdf0 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -15,6 +15,7 @@ import type { import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core"; const DEFAULT_BASE_URL = ""; +const UNAVAILABLE_STATUSES = new Set([408, 429, 502, 503, 504]); type SearchJourneyOptions = { arriveBy?: boolean; @@ -92,14 +93,19 @@ function toStation(location: HafasLocationWithCoords): Station { * Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app. */ export class ApiClient { - private readonly baseUrl: string; + private readonly baseUrls: string[]; - constructor(baseUrl?: string) { - this.baseUrl = baseUrl ?? DEFAULT_BASE_URL; + constructor(baseUrl?: string | string[]) { + const baseUrls = Array.isArray(baseUrl) ? baseUrl : [baseUrl ?? DEFAULT_BASE_URL]; + this.baseUrls = Array.from(new Set(baseUrls.map((url) => url?.trim()).filter((url): url is string => Boolean(url)))); + + if (this.baseUrls.length === 0) { + this.baseUrls = [DEFAULT_BASE_URL]; + } } async getHealth(): Promise<{ status: "ok"; uptime: number }> { - const res = await fetch(`${this.baseUrl}/api/health`); + const res = await this.fetchApi("/api/health"); if (!res.ok) throw new Error(`Health check failed: ${res.status}`); return res.json(); } @@ -107,8 +113,7 @@ export class ApiClient { async geocode(name: string, countrycodes?: string): Promise { const params: Record = { name }; if (countrycodes) params.countrycodes = countrycodes; - const url = buildUrl(this.baseUrl, "/api/geocode", params); - const res = await fetch(url); + const res = await this.fetchApi("/api/geocode", undefined, params); if (!res.ok) throw new Error(`Geocode failed: ${res.status}`); const result: GeocodeResult = await res.json(); return [result]; @@ -120,13 +125,12 @@ export class ApiClient { toLat: number, toLng: number, ): Promise { - const url = buildUrl(this.baseUrl, "/api/bike-route", { + const res = await this.fetchApi("/api/bike-route", undefined, { fromLat: String(fromLat), fromLng: String(fromLng), toLat: String(toLat), toLng: String(toLng), }); - const res = await fetch(url); if (!res.ok) throw new Error(`Bike route failed: ${res.status}`); return res.json(); } @@ -137,13 +141,12 @@ export class ApiClient { toLat: number, toLng: number, ): Promise { - const url = buildUrl(this.baseUrl, "/api/walk-route", { + const res = await this.fetchApi("/api/walk-route", undefined, { fromLat: String(fromLat), fromLng: String(fromLng), toLat: String(toLat), toLng: String(toLng), }); - const res = await fetch(url); if (!res.ok) throw new Error(`Walk route failed: ${res.status}`); return res.json(); } @@ -151,14 +154,13 @@ export class ApiClient { async fetchCalendar(url: string, days?: number): Promise { const params: Record = { url }; if (days) params.days = String(days); - const api = buildUrl(this.baseUrl, "/api/calendar", params); - const res = await fetch(api); + const res = await this.fetchApi("/api/calendar", undefined, params); if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`); return res.json(); } async parseCalendarIcs(content: string): Promise { - const res = await fetch(`${this.baseUrl}/api/calendar/parse`, { + const res = await this.fetchApi("/api/calendar/parse", { method: "POST", headers: { "Content-Type": "text/calendar" }, body: content, @@ -172,7 +174,7 @@ export class ApiClient { * This handles TripSearch, LocMatch, and any other HAFAS methods. */ async hafasRequest>(body: unknown): Promise { - const res = await fetch(`${this.baseUrl}/api/hafas`, { + const res = await this.fetchApi("/api/hafas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -209,7 +211,7 @@ export class ApiClient { } private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise { - const res = await fetch(`${this.baseUrl}/api/hafas`, { + const res = await this.fetchApi("/api/hafas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -221,22 +223,20 @@ export class ApiClient { } async reverseGeocode(lat: number, lng: number): Promise { - const url = buildUrl(this.baseUrl, "/api/geocode/reverse", { + const res = await this.fetchApi("/api/geocode/reverse", undefined, { lat: String(lat), lng: String(lng), }); - const res = await fetch(url); if (!res.ok) return null; return res.json(); } async findNearbyStops(lat: number, lng: number, radius: number = 1000): Promise { - const url = buildUrl(this.baseUrl, "/api/wienerlinien/stops", { + const res = await this.fetchApi("/api/wienerlinien/stops", undefined, { lat: String(lat), lng: String(lng), radius: String(radius), }); - const res = await fetch(url); if (!res.ok) throw new Error(`Nearby stops failed: ${res.status}`); const data = await res.json(); return data.stops ?? []; @@ -331,10 +331,36 @@ export class ApiClient { for (const id of stopIds) { params.append("stopIds", id); } - const url = `${this.baseUrl}/api/wienerlinien/monitor?${params.toString()}`; - const res = await fetch(url); + const res = await this.fetchApi(`/api/wienerlinien/monitor?${params.toString()}`); if (!res.ok) throw new Error(`Monitor failed: ${res.status}`); const data: { departures?: WienerLinienDeparture[] } = await res.json(); return data.departures ?? []; } + + private async fetchApi(path: string, init?: RequestInit, params?: Record): Promise { + let lastError: unknown; + let lastResponse: Response | null = null; + + for (const baseUrl of this.baseUrls) { + try { + const res = await fetch(buildUrl(baseUrl, path, params), init); + if (res.ok || !this.shouldTryNextBaseUrl(res.status)) { + return res; + } + lastResponse = res; + } catch (error) { + lastError = error; + } + } + + if (lastResponse) { + return lastResponse; + } + + throw lastError instanceof Error ? lastError : new Error("API request failed"); + } + + private shouldTryNextBaseUrl(status: number): boolean { + return status >= 500 || UNAVAILABLE_STATUSES.has(status); + } }