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', () => {
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ interface Props {
|
||||
walkRoute: WalkRoute | null;
|
||||
loadingWalk: boolean;
|
||||
showWalkingOption: boolean;
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
origin: Station | null;
|
||||
colors: AppColors;
|
||||
}
|
||||
@@ -19,9 +21,14 @@ export function JourneyList({
|
||||
walkRoute,
|
||||
loadingWalk,
|
||||
showWalkingOption,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
origin,
|
||||
colors,
|
||||
}: Props) {
|
||||
const walkDurationMs = showWalkingOption ? (walkRoute?.duration ?? 0) * 1000 : 0;
|
||||
const targetArrivalTime = eventTime.getTime() - arrivalBufferMinutes * 60_000;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Zugverbindungen</Text>
|
||||
@@ -38,8 +45,19 @@ export function JourneyList({
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={[styles.card, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
journeys.map((j) => {
|
||||
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}>
|
||||
<Text style={[styles.line, { color: colors.text }]}>
|
||||
{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' })}
|
||||
{' '}({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</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>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{showWalkingOption && walkRoute && (
|
||||
@@ -94,6 +119,7 @@ const styles = StyleSheet.create({
|
||||
hint: { fontSize: 15, marginTop: 12 },
|
||||
empty: { fontSize: 14 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, borderWidth: 1 },
|
||||
lateCard: { opacity: 0.55 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
line: { fontSize: 16, fontWeight: '600' },
|
||||
delayBadge: { color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
|
||||
@@ -17,6 +17,7 @@ export function useDepartureTime(
|
||||
bikeDurationSeconds: number | null,
|
||||
activeMode: 'train' | 'bike' | null,
|
||||
arrivalBufferMinutes: number,
|
||||
trainWalkDurationSeconds = 0,
|
||||
): DepartureTimeResult {
|
||||
return useMemo(() => {
|
||||
// Calculate target arrival time (event time minus buffer)
|
||||
@@ -32,8 +33,9 @@ export function useDepartureTime(
|
||||
|
||||
if (activeMode === 'train' && validJourneys.length > 0) {
|
||||
// Find journeys that arrive by target time
|
||||
const walkDurationMs = trainWalkDurationSeconds * 1000;
|
||||
const onTimeJourneys = validJourneys.filter(
|
||||
(journey) => journey.rA.getTime() <= targetArrivalTime.getTime(),
|
||||
(journey) => journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime(),
|
||||
);
|
||||
|
||||
if (onTimeJourneys.length > 0) {
|
||||
@@ -43,7 +45,7 @@ export function useDepartureTime(
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.rD);
|
||||
arrivalTime = new Date(bestJourney.rA);
|
||||
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||
mode = 'train';
|
||||
}
|
||||
}
|
||||
@@ -59,5 +61,5 @@ export function useDepartureTime(
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes]);
|
||||
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ interface HafasLocation {
|
||||
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) {
|
||||
const [station, setStation] = useState<Station | null>(null);
|
||||
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<any>(body);
|
||||
const data = await api.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body);
|
||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||
const stations = locL
|
||||
.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;
|
||||
setStation(stations[0] ?? null);
|
||||
|
||||
@@ -21,6 +21,9 @@ export function useWalkRoute(
|
||||
|
||||
const fetchRoute = async () => {
|
||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||
setWalkRoute(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
<Text style={[styles.label, { color: colors.text }]}>Ziel</Text>
|
||||
<TextInput
|
||||
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}
|
||||
value={destination}
|
||||
onChangeText={setDestination}
|
||||
|
||||
@@ -31,6 +31,14 @@ type ScreenProps = {
|
||||
|
||||
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) {
|
||||
const { eventId } = route.params;
|
||||
const colors = useColors();
|
||||
@@ -80,7 +88,21 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
if (originStation) {
|
||||
const destExtId = destStation.station?.extId;
|
||||
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);
|
||||
} else if (destStation.error) {
|
||||
setError(`Ziel-Station nicht auflösbar: ${destStation.error}`);
|
||||
@@ -106,7 +128,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId, destStation.station, destStation.error, destCoords.coords]);
|
||||
}, [eventId, destStation.station, destStation.error, destCoords.coords, walkHook.walkRoute, walkHook.error]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,6 +154,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
? 'bike'
|
||||
: null,
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -227,13 +250,15 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{effectiveMode === 'train' && (
|
||||
{event && effectiveMode === 'train' && (
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
destStationLoading={destStation.loading}
|
||||
walkRoute={walkRoute}
|
||||
loadingWalk={loadingWalk}
|
||||
showWalkingOption={showWalkingOption}
|
||||
eventTime={event.eventTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
origin={origin}
|
||||
colors={colors}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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> {
|
||||
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> {
|
||||
|
||||
@@ -127,7 +127,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, editEven
|
||||
type="text"
|
||||
value={destination}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -12,14 +12,6 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
const events = extractEvents(body, DEFAULT_DAYS);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useJourneys } from "@/hooks/useJourneys";
|
||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||
@@ -36,12 +36,6 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
|
||||
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 {
|
||||
@@ -69,6 +63,30 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
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";
|
||||
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
|
||||
@@ -79,6 +97,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
journeys,
|
||||
bikeRoute?.duration ?? null,
|
||||
activeMode,
|
||||
trainWalkDurationSeconds,
|
||||
);
|
||||
|
||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||
@@ -190,6 +209,7 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
loading={journeysLoading}
|
||||
error={journeysError}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
walkDurationSeconds={trainWalkDurationSeconds}
|
||||
showWalkingOption={showWalkingOption}
|
||||
walkRoute={walkRoute}
|
||||
walkLoading={walkLoading}
|
||||
|
||||
@@ -10,6 +10,7 @@ type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
walkDurationSeconds?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -17,9 +18,11 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
journeys,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds = 0,
|
||||
className = "",
|
||||
}) => {
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
const walkDurationMs = walkDurationSeconds * 1000;
|
||||
|
||||
if (journeys.length === 0) {
|
||||
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 (
|
||||
<ul className={`space-y-3 p-4 ${className}`}>
|
||||
{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 arrival = journey.rA ?? journey.sA;
|
||||
|
||||
@@ -60,7 +64,10 @@ const JourneyList: React.FC<JourneyListProps> = ({
|
||||
</div>
|
||||
<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>Arrives {formatTime(arrival)}</span>
|
||||
<span>
|
||||
Arrives {formatTime(arrival)}
|
||||
{walkDurationSeconds > 0 ? `, destination ${formatTime(finalArrival)}` : ""}
|
||||
</span>
|
||||
{arrivesTooLate && (
|
||||
<span className="font-medium text-brand-pink">
|
||||
misses {arrivalBufferMinutes} min buffer
|
||||
|
||||
@@ -17,6 +17,7 @@ type TrainSectionProps = {
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
arrivalBufferMinutes?: number;
|
||||
walkDurationSeconds?: number;
|
||||
showWalkingOption?: boolean;
|
||||
walkRoute?: WalkRoute | null;
|
||||
walkLoading?: boolean;
|
||||
@@ -32,6 +33,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
onRefresh,
|
||||
className = "",
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds,
|
||||
showWalkingOption,
|
||||
walkRoute,
|
||||
walkLoading,
|
||||
@@ -68,7 +70,12 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
<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) && (
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<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", () => {
|
||||
const bikeRouteDuration = 1800; // 30 minutes in seconds
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
journeys: Journey[] | null,
|
||||
bikeRoute: number | null, // duration in seconds
|
||||
activeMode: "train" | "bike" | null
|
||||
activeMode: "train" | "bike" | null,
|
||||
trainWalkDurationSeconds = 0,
|
||||
): DepartureTimeResult {
|
||||
const { arrivalBufferMinutes } = useReminderSettings();
|
||||
|
||||
@@ -31,9 +32,10 @@ export function useDepartureTime(
|
||||
let mode: "train" | "bike" | null = null;
|
||||
|
||||
if (activeMode === "train" && validJourneys.length > 0) {
|
||||
const onTimeJourneys = validJourneys.filter(journey =>
|
||||
journey.rA.getTime() <= targetArrivalTime.getTime()
|
||||
);
|
||||
const walkDurationMs = trainWalkDurationSeconds * 1000;
|
||||
const onTimeJourneys = validJourneys.filter((journey) => (
|
||||
journey.rA.getTime() + walkDurationMs <= targetArrivalTime.getTime()
|
||||
));
|
||||
|
||||
if (onTimeJourneys.length > 0) {
|
||||
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
||||
@@ -41,7 +43,7 @@ export function useDepartureTime(
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.rD);
|
||||
arrivalTime = new Date(bestJourney.rA);
|
||||
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||
mode = "train";
|
||||
}
|
||||
}
|
||||
@@ -57,5 +59,5 @@ export function useDepartureTime(
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes]);
|
||||
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,25 @@ interface HafasLocation {
|
||||
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) {
|
||||
const [station, setStation] = useState<Station | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!destination.trim()) return;
|
||||
if (!destination.trim()) {
|
||||
const resetId = setTimeout(() => {
|
||||
setStation(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}, 0);
|
||||
return () => clearTimeout(resetId);
|
||||
}
|
||||
let isMounted = true;
|
||||
|
||||
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<any>(body);
|
||||
const data = await client.hafasRequest<{ svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }> }>(body);
|
||||
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||
const stations = locL
|
||||
.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;
|
||||
setStation(stations[0] ?? null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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";
|
||||
|
||||
const STORAGE_KEY = "ttl_events";
|
||||
@@ -30,9 +30,18 @@ interface EventsContextType {
|
||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||
|
||||
export function EventsProvider({ children }: { children: ReactNode }) {
|
||||
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
|
||||
const [events, setEventsState] = useState<Event[]>([]);
|
||||
const skipInitialWrite = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
setEventsState(loadFromStorage());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipInitialWrite.current) {
|
||||
skipInitialWrite.current = false;
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
|
||||
}, [events]);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
const client = new ApiClient();
|
||||
import { api as client } from "@/lib/api";
|
||||
|
||||
export function useGeocode(destination: string) {
|
||||
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);
|
||||
|
||||
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 abortController: AbortController | null = null;
|
||||
|
||||
@@ -22,7 +27,8 @@ export function useGeocode(destination: string) {
|
||||
try {
|
||||
const data = await client.geocode(destination, "at");
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,11 +3,39 @@ import type { Journey } from "@timetoleave/core";
|
||||
import { parseHafasJourneys, hafasDateTime } from "@timetoleave/core";
|
||||
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(
|
||||
fromStationExtId: string | null,
|
||||
toStationExtId: string | null,
|
||||
date: Date,
|
||||
refreshKey = 0,
|
||||
arriveBy = false,
|
||||
) {
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -18,6 +46,9 @@ export function useJourneys(
|
||||
|
||||
const fetchJourneys = async () => {
|
||||
if (!fromStationExtId || !toStationExtId || !date) {
|
||||
setJourneys([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,24 +57,26 @@ export function useJourneys(
|
||||
|
||||
try {
|
||||
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 = {
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "TripSearch",
|
||||
req: {
|
||||
depLocL: [{ type: "S", extId: fromStationExtId }],
|
||||
arrLocL: [{ type: "S", extId: toStationExtId }],
|
||||
outDate: hafasDate,
|
||||
outTime: hafasTime,
|
||||
numF: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const data = await client.hafasRequest(body);
|
||||
const result = parseHafasJourneys(data, hafasDate, date);
|
||||
if (arriveBy && result.length === 0) {
|
||||
const fallbackDate = new Date(date.getTime() - ARRIVE_BY_FALLBACK_WINDOW_MS);
|
||||
const { date: fallbackHafasDate, time: fallbackHafasTime } = hafasDateTime(fallbackDate);
|
||||
const fallbackData = await client.hafasRequest(
|
||||
buildTripSearchBody(
|
||||
fromStationExtId,
|
||||
toStationExtId,
|
||||
fallbackHafasDate,
|
||||
fallbackHafasTime,
|
||||
false,
|
||||
10,
|
||||
),
|
||||
);
|
||||
result = parseHafasJourneys(fallbackData, fallbackHafasDate, fallbackDate);
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setJourneys(result);
|
||||
@@ -63,7 +96,7 @@ export function useJourneys(
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fromStationExtId, toStationExtId, date, refreshKey]);
|
||||
}, [fromStationExtId, toStationExtId, date, refreshKey, arriveBy]);
|
||||
|
||||
return { journeys, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
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";
|
||||
|
||||
interface HafasLocation {
|
||||
@@ -23,7 +23,7 @@ export function useOriginStation() {
|
||||
const fetchNearestStation = async () => {
|
||||
if (!location || state !== "granted") {
|
||||
if (isMounted) {
|
||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||
setStation(DEFAULT_STATION);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -76,14 +76,14 @@ export function useOriginStation() {
|
||||
if (stations.length > 0) {
|
||||
setStation(stations[0]);
|
||||
} else {
|
||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||
setStation(DEFAULT_STATION);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err: unknown) {
|
||||
if (isMounted) {
|
||||
const message = err instanceof Error ? err.message : "Failed to find nearest station";
|
||||
setError(message);
|
||||
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||
setStation(DEFAULT_STATION);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { WalkRoute } from "@timetoleave/core";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
const client = new ApiClient();
|
||||
import { api as client } from "@/lib/api";
|
||||
|
||||
export function useWalkRoute(
|
||||
fromLat: number | undefined,
|
||||
@@ -19,6 +17,9 @@ export function useWalkRoute(
|
||||
|
||||
const fetchRoute = async () => {
|
||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||
setWalkRoute(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("calendar-utils", () => {
|
||||
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("Berlin")).toBe("Berlin");
|
||||
expect(cleanLocation("Hoechstaedtplatz 6, 1200 Wien")).toBe("Hoechstaedtplatz 6, 1200 Wien");
|
||||
});
|
||||
|
||||
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_TIMEOUT_MS = parseInt(process.env.HAFAS_TIMEOUT_MS ?? "10000", 10);
|
||||
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 WIENER_LINIEN_API_URL = process.env.WIENER_LINIEN_API_URL || "https://api.wienerlinien.at/darwin-v2";
|
||||
export const DEFAULT_DAYS = 14;
|
||||
export const DEFAULT_STATION_NAME = "Mödling Bahnhof";
|
||||
export const DEFAULT_STATION_EXT_ID = "1231701";
|
||||
export const DEFAULT_STATION = DEFAULT_ORIGIN_STATION;
|
||||
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";
|
||||
|
||||
@@ -25,11 +25,14 @@ const allowedOrigins: Set<string> = new Set(
|
||||
: ["http://localhost:3000"], // dev fallback
|
||||
);
|
||||
|
||||
// Rate limiter: 30 requests / minute per IP. Tune these values for
|
||||
// your deployment.
|
||||
// Rate limiter: tune through env for deployment. Calendar pages legitimately
|
||||
// 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({
|
||||
maxRequests: 30,
|
||||
windowMs: 60_000,
|
||||
maxRequests: Number.isFinite(rateLimitMaxRequests) ? rateLimitMaxRequests : 120,
|
||||
windowMs: Number.isFinite(rateLimitWindowMs) ? rateLimitWindowMs : 60_000,
|
||||
});
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
Reference in New Issue
Block a user