Account for origin walk in departure time
Add useOriginStationWalk and integrate it into EventDetail/EventList to resolve walking time from origin to station. Introduce calculateLeaveByTime for notification scheduling and use exported Expo trigger types. Support HAFAS 'crd' coordinates in client and destination hooks, update tests, jest mappings, and Expo run scripts.
This commit is contained in:
@@ -2,11 +2,7 @@ module.exports = {
|
||||
preset: 'jest-expo',
|
||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||
moduleNameMapper: {
|
||||
'^react$': '<rootDir>/node_modules/react',
|
||||
'^react-test-renderer$': '<rootDir>/node_modules/react-test-renderer',
|
||||
'^react-native-safe-area-context$': '<rootDir>/node_modules/react-native-safe-area-context',
|
||||
'^react-native-screens$': '<rootDir>/node_modules/react-native-screens',
|
||||
'^@react-native-async-storage/async-storage$':
|
||||
'<rootDir>/node_modules/@react-native-async-storage/async-storage',
|
||||
'@react-native-async-storage/async-storage/jest/async-storage-mock',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"lint": "eslint src/",
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { loadEvents } from '../store/eventStore';
|
||||
import { loadEvents, loadNotificationSettings, loadOriginStation } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
|
||||
// Mock the store and utilities
|
||||
jest.mock('../store/eventStore', () => ({
|
||||
loadEvents: jest.fn(),
|
||||
loadOriginStation: jest.fn(),
|
||||
loadNotificationSettings: jest.fn(),
|
||||
removeEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,12 +20,80 @@ jest.mock('@timetoleave/core', () => ({
|
||||
calculateCountdown: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useColors', () => ({
|
||||
useColors: () => ({
|
||||
background: '#000',
|
||||
card: '#111',
|
||||
text: '#fff',
|
||||
subtext: '#aaa',
|
||||
accent: '#8B5CF6',
|
||||
border: '#333',
|
||||
delete: '#ff3b30',
|
||||
error: '#ff3b30',
|
||||
overlay: '#111',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useDestinationStation', () => ({
|
||||
useDestinationStation: () => ({
|
||||
station: { name: 'Ziel Bahnhof', extId: '8103000', lat: 48.2, lng: 16.3 },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useGeocode', () => ({
|
||||
useGeocode: () => ({
|
||||
coords: { lat: 48.21, lng: 16.31, display_name: 'Test Destination' },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useWalkRoute', () => ({
|
||||
useWalkRoute: () => ({
|
||||
walkRoute: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/useOriginStationWalk', () => ({
|
||||
useOriginStationWalk: () => ({
|
||||
station: { name: 'Mödling Bahnhof', extId: '1231701', lat: 48.085, lng: 16.296 },
|
||||
walkRoute: { distance: 700, duration: 600, steps: [] },
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../services/api', () => ({
|
||||
api: {
|
||||
searchJourneys: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2099-01-01T08:00:00Z'),
|
||||
rD: new Date('2099-01-01T08:00:00Z'),
|
||||
sA: new Date('2099-01-01T09:00:00Z'),
|
||||
rA: new Date('2099-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1 -> Wien'],
|
||||
cancelled: false,
|
||||
},
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
...jest.requireActual('@react-navigation/native'),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
// Execute the callback immediately so the component loads data
|
||||
callback();
|
||||
const React = jest.requireActual('react');
|
||||
React.useEffect(() => {
|
||||
callback();
|
||||
}, [callback]);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,6 +137,19 @@ const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as un
|
||||
describe('EventListScreen', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(loadOriginStation as jest.Mock).mockResolvedValue({
|
||||
name: 'Mödling Bahnhof',
|
||||
extId: '1231701',
|
||||
lat: 48.08,
|
||||
lng: 16.29,
|
||||
});
|
||||
(loadNotificationSettings as jest.Mock).mockResolvedValue({
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render empty state when no events', async () => {
|
||||
@@ -77,7 +160,7 @@ describe('EventListScreen', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Keine Termine')).toBeTruthy();
|
||||
expect(getByText('Keine kommenden Termine')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +170,7 @@ describe('EventListScreen', () => {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
@@ -107,6 +190,11 @@ describe('EventListScreen', () => {
|
||||
expect(getByText('Test Event')).toBeTruthy();
|
||||
expect(getByText('Test Destination')).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getByText('Losgehen')).toBeTruthy();
|
||||
expect(getByText('S1 -> Wien')).toBeTruthy();
|
||||
expect(getByText('Inkl. Fußweg zur Station: 10 min')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refresh correctly', async () => {
|
||||
@@ -115,7 +203,7 @@ describe('EventListScreen', () => {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
eventTime: new Date('2099-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
@@ -148,7 +236,7 @@ describe('AddEventScreen', () => {
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy();
|
||||
expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy();
|
||||
expect(getByPlaceholderText('z.B. Technikum Wien')).toBeTruthy();
|
||||
expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy();
|
||||
expect(getByPlaceholderText('SS:MM')).toBeTruthy();
|
||||
expect(getByText('Speichern')).toBeTruthy();
|
||||
@@ -175,7 +263,7 @@ describe('AddEventScreen', () => {
|
||||
|
||||
// Fill in all required fields except date format is invalid
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Technikum Wien'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
|
||||
@@ -192,7 +280,7 @@ describe('AddEventScreen', () => {
|
||||
|
||||
// Fill in all required fields with a past date
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Technikum Wien'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface DepartureTimeResult {
|
||||
* @param activeMode - The currently selected transport mode.
|
||||
* @param arrivalBufferMinutes - Minutes to arrive before the event starts.
|
||||
* @param trainWalkDurationSeconds - Walking time from destination station to event (seconds).
|
||||
* @param originWalkDurationSeconds - Walking time from start point to origin station (seconds).
|
||||
*/
|
||||
export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
@@ -30,6 +31,7 @@ export function useDepartureTime(
|
||||
activeMode: 'train' | 'bike' | null,
|
||||
arrivalBufferMinutes: number,
|
||||
trainWalkDurationSeconds = 0,
|
||||
originWalkDurationSeconds = 0,
|
||||
): DepartureTimeResult {
|
||||
return useMemo(() => {
|
||||
// Calculate target arrival time (event time minus buffer)
|
||||
@@ -56,7 +58,7 @@ export function useDepartureTime(
|
||||
current.rD.getTime() > latest.rD.getTime() ? current : latest,
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.rD);
|
||||
departureTime = new Date(bestJourney.rD.getTime() - originWalkDurationSeconds * 1000);
|
||||
arrivalTime = new Date(bestJourney.rA.getTime() + walkDurationMs);
|
||||
mode = 'train';
|
||||
}
|
||||
@@ -73,5 +75,13 @@ export function useDepartureTime(
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [eventTime, journeys, bikeDurationSeconds, activeMode, arrivalBufferMinutes, trainWalkDurationSeconds]);
|
||||
}, [
|
||||
eventTime,
|
||||
journeys,
|
||||
bikeDurationSeconds,
|
||||
activeMode,
|
||||
arrivalBufferMinutes,
|
||||
trainWalkDurationSeconds,
|
||||
originWalkDurationSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,12 @@ interface HafasLocation {
|
||||
type: string;
|
||||
name: string;
|
||||
extId: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
crd?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
|
||||
@@ -84,8 +88,8 @@ export function useDestinationStation(destination: string | undefined) {
|
||||
.map((l) => ({
|
||||
name: l.name,
|
||||
extId: l.extId,
|
||||
lat: normalizeHafasCoordinate(l.lat),
|
||||
lng: normalizeHafasCoordinate(l.lon),
|
||||
lat: normalizeHafasCoordinate(l.lat ?? l.crd?.y),
|
||||
lng: normalizeHafasCoordinate(l.lon ?? l.crd?.x),
|
||||
}));
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Station } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
import { useWalkRoute } from './useWalkRoute';
|
||||
|
||||
/**
|
||||
* Resolve the walking leg from the saved origin point to the departure station.
|
||||
*
|
||||
* `origin.lat/lng` may represent the user's real start point while `origin.extId`
|
||||
* identifies the station used for train search. When coordinates are missing or
|
||||
* station resolution fails, callers can safely fall back to zero duration.
|
||||
*/
|
||||
export function useOriginStationWalk(origin: Station | null) {
|
||||
const [station, setStation] = useState<Station | null>(null);
|
||||
const [lookupError, setLookupError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const resolveStation = async () => {
|
||||
setStation(null);
|
||||
setLookupError(null);
|
||||
|
||||
if (origin?.lat == null || origin.lng == null) return;
|
||||
|
||||
try {
|
||||
const nearest = await api.findNearestStationByCoords(origin.lat, origin.lng);
|
||||
if (!isMounted) return;
|
||||
setStation(nearest);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed');
|
||||
}
|
||||
};
|
||||
|
||||
resolveStation();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [origin?.lat, origin?.lng]);
|
||||
|
||||
const walk = useWalkRoute(origin?.lat, origin?.lng, station?.lat, station?.lng);
|
||||
|
||||
return {
|
||||
station,
|
||||
walkRoute: walk.walkRoute,
|
||||
loading: walk.loading,
|
||||
error: lookupError ?? walk.error,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } f
|
||||
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||
import { useGeocode } from '../hooks/useGeocode';
|
||||
import { useOriginStationWalk } from '../hooks/useOriginStationWalk';
|
||||
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { useWienerLinien } from '../hooks/useWienerLinien';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
@@ -70,6 +71,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
const originWalk = useOriginStationWalk(origin);
|
||||
const wienerLinien = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -160,6 +162,7 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
: null,
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption ? (walkRoute?.duration ?? 0) : 0,
|
||||
originWalk.walkRoute?.duration ?? 0,
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
import { loadEvents, loadNotificationSettings, loadOriginStation, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown, formatTime } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent, Journey, Station } from '@timetoleave/core';
|
||||
import type { RootStack } from '../types/navigation';
|
||||
import { useColors } from '../hooks/useColors';
|
||||
import { useDepartureTime } from '../hooks/useDepartureTime';
|
||||
import { useDestinationStation } from '../hooks/useDestinationStation';
|
||||
import { useGeocode } from '../hooks/useGeocode';
|
||||
import { useOriginStationWalk } from '../hooks/useOriginStationWalk';
|
||||
import { useWalkRoute } from '../hooks/useWalkRoute';
|
||||
import { api } from '../services/api';
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
@@ -29,13 +35,26 @@ type ScreenProps = {
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [arrivalBufferMinutes, setArrivalBufferMinutes] = useState(5);
|
||||
const [showWalkingOption, setShowWalkingOption] = useState(true);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [journeysLoading, setJourneysLoading] = useState(false);
|
||||
const [journeysError, setJourneysError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
// Force countdown recalculation periodically
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
const [list, originStation, settings] = await Promise.all([
|
||||
loadEvents(),
|
||||
loadOriginStation(),
|
||||
loadNotificationSettings(),
|
||||
]);
|
||||
setEvents(list);
|
||||
setOrigin(originStation);
|
||||
setArrivalBufferMinutes(settings.arrivalBufferMinutes);
|
||||
setShowWalkingOption(settings.showWalkingOption);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
@@ -63,8 +82,100 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime())[0] ?? null;
|
||||
}, [events]);
|
||||
|
||||
const destStation = useDestinationStation(upcomingEvent?.destination);
|
||||
const destCoords = useGeocode(upcomingEvent?.destination);
|
||||
const walkHook = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
const destinationStationExtId = destStation.station?.extId;
|
||||
const destinationStationLat = destStation.station?.lat;
|
||||
const destinationStationLng = destStation.station?.lng;
|
||||
const destinationLat = destCoords.coords?.lat;
|
||||
const destinationLng = destCoords.coords?.lng;
|
||||
const originExtId = origin?.extId;
|
||||
const originWalk = useOriginStationWalk(origin);
|
||||
const originWalkDurationSeconds = originWalk.walkRoute?.duration ?? 0;
|
||||
const walkDurationSeconds = showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0;
|
||||
const departureInfo = useDepartureTime(
|
||||
upcomingEvent?.eventTime ?? new Date(),
|
||||
journeys.length > 0 ? journeys : null,
|
||||
null,
|
||||
journeys.length > 0 ? 'train' : null,
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds,
|
||||
originWalkDurationSeconds,
|
||||
);
|
||||
const selectedJourney = useMemo(() => {
|
||||
if (!departureInfo.departureTime) return null;
|
||||
const trainDepartureTime = departureInfo.departureTime.getTime() + originWalkDurationSeconds * 1000;
|
||||
return journeys.find((journey) => journey.rD.getTime() === trainDepartureTime) ?? null;
|
||||
}, [departureInfo.departureTime, journeys, originWalkDurationSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchJourneys = async () => {
|
||||
if (!upcomingEvent || !originExtId || !destinationStationExtId) return;
|
||||
|
||||
const finalWalkPending =
|
||||
showWalkingOption &&
|
||||
destinationLat != null &&
|
||||
destinationLng != null &&
|
||||
destinationStationLat != null &&
|
||||
destinationStationLng != null &&
|
||||
walkHook.loading &&
|
||||
!walkHook.walkRoute &&
|
||||
!walkHook.error;
|
||||
if (finalWalkPending) return;
|
||||
|
||||
setJourneys((current) => (current.length > 0 ? [] : current));
|
||||
setJourneysError((current) => (current === null ? current : null));
|
||||
setJourneysLoading(true);
|
||||
try {
|
||||
const target = new Date(
|
||||
upcomingEvent.eventTime.getTime() -
|
||||
arrivalBufferMinutes * 60_000 -
|
||||
walkDurationSeconds * 1000,
|
||||
);
|
||||
const results = await api.searchJourneys(originExtId, destinationStationExtId, target, { arriveBy: true });
|
||||
if (!isMounted) return;
|
||||
setJourneys(results);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setJourneysError(err instanceof Error ? err.message : 'Verbindungen konnten nicht geladen werden');
|
||||
} finally {
|
||||
if (isMounted) setJourneysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJourneys();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [
|
||||
upcomingEvent,
|
||||
originExtId,
|
||||
destinationStationExtId,
|
||||
destinationStationLat,
|
||||
destinationStationLng,
|
||||
destinationLat,
|
||||
destinationLng,
|
||||
walkHook.loading,
|
||||
walkHook.walkRoute,
|
||||
walkHook.error,
|
||||
showWalkingOption,
|
||||
arrivalBufferMinutes,
|
||||
walkDurationSeconds,
|
||||
]);
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
const countdown = calculateCountdown(item.eventTime);
|
||||
const leaveBy = departureInfo.departureTime;
|
||||
const trainLabel = selectedJourney?.trains.length ? selectedJourney.trains.join(', ') : 'Zugverbindung wird gesucht';
|
||||
|
||||
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
||||
// so we show countdown-based status instead
|
||||
@@ -86,8 +197,36 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.subtitle, { color: colors.subtext }]}>{item.destination}</Text>
|
||||
<Text style={[styles.time, { color: colors.accent }]}>
|
||||
{item.eventTime.toLocaleString('de-AT', {
|
||||
<Text style={[styles.leaveLabel, { color: colors.subtext }]}>Losgehen</Text>
|
||||
<Text style={[styles.leaveTime, { color: leaveBy ? colors.accent : colors.subtext }]}>
|
||||
{leaveBy ? formatTime(leaveBy) : journeysLoading || destStation.loading ? '--:--' : 'Keine Verbindung'}
|
||||
</Text>
|
||||
<View style={[styles.trainBox, { borderColor: colors.border }]}>
|
||||
<Text style={[styles.trainTitle, { color: colors.text }]} numberOfLines={2}>
|
||||
{journeysError ? 'Zugverbindung nicht erreichbar' : trainLabel}
|
||||
</Text>
|
||||
{selectedJourney ? (
|
||||
<Text style={[styles.trainMeta, { color: colors.subtext }]}>
|
||||
Abfahrt {formatTime(selectedJourney.rD)}
|
||||
{selectedJourney.platform ? ` · Gleis ${selectedJourney.platform}` : ''}
|
||||
{' · '}
|
||||
Ankunft {formatTime(selectedJourney.rA)}
|
||||
{' · '}
|
||||
{selectedJourney.changes === 0 ? 'Direkt' : `${selectedJourney.changes} Umstiege`}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[styles.trainMeta, { color: journeysError ? colors.delete : colors.subtext }]}>
|
||||
{journeysError ?? (origin ? 'Beste Verbindung für den nächsten Termin' : 'Ursprungstation festlegen')}
|
||||
</Text>
|
||||
)}
|
||||
{originWalk.walkRoute && originWalk.walkRoute.duration > 30 && (
|
||||
<Text style={[styles.trainMeta, { color: colors.subtext }]}>
|
||||
Inkl. Fußweg zur Station: {Math.ceil(originWalk.walkRoute.duration / 60)} min
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.time, { color: colors.subtext }]}>
|
||||
Termin: {item.eventTime.toLocaleString('de-AT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -103,7 +242,7 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
onPress={() => navigation.navigate('AddEvent', { editEventId: item.id })}
|
||||
style={styles.editBtn}
|
||||
>
|
||||
<Text style={[styles.editText, { color: colors.accent }]}>✏️ Bearbeiten</Text>
|
||||
<Text style={[styles.editText, { color: colors.accent }]}>Bearbeiten</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Delete button */}
|
||||
@@ -130,20 +269,6 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('CalendarImport')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={[styles.topBtnText, { color: colors.accent }]}>📅 Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={[styles.topBtnText, { color: colors.accent }]}>⚙️ Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={[upcomingEvent]}
|
||||
keyExtractor={(item) => item.id}
|
||||
@@ -165,9 +290,6 @@ export function EventListScreen({ navigation }: ScreenProps) {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||
topBtnText: { fontSize: 15 },
|
||||
list: { padding: 12 },
|
||||
cardWrapper: { marginBottom: 12 },
|
||||
card: {
|
||||
@@ -185,6 +307,11 @@ const styles = StyleSheet.create({
|
||||
badge: { fontSize: 12, fontWeight: '600' },
|
||||
subtitle: { fontSize: 14, marginBottom: 4 },
|
||||
time: { fontSize: 13 },
|
||||
leaveLabel: { fontSize: 12, fontWeight: '600', marginTop: 18, textTransform: 'uppercase' },
|
||||
leaveTime: { fontSize: 52, lineHeight: 58, fontWeight: '800', marginTop: 2, marginBottom: 12 },
|
||||
trainBox: { borderWidth: 1, borderRadius: 10, padding: 12, marginBottom: 12 },
|
||||
trainTitle: { fontSize: 16, fontWeight: '700' },
|
||||
trainMeta: { fontSize: 13, marginTop: 6, lineHeight: 18 },
|
||||
status: { fontSize: 13, marginTop: 2, fontWeight: '500' },
|
||||
editBtn: { alignSelf: 'flex-start', marginTop: 4 },
|
||||
editText: { fontSize: 13, fontWeight: '500' },
|
||||
|
||||
@@ -138,8 +138,8 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: closest.lat,
|
||||
lng: closest.lng,
|
||||
lat: userLat,
|
||||
lng: userLng,
|
||||
};
|
||||
await selectStation(station);
|
||||
return;
|
||||
@@ -152,7 +152,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
||||
return;
|
||||
}
|
||||
await selectStation(nearestStation);
|
||||
await selectStation({
|
||||
...nearestStation,
|
||||
lat: userLat,
|
||||
lng: userLng,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// API not reachable
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
cancelScheduledNotificationAsync,
|
||||
cancelAllScheduledNotificationsAsync,
|
||||
scheduleNotificationAsync,
|
||||
SchedulableTriggerInputTypes,
|
||||
} from 'expo-notifications';
|
||||
|
||||
// Re-export types from the public package
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Event, Journey } from '@timetoleave/core';
|
||||
|
||||
/**
|
||||
* Calculate the time at which a leave reminder should fire.
|
||||
*
|
||||
* If journey data is available, use the earliest non-cancelled departure and
|
||||
* subtract the reminder buffer. Otherwise, fall back to event time minus the
|
||||
* requested arrival buffer and reminder buffer.
|
||||
*/
|
||||
export function calculateLeaveByTime(
|
||||
event: Event,
|
||||
journeys: Journey[],
|
||||
arrivalBufferMinutes: number,
|
||||
reminderBufferMinutes: number,
|
||||
): Date {
|
||||
const validJourneys = journeys.filter((journey) => !journey.cancelled);
|
||||
|
||||
if (validJourneys.length > 0) {
|
||||
const earliestDeparture = validJourneys.reduce((earliest, journey) =>
|
||||
journey.rD.getTime() < earliest.rD.getTime() ? journey : earliest,
|
||||
);
|
||||
|
||||
return new Date(earliestDeparture.rD.getTime() - reminderBufferMinutes * 60_000);
|
||||
}
|
||||
|
||||
const totalBufferMs = (arrivalBufferMinutes + reminderBufferMinutes) * 60_000;
|
||||
return new Date(event.eventTime.getTime() - totalBufferMs);
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
|
||||
// ── Keys ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,7 +88,7 @@ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promi
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user