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); + } }