Refactor departure time calculation to account for walk duration
Introduce `trainWalkDurationSeconds` in `useDepartureTime` hooks for both mobile and web apps to filter train journeys based on total arrival time including walking. Add default origin station constants in core package and use them in mobile store instead of returning null when no origin is saved. Normalize HAFAS coordinates in destination station hooks to handle large integer values. Update `cleanLocation` to preserve full addresses with commas and remove the 10KB request body limit on ICS parsing to support larger calendar files. Make rate limiting configurable via environment variables to handle higher API fan-out from calendar event pages.
This commit is contained in:
@@ -137,11 +137,16 @@ describe('eventStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('origin station', () => {
|
describe('origin station', () => {
|
||||||
it('should load null when no origin exists', async () => {
|
it('should load the default origin when no saved origin exists', async () => {
|
||||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
const station = await loadOriginStation();
|
const station = await loadOriginStation();
|
||||||
expect(station).toBeNull();
|
expect(station).toEqual({
|
||||||
|
name: 'Goethegasse 36, 2340 Moedling',
|
||||||
|
extId: '1231701',
|
||||||
|
lat: 48.0806926,
|
||||||
|
lng: 16.2908052,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load origin station from AsyncStorage', async () => {
|
it('should load origin station from AsyncStorage', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
walkRoute: WalkRoute | null;
|
walkRoute: WalkRoute | null;
|
||||||
loadingWalk: boolean;
|
loadingWalk: boolean;
|
||||||
showWalkingOption: boolean;
|
showWalkingOption: boolean;
|
||||||
|
eventTime: Date;
|
||||||
|
arrivalBufferMinutes: number;
|
||||||
origin: Station | null;
|
origin: Station | null;
|
||||||
colors: AppColors;
|
colors: AppColors;
|
||||||
}
|
}
|
||||||
@@ -19,9 +21,14 @@ export function JourneyList({
|
|||||||
walkRoute,
|
walkRoute,
|
||||||
loadingWalk,
|
loadingWalk,
|
||||||
showWalkingOption,
|
showWalkingOption,
|
||||||
|
eventTime,
|
||||||
|
arrivalBufferMinutes,
|
||||||
origin,
|
origin,
|
||||||
colors,
|
colors,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0;
|
||||||
|
const targetArrivalTime = eventTime.getTime() - arrivalBufferMinutes * 60_000;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||||
@@ -38,8 +45,19 @@ export function JourneyList({
|
|||||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
journeys.map((j) => (
|
journeys.map((j) => {
|
||||||
<View key={j.id} style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
const finalArrival = new Date(j.rA.getTime() + walkDurationMs);
|
||||||
|
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={j.id}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{ backgroundColor: colors.card, borderColor: arrivesTooLate ? colors.error : colors.border },
|
||||||
|
arrivesTooLate && styles.lateCard,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={[styles.line, { color: colors.text }]}>
|
<Text style={[styles.line, { color: colors.text }]}>
|
||||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||||
@@ -59,8 +77,15 @@ export function JourneyList({
|
|||||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||||
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||||
</Text>
|
</Text>
|
||||||
|
{walkDurationMs > 0 && (
|
||||||
|
<Text style={[styles.detail, { color: arrivesTooLate ? colors.error : colors.subtext }]}>
|
||||||
|
Ziel: {finalArrival.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{arrivesTooLate ? ' (zu spät)' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showWalkingOption && walkRoute && (
|
{showWalkingOption && walkRoute && (
|
||||||
@@ -94,6 +119,7 @@ const styles = StyleSheet.create({
|
|||||||
hint: { fontSize: 15, marginTop: 12 },
|
hint: { fontSize: 15, marginTop: 12 },
|
||||||
empty: { fontSize: 14 },
|
empty: { fontSize: 14 },
|
||||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||||
|
lateCard: { opacity: 0.55 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
line: { fontSize: 16, fontWeight: '600' },
|
line: { fontSize: 16, fontWeight: '600' },
|
||||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useDepartureTime(
|
|||||||
bikeDurationSeconds: number | null,
|
bikeDurationSeconds: number | null,
|
||||||
activeMode: 'train' | 'bike' | null,
|
activeMode: 'train' | 'bike' | null,
|
||||||
arrivalBufferMinutes: number,
|
arrivalBufferMinutes: number,
|
||||||
|
trainWalkDurationSeconds = 0,
|
||||||
): DepartureTimeResult {
|
): DepartureTimeResult {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
// Calculate target arrival time (event time minus buffer)
|
// Calculate target arrival time (event time minus buffer)
|
||||||
@@ -32,8 +33,9 @@ export function useDepartureTime(
|
|||||||
|
|
||||||
if (activeMode === 'train' && validJourneys.length > 0) {
|
if (activeMode === 'train' && validJourneys.length > 0) {
|
||||||
// Find journeys that arrive by target time
|
// Find journeys that arrive by target time
|
||||||
|
const walkDurationMs = trainWalkDurationSeconds * 1000;
|
||||||
const onTimeJourneys = validJourneys.filter(
|
const onTimeJourneys = validJourneys.filter(
|
||||||
(journey) => journey.rA.getTime() <= targetArrivalTime.getTime(),
|
(journey) => journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (onTimeJourneys.length > 0) {
|
if (onTimeJourneys.length > 0) {
|
||||||
@@ -43,7 +45,7 @@ export function useDepartureTime(
|
|||||||
);
|
);
|
||||||
|
|
||||||
departureTime = new Date(bestJourney.rD);
|
departureTime = new Date(bestJourney.rD);
|
||||||
arrivalTime = new Date(bestJourney.rA);
|
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||||
mode = 'train';
|
mode = 'train';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,5 +61,5 @@ export function useDepartureTime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { departureTime, arrivalTime, mode };
|
return { departureTime, arrivalTime, mode };
|
||||||
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]);
|
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface HafasLocation {
|
|||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDestinationStation(destination: string | undefined) {
|
export function useDestinationStation(destination: string | undefined) {
|
||||||
const [station, setStation] = useState<Station | null>(null);
|
const [station, setStation] = useState<Station | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -63,12 +68,16 @@ export function useDestinationStation(destination: string | undefined) {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const data = await api.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body);
|
||||||
const data = await api.hafasRequest<any>(body);
|
|
||||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||||
const stations = locL
|
const stations = locL
|
||||||
.filter((l) => l.type === 'S')
|
.filter((l) => l.type === 'S')
|
||||||
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
|
.map((l) => ({
|
||||||
|
name: l.name,
|
||||||
|
extId: l.extId,
|
||||||
|
lat: normalizeHafasCoordinate(l.lat),
|
||||||
|
lng: normalizeHafasCoordinate(l.lon),
|
||||||
|
}));
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setStation(stations[0] ?? null);
|
setStation(stations[0] ?? null);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function useWalkRoute(
|
|||||||
|
|
||||||
const fetchRoute = async () => {
|
const fetchRoute = async () => {
|
||||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||||
|
setWalkRoute(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
|||||||
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
|
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
style={[styles.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border }]}
|
||||||
placeholder="z.B. Wien, Donau-City"
|
placeholder="z.B. Technikum Wien"
|
||||||
placeholderTextColor={colors.subtext}
|
placeholderTextColor={colors.subtext}
|
||||||
value={destination}
|
value={destination}
|
||||||
onChangeText={setDestination}
|
onChangeText={setDestination}
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ type ScreenProps = {
|
|||||||
|
|
||||||
type TransportMode = 'train' | 'bike';
|
type TransportMode = 'train' | 'bike';
|
||||||
|
|
||||||
|
function stationArrivalTarget(
|
||||||
|
eventTime: Date,
|
||||||
|
arrivalBufferMinutes: number,
|
||||||
|
walkDurationSeconds: number,
|
||||||
|
) {
|
||||||
|
return new Date(eventTime.getTime() - arrivalBufferMinutes * 60_000 - walkDurationSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||||
const { eventId } = route.params;
|
const { eventId } = route.params;
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@@ -80,7 +88,21 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
if (originStation) {
|
if (originStation) {
|
||||||
const destExtId = destStation.station?.extId;
|
const destExtId = destStation.station?.extId;
|
||||||
if (destExtId) {
|
if (destExtId) {
|
||||||
const results = await api.searchJourneys(originStation.extId, destExtId, found.eventTime);
|
const finalWalkLookupPending =
|
||||||
|
settings.showWalkingOption &&
|
||||||
|
destCoords.coords !== null &&
|
||||||
|
destStation.station?.lat != null &&
|
||||||
|
destStation.station?.lng != null &&
|
||||||
|
!walkHook.walkRoute &&
|
||||||
|
!walkHook.error;
|
||||||
|
if (finalWalkLookupPending) {
|
||||||
|
setJourneys([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkDurationSeconds = settings.showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
|
||||||
|
const target = stationArrivalTarget(found.eventTime, settings.arrivalBufferMinutes, walkDurationSeconds);
|
||||||
|
const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true });
|
||||||
setJourneys(results);
|
setJourneys(results);
|
||||||
} else if (destStation.error) {
|
} else if (destStation.error) {
|
||||||
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||||
@@ -106,7 +128,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [eventId, destStation.station, destStation.error, destCoords.coords]);
|
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
|
||||||
|
|
||||||
useEffect(() => { fetchData(); }, [fetchData]);
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
@@ -132,6 +154,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
? 'bike'
|
? 'bike'
|
||||||
: null,
|
: null,
|
||||||
arrivalBufferMinutes,
|
arrivalBufferMinutes,
|
||||||
|
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -227,13 +250,15 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{effectiveMode === 'train' && (
|
{event && effectiveMode === 'train' && (
|
||||||
<JourneyList
|
<JourneyList
|
||||||
journeys={journeys}
|
journeys={journeys}
|
||||||
destStationLoading={destStation.loading}
|
destStationLoading={destStation.loading}
|
||||||
walkRoute={walkRoute}
|
walkRoute={walkRoute}
|
||||||
loadingWalk={loadingWalk}
|
loadingWalk={loadingWalk}
|
||||||
showWalkingOption={showWalkingOption}
|
showWalkingOption={showWalkingOption}
|
||||||
|
eventTime={event.eventTime}
|
||||||
|
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||||
origin={origin}
|
origin={origin}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
|
||||||
import * as Notifications from '../services/expoNotifications';
|
import * as Notifications from '../services/expoNotifications';
|
||||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export async function removeEvent(id: string, onDone?: () => void): Promise<void
|
|||||||
|
|
||||||
export async function loadOriginStation(): Promise<Station | null> {
|
export async function loadOriginStation(): Promise<Station | null> {
|
||||||
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
||||||
return json ? JSON.parse(json) : null;
|
return json ? JSON.parse(json) : DEFAULT_ORIGIN_STATION;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveOriginStation(station: Station): Promise<void> {
|
export async function saveOriginStation(station: Station): Promise<void> {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
|
|||||||
type="text"
|
type="text"
|
||||||
value={destination}
|
value={destination}
|
||||||
onChange={(e) => setDestination(e.target.value)}
|
onChange={(e) => setDestination(e.target.value)}
|
||||||
placeholder="Vienna Main Station"
|
placeholder="Technikum Wien or Hoechstaedtplatz 6, 1200 Wien"
|
||||||
className="brand-input px-3 py-2"
|
className="brand-input px-3 py-2"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
|
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard: cap at 10 000 chars to prevent OOM from node-ical parsing
|
|
||||||
if (body.length > 10_000) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Request body too large (max 10 KB for ICS content)" },
|
|
||||||
{ status: 413 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use extractEvents for consistent parsing with cleanLocation() and filtering
|
// Use extractEvents for consistent parsing with cleanLocation() and filtering
|
||||||
const events = extractEvents(body, DEFAULT_DAYS);
|
const events = extractEvents(body, DEFAULT_DAYS);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useJourneys } from "@/hooks/useJourneys";
|
import { useJourneys } from "@/hooks/useJourneys";
|
||||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||||
@@ -36,12 +36,6 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
|
|
||||||
const destStation = useDestinationStation(event.destination);
|
const destStation = useDestinationStation(event.destination);
|
||||||
|
|
||||||
const {
|
|
||||||
journeys,
|
|
||||||
loading: journeysLoading,
|
|
||||||
error: journeysError,
|
|
||||||
} = useJourneys(originStation?.extId ?? null, destStation.station?.extId ?? null, event.eventTime, 0);
|
|
||||||
|
|
||||||
const destCoords = useGeocode(event.destination);
|
const destCoords = useGeocode(event.destination);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -69,6 +63,30 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||||
|
|
||||||
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
|
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
|
||||||
|
const finalWalkLookupPending =
|
||||||
|
showWalkingOption &&
|
||||||
|
destCoords.coords !== null &&
|
||||||
|
destStation.station?.lat != null &&
|
||||||
|
destStation.station?.lng != null &&
|
||||||
|
!walkRoute &&
|
||||||
|
!walkError;
|
||||||
|
const trainWalkDurationSeconds = showWalkingOption ? (walkRoute?.duration ?? 0) : 0;
|
||||||
|
const trainStationArrivalTarget = useMemo(
|
||||||
|
() => new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000 - trainWalkDurationSeconds * 1000),
|
||||||
|
[event.eventTime, arrivalBufferMinutes, trainWalkDurationSeconds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
journeys,
|
||||||
|
loading: journeysLoading,
|
||||||
|
error: journeysError,
|
||||||
|
} = useJourneys(
|
||||||
|
finalWalkLookupPending ? null : (originStation?.extId ?? null),
|
||||||
|
finalWalkLookupPending ? null : (destStation.station?.extId ?? null),
|
||||||
|
trainStationArrivalTarget,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
type TransportMode = "train" | "bike";
|
type TransportMode = "train" | "bike";
|
||||||
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
|
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
|
||||||
@@ -79,6 +97,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
journeys,
|
journeys,
|
||||||
bikeRoute?.duration ?? null,
|
bikeRoute?.duration ?? null,
|
||||||
activeMode,
|
activeMode,
|
||||||
|
trainWalkDurationSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||||
@@ -190,6 +209,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
|||||||
loading={journeysLoading}
|
loading={journeysLoading}
|
||||||
error={journeysError}
|
error={journeysError}
|
||||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||||
|
walkDurationSeconds={trainWalkDurationSeconds}
|
||||||
showWalkingOption={showWalkingOption}
|
showWalkingOption={showWalkingOption}
|
||||||
walkRoute={walkRoute}
|
walkRoute={walkRoute}
|
||||||
walkLoading={walkLoading}
|
walkLoading={walkLoading}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type JourneyListProps = {
|
|||||||
journeys: Journey[];
|
journeys: Journey[];
|
||||||
eventTime: Date;
|
eventTime: Date;
|
||||||
arrivalBufferMinutes: number;
|
arrivalBufferMinutes: number;
|
||||||
|
walkDurationSeconds?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
journeys,
|
journeys,
|
||||||
eventTime,
|
eventTime,
|
||||||
arrivalBufferMinutes,
|
arrivalBufferMinutes,
|
||||||
|
walkDurationSeconds = 0,
|
||||||
className = "",
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||||
|
const walkDurationMs = walkDurationSeconds * 1000;
|
||||||
|
|
||||||
if (journeys.length === 0) {
|
if (journeys.length === 0) {
|
||||||
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
|
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
|
||||||
@@ -28,7 +31,8 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
return (
|
return (
|
||||||
<ul className={`space-y-3 p-4 ${className}`}>
|
<ul className={`space-y-3 p-4 ${className}`}>
|
||||||
{journeys.map((journey) => {
|
{journeys.map((journey) => {
|
||||||
const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime();
|
const finalArrival = new Date(journey.rA.getTime() + walkDurationMs);
|
||||||
|
const arrivesTooLate = finalArrival.getTime() > targetArrivalTime.getTime();
|
||||||
const departure = journey.rD ?? journey.sD;
|
const departure = journey.rD ?? journey.sD;
|
||||||
const arrival = journey.rA ?? journey.sA;
|
const arrival = journey.rA ?? journey.sA;
|
||||||
|
|
||||||
@@ -60,7 +64,10 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-brand-light/64">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-brand-light/64">
|
||||||
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
||||||
<span>Arrives {formatTime(arrival)}</span>
|
<span>
|
||||||
|
Arrives {formatTime(arrival)}
|
||||||
|
{walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""}
|
||||||
|
</span>
|
||||||
{arrivesTooLate && (
|
{arrivesTooLate && (
|
||||||
<span className="font-medium text-brand-pink">
|
<span className="font-medium text-brand-pink">
|
||||||
misses {arrivalBufferMinutes} min buffer
|
misses {arrivalBufferMinutes} min buffer
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type TrainSectionProps = {
|
|||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
arrivalBufferMinutes?: number;
|
arrivalBufferMinutes?: number;
|
||||||
|
walkDurationSeconds?: number;
|
||||||
showWalkingOption?: boolean;
|
showWalkingOption?: boolean;
|
||||||
walkRoute?: WalkRoute | null;
|
walkRoute?: WalkRoute | null;
|
||||||
walkLoading?: boolean;
|
walkLoading?: boolean;
|
||||||
@@ -32,6 +33,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
className = "",
|
className = "",
|
||||||
arrivalBufferMinutes,
|
arrivalBufferMinutes,
|
||||||
|
walkDurationSeconds,
|
||||||
showWalkingOption,
|
showWalkingOption,
|
||||||
walkRoute,
|
walkRoute,
|
||||||
walkLoading,
|
walkLoading,
|
||||||
@@ -68,7 +70,12 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
<div className="p-8 text-center text-sm text-brand-pink">{error}</div>
|
<div className="p-8 text-center text-sm text-brand-pink">{error}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
<JourneyList
|
||||||
|
journeys={journeys}
|
||||||
|
eventTime={eventTime}
|
||||||
|
arrivalBufferMinutes={arrivalBufferMinutes ?? 0}
|
||||||
|
walkDurationSeconds={walkDurationSeconds ?? 0}
|
||||||
|
/>
|
||||||
{showWalkingOption && (walkRoute || walkLoading || walkError) && (
|
{showWalkingOption && (walkRoute || walkLoading || walkError) && (
|
||||||
<div className="border-t border-white/10 p-4">
|
<div className="border-t border-white/10 p-4">
|
||||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||||
|
|||||||
@@ -78,6 +78,24 @@ describe("useDepartureTime", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should include final walking duration when selecting a train journey", () => {
|
||||||
|
const journeys: Journey[] = [
|
||||||
|
createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z"),
|
||||||
|
createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDepartureTime(eventTime, journeys, null, "train", 8 * 60)
|
||||||
|
, { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.departureTime?.getTime()).toBe(
|
||||||
|
new Date("2024-01-01T10:30:00Z").getTime()
|
||||||
|
);
|
||||||
|
expect(result.current.arrivalTime?.getTime()).toBe(
|
||||||
|
new Date("2024-01-01T11:53:00Z").getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should calculate correct departure time for bike mode", () => {
|
it("should calculate correct departure time for bike mode", () => {
|
||||||
const bikeRouteDuration = 1800; // 30 minutes in seconds
|
const bikeRouteDuration = 1800; // 30 minutes in seconds
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function useDepartureTime(
|
|||||||
eventTime: Date,
|
eventTime: Date,
|
||||||
journeys: Journey[] | null,
|
journeys: Journey[] | null,
|
||||||
bikeRoute: number | null, // duration in seconds
|
bikeRoute: number | null, // duration in seconds
|
||||||
activeMode: "train" | "bike" | null
|
activeMode: "train" | "bike" | null,
|
||||||
|
trainWalkDurationSeconds = 0,
|
||||||
): DepartureTimeResult {
|
): DepartureTimeResult {
|
||||||
const { arrivalBufferMinutes } = useReminderSettings();
|
const { arrivalBufferMinutes } = useReminderSettings();
|
||||||
|
|
||||||
@@ -31,9 +32,10 @@ export function useDepartureTime(
|
|||||||
let mode: "train" | "bike" | null = null;
|
let mode: "train" | "bike" | null = null;
|
||||||
|
|
||||||
if (activeMode === "train" && validJourneys.length > 0) {
|
if (activeMode === "train" && validJourneys.length > 0) {
|
||||||
const onTimeJourneys = validJourneys.filter(journey =>
|
const walkDurationMs = trainWalkDurationSeconds * 1000;
|
||||||
journey.rA.getTime() <= targetArrivalTime.getTime()
|
const onTimeJourneys = validJourneys.filter((journey) => (
|
||||||
);
|
journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime()
|
||||||
|
));
|
||||||
|
|
||||||
if (onTimeJourneys.length > 0) {
|
if (onTimeJourneys.length > 0) {
|
||||||
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
||||||
@@ -41,7 +43,7 @@ export function useDepartureTime(
|
|||||||
);
|
);
|
||||||
|
|
||||||
departureTime = new Date(bestJourney.rD);
|
departureTime = new Date(bestJourney.rD);
|
||||||
arrivalTime = new Date(bestJourney.rA);
|
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||||
mode = "train";
|
mode = "train";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,5 +59,5 @@ export function useDepartureTime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { departureTime, arrivalTime, mode };
|
return { departureTime, arrivalTime, mode };
|
||||||
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes]);
|
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,25 @@ interface HafasLocation {
|
|||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDestinationStation(destination: string) {
|
export function useDestinationStation(destination: string) {
|
||||||
const [station, setStation] = useState<Station | null>(null);
|
const [station, setStation] = useState<Station | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!destination.trim()) return;
|
if (!destination.trim()) {
|
||||||
|
const resetId = setTimeout(() => {
|
||||||
|
setStation(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(resetId);
|
||||||
|
}
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const timeoutId = setTimeout(async () => {
|
const timeoutId = setTimeout(async () => {
|
||||||
@@ -55,12 +67,16 @@ export function useDestinationStation(destination: string) {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const data = await client.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body);
|
||||||
const data = await client.hafasRequest<any>(body);
|
|
||||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||||
const stations = locL
|
const stations = locL
|
||||||
.filter((l) => l.type === "S")
|
.filter((l) => l.type === "S")
|
||||||
.map((l) => ({ name: l.name, extId: l.extId, lat: l.lat, lng: l.lon }));
|
.map((l) => ({
|
||||||
|
name: l.name,
|
||||||
|
extId: l.extId,
|
||||||
|
lat: normalizeHafasCoordinate(l.lat),
|
||||||
|
lng: normalizeHafasCoordinate(l.lon),
|
||||||
|
}));
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setStation(stations[0] ?? null);
|
setStation(stations[0] ?? null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from "react";
|
||||||
import type { Event, CalendarEvent } from "@timetoleave/core";
|
import type { Event, CalendarEvent } from "@timetoleave/core";
|
||||||
|
|
||||||
const STORAGE_KEY = "ttl_events";
|
const STORAGE_KEY = "ttl_events";
|
||||||
@@ -30,9 +30,18 @@ interface EventsContextType {
|
|||||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function EventsProvider({ children }: { children: ReactNode }) {
|
export function EventsProvider({ children }: { children: ReactNode }) {
|
||||||
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
|
const [events, setEventsState] = useState<Event[]>([]);
|
||||||
|
const skipInitialWrite = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setEventsState(loadFromStorage());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipInitialWrite.current) {
|
||||||
|
skipInitialWrite.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ApiClient } from "@timetoleave/api-client";
|
import { api as client } from "@/lib/api";
|
||||||
|
|
||||||
const client = new ApiClient();
|
|
||||||
|
|
||||||
export function useGeocode(destination: string) {
|
export function useGeocode(destination: string) {
|
||||||
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
@@ -9,7 +7,14 @@ export function useGeocode(destination: string) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!destination.trim()) return;
|
if (!destination.trim()) {
|
||||||
|
const resetId = setTimeout(() => {
|
||||||
|
setCoords(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(resetId);
|
||||||
|
}
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
let abortController: AbortController | null = null;
|
let abortController: AbortController | null = null;
|
||||||
|
|
||||||
@@ -22,7 +27,8 @@ export function useGeocode(destination: string) {
|
|||||||
try {
|
try {
|
||||||
const data = await client.geocode(destination, "at");
|
const data = await client.geocode(destination, "at");
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setCoords({ lat: data[0]?.lat ?? 0, lng: data[0]?.lng ?? 0 });
|
const first = data[0];
|
||||||
|
setCoords(first ? { lat: first.lat, lng: first.lng } : null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,11 +3,39 @@ import type { Journey } from "@timetoleave/core";
|
|||||||
import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core";
|
import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core";
|
||||||
import { api as client } from "@/lib/api";
|
import { api as client } from "@/lib/api";
|
||||||
|
|
||||||
|
const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function buildTripSearchBody(
|
||||||
|
fromStationExtId: string,
|
||||||
|
toStationExtId: string,
|
||||||
|
hafasDate: string,
|
||||||
|
hafasTime: string,
|
||||||
|
arriveBy: boolean,
|
||||||
|
numF = 5,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
svcReqL: [
|
||||||
|
{
|
||||||
|
meth: "TripSearch",
|
||||||
|
req: {
|
||||||
|
depLocL: [{ type: "S", extId: fromStationExtId }],
|
||||||
|
arrLocL: [{ type: "S", extId: toStationExtId }],
|
||||||
|
outDate: hafasDate,
|
||||||
|
outTime: hafasTime,
|
||||||
|
outFrwd: !arriveBy,
|
||||||
|
numF,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useJourneys(
|
export function useJourneys(
|
||||||
fromStationExtId: string | null,
|
fromStationExtId: string | null,
|
||||||
toStationExtId: string | null,
|
toStationExtId: string | null,
|
||||||
date: Date,
|
date: Date,
|
||||||
refreshKey = 0,
|
refreshKey = 0,
|
||||||
|
arriveBy = false,
|
||||||
) {
|
) {
|
||||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
@@ -18,6 +46,9 @@ export function useJourneys(
|
|||||||
|
|
||||||
const fetchJourneys = async () => {
|
const fetchJourneys = async () => {
|
||||||
if (!fromStationExtId || !toStationExtId || !date) {
|
if (!fromStationExtId || !toStationExtId || !date) {
|
||||||
|
setJourneys([]);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,24 +57,26 @@ export function useJourneys(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
|
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
|
||||||
|
const data = await client.hafasRequest(
|
||||||
|
buildTripSearchBody(fromStationExtId, toStationExtId, hafasDate, hafasTime, arriveBy),
|
||||||
|
);
|
||||||
|
let result = parseHafasJourneys(data, hafasDate, date);
|
||||||
|
|
||||||
const body = {
|
if (arriveBy && result.length === 0) {
|
||||||
svcReqL: [
|
const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS);
|
||||||
{
|
const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate);
|
||||||
meth: "TripSearch",
|
const fallbackData = await client.hafasRequest(
|
||||||
req: {
|
buildTripSearchBody(
|
||||||
depLocL: [{ type: "S", extId: fromStationExtId }],
|
fromStationExtId,
|
||||||
arrLocL: [{ type: "S", extId: toStationExtId }],
|
toStationExtId,
|
||||||
outDate: hafasDate,
|
fallbackHafasDate,
|
||||||
outTime: hafasTime,
|
fallbackHafasTime,
|
||||||
numF: 5,
|
false,
|
||||||
},
|
10,
|
||||||
},
|
),
|
||||||
],
|
);
|
||||||
};
|
result = parseHafasJourneys(fallbackData, fallbackHafasDate, fallbackDate);
|
||||||
|
}
|
||||||
const data = await client.hafasRequest(body);
|
|
||||||
const result = parseHafasJourneys(data, hafasDate, date);
|
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setJourneys(result);
|
setJourneys(result);
|
||||||
@@ -63,7 +96,7 @@ export function useJourneys(
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [fromStationExtId, toStationExtId, date, refreshKey]);
|
}, [fromStationExtId, toStationExtId, date, refreshKey, arriveBy]);
|
||||||
|
|
||||||
return { journeys, loading, error };
|
return { journeys, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Station } from "@timetoleave/core";
|
import type { Station } from "@timetoleave/core";
|
||||||
import { DEFAULT_STATION_NAME, DEFAULT_STATION_EXT_ID } from "@/lib/constants";
|
import { DEFAULT_STATION } from "@/lib/constants";
|
||||||
import { useGeolocation } from "./useGeolocation";
|
import { useGeolocation } from "./useGeolocation";
|
||||||
|
|
||||||
interface HafasLocation {
|
interface HafasLocation {
|
||||||
@@ -23,7 +23,7 @@ export function useOriginStation() {
|
|||||||
const fetchNearestStation = async () => {
|
const fetchNearestStation = async () => {
|
||||||
if (!location || state !== "granted") {
|
if (!location || state !== "granted") {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
setStation(DEFAULT_STATION);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -76,14 +76,14 @@ export function useOriginStation() {
|
|||||||
if (stations.length > 0) {
|
if (stations.length > 0) {
|
||||||
setStation(stations[0]);
|
setStation(stations[0]);
|
||||||
} else {
|
} else {
|
||||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
setStation(DEFAULT_STATION);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to find nearest station";
|
const message = err instanceof Error ? err.message : "Failed to find nearest station";
|
||||||
setError(message);
|
setError(message);
|
||||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
setStation(DEFAULT_STATION);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { WalkRoute } from "@timetoleave/core";
|
import type { WalkRoute } from "@timetoleave/core";
|
||||||
import { ApiClient } from "@timetoleave/api-client";
|
import { api as client } from "@/lib/api";
|
||||||
|
|
||||||
const client = new ApiClient();
|
|
||||||
|
|
||||||
export function useWalkRoute(
|
export function useWalkRoute(
|
||||||
fromLat: number | undefined,
|
fromLat: number | undefined,
|
||||||
@@ -19,6 +17,9 @@ export function useWalkRoute(
|
|||||||
|
|
||||||
const fetchRoute = async () => {
|
const fetchRoute = async () => {
|
||||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||||
|
setWalkRoute(null);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("calendar-utils", () => {
|
|||||||
it("should return the location as-is if it's not a known station", () => {
|
it("should return the location as-is if it's not a known station", () => {
|
||||||
expect(cleanLocation("Some Other Station")).toBe("Some Other Station");
|
expect(cleanLocation("Some Other Station")).toBe("Some Other Station");
|
||||||
expect(cleanLocation("Berlin")).toBe("Berlin");
|
expect(cleanLocation("Berlin")).toBe("Berlin");
|
||||||
|
expect(cleanLocation("Hoechstaedtplatz 6, 1200 Wien")).toBe("Hoechstaedtplatz 6, 1200 Wien");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle partial matches", () => {
|
it("should handle partial matches", () => {
|
||||||
|
|||||||
@@ -84,5 +84,5 @@ export function cleanLocation(location: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return location.split(",")[0].trim();
|
return location.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ORIGIN_STATION,
|
||||||
|
DEFAULT_ORIGIN_STATION_EXT_ID,
|
||||||
|
DEFAULT_ORIGIN_STATION_NAME,
|
||||||
|
} from "@timetoleave/core";
|
||||||
|
|
||||||
export const HAFAS_URL = process.env.HAFAS_URL || "https://fahrplan.oebb.at/bin/mgate.exe";
|
export const HAFAS_URL = process.env.HAFAS_URL || "https://fahrplan.oebb.at/bin/mgate.exe";
|
||||||
export const HAFAS_TIMEOUT_MS = parseInt(process.env.HAFAS_TIMEOUT_MS ?? "10000", 10);
|
export const HAFAS_TIMEOUT_MS = parseInt(process.env.HAFAS_TIMEOUT_MS ?? "10000", 10);
|
||||||
export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org";
|
export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org";
|
||||||
@@ -5,6 +11,7 @@ export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT || "TimeToL
|
|||||||
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
|
export const OSRM_URL = process.env.OSRM_URL || "https://router.project-osrm.org";
|
||||||
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
|
export const WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
|
||||||
export const DEFAULT_DAYS = 14;
|
export const DEFAULT_DAYS = 14;
|
||||||
export const DEFAULT_STATION_NAME = "Mödling Bahnhof";
|
export const DEFAULT_STATION = DEFAULT_ORIGIN_STATION;
|
||||||
export const DEFAULT_STATION_EXT_ID = "1231701";
|
export const DEFAULT_STATION_NAME = DEFAULT_ORIGIN_STATION_NAME;
|
||||||
|
export const DEFAULT_STATION_EXT_ID = DEFAULT_ORIGIN_STATION_EXT_ID;
|
||||||
export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
|
export const APP_VERSION = process.env.APP_VERSION || "0.1.0";
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ const allowedOrigins: Set<string> = new Set(
|
|||||||
: ["http://localhost:3000"], // dev fallback
|
: ["http://localhost:3000"], // dev fallback
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rate limiter: 30 requests / minute per IP. Tune these values for
|
// Rate limiter: tune through env for deployment. Calendar pages legitimately
|
||||||
// your deployment.
|
// fan out across geocode, routing, transit, and nearby-stop APIs per event.
|
||||||
|
const rateLimitMaxRequests = Number.parseInt(process.env.API_RATE_LIMIT_MAX_REQUESTS ?? "120", 10);
|
||||||
|
const rateLimitWindowMs = Number.parseInt(process.env.API_RATE_LIMIT_WINDOW_MS ?? "60000", 10);
|
||||||
|
|
||||||
const limiter = new RateLimiter({
|
const limiter = new RateLimiter({
|
||||||
maxRequests: 30,
|
maxRequests: Number.isFinite(rateLimitMaxRequests) ? rateLimitMaxRequests : 120,
|
||||||
windowMs: 60_000,
|
windowMs: Number.isFinite(rateLimitWindowMs) ? rateLimitWindowMs : 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ This client talks to the web app's backend proxy routes. `baseUrl` defaults to a
|
|||||||
|
|
||||||
| Function or method | What it does |
|
| Function or method | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `parseHafasJourneys(json, hafasDate, queryDate)` | Internal parser that converts raw HAFAS `outConL` data into shared `Journey` objects. It parses scheduled and realtime times, computes delay, extracts train names, change count, platform, and cancellation state. |
|
|
||||||
| `buildUrl(base, path, params)` | Internal helper that builds a URL and encodes query parameters. |
|
| `buildUrl(base, path, params)` | Internal helper that builds a URL and encodes query parameters. |
|
||||||
| `ApiClient.constructor(baseUrl?)` | Stores the backend base URL. |
|
| `ApiClient.constructor(baseUrl?)` | Stores the backend base URL. |
|
||||||
| `ApiClient.getHealth()` | Calls `/api/health` and returns the health payload, throwing on non-OK responses. |
|
| `ApiClient.getHealth()` | Calls `/api/health` and returns the health payload, throwing on non-OK responses. |
|
||||||
@@ -109,7 +108,7 @@ This client talks to the web app's backend proxy routes. `baseUrl` defaults to a
|
|||||||
| `ApiClient.fetchCalendar(url, days?)` | Calls `/api/calendar` for a remote ICS URL and returns normalized `CalendarEvent[]`. |
|
| `ApiClient.fetchCalendar(url, days?)` | Calls `/api/calendar` for a remote ICS URL and returns normalized `CalendarEvent[]`. |
|
||||||
| `ApiClient.parseCalendarIcs(content)` | POSTs raw ICS content to `/api/calendar/parse` and returns normalized `CalendarEvent[]`. |
|
| `ApiClient.parseCalendarIcs(content)` | POSTs raw ICS content to `/api/calendar/parse` and returns normalized `CalendarEvent[]`. |
|
||||||
| `ApiClient.hafasRequest(body)` | POSTs an arbitrary allowed HAFAS body to `/api/hafas`. Used for `TripSearch` and `LocMatch`. |
|
| `ApiClient.hafasRequest(body)` | POSTs an arbitrary allowed HAFAS body to `/api/hafas`. Used for `TripSearch` and `LocMatch`. |
|
||||||
| `ApiClient.searchJourneys(fromStationExtId, toStationExtId, date)` | Builds a HAFAS `TripSearch`, sends it to `/api/hafas`, and parses the response into `Journey[]`. |
|
| `ApiClient.searchJourneys(fromStationExtId, toStationExtId, date)` | Builds a HAFAS `TripSearch`, sends it to `/api/hafas`, and parses the response into `Journey[]` with the shared `parseHafasJourneys` from `@timetoleave/core`. |
|
||||||
| `ApiClient.reverseGeocode(lat, lng)` | Calls `/api/geocode/reverse`. Note: the current web app does not define this route, so callers should tolerate `null` or failures. |
|
| `ApiClient.reverseGeocode(lat, lng)` | Calls `/api/geocode/reverse`. Note: the current web app does not define this route, so callers should tolerate `null` or failures. |
|
||||||
| `ApiClient.findNearbyStops(lat, lng, radius?)` | Calls `/api/wienerlinien/stops` and returns nearby stops. |
|
| `ApiClient.findNearbyStops(lat, lng, radius?)` | Calls `/api/wienerlinien/stops` and returns nearby stops. |
|
||||||
| `ApiClient.searchStation(query)` | Uses HAFAS `LocMatch` through `/api/hafas` to search station names. |
|
| `ApiClient.searchStation(query)` | Uses HAFAS `LocMatch` through `/api/hafas` to search station names. |
|
||||||
@@ -133,7 +132,7 @@ Defines environment-backed service URLs and defaults:
|
|||||||
| `OSRM_URL` | Routing endpoint for bike and walk routes. |
|
| `OSRM_URL` | Routing endpoint for bike and walk routes. |
|
||||||
| `WIENER_LINIEN_API_URL` | Wiener Linien API base. |
|
| `WIENER_LINIEN_API_URL` | Wiener Linien API base. |
|
||||||
| `DEFAULT_DAYS` | Default calendar import horizon. |
|
| `DEFAULT_DAYS` | Default calendar import horizon. |
|
||||||
| `DEFAULT_STATION_NAME`, `DEFAULT_STATION_EXT_ID` | Web fallback origin station. |
|
| `DEFAULT_STATION`, `DEFAULT_STATION_NAME`, `DEFAULT_STATION_EXT_ID` | Web fallback origin. The default origin is Goethegasse 36, 2340 Moedling, using Mödling Bahnhof as the nearest transit station fallback. |
|
||||||
| `APP_VERSION` | Health endpoint version string. |
|
| `APP_VERSION` | Health endpoint version string. |
|
||||||
|
|
||||||
### `apps/web/src/lib/api-service.ts`
|
### `apps/web/src/lib/api-service.ts`
|
||||||
@@ -162,6 +161,12 @@ Generic HTTP helper layer used by service clients.
|
|||||||
| `ApiClient.cacheStats()` | Exposes underlying cache stats. |
|
| `ApiClient.cacheStats()` | Exposes underlying cache stats. |
|
||||||
| `ApiClient.clearCache()` | Clears the underlying cache. |
|
| `ApiClient.clearCache()` | Clears the underlying cache. |
|
||||||
|
|
||||||
|
### `apps/web/src/lib/api.ts`
|
||||||
|
|
||||||
|
| Export | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `api` | Shared browser-side `@timetoleave/api-client` instance with the default same-origin base URL. Web hooks import this singleton instead of creating their own client instances. |
|
||||||
|
|
||||||
### `apps/web/src/lib/api-guards.ts`
|
### `apps/web/src/lib/api-guards.ts`
|
||||||
|
|
||||||
| Function | What it does |
|
| Function | What it does |
|
||||||
@@ -200,10 +205,9 @@ Generic HTTP helper layer used by service clients.
|
|||||||
|
|
||||||
| Function, class, or method | What it does |
|
| Function, class, or method | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `parseHafasJourneys(json, hafasDate, queryDate)` | Converts raw HAFAS trip responses into shared `Journey[]` using `parseHafasTime`. |
|
|
||||||
| `HafasClient.constructor(baseUrl?, timeoutMs?)` | Creates an internal retrying `ApiClient` for live HAFAS calls. Caching is disabled because journey data changes often. |
|
| `HafasClient.constructor(baseUrl?, timeoutMs?)` | Creates an internal retrying `ApiClient` for live HAFAS calls. Caching is disabled because journey data changes often. |
|
||||||
| `HafasClient.searchStation(query)` | Sends HAFAS `LocMatch` and returns matching station names and extIds. |
|
| `HafasClient.searchStation(query)` | Sends HAFAS `LocMatch` and returns matching station names and extIds. |
|
||||||
| `HafasClient.fetchJourneys(from, to, date)` | Sends HAFAS `TripSearch` between two stations for a date and parses the journeys. |
|
| `HafasClient.fetchJourneys(from, to, date)` | Sends HAFAS `TripSearch` between two stations for a date and parses the journeys with the shared core HAFAS parser. |
|
||||||
| `HafasClient.cacheStats()` | Exposes cache stats from the internal client, mostly for debugging. |
|
| `HafasClient.cacheStats()` | Exposes cache stats from the internal client, mostly for debugging. |
|
||||||
|
|
||||||
### `apps/web/src/lib/geocoding-client.ts`
|
### `apps/web/src/lib/geocoding-client.ts`
|
||||||
@@ -493,19 +497,31 @@ All route functions are Next.js App Router route handlers.
|
|||||||
| `apps/mobile/src/store/eventStore.ts` | `reviveDates(json)` | Internal helper that parses stored events and converts event time strings back to `Date`. |
|
| `apps/mobile/src/store/eventStore.ts` | `reviveDates(json)` | Internal helper that parses stored events and converts event time strings back to `Date`. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `getNotificationSettings()` | Internal helper that loads notification settings or returns defaults. |
|
| `apps/mobile/src/store/eventStore.ts` | `getNotificationSettings()` | Internal helper that loads notification settings or returns defaults. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `calculateLeaveByTime(event, arrivalBufferMinutes, bufferMinutes)` | Computes a fallback leave-by time from event time minus arrival buffer minus reminder buffer. It currently does not use live journey data. |
|
| `apps/mobile/src/store/eventStore.ts` | `calculateLeaveByTime(event, arrivalBufferMinutes, bufferMinutes)` | Computes a fallback leave-by time from event time minus arrival buffer minus reminder buffer. It currently does not use live journey data. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `scheduleEventNotification(event)` | Cancels existing notifications for an event and schedules default reminders 30 minutes, 10 minutes, and 0 minutes before leave-by time when enabled. |
|
| `apps/mobile/src/store/eventStore.ts` | `fireNotificationsForEvent(event, leaveByTime)` | Internal helper that schedules the standard 30 minute, 10 minute, and leave-now notifications, skipping past triggers and triggers more than two hours before the event. |
|
||||||
|
| `apps/mobile/src/store/eventStore.ts` | `scheduleEventNotification(event)` | Cancels existing notifications for one event and delegates standard reminder creation to `fireNotificationsForEvent` when notifications are enabled. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `loadEvents()` | Loads persisted events from AsyncStorage. |
|
| `apps/mobile/src/store/eventStore.ts` | `loadEvents()` | Loads persisted events from AsyncStorage. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `saveEvents(events)` | Persists events to AsyncStorage. |
|
| `apps/mobile/src/store/eventStore.ts` | `saveEvents(events)` | Persists events to AsyncStorage. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `addEvent(event)` | Adds an event, saves the list, and schedules notifications. |
|
| `apps/mobile/src/store/eventStore.ts` | `addEvent(event)` | Adds an event, saves the list, and schedules notifications. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `updateEvent(id, updates)` | Updates a stored event, saves the list, and reschedules notifications for that event. |
|
| `apps/mobile/src/store/eventStore.ts` | `updateEvent(id, updates)` | Updates a stored event, saves the list, and reschedules notifications for that event. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `removeEvent(id, onDone?)` | Removes an event, cancels its scheduled notifications, and calls an optional completion callback. |
|
| `apps/mobile/src/store/eventStore.ts` | `removeEvent(id, onDone?)` | Removes an event, cancels its scheduled notifications, and calls an optional completion callback. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `loadOriginStation()` | Loads the saved origin station. |
|
| `apps/mobile/src/store/eventStore.ts` | `loadOriginStation()` | Loads the saved origin station or returns the shared default origin at Goethegasse 36, 2340 Moedling when none is saved. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `saveOriginStation(station)` | Persists the selected origin station. |
|
| `apps/mobile/src/store/eventStore.ts` | `saveOriginStation(station)` | Persists the selected origin station. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `loadNotificationSettings()` | Loads notification settings or returns defaults. |
|
| `apps/mobile/src/store/eventStore.ts` | `loadNotificationSettings()` | Loads notification settings or returns defaults. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `saveNotificationSettings(settings)` | Persists notification settings. |
|
| `apps/mobile/src/store/eventStore.ts` | `saveNotificationSettings(settings)` | Persists notification settings. |
|
||||||
| `apps/mobile/src/store/eventStore.ts` | `rescheduleAllNotifications()` | Cancels all scheduled notifications and recreates reminders for every stored event using current settings. |
|
| `apps/mobile/src/store/eventStore.ts` | `rescheduleAllNotifications()` | Loads events and settings together, cancels all scheduled notifications, exits early when notifications are disabled, and recreates reminders for every stored event through `fireNotificationsForEvent`. |
|
||||||
| `apps/mobile/src/polyfills/sharedArrayBuffer.ts` | `toWellFormedString(value)` | Internal polyfill helper that replaces malformed UTF-16 surrogate pairs. The file also polyfills `String.prototype.toWellFormed`, `String.prototype.isWellFormed`, `ArrayBuffer.prototype.resizable`, and `SharedArrayBuffer` when missing. |
|
| `apps/mobile/src/polyfills/sharedArrayBuffer.ts` | `toWellFormedString(value)` | Internal polyfill helper that replaces malformed UTF-16 surrogate pairs. The file also polyfills `String.prototype.toWellFormed`, `String.prototype.isWellFormed`, `ArrayBuffer.prototype.resizable`, and `SharedArrayBuffer` when missing. |
|
||||||
|
|
||||||
|
### Mobile components
|
||||||
|
|
||||||
|
These components were extracted from the mobile detail screen so `EventDetailScreen` now handles orchestration while the display sections stay focused.
|
||||||
|
|
||||||
|
| File | Function/component | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `apps/mobile/src/components/EventHeader.tsx` | `EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors })` | Displays the selected event title, destination, localized event time, source, leave-by time, arrive-by time, and arrival buffer. |
|
||||||
|
| `apps/mobile/src/components/JourneyList.tsx` | `JourneyList(props)` | Displays train connections, destination-station loading state, empty state based on whether an origin station exists, delay/cancellation badges, and optional final walking route summary. |
|
||||||
|
| `apps/mobile/src/components/BikeSection.tsx` | `BikeSection({ bikeRoute, loading, origin, colors })` | Displays bike route loading, empty, duration, distance, and a placeholder map area. |
|
||||||
|
| `apps/mobile/src/components/NearbyStops.tsx` | `NearbyStops({ stops, departures, loading, error, colors })` | Displays nearby destination public-transport stops or the first eight live departures. It hides itself when there is no loading state, no stops, and no error. |
|
||||||
|
|
||||||
### Mobile screens
|
### Mobile screens
|
||||||
|
|
||||||
| File | Function/component | What it does |
|
| File | Function/component | What it does |
|
||||||
@@ -513,7 +529,7 @@ All route functions are Next.js App Router route handlers.
|
|||||||
| `apps/mobile/src/screens/EventListScreen.tsx` | `EventListScreen({ navigation })` | Main mobile list screen. Loads events from AsyncStorage, refreshes on focus and pull-to-refresh, recalculates countdowns every 30 seconds, and lets users navigate to details, edit, delete, settings, or calendar import. Significant inner callbacks: `reload`, `onRefresh`, and `renderItem`. |
|
| `apps/mobile/src/screens/EventListScreen.tsx` | `EventListScreen({ navigation })` | Main mobile list screen. Loads events from AsyncStorage, refreshes on focus and pull-to-refresh, recalculates countdowns every 30 seconds, and lets users navigate to details, edit, delete, settings, or calendar import. Significant inner callbacks: `reload`, `onRefresh`, and `renderItem`. |
|
||||||
| `apps/mobile/src/screens/AddEventScreen.tsx` | `AddEventScreen({ navigation, route })` | Manual add/edit form. Loads an existing event when `editEventId` is present, validates title/destination/date/time, then calls `addEvent` or `updateEvent`. Significant inner functions: `validate` and `handleSave`. |
|
| `apps/mobile/src/screens/AddEventScreen.tsx` | `AddEventScreen({ navigation, route })` | Manual add/edit form. Loads an existing event when `editEventId` is present, validates title/destination/date/time, then calls `addEvent` or `updateEvent`. Significant inner functions: `validate` and `handleSave`. |
|
||||||
| `apps/mobile/src/screens/CalendarImportScreen.tsx` | `CalendarImportScreen({ navigation })` | Imports ICS URL events through the backend or syncs native device calendar events. Avoids duplicates by ID before calling `addEvent`. Significant inner functions: `handleImport` and `handleSyncNative`. |
|
| `apps/mobile/src/screens/CalendarImportScreen.tsx` | `CalendarImportScreen({ navigation })` | Imports ICS URL events through the backend or syncs native device calendar events. Avoids duplicates by ID before calling `addEvent`. Significant inner functions: `handleImport` and `handleSyncNative`. |
|
||||||
| `apps/mobile/src/screens/EventDetailScreen.tsx` | `EventDetailScreen({ navigation, route })` | Mobile event detail and route screen. Loads event, origin station, settings, resolves destination station, searches journeys, loads bike/walk routes, displays leave-by data, and shows nearby Wiener Linien data. Significant inner functions: `fetchData` and `handleRefresh`. |
|
| `apps/mobile/src/screens/EventDetailScreen.tsx` | `EventDetailScreen({ navigation, route })` | Mobile event detail orchestrator. Loads event, origin station, settings, resolves destination station, searches journeys, loads bike/walk routes, computes leave-by data, manages train/bike mode selection, and passes display data to `EventHeader`, `JourneyList`, `BikeSection`, and `NearbyStops`. Significant inner functions: `fetchData` and `handleRefresh`. |
|
||||||
| `apps/mobile/src/screens/SettingsScreen.tsx` | `SettingsScreen({ navigation })` | Settings screen for origin station, current-location station lookup, reminder settings, advanced transport toggles, and theme toggle. Significant inner functions: `searchStation`, `onQueryChange`, `selectStation`, `useCurrentLocation`, `toggleNotifications`, `updateBufferMinutes`, `updateArrivalBuffer`, `toggleWalking`, and `toggleBike`. |
|
| `apps/mobile/src/screens/SettingsScreen.tsx` | `SettingsScreen({ navigation })` | Settings screen for origin station, current-location station lookup, reminder settings, advanced transport toggles, and theme toggle. Significant inner functions: `searchStation`, `onQueryChange`, `selectStation`, `useCurrentLocation`, `toggleNotifications`, `updateBufferMinutes`, `updateArrivalBuffer`, `toggleWalking`, and `toggleBike`. |
|
||||||
|
|
||||||
## Development Notes for New Contributors
|
## Development Notes for New Contributors
|
||||||
@@ -522,12 +538,16 @@ Use `packages/core` for logic that must behave the same on web and mobile. Time
|
|||||||
|
|
||||||
Use `packages/api-client` for calls from client code to the web backend. If a new backend route is shared by web and mobile, add a method there instead of duplicating `fetch` calls in screens.
|
Use `packages/api-client` for calls from client code to the web backend. If a new backend route is shared by web and mobile, add a method there instead of duplicating `fetch` calls in screens.
|
||||||
|
|
||||||
|
Use `apps/web/src/lib/api.ts` when a web hook needs the shared browser API client. Creating ad-hoc `new ApiClient()` instances in each hook is no longer the local pattern.
|
||||||
|
|
||||||
Use `apps/web/src/lib/*` for server-side integrations and wrappers around external services. These files are the right place for caching, retries, protocol parsing, and API-specific response validation.
|
Use `apps/web/src/lib/*` for server-side integrations and wrappers around external services. These files are the right place for caching, retries, protocol parsing, and API-specific response validation.
|
||||||
|
|
||||||
Use `apps/web/src/app/api/**/route.ts` for public backend proxy endpoints. Keep validation and size limits close to the route handler, then delegate external service work to `apps/web/src/lib`.
|
Use `apps/web/src/app/api/**/route.ts` for public backend proxy endpoints. Keep validation and size limits close to the route handler, then delegate external service work to `apps/web/src/lib`.
|
||||||
|
|
||||||
Use hooks for UI-side asynchronous state. Most web and mobile hooks follow the same pattern: inputs, `loading`, `error`, result state, debounce or refresh logic, and cleanup guards to prevent setting state after unmount.
|
Use hooks for UI-side asynchronous state. Most web and mobile hooks follow the same pattern: inputs, `loading`, `error`, result state, debounce or refresh logic, and cleanup guards to prevent setting state after unmount.
|
||||||
|
|
||||||
|
For mobile detail UI, keep orchestration in `EventDetailScreen` and reusable display sections in `apps/mobile/src/components`. The extracted components expect already-loaded data plus the `useColors()` palette.
|
||||||
|
|
||||||
Be especially careful with HAFAS time handling. HAFAS dates and times are Vienna-local strings. Use `hafasDateTime()` before building requests and `parseHafasTime()` when parsing responses.
|
Be especially careful with HAFAS time handling. HAFAS dates and times are Vienna-local strings. Use `hafasDateTime()` before building requests and `parseHafasTime()` when parsing responses.
|
||||||
|
|
||||||
Be careful with stored dates. Web localStorage and mobile AsyncStorage serialize `Date` objects to strings, so both apps have helper functions that revive `eventTime` back into `Date`.
|
Be careful with stored dates. Web localStorage and mobile AsyncStorage serialize `Date` objects to strings, so both apps have helper functions that revive `eventTime` back into `Date`.
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { hafasDateTime, parseHafasJourneys } from "@timetoleave/core";
|
|||||||
|
|
||||||
const DEFAULT_BASE_URL = "";
|
const DEFAULT_BASE_URL = "";
|
||||||
|
|
||||||
|
type SearchJourneyOptions = {
|
||||||
|
arriveBy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function buildUrl(base: string, path: string, params: Record<string, string> = {}): string {
|
function buildUrl(base: string, path: string, params: Record<string, string> = {}): string {
|
||||||
const queryString = Object.entries(params)
|
const queryString = Object.entries(params)
|
||||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||||
@@ -20,6 +26,36 @@ function buildUrl(base: string, path: string, params: Record<string, string> = {
|
|||||||
return queryString ? `${full}?${queryString}` : full;
|
return queryString ? `${full}?${queryString}` : full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTripSearchBody(
|
||||||
|
fromStationExtId: string,
|
||||||
|
toStationExtId: string,
|
||||||
|
hafasDate: string,
|
||||||
|
hafasTime: string,
|
||||||
|
arriveBy: boolean,
|
||||||
|
numF = 5,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
svcReqL: [
|
||||||
|
{
|
||||||
|
meth: "TripSearch",
|
||||||
|
req: {
|
||||||
|
depLocL: [{ type: "S", extId: fromStationExtId }],
|
||||||
|
arrLocL: [{ type: "S", extId: toStationExtId }],
|
||||||
|
outDate: hafasDate,
|
||||||
|
outTime: hafasTime,
|
||||||
|
outFrwd: !arriveBy,
|
||||||
|
numF,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
@@ -114,24 +150,30 @@ export class ApiClient {
|
|||||||
fromStationExtId: string,
|
fromStationExtId: string,
|
||||||
toStationExtId: string,
|
toStationExtId: string,
|
||||||
date: Date,
|
date: Date,
|
||||||
|
options: SearchJourneyOptions = {},
|
||||||
): Promise<Journey[]> {
|
): Promise<Journey[]> {
|
||||||
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
|
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
|
||||||
|
const arriveBy = options.arriveBy === true;
|
||||||
|
const journeys = await this.fetchTripSearch(
|
||||||
|
buildTripSearchBody(fromStationExtId, toStationExtId, hafasDate, hafasTime, arriveBy),
|
||||||
|
hafasDate,
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
|
||||||
const body = {
|
if (!arriveBy || journeys.length > 0) {
|
||||||
svcReqL: [
|
return journeys;
|
||||||
{
|
}
|
||||||
meth: "TripSearch",
|
|
||||||
req: {
|
|
||||||
depLocL: [{ type: "S", extId: fromStationExtId }],
|
|
||||||
arrLocL: [{ type: "S", extId: toStationExtId }],
|
|
||||||
outDate: hafasDate,
|
|
||||||
outTime: hafasTime,
|
|
||||||
numF: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS);
|
||||||
|
const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate);
|
||||||
|
return this.fetchTripSearch(
|
||||||
|
buildTripSearchBody(fromStationExtId, toStationExtId, fallbackHafasDate, fallbackHafasTime, false, 10),
|
||||||
|
fallbackHafasDate,
|
||||||
|
fallbackDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTripSearch(body: unknown, hafasDate: string, queryDate: Date): Promise<Journey[]> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/hafas`, {
|
const res = await fetch(`${this.baseUrl}/api/hafas`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -140,7 +182,7 @@ export class ApiClient {
|
|||||||
|
|
||||||
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return parseHafasJourneys(data, hafasDate, date);
|
return parseHafasJourneys(data, hafasDate, queryDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
|
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
|
||||||
@@ -225,12 +267,21 @@ export class ApiClient {
|
|||||||
|
|
||||||
// Find the closest station by Euclidean distance
|
// Find the closest station by Euclidean distance
|
||||||
const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => {
|
const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => {
|
||||||
const bestDist = Math.hypot((best.lat ?? lat) - lat, (best.lng ?? lng) - lng);
|
const bestLat = normalizeHafasCoordinate(best.lat) ?? lat;
|
||||||
const candDist = Math.hypot((candidate.lat ?? lat) - lat, (candidate.lng ?? lng) - lng);
|
const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon) ?? lng;
|
||||||
|
const candidateLat = normalizeHafasCoordinate(candidate.lat) ?? lat;
|
||||||
|
const candidateLng = normalizeHafasCoordinate(candidate.lng ?? candidate.lon) ?? lng;
|
||||||
|
const bestDist = Math.hypot(bestLat - lat, bestLng - lng);
|
||||||
|
const candDist = Math.hypot(candidateLat - lat, candidateLng - lng);
|
||||||
return candDist < bestDist ? candidate : best;
|
return candDist < bestDist ? candidate : best;
|
||||||
}, stationResults[0]);
|
}, stationResults[0]);
|
||||||
|
|
||||||
return closest;
|
return {
|
||||||
|
name: closest.name,
|
||||||
|
extId: closest.extId,
|
||||||
|
lat: normalizeHafasCoordinate(closest.lat),
|
||||||
|
lng: normalizeHafasCoordinate(closest.lng ?? closest.lon),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {
|
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Station } from "./types";
|
||||||
|
|
||||||
|
export const DEFAULT_ORIGIN_ADDRESS = "Goethegasse 36, 2340 Moedling";
|
||||||
|
export const DEFAULT_ORIGIN_LAT = 48.0806926;
|
||||||
|
export const DEFAULT_ORIGIN_LNG = 16.2908052;
|
||||||
|
export const DEFAULT_ORIGIN_STATION_NAME = "Mödling Bahnhof";
|
||||||
|
export const DEFAULT_ORIGIN_STATION_EXT_ID = "1231701";
|
||||||
|
|
||||||
|
export const DEFAULT_ORIGIN_STATION: Station = {
|
||||||
|
name: DEFAULT_ORIGIN_ADDRESS,
|
||||||
|
extId: DEFAULT_ORIGIN_STATION_EXT_ID,
|
||||||
|
lat: DEFAULT_ORIGIN_LAT,
|
||||||
|
lng: DEFAULT_ORIGIN_LNG,
|
||||||
|
};
|
||||||
@@ -5,3 +5,4 @@ export * from './formatting';
|
|||||||
export * from './status-utils';
|
export * from './status-utils';
|
||||||
export * from './hafas-time';
|
export * from './hafas-time';
|
||||||
export * from './hafas-parser';
|
export * from './hafas-parser';
|
||||||
|
export * from './defaults';
|
||||||
|
|||||||
Reference in New Issue
Block a user