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> {
|
||||
|
||||
Reference in New Issue
Block a user