Add JSDoc documentation to all TypeScript files

This commit is contained in:
2026-05-14 13:53:12 +02:00
parent 09b5e7725d
commit 498958a27c
86 changed files with 728 additions and 16 deletions
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
import type { BikeRoute, Station } from '@timetoleave/core'; import type { BikeRoute, Station } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
/** Props for the bike route information section shown on event detail. */
interface Props { interface Props {
bikeRoute: BikeRoute | null; bikeRoute: BikeRoute | null;
loading: boolean; loading: boolean;
@@ -10,6 +11,11 @@ interface Props {
colors: AppColors; colors: AppColors;
} }
/**
* Displays cycling route duration, distance, and a map placeholder for the
* event's origin → destination leg. Shows contextual empty states when no
* origin is set or no route is available.
*/
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) { export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <View style={[styles.container, { backgroundColor: colors.background }]}>
@@ -2,6 +2,7 @@ import { StyleSheet, Text, View } from 'react-native';
import type { Event } from '@timetoleave/core'; import type { Event } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
/** Props for the event detail header showing title, destination, times, and buffers. */
interface Props { interface Props {
event: Event; event: Event;
leaveByTime: Date | null; leaveByTime: Date | null;
@@ -9,6 +10,10 @@ interface Props {
colors: AppColors; colors: AppColors;
} }
/**
* Renders the event title, destination, formatted date/time, data source,
* and a three-column grid with leave-by time, arrive-by time, and buffer.
*/
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) { export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000); const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
import type { Journey, Station, WalkRoute } from '@timetoleave/core'; import type { Journey, Station, WalkRoute } from '@timetoleave/core';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
/** Props for the train journey list and optional final walk leg. */
interface Props { interface Props {
journeys: Journey[]; journeys: Journey[];
destStationLoading: boolean; destStationLoading: boolean;
@@ -15,6 +16,12 @@ interface Props {
colors: AppColors; colors: AppColors;
} }
/**
* Renders each journey card with train lines, departure/arrival times, platform,
* transfer count, and delay/cancellation badges. Cards that arrive too late are
* visually dimmed and bordered in red. Optionally shows the final walking leg
* from the destination station to the event location.
*/
export function JourneyList({ export function JourneyList({
journeys, journeys,
destStationLoading, destStationLoading,
@@ -3,6 +3,7 @@ import type { WienerLinienStop } from '@timetoleave/core';
import type { DepartureRow } from '../hooks/useWienerLinien'; import type { DepartureRow } from '../hooks/useWienerLinien';
import type { AppColors } from '../hooks/useColors'; import type { AppColors } from '../hooks/useColors';
/** Props for the nearby public transport stops section. */
interface Props { interface Props {
stops: WienerLinienStop[]; stops: WienerLinienStop[];
departures: DepartureRow[]; departures: DepartureRow[];
@@ -11,6 +12,11 @@ interface Props {
colors: AppColors; colors: AppColors;
} }
/**
* Shows public transport stops near the event destination and their upcoming
* departures. Caps the departure list at 8 items to avoid overwhelming the UI.
* Falls back to showing plain stop names when no departure data is available.
*/
export function NearbyStops({ stops, departures, loading, error, colors }: Props) { export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
if (!loading && stops.length === 0 && !error) return null; if (!loading && stops.length === 0 && !error) return null;
+5
View File
@@ -32,8 +32,13 @@ const LIGHT = {
highlight: '#e8f4fd', highlight: '#e8f4fd',
} as const; } as const;
/** Color token type — every theme variant shares the same keys. */
export type AppColors = Record<keyof typeof DARK, string>; export type AppColors = Record<keyof typeof DARK, string>;
/**
* Returns the full color palette (dark or light) based on the current theme.
* Derives from {@link useTheme} so it stays in sync with user preferences.
*/
export function useColors(): AppColors { export function useColors(): AppColors {
const { dark } = useTheme(); const { dark } = useTheme();
return dark ? DARK : LIGHT; return dark ? DARK : LIGHT;
+12
View File
@@ -9,7 +9,19 @@ interface DepartureTimeResult {
/** /**
* Calculate departure time based on selected transport mode. * Calculate departure time based on selected transport mode.
*
* For train mode, picks the latest-departing journey that still arrives on time
* (factoring in the final walking leg). For bike mode, simply subtracts the
* cycling duration from the target arrival time.
*
* Mirrors the web app's useDepartureTime hook. * Mirrors the web app's useDepartureTime hook.
*
* @param eventTime - The scheduled event start time.
* @param journeys - Available train journeys (may be null if not loaded yet).
* @param bikeDurationSeconds - Cycling duration in seconds, or null if unavailable.
* @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).
*/ */
export function useDepartureTime( export function useDepartureTime(
eventTime: Date, eventTime: Date,
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
import type { Station } from '@timetoleave/core'; import type { Station } from '@timetoleave/core';
import { api } from '../services/api'; import { api } from '../services/api';
/**
* Geocodes a destination string to the nearest HAFAS station.
*
* Two-step process: first geocodes the address to lat/lng via Nominatim,
* then sends those coordinates to HAFAS LocMatch to find the closest station.
* Debounces lookups by 400 ms to avoid excessive API calls while typing.
*/
/** Intermediate shape returned by HAFAS LocMatch before we pick the best station. */
interface HafasLocation { interface HafasLocation {
type: string; type: string;
name: string; name: string;
@@ -10,6 +18,7 @@ interface HafasLocation {
lon: number; lon: number;
} }
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
function normalizeHafasCoordinate(value: number | undefined): number | undefined { function normalizeHafasCoordinate(value: number | undefined): number | undefined {
if (value == null) return undefined; if (value == null) return undefined;
return Math.abs(value) > 1000 ? value / 1e6 : value; return Math.abs(value) > 1000 ? value / 1e6 : value;
+2
View File
@@ -4,6 +4,8 @@ import { api } from '../services/api';
/** /**
* Geocode a destination name to coordinates. * Geocode a destination name to coordinates.
* Debounces API calls by 400 ms. Automatically resets to null when the
* destination is cleared or becomes empty.
* Mirrors the web app's useGeocode hook. * Mirrors the web app's useGeocode hook.
*/ */
export function useGeocode(destination: string | undefined) { export function useGeocode(destination: string | undefined) {
+7
View File
@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
/** Theme storage key in AsyncStorage. */
const THEME_KEY = '@timetoleave_theme'; const THEME_KEY = '@timetoleave_theme';
type Theme = 'dark' | 'light'; type Theme = 'dark' | 'light';
/** Returns the default theme. Mobile defaults to dark to match the web app. */
function getDefaultTheme(): Theme { function getDefaultTheme(): Theme {
// React Native doesn't have window.matchMedia, but we can use a simple default // React Native doesn't have window.matchMedia, but we can use a simple default
// The web app uses a dark-first theme, so we match that default // The web app uses a dark-first theme, so we match that default
@@ -13,6 +15,11 @@ function getDefaultTheme(): Theme {
/** /**
* Theme management for the mobile app. * Theme management for the mobile app.
*
* Persists the user's choice in AsyncStorage and initializes from storage
* on mount (guarded by a ref so hot-reload doesn't re-read). Returns a
* `dark` boolean and a `toggle` callback for switching themes.
*
* Mirrors the web app's useTheme hook. * Mirrors the web app's useTheme hook.
*/ */
export function useTheme() { export function useTheme() {
+2 -1
View File
@@ -3,7 +3,8 @@ import type { WalkRoute } from '@timetoleave/core';
import { api } from '../services/api'; import { api } from '../services/api';
/** /**
* Fetch walk route between two points. * Fetch walk route between two points using the OSRM walking router.
* Skips the request if any coordinate is missing, resetting state to null.
* Mirrors the web app's useWalkRoute hook. * Mirrors the web app's useWalkRoute hook.
*/ */
export function useWalkRoute( export function useWalkRoute(
+15
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core'; import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
import { api } from '../services/api'; import { api } from '../services/api';
/** Flattened departure row shown in the NearbyStops UI. */
export interface DepartureRow { export interface DepartureRow {
stopId: string; stopId: string;
lineName: string; lineName: string;
@@ -12,6 +13,7 @@ export interface DepartureRow {
const DEBOUNCE_MS = 400; const DEBOUNCE_MS = 400;
const REFRESH_INTERVAL_MS = 60_000; const REFRESH_INTERVAL_MS = 60_000;
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
function transformDeparture(dep: WienerLinienDeparture): DepartureRow { function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000)); const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
return { return {
@@ -22,6 +24,16 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
}; };
} }
/**
* Fetches nearby WienerLinien stops around given coordinates and their live
* departures. Debounces initial lookups (400 ms) and refreshes departures
* every 60 seconds. Uses a cancellation ref to prevent stale overwrites when
* coordinates change mid-request.
*
* @param lat - Latitude of the point of interest.
* @param lng - Longitude of the point of interest.
* @param radius - Search radius in meters (defaults to 500).
*/
export function useWienerLinien( export function useWienerLinien(
lat: number | undefined, lat: number | undefined,
lng: number | undefined, lng: number | undefined,
@@ -32,9 +44,12 @@ export function useWienerLinien(
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Stopped by the cleanup effect when coordinates change, so in-flight
// monitor requests don't overwrite stale stop lists.
const stopIdsRef = useRef<string[]>([]); const stopIdsRef = useRef<string[]>([]);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => { const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
if (stopIds.length === 0 || cancelledRef.current) return; if (stopIds.length === 0 || cancelledRef.current) return;
try { try {
+20 -6
View File
@@ -1,5 +1,6 @@
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Image, Text, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { EventListScreen } from '../screens/EventListScreen'; import { EventListScreen } from '../screens/EventListScreen';
@@ -13,6 +14,10 @@ import type { RootStack } from '../types/navigation';
const Root = createNativeStackNavigator<RootStack>(); const Root = createNativeStackNavigator<RootStack>();
/**
* Root native stack navigator for the app.
* Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
*/
export default function AppNavigator() { export default function AppNavigator() {
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
@@ -21,13 +26,22 @@ export default function AppNavigator() {
<NavigationContainer> <NavigationContainer>
<Root.Navigator <Root.Navigator
initialRouteName="EventList" initialRouteName="EventList"
screenOptions={{ headerStyle: { backgroundColor: '#17112A' }, headerTintColor: '#F4F1EA' }} screenOptions={{
headerStyle: { backgroundColor: '#17112A' },
headerTintColor: '#F4F1EA',
headerTitle: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Image source={require('../../assets/icon.png')} style={{ width: 28, height: 28 }} resizeMode="contain" />
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time To Leave</Text>
</View>
),
}}
> >
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} /> <Root.Screen name="EventList" component={EventListScreen} />
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} /> <Root.Screen name="AddEvent" component={AddEventScreen} />
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} /> <Root.Screen name="EventDetail" component={EventDetailScreen} />
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} /> <Root.Screen name="Settings" component={SettingsScreen} />
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} /> <Root.Screen name="CalendarImport" component={CalendarImportScreen} />
</Root.Navigator> </Root.Navigator>
</NavigationContainer> </NavigationContainer>
</SafeAreaView> </SafeAreaView>
@@ -1,3 +1,14 @@
/**
* Polyfills for newer JavaScript features that React Native's Hermes engine
* doesn't provide out of the box.
*
* - `String.prototype.toWellFormed` / `isWellFormed` — handles lone surrogates
* by replacing them with the Unicode replacement character (U+FFFD).
* - `ArrayBuffer.prototype.resizable` — required by newer Intl APIs.
* - `SharedArrayBuffer` — stubbed so that libraries which check for its
* presence (e.g. the Intl locale data) don't crash at runtime.
*/
const globalScope = globalThis as Record<string, unknown>; const globalScope = globalThis as Record<string, unknown>;
const stringPrototype = String.prototype as typeof String.prototype & { const stringPrototype = String.prototype as typeof String.prototype & {
isWellFormed?: () => boolean; isWellFormed?: () => boolean;
@@ -6,6 +17,11 @@ const stringPrototype = String.prototype as typeof String.prototype & {
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get; const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
/**
* Replace lone surrogates (U+D800…U+DBFF without a partner, or U+DC00…U+DFFF
* without a lead) with the Unicode replacement character U+FFFD. Valid
* surrogate pairs are kept as-is. Used by both `toWellFormed` and `isWellFormed`.
*/
function toWellFormedString(value: string): string { function toWellFormedString(value: string): string {
let result = ''; let result = '';
@@ -18,6 +18,13 @@ type ScreenProps = {
route: RouteProp<RootStack, 'AddEvent'>; route: RouteProp<RootStack, 'AddEvent'>;
}; };
/**
* Form screen for creating or editing an event.
*
* When navigated with `editEventId`, loads the existing event and populates
* the form fields. Validates that the event time is in the future before saving.
* Shows a transient success overlay for 1.5 s before popping back.
*/
export function AddEventScreen({ navigation, route }: ScreenProps) { export function AddEventScreen({ navigation, route }: ScreenProps) {
const colors = useColors(); const colors = useColors();
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -22,6 +22,10 @@ type ScreenProps = {
route: RouteProp<RootStack, 'CalendarImport'>; route: RouteProp<RootStack, 'CalendarImport'>;
}; };
/**
* Screen for importing events from either an ICS calendar URL or the device's
* native calendar. Deduplicates against already-imported events by ID.
*/
export function CalendarImportScreen({ navigation }: ScreenProps) { export function CalendarImportScreen({ navigation }: ScreenProps) {
const colors = useColors(); const colors = useColors();
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
@@ -31,6 +31,11 @@ type ScreenProps = {
type TransportMode = 'train' | 'bike'; type TransportMode = 'train' | 'bike';
/**
* Compute the target arrival time at the destination station.
* Subtracts both the arrival buffer (time before event) and the walking
* duration from station to event location.
*/
function stationArrivalTarget( function stationArrivalTarget(
eventTime: Date, eventTime: Date,
arrivalBufferMinutes: number, arrivalBufferMinutes: number,
@@ -21,6 +21,11 @@ type ScreenProps = {
route: RouteProp<RootStack, 'EventList'>; route: RouteProp<RootStack, 'EventList'>;
}; };
/**
* Home screen showing a scrollable list of upcoming events with countdown
* badges. Pull-to-refresh reloads events from storage. Reloads automatically
* when the screen gains focus (so edits on other screens are reflected).
*/
export function EventListScreen({ navigation }: ScreenProps) { export function EventListScreen({ navigation }: ScreenProps) {
const colors = useColors(); const colors = useColors();
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
+16 -3
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Alert, Alert,
ActivityIndicator, ActivityIndicator,
ScrollView,
StyleSheet, StyleSheet,
Switch, Switch,
Text, Text,
@@ -24,6 +25,13 @@ type ScreenProps = {
route: RouteProp<RootStack, 'Settings'>; route: RouteProp<RootStack, 'Settings'>;
}; };
/**
* Settings screen for managing the origin station, notification preferences,
* appearance (dark/light mode), and advanced options (walk/bike toggles).
*
* Station search is debounced by 400 ms. When the origin changes, all
* scheduled push notifications are recalculated to use the new departure station.
*/
export function SettingsScreen({ navigation: _navigation }: ScreenProps) { export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
const { dark, toggle: toggleTheme } = useTheme(); const { dark, toggle: toggleTheme } = useTheme();
const colors = useColors(); const colors = useColors();
@@ -209,7 +217,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
}; };
return ( return (
<View style={[styles.container, { backgroundColor: colors.background }]}> <ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps="handled"
>
{/* Appearance */} {/* Appearance */}
<View style={styles.section}> <View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text> <Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
@@ -322,12 +334,13 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
</View> </View>
)} )}
</View> </View>
</View> </ScrollView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, padding: 20 }, container: { flex: 1 },
contentContainer: { flexGrow: 1, padding: 20 },
section: { marginBottom: 24 }, section: { marginBottom: 24 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 }, sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
input: { input: {
+4
View File
@@ -1,4 +1,8 @@
import { ApiClient } from '@timetoleave/api-client'; import { ApiClient } from '@timetoleave/api-client';
/**
* Thin wrapper around the shared `ApiClient` class.
* Reads the base URL from the Expo environment variable `EXPO_PUBLIC_API_BASE_URL`.
*/
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? ''; const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
export const api = new ApiClient(baseUrl); export const api = new ApiClient(baseUrl);
+4
View File
@@ -4,8 +4,12 @@ import type { Event as CoreEvent } from '@timetoleave/core';
/** /**
* Native calendar integration for the mobile app. * Native calendar integration for the mobile app.
* Reads events from device calendars and converts them to our internal format. * Reads events from device calendars and converts them to our internal format.
*
* Only events that have a non-empty `location` field are included, since the
* app requires a destination to compute routes.
*/ */
/** Requests calendar read permission and verifies the calendar service is available. */
export async function ensureCalendarPermission(): Promise<boolean> { export async function ensureCalendarPermission(): Promise<boolean> {
const { status } = await Calendar.requestCalendarPermissionsAsync(); const { status } = await Calendar.requestCalendarPermissionsAsync();
if (status !== 'granted') if (status !== 'granted')
@@ -1,3 +1,9 @@
/**
* Re-exports the stable public API from `expo-notifications`.
* Using public exports instead of internal `/build/` paths to avoid
* breaking when the Expo package is updated.
*/
// Public API re-exports from expo-notifications // Public API re-exports from expo-notifications
// Using stable public exports instead of internal /build/ paths // Using stable public exports instead of internal /build/ paths
+28
View File
@@ -1,3 +1,11 @@
/**
* Persistent event store backed by AsyncStorage.
*
* Every mutation (add, update, remove) also reschedules push notifications
* so the user is reminded at the correct departure time. Settings changes
* (origin station, buffer) trigger a full notification reschedule.
*/
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type 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';
@@ -21,6 +29,10 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
// ── Helpers ──────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────
/**
* Revives `eventTime` strings from JSON storage back into Date objects.
* Returns an empty array if the stored data is corrupted.
*/
function reviveDates(json: string): Event[] { function reviveDates(json: string): Event[] {
try { try {
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>; const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
@@ -39,6 +51,11 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
// Notification scheduling utilities // Notification scheduling utilities
// ─────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────
/**
* Calculate the leave-by time using a pessimistic fallback.
* Because the store doesn't hold live journey data, it subtracts
* both the arrival buffer and the notification buffer from the event time.
*/
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> { async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
// Calculate target arrival time (event time minus arrival buffer) // Calculate target arrival time (event time minus arrival buffer)
const targetArrivalTime = new Date(event.eventTime); const targetArrivalTime = new Date(event.eventTime);
@@ -48,6 +65,12 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000); return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
} }
/**
* Schedule three reminder notifications for an event:
* 30 min, 10 min, and 0 min before the leave-by time.
* Skips notifications that would fire in the past or more than 2 hours before
* the event (push notifications are unreliable beyond that window).
*/
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> { async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
const REMINDERS_MIN = [30, 10, 0]; const REMINDERS_MIN = [30, 10, 0];
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000); const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
@@ -70,6 +93,11 @@ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promi
} }
} }
/**
* Schedule (or re-schedule) notifications for a single event.
* Cancels any existing notifications for this event first to avoid duplicates.
* No-ops if notifications are globally disabled in settings.
*/
async function scheduleEventNotification(event: Event): Promise<void> { async function scheduleEventNotification(event: Event): Promise<void> {
const settings = await getNotificationSettings(); const settings = await getNotificationSettings();
if (!settings.enabled) return; if (!settings.enabled) return;
@@ -6,6 +6,15 @@ import Button from "@/app/ui/Button";
import { useEventsStore } from "@/hooks/useEventsStore"; import { useEventsStore } from "@/hooks/useEventsStore";
import type { Event } from "@timetoleave/core"; import type { Event } from "@timetoleave/core";
/**
* Modal for creating or editing a manually added event.
*
* Collects title, destination, date, and time. In edit mode, populates
* fields from the existing `editEvent` and calls `updateEvent` instead of `addEvent`.
* Shows a brief success overlay before closing on submit.
*
* Closes on Escape key press or backdrop click.
*/
type AddEventModalProps = { type AddEventModalProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -1,6 +1,16 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
/**
* OAuth2 callback handler. Exchanges the authorization code for tokens.
*
* Flow:
* 1. Verify the CSRF `state` param matches the stored cookie.
* 2. Exchange the `code` for access+refresh tokens at Google's token endpoint.
* 3. Store tokens in an httpOnly `google_tokens` cookie (1 year maxAge).
* 4. Redirect back to `/calendar` with a success indicator.
*/
function getBaseUrl(): string { function getBaseUrl(): string {
return process.env.DEPLOYMENT_URL || "http://localhost:3000"; return process.env.DEPLOYMENT_URL || "http://localhost:3000";
} }
@@ -1,6 +1,9 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
/**
* Disconnects the user's Google Calendar integration by clearing stored tokens.
*/
export async function POST() { export async function POST() {
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.delete("google_tokens"); cookieStore.delete("google_tokens");
+10
View File
@@ -2,6 +2,16 @@ import { randomBytes } from "crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
/**
* Initiates the Google OAuth2 authorization flow.
*
* Generates a random CSRF state token, stores it in an httpOnly cookie,
* and redirects the user to Google's consent screen.
*
* On return, Google redirects to `/api/auth/google/callback` with the
* authorization code and the state for verification.
*/
function getBaseUrl(): string { function getBaseUrl(): string {
return process.env.DEPLOYMENT_URL || "http://localhost:3000"; return process.env.DEPLOYMENT_URL || "http://localhost:3000";
} }
@@ -1,6 +1,13 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
/**
* Returns the current Google Calendar connection status.
*
* Response: `{ configured: boolean, connected: boolean }`
* - `configured` — env vars (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) are set.
* - `connected` — valid OAuth tokens exist in the user's cookie store.
*/
export async function GET() { export async function GET() {
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
if (!configured) { if (!configured) {
+11
View File
@@ -3,6 +3,17 @@ import { NextRequest, NextResponse } from "next/server";
import { BikeRoutingClient } from "@/lib/bike-routing-client"; import { BikeRoutingClient } from "@/lib/bike-routing-client";
import { validateCoordinate } from "@/lib/api-guards"; import { validateCoordinate } from "@/lib/api-guards";
/**
* OSRM bicycle routing endpoint.
*
* Computes a door-to-door bike route between two coordinate pairs.
* Uses a module-level singleton client that caches results.
*
* Query params: `fromLat`, `fromLng`, `toLat`, `toLng` (all required).
*
* Returns distance in meters, duration in seconds, and turn-by-turn steps.
*/
// Module-level singleton — cache persists across requests // Module-level singleton — cache persists across requests
const client = new BikeRoutingClient(); const client = new BikeRoutingClient();
@@ -4,12 +4,14 @@ import type { CalendarEvent } from "@timetoleave/core";
import { cleanLocation } from "@/lib/calendar-utils"; import { cleanLocation } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
/** Stored Google OAuth tokens, persisted in an httpOnly cookie. */
interface GoogleTokens { interface GoogleTokens {
access_token: string; access_token: string;
refresh_token: string | null; refresh_token: string | null;
expires_at: number; expires_at: number;
} }
/** Raw event shape from the Google Calendar API response. */
interface GoogleEventItem { interface GoogleEventItem {
id: string; id: string;
summary?: string; summary?: string;
@@ -17,6 +19,11 @@ interface GoogleEventItem {
start?: { dateTime?: string; date?: string }; start?: { dateTime?: string; date?: string };
} }
/**
* Refreshes an expired Google access token using the stored refresh token.
*
* @returns New token set, or `null` if refresh is impossible (no refresh token / network failure).
*/
async function refreshAccessToken(tokens: GoogleTokens): Promise<GoogleTokens | null> { async function refreshAccessToken(tokens: GoogleTokens): Promise<GoogleTokens | null> {
if (!tokens.refresh_token) return null; if (!tokens.refresh_token) return null;
@@ -4,6 +4,13 @@ import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
import { readBodyWithLimit, MAX_REQUEST_BODY_BYTES } from "@/lib/api-guards"; import { readBodyWithLimit, MAX_REQUEST_BODY_BYTES } from "@/lib/api-guards";
/**
* Local ICS file parse endpoint.
*
* Accepts raw ICS content in the request body and parses it into events
* using the same `extractEvents` pipeline as the remote fetch endpoint.
* This ensures consistent location cleaning and date filtering.
*/
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES); const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES);
+16
View File
@@ -4,6 +4,22 @@ import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants"; import { DEFAULT_DAYS } from "@/lib/constants";
import { isCalendarUrlAllowed } from "@/lib/url-validation"; import { isCalendarUrlAllowed } from "@/lib/url-validation";
/**
* Remote ICS calendar fetch and parse endpoint.
*
* Fetches an ICS file from a remote URL, parses it, and returns events.
*
* Security measures:
* - SSRF protection via `isCalendarUrlAllowed` (whitelist + local-address blocklist)
* - Redirects are blocked (`redirect: 'manual'`) to prevent SSRF via redirect chains
* - Response size is capped at 5 MB to prevent large-body DoS
* - Content-Type is validated to ensure the response is actual calendar data
*
* Query params:
* - `url` (required) — the ICS feed URL
* - `days` (optional) — only return events within this many days from now
*/
// Maximum allowed ICS response size: 5 MB // Maximum allowed ICS response size: 5 MB
const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024; const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024;
+11
View File
@@ -2,6 +2,17 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GeocodingClient } from "@/lib/geocoding-client"; import { GeocodingClient } from "@/lib/geocoding-client";
/**
* Nominatim-based geocoding endpoint.
*
* Converts a place name or address into coordinates. Uses a module-level
* singleton client that caches results across requests.
*
* Query params:
* - `name` (required) — the place or address to geocode
* - `countrycodes` (optional) — up to 5 ISO 3166-1 alpha-2 codes to narrow results
*/
// Module-level singleton — cache persists across requests // Module-level singleton — cache persists across requests
const client = new GeocodingClient(); const client = new GeocodingClient();
+32
View File
@@ -4,27 +4,40 @@ import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core"; import { hafasDateTime } from "@timetoleave/core";
import { readBodyWithLimit } from "@/lib/api-guards"; import { readBodyWithLimit } from "@/lib/api-guards";
/** HAFAS protocol version identifier sent in every request envelope. */
const HAFAS_VER = process.env.HAFAS_VER || "1.36"; const HAFAS_VER = process.env.HAFAS_VER || "1.36";
const HAFAS_LANG = process.env.HAFAS_LANG || "eng"; const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
/** Application ID registered with ÖBB for HAFAS access. */
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f"; const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB"; const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700"; const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp"; const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
/** Hard limit on the POST body size to prevent abuse. */
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
/** Only these HAFAS service methods are allowed through the proxy. */
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const; const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
type HafasMethod = (typeof ALLOWED_METHODS)[number]; type HafasMethod = (typeof ALLOWED_METHODS)[number];
/** Single service request entry inside a HAFAS envelope. */
interface HafasServiceRequest { interface HafasServiceRequest {
meth: string; meth: string;
req?: Record<string, unknown>; req?: Record<string, unknown>;
} }
/** Full HAFAS request body with a list of service requests. */
interface HafasBody extends Record<string, unknown> { interface HafasBody extends Record<string, unknown> {
svcReqL: HafasServiceRequest[]; svcReqL: HafasServiceRequest[];
} }
/**
* Injects required HAFAS protocol fields (version, language, auth, client)
* into the outgoing request. These fields identify the caller to ÖBB's system.
*
* @param body - Client-provided partial body (typically containing `svcReqL`).
* @returns Fully assembled HAFAS request envelope.
*/
function injectHafasAuth( function injectHafasAuth(
body: Record<string, unknown> | null | undefined, body: Record<string, unknown> | null | undefined,
): Record<string, unknown> { ): Record<string, unknown> {
@@ -40,6 +53,15 @@ function injectHafasAuth(
}; };
} }
/**
* GET handler — convenience endpoint for simple trip searches.
*
* Accepts `from`, `to` (numeric ÖBB station IDs) and `date` as query params.
* Constructs a TripSearch request, injects auth, and forwards it to HAFAS.
*
* This is a simplified interface; the POST handler below supports arbitrary
* HAFAS envelopes (TripSearch, LocMatch).
*/
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -110,6 +132,16 @@ export async function GET(request: NextRequest) {
} }
} }
/**
* POST handler — generic HAFAS proxy for advanced callers.
*
* Accepts a JSON body containing a HAFAS service request (`svcReqL`).
* Validates the method is allowed, caps TripSearch results at 10,
* injects auth credentials, and forwards to the ÖBB HAFAS endpoint.
*
* Timeout is enforced via AbortController. Errors include a correlation ID
* for server-side log lookup.
*/
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Body size guard before parsing // Body size guard before parsing
+6
View File
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { APP_VERSION } from "@/lib/constants"; import { APP_VERSION } from "@/lib/constants";
/**
* Health check endpoint.
*
* Returns a simple JSON payload with a timestamp and app version.
* Used by the UI to determine if the backend is reachable.
*/
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
+11
View File
@@ -3,6 +3,17 @@ import { NextRequest, NextResponse } from "next/server";
import { WalkRoutingClient } from "@/lib/walk-routing-client"; import { WalkRoutingClient } from "@/lib/walk-routing-client";
import { validateCoordinate } from "@/lib/api-guards"; import { validateCoordinate } from "@/lib/api-guards";
/**
* OSRM walking routing endpoint.
*
* Computes a pedestrian route between two coordinate pairs.
* Primarily used for the "last mile" from a train station to the event destination.
*
* Query params: `fromLat`, `fromLng`, `toLat`, `toLng` (all required).
*
* Returns distance in meters, duration in seconds, and turn-by-turn steps.
*/
// Module-level singleton — cache persists across requests // Module-level singleton — cache persists across requests
const client = new WalkRoutingClient(); const client = new WalkRoutingClient();
@@ -2,6 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client"; import { WienerLinienClient } from "@/lib/wienerlinien-client";
/**
* Fetches real-time departure information for given WienerLinien stops.
*
* Accepts a comma-separated or repeated `stopIds` query parameter.
* Stop IDs can be numeric or in `WL:12345` format.
* Results are flattened into a single departures list regardless of stop grouping.
*
* Caps the batch at `MAX_STOP_IDS` to avoid oversized API payloads.
*/
const client = new WienerLinienClient(); const client = new WienerLinienClient();
/** Wiener Linien stop IDs can be numeric or in WL:format */ /** Wiener Linien stop IDs can be numeric or in WL:format */
@@ -3,6 +3,15 @@ import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client"; import { WienerLinienClient } from "@/lib/wienerlinien-client";
import { validateCoordinate } from "@/lib/api-guards"; import { validateCoordinate } from "@/lib/api-guards";
/**
* Finds nearby Vienna public transport (WienerLinien) stops.
*
* Queries the WienerLinien API for stops within a radius of the given coordinates.
* Default radius is 1000 m, capped at 5000 m.
*
* Query params: `lat`, `lng` (required), `radius` (optional, meters).
*/
const client = new WienerLinienClient(); const client = new WienerLinienClient();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -5,6 +5,10 @@ import { format } from "date-fns";
import { useEventsStore } from "@/hooks/useEventsStore"; import { useEventsStore } from "@/hooks/useEventsStore";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* Resolves a raw source string to a human-readable label.
* E.g., "calendar:https://example.com/cal.ics" → "example.com"
*/
function sourceLabel(source: string): string { function sourceLabel(source: string): string {
if (source === "manual") return "Manually added"; if (source === "manual") return "Manually added";
if (source === "google_calendar") return "Google Calendar"; if (source === "google_calendar") return "Google Calendar";
@@ -20,6 +24,13 @@ function sourceLabel(source: string): string {
return source; return source;
} }
/**
* Batch-edit panel for replacing destinations across multiple events.
*
* Useful when imported calendars store room numbers or short labels
* instead of full addresses. Filters events by source and destination
* substring, previews matches, then applies a new destination in bulk.
*/
export default function BatchEditPanel() { export default function BatchEditPanel() {
const { events, updateEvent } = useEventsStore(); const { events, updateEvent } = useEventsStore();
@@ -8,6 +8,16 @@ import UrlTab from "./UrlTab";
import FileTab from "./FileTab"; import FileTab from "./FileTab";
import GoogleTab from "./GoogleTab"; import GoogleTab from "./GoogleTab";
/**
* Tabbed panel for importing calendar events from multiple sources.
*
* Tabs:
* - URL — fetch a remote ICS feed via the `/api/calendar` endpoint.
* - File — upload a local `.ics` file, parsed via `/api/calendar/parse`.
* - Google — OAuth2 flow to sync Google Calendar events.
*
* Parsed events are automatically merged into the global events store.
*/
type CalendarPanelProps = { type CalendarPanelProps = {
className?: string; className?: string;
}; };
@@ -4,6 +4,13 @@ import React, { useState } from "react";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns"; import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
import { Event } from "@timetoleave/core"; import { Event } from "@timetoleave/core";
/**
* Interactive month-grid calendar view.
*
* Renders a navigable grid of days with event indicators. Events are
* grouped by date key (YYYY-MM-DD) for efficient lookups.
* Non-current-month days are shown muted. Today is highlighted with a ring.
*/
type CalendarViewProps = { type CalendarViewProps = {
events: Event[]; events: Event[];
onDateSelect: (date: Date) => void; onDateSelect: (date: Date) => void;
+4
View File
@@ -5,6 +5,10 @@ import { format } from "date-fns";
import { Event, Station } from "@timetoleave/core"; import { Event, Station } from "@timetoleave/core";
import EventCard from "@/app/event/EventCard"; import EventCard from "@/app/event/EventCard";
/**
* Lists all `EventCard` instances for a specific calendar date.
* Filters the global event list by matching the full date (day, month, year).
*/
type DayEventsProps = { type DayEventsProps = {
events: Event[]; events: Event[];
date: Date; date: Date;
+6
View File
@@ -3,6 +3,12 @@
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* File upload tab for importing `.ics` calendar files.
*
* Supports both file picker selection and drag-and-drop.
* Sends the file content to `/api/calendar/parse` for server-side ICS parsing.
*/
type FileTabProps = { type FileTabProps = {
onLoadCalendar: (file: File) => Promise<void>; onLoadCalendar: (file: File) => Promise<void>;
loading: boolean; loading: boolean;
+12
View File
@@ -4,8 +4,20 @@ import React, { useState, useEffect, useCallback } from "react";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
import type { CalendarEvent } from "@timetoleave/core"; import type { CalendarEvent } from "@timetoleave/core";
/** OAuth connection states for the Google Calendar integration. */
type GoogleStatus = "loading" | "not-configured" | "not-connected" | "connected"; type GoogleStatus = "loading" | "not-configured" | "not-connected" | "connected";
/**
* Google Calendar import tab.
*
* Workflow:
* 1. Check config/connection status on mount.
* 2. If connected, prompt OAuth via `/api/auth/google` → Google → `/api/auth/google/callback`.
* 3. On success, store tokens in an httpOnly cookie and auto-sync events.
* 4. User can re-sync or disconnect at any time.
*
* Handles OAuth return params (`google_connected`, `google_error`) from URL search params.
*/
type GoogleTabProps = { type GoogleTabProps = {
onEventsLoaded: (events: CalendarEvent[]) => void; onEventsLoaded: (events: CalendarEvent[]) => void;
className?: string; className?: string;
+6
View File
@@ -3,6 +3,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* URL input tab for fetching remote ICS calendar feeds.
*
* Submits the URL to the parent `CalendarPanel`, which calls `/api/calendar`
* to fetch and parse the feed on the server (SSRF-protected).
*/
type UrlTabProps = { type UrlTabProps = {
onLoadCalendar: (url: string) => Promise<void>; onLoadCalendar: (url: string) => Promise<void>;
loading: boolean; loading: boolean;
+6
View File
@@ -8,6 +8,12 @@ import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel"; import CalendarPanel from "./CalendarPanel";
import BatchEditPanel from "./BatchEditPanel"; import BatchEditPanel from "./BatchEditPanel";
/**
* Calendar page layout.
*
* Combines the import panel, batch editor, month-grid view,
* and the day-specific event list in a responsive grid.
*/
export default function CalendarPage() { export default function CalendarPage() {
const { events } = useEventsStore(); const { events } = useEventsStore();
const { station: originStation } = useOriginStation(); const { station: originStation } = useOriginStation();
+6
View File
@@ -5,6 +5,12 @@ import { BikeRoute } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner"; import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* Displays the door-to-door bicycle route computed via OSRM.
*
* Shows distance, duration, and turn-by-turn steps.
* Hidden by default unless `forceVisible` is set or data is available.
*/
type BikeSectionProps = { type BikeSectionProps = {
bikeRoute: BikeRoute | null | undefined; bikeRoute: BikeRoute | null | undefined;
bikeLoading: boolean; bikeLoading: boolean;
+14
View File
@@ -19,6 +19,20 @@ import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge"; import CountdownBadge from "@/app/ui/CountdownBadge";
import AddEventModal from "@/app/add-event/AddEventModal"; import AddEventModal from "@/app/add-event/AddEventModal";
/**
* The central card rendered per event on the dashboard.
*
* Data flow:
* 1. Resolve the destination station via `useDestinationStation` (geocode → LocMatch).
* 2. Geocode the raw destination address to coordinates.
* 3. Fetch a bike route (door-to-door) and a walking route (station → destination).
* 4. Query HAFAS for train journeys, arriving early enough to cover the walk + buffer.
* 5. Query WienerLinien for nearby public transport stops and departures.
* 6. Compute the latest departure time based on the active transport mode.
*
* The user toggles between "Train" (rail + optional final walk) and "Bike" modes.
*/
interface EventCardProps { interface EventCardProps {
event: Event; event: Event;
originStation: Station | null; originStation: Station | null;
+12
View File
@@ -6,6 +6,18 @@ import { formatTime } from "@timetoleave/core";
import LeaveByBadge from "./LeaveByBadge"; import LeaveByBadge from "./LeaveByBadge";
import { calculateCountdown } from "@timetoleave/core"; import { calculateCountdown } from "@timetoleave/core";
/**
* Renders a list of train journeys returned by the HAFAS TripSearch query.
*
* Each journey card shows:
* - Departure time and platform
* - Train types (e.g., "EC -> S7") and cancellation/delay status
* - Countdown badge indicating how far away the departure is
* - Whether the journey arrives in time for the user's buffer
*
* Journeys that miss the buffer or are cancelled are visually de-emphasized
* with reduced opacity, strikethrough text, and a pink border.
*/
type JourneyListProps = { type JourneyListProps = {
journeys: Journey[]; journeys: Journey[];
eventTime: Date; eventTime: Date;
+5
View File
@@ -4,6 +4,7 @@ import React from "react";
import { CountdownInfo } from "@timetoleave/core"; import { CountdownInfo } from "@timetoleave/core";
import Chip from "@/app/ui/Chip"; import Chip from "@/app/ui/Chip";
/** Color variants matching the countdown engine's severity levels. */
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100", red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100",
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100", orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
@@ -12,6 +13,10 @@ const colorMap: Record<string, string> = {
blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]", blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
}; };
/**
* Compact badge showing a color-coded countdown to a departure time.
* Pulses when the countdown is marked as urgent (typically less than 15 minutes).
*/
type LeaveByBadgeProps = { type LeaveByBadgeProps = {
countdown: CountdownInfo; countdown: CountdownInfo;
className?: string; className?: string;
+7
View File
@@ -8,6 +8,13 @@ import JourneyList from "./JourneyList";
import LoadingSpinner from "@/app/ui/LoadingSpinner"; import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* Displays the train journey results for an event.
*
* Shows a list of matching journeys from HAFAS, each with departure/arrival times,
* platform info, delays, and buffer compliance. Optionally renders the final walk
* from the arrival station to the actual destination.
*/
type TrainSectionProps = { type TrainSectionProps = {
journeys: Journey[]; journeys: Journey[];
eventTime: Date; eventTime: Date;
+6
View File
@@ -5,6 +5,12 @@ import { WalkRoute } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner"; import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Button from "@/app/ui/Button"; import Button from "@/app/ui/Button";
/**
* Renders the walking route from the arrival station to the event destination.
*
* This is the "last mile" leg computed via OSRM walking profile.
* Hidden when no route data, no loading, and no error are present.
*/
type WalkingOptionProps = { type WalkingOptionProps = {
walkRoute: WalkRoute | null | undefined; walkRoute: WalkRoute | null | undefined;
walkLoading?: boolean; walkLoading?: boolean;
@@ -4,6 +4,7 @@ import type { WienerLinienStop } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner"; import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Chip from "@/app/ui/Chip"; import Chip from "@/app/ui/Chip";
/** Individual departure entry from a WienerLinien stop. */
interface DepartureRow { interface DepartureRow {
stopId: string; stopId: string;
lineName: string; lineName: string;
@@ -11,6 +12,12 @@ interface DepartureRow {
minutes: number; minutes: number;
} }
/**
* Displays nearby WienerLinien (Vienna public transport) stops and upcoming departures.
*
* Departures are grouped by stop and rendered as a list of line names,
* directions, and minutes until departure. Only rendered when stops exist.
*/
type WienerLinienSectionProps = { type WienerLinienSectionProps = {
stops: WienerLinienStop[]; stops: WienerLinienStop[];
departures: DepartureRow[]; departures: DepartureRow[];
+10
View File
@@ -10,6 +10,16 @@ import { useTheme } from "@/hooks/useTheme";
import Link from "next/link"; import Link from "next/link";
import { LogoHorizontal } from "@/app/ui/logos"; import { LogoHorizontal } from "@/app/ui/logos";
/**
* Sticky top header bar.
*
* Contains:
* - App logo (links to home)
* - Event count + server health indicator (online/offline dot)
* - "Add Event" button (opens modal)
* - Theme toggle (sun/moon icon)
* - Settings button (opens reminder settings modal)
*/
type HeaderProps = { type HeaderProps = {
className?: string; className?: string;
}; };
+4
View File
@@ -4,6 +4,10 @@ import React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
/**
* Sticky bottom navigation bar with route-aware active highlighting.
* Active links receive the brand gradient; inactive links are muted.
*/
type NavbarProps = { type NavbarProps = {
className?: string; className?: string;
}; };
@@ -2,6 +2,12 @@
import { useReminder } from "@/hooks/useReminder"; import { useReminder } from "@/hooks/useReminder";
/**
* Headless component that activates the browser notification reminder engine.
*
* Renders nothing visually — its sole purpose is to mount `useReminder`,
* which sets up intervals that check upcoming events and fire notifications.
*/
export default function ReminderEngine() { export default function ReminderEngine() {
useReminder(); useReminder();
return null; return null;
+7
View File
@@ -4,6 +4,13 @@ import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation"; import { useOriginStation } from "@/hooks/useOriginStation";
import EventCard from "@/app/event/EventCard"; import EventCard from "@/app/event/EventCard";
/**
* Home page — the "Departure Desk" dashboard.
*
* Shows a summary hero (upcoming/total event counts) and lists
* all future events as `EventCard` instances, sorted chronologically.
* Displays an empty-state prompt when no upcoming events exist.
*/
export default function Home() { export default function Home() {
const { events } = useEventsStore(); const { events } = useEventsStore();
const { station: originStation } = useOriginStation(); const { station: originStation } = useOriginStation();
+8
View File
@@ -2,6 +2,14 @@
import React from "react"; import React from "react";
/**
* Reusable button component with variant and size options.
*
* - `primary` — gradient purple-to-pink, default call-to-action style.
* - `secondary` — subtle border + translucent background.
* - `accent` — solid pink for emphasis.
* - `danger` — red for destructive actions.
*/
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "accent" | "danger"; variant?: "primary" | "secondary" | "accent" | "danger";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
+4
View File
@@ -2,6 +2,10 @@
import React from 'react'; import React from 'react';
/**
* Pill-shaped badge component used for status indicators,
* transport line labels, and compact metadata display.
*/
type ChipProps = { type ChipProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
+1
View File
@@ -3,6 +3,7 @@
import React from "react"; import React from "react";
import Chip from "./Chip"; import Chip from "./Chip";
/** Color variants mapped from the countdown engine output to Tailwind classes. */
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
red: "border-red-400/30 bg-red-500/16 text-red-100", red: "border-red-400/30 bg-red-500/16 text-red-100",
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100", orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
+4
View File
@@ -2,6 +2,10 @@
import React from 'react'; import React from 'react';
/**
* Animated spinning indicator shown during data fetching.
* Includes an accessible screen-reader-only label.
*/
type LoadingSpinnerProps = { type LoadingSpinnerProps = {
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
className?: string; className?: string;
+8
View File
@@ -1,3 +1,11 @@
/**
* Horizontal wordmark version of the TimeToLeave logo.
*
* Combines the compact icon (scaled and positioned) with the "Time To Leave" text,
* where "To" is rendered in the brand gradient. Designed for the header bar.
*
* Accepts a `height` prop; width is computed proportionally.
*/
export default function LogoHorizontal({ export default function LogoHorizontal({
height = 44, height = 44,
className = "", className = "",
+10
View File
@@ -1,3 +1,13 @@
/**
* The application icon — a stylized arrow with gradient stroke and glow filter.
*
* Renders an accessible SVG logo with:
* - A multi-stop gradient from violet to magenta/pink
* - SVG glow filter for a soft neon effect
* - Compass/tick marks around the central node
*
* Accepts a `size` prop that controls width and height.
*/
import { useId } from "react"; import { useId } from "react";
export default function LogoIcon({ export default function LogoIcon({
@@ -3,6 +3,16 @@
import React from "react"; import React from "react";
import { useReminderSettings } from "@/hooks/useReminderSettings"; import { useReminderSettings } from "@/hooks/useReminderSettings";
/**
* Modal panel for configuring departure reminder preferences.
*
* Controls exposed:
* - Leave reminders toggle (browser notification enable/disable)
* - Buffer minutes slider (how many minutes before the event to notify)
* - Arrival early slider (how many minutes before the event to aim to arrive)
* - Show walking / bike option toggles
* - Notification permission status and request button
*/
type ReminderSettingsPanelProps = { type ReminderSettingsPanelProps = {
className?: string; className?: string;
}; };
+4
View File
@@ -4,6 +4,10 @@ import { ApiClient } from "@timetoleave/api-client";
const client = new ApiClient(); const client = new ApiClient();
/**
* Bike route hook. Fetches a bicycle route from origin to destination
* via OSRM. Skips the request if any coordinate is undefined.
*/
export function useBikeRoute( export function useBikeRoute(
fromLat: number | undefined, fromLat: number | undefined,
fromLng: number | undefined, fromLng: number | undefined,
+4
View File
@@ -2,6 +2,10 @@ import { useState, useCallback } from "react";
import type { CalendarEvent } from "@timetoleave/core"; import type { CalendarEvent } from "@timetoleave/core";
import { api as client } from "@/lib/api"; import { api as client } from "@/lib/api";
/**
* Calendar import hook. Supports fetching from a remote ICS URL or parsing
* a locally uploaded .ics file. Merges incoming events by ID to prevent duplicates.
*/
export function useCalendar(days: number = 14) { export function useCalendar(days: number = 14) {
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
+5
View File
@@ -8,6 +8,11 @@ interface ClockResult {
status: "upcoming" | "now" | "past"; status: "upcoming" | "now" | "past";
} }
/**
* Live clock hook. Ticks every 10s and calculates a countdown to the target date.
* Optionally uses `departureTime` (computed by `useDepartureTime`) instead of
* the raw event time, so the countdown reflects "time until you should leave".
*/
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult { export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
const [now, setNow] = useState<Date>(new Date()); const [now, setNow] = useState<Date>(new Date());
+9
View File
@@ -10,6 +10,15 @@ interface DepartureTimeResult {
mode: "train" | "bike" | null; mode: "train" | "bike" | null;
} }
/**
* Calculate the optimal departure time for an event.
*
* For train mode: picks the latest journey that still arrives in time
* (accounting for the final walk from station to destination).
* For bike mode: subtracts bike route duration from the target arrival time.
*
* @param trainWalkDurationSeconds - Walk time from destination station to event location
*/
export function useDepartureTime( export function useDepartureTime(
eventTime: Date, eventTime: Date,
journeys: Journey[] | null, journeys: Journey[] | null,
@@ -15,6 +15,13 @@ function normalizeHafasCoordinate(value: number | undefined): number | undefined
return Math.abs(value) > 1000 ? value / 1e6 : value; return Math.abs(value) > 1000 ? value / 1e6 : value;
} }
/**
* Resolve a destination string to a HAFAS station via a two-step lookup:
* 1. Geocode the address to coordinates (Nominatim)
* 2. Find the nearest station at those coordinates (HAFAS LocMatch)
*
* Debounced by 400ms to avoid excessive API calls during typing.
*/
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);
+19
View File
@@ -1,3 +1,7 @@
// ── Events Store ──
// React context-based store for events, persisted to localStorage.
// Provides add/update/remove/merge operations and automatic serialization.
"use client"; "use client";
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from "react"; import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from "react";
@@ -5,6 +9,10 @@ import type { Event, CalendarEvent } from "@timetoleave/core";
const STORAGE_KEY = "ttl_events"; const STORAGE_KEY = "ttl_events";
/**
* Load events from localStorage, converting ISO-string eventTime back
* to Date objects. Returns empty array on SSR or parse failure.
*/
function loadFromStorage(): Event[] { function loadFromStorage(): Event[] {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
try { try {
@@ -17,6 +25,9 @@ function loadFromStorage(): Event[] {
} }
} }
/**
* Context value shape for the events store.
*/
interface EventsContextType { interface EventsContextType {
events: Event[]; events: Event[];
addEvent: (event: Event) => void; addEvent: (event: Event) => void;
@@ -29,6 +40,10 @@ interface EventsContextType {
const EventsContext = createContext<EventsContextType | undefined>(undefined); const EventsContext = createContext<EventsContextType | undefined>(undefined);
/**
* Provider component that wraps the app with event state management.
* Persists events to localStorage on every change (skips initial hydration write).
*/
export function EventsProvider({ children }: { children: ReactNode }) { export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEventsState] = useState<Event[]>([]); const [events, setEventsState] = useState<Event[]>([]);
const skipInitialWrite = useRef(true); const skipInitialWrite = useRef(true);
@@ -92,6 +107,10 @@ export function EventsProvider({ children }: { children: ReactNode }) {
); );
} }
/**
* Access the events store from any component inside `EventsProvider`.
* Throws if called outside the provider (safety check).
*/
export function useEventsStore() { export function useEventsStore() {
const context = useContext(EventsContext); const context = useContext(EventsContext);
if (context === undefined) { if (context === undefined) {
+4
View File
@@ -1,6 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { api as client } from "@/lib/api"; import { api as client } from "@/lib/api";
/**
* Debounce-wrapped geocoding hook. Resolves an address string to GPS coordinates
* via the Nominatim API (proxied through the server). Debounced 400ms.
*/
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);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
+8
View File
@@ -30,6 +30,14 @@ function buildTripSearchBody(
}; };
} }
/**
* Fetch train journeys between two stations via HAFAS TripSearch.
*
* When `arriveBy=true` and no journeys are found, retries with a 2-hour
* fallback window to handle edge cases near midnight.
*
* @param refreshKey - Increment to force a refetch even if deps haven't changed
*/
export function useJourneys( export function useJourneys(
fromStationExtId: string | null, fromStationExtId: string | null,
toStationExtId: string | null, toStationExtId: string | null,
+6
View File
@@ -11,6 +11,12 @@ interface HafasLocation {
lon: number; lon: number;
} }
/**
* Resolve the nearest HAFAS station to the user's GPS location.
* Falls back to the default station if geolocation is denied or fails.
*
* Uses HAFAS LocMatch with coordinate input for accurate station matching.
*/
export function useOriginStation() { export function useOriginStation() {
const [station, setStation] = useState<Station | null>(null); const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
+6
View File
@@ -4,6 +4,12 @@ import { useReminderSettings } from "./useReminderSettings";
const POLLS_MS = 30_000; // 30s — matches useClock cadence const POLLS_MS = 30_000; // 30s — matches useClock cadence
/**
* Browser notification reminder engine. Polls every 30s and fires a
* `Notification` when an event's leave-by time is reached (bufferMinutes
* before the event). Uses a ref-based fired-set to avoid duplicate alerts
* for the same event across polls.
*/
export function useReminder() { export function useReminder() {
const { events } = useEventsStore(); const { events } = useEventsStore();
const { bufferMinutes, enabled } = useReminderSettings(); const { bufferMinutes, enabled } = useReminderSettings();
@@ -3,6 +3,10 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react"; import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import { ReminderSettings } from "@timetoleave/core"; import { ReminderSettings } from "@timetoleave/core";
/**
* Reminder settings context. Persists to localStorage and provides setters
* with clamped ranges (buffer: 1-120 min, arrival buffer: 0-30 min).
*/
const STORAGE_KEY = "ttl_reminder_settings"; const STORAGE_KEY = "ttl_reminder_settings";
const DEFAULTS: ReminderSettings = { const DEFAULTS: ReminderSettings = {
bufferMinutes: 15, bufferMinutes: 15,
+6
View File
@@ -15,6 +15,12 @@ function getClientTheme(): "dark" | "light" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
/**
* Theme toggle hook. Reads from `prefers-color-scheme` on first load, then
* respects the user's explicit choice persisted in localStorage.
*
* Returns `mounted` flag so child components can avoid hydration mismatch.
*/
export function useTheme() { export function useTheme() {
const [dark, setDark] = useState(() => getClientTheme() === "dark"); const [dark, setDark] = useState(() => getClientTheme() === "dark");
const [mounted] = useState(() => typeof window !== "undefined"); const [mounted] = useState(() => typeof window !== "undefined");
+4
View File
@@ -2,6 +2,10 @@ import { useState, useEffect } from "react";
import type { WalkRoute } from "@timetoleave/core"; import type { WalkRoute } from "@timetoleave/core";
import { api as client } from "@/lib/api"; import { api as client } from "@/lib/api";
/**
* Walk route hook. Fetches a walking route between two coordinates
* via OSRM (proxied through the server). Clears state when coordinates are null.
*/
export function useWalkRoute( export function useWalkRoute(
fromLat: number | undefined, fromLat: number | undefined,
fromLng: number | undefined, fromLng: number | undefined,
+9
View File
@@ -1,6 +1,7 @@
import * as ical from "node-ical"; import * as ical from "node-ical";
import type { CalendarEvent } from "@timetoleave/core"; import type { CalendarEvent } from "@timetoleave/core";
/** ICS component shape extracted by node-ical. */
type IcalComponent = { type IcalComponent = {
type?: string; type?: string;
uid?: string; uid?: string;
@@ -12,6 +13,13 @@ type IcalComponent = {
[key: string]: unknown; [key: string]: unknown;
}; };
/**
* Parse an ICS calendar feed and extract events within the next `days` window.
*
* Handles both flat and nested ICS structures (some parsers wrap VEVENTs
* inside a VCALENDAR envelope). Events without a location are skipped,
* as the app needs a destination to plan a route.
*/
export function extractEvents(content: string, days: number = 14, now?: Date): CalendarEvent[] { export function extractEvents(content: string, days: number = 14, now?: Date): CalendarEvent[] {
const _now = now ?? new Date(); const _now = now ?? new Date();
const cutoff = new Date(_now.getTime() + days * 24 * 60 * 60 * 1000); const cutoff = new Date(_now.getTime() + days * 24 * 60 * 60 * 1000);
@@ -67,6 +75,7 @@ export function extractEvents(content: string, days: number = 14, now?: Date): C
return events; return events;
} }
/** Normalise Austrian station names so HAFAS lookup succeeds. */
export function cleanLocation(location: string): string { export function cleanLocation(location: string): string {
const knownStations: Record<string, string> = { const knownStations: Record<string, string> = {
"Graz Hauptbahnhof": "Graz Hbf", "Graz Hauptbahnhof": "Graz Hbf",
+11
View File
@@ -1,6 +1,10 @@
// Demo data for TimeToLeave // Demo data for TimeToLeave
import type { Journey, Station } from "@timetoleave/core"; import type { Journey, Station } from "@timetoleave/core";
/**
* Demo stations for Austrian rail network (major HBF stations).
* Used for quick testing when no real data is available.
*/
export const DEMO_STATIONS: Station[] = [ export const DEMO_STATIONS: Station[] = [
{ name: "Wien Hbf", extId: "0WB0F0001500" }, { name: "Wien Hbf", extId: "0WB0F0001500" },
{ name: "Graz Hbf", extId: "0WB0F0000600" }, { name: "Graz Hbf", extId: "0WB0F0000600" },
@@ -11,6 +15,13 @@ export const DEMO_STATIONS: Station[] = [
{ name: "Klagenfurt Hbf", extId: "0WB000086000" }, { name: "Klagenfurt Hbf", extId: "0WB000086000" },
]; ];
/**
* Generate a synthetic journey for testing/demo purposes.
* Creates a train departing ~30 min from now with a 1-hour trip.
*
* @param id - Journey identifier
* @param _station - Destination station (unused, kept for API compatibility)
*/
export function createDemoJourney(id: string, _station: Station): Journey { export function createDemoJourney(id: string, _station: Station): Journey {
const now = new Date(); const now = new Date();
return { return {
+17
View File
@@ -1,3 +1,7 @@
// ── API Client ──
// Thin wrapper around the server-side API routes. Handles journey search,
// geocoding, bike/walk routing, calendar fetching, and WienerLinien monitoring.
import type { import type {
GeocodeResult, GeocodeResult,
NearbyStop, NearbyStop,
@@ -16,6 +20,11 @@ type SearchJourneyOptions = {
arriveBy?: boolean; arriveBy?: boolean;
}; };
/**
* Fallback window (2 hours) to search backwards when an arrive-by query
* returns no journeys. HAFAS sometimes fails to find connections if the
* target time is too tight.
*/
const ARRIVE_BY_FALLBACK_WINDOW_MS = 2 * 60 * 60 * 1000; 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 {
@@ -51,11 +60,19 @@ function buildTripSearchBody(
}; };
} }
/**
* Normalize HAFAS coordinates. HAFAS may return coordinates in either
* decimal degrees or micro-degrees (1e6 factor). This detects and fixes it.
*/
function normalizeHafasCoordinate(value: number | undefined): number | undefined { function normalizeHafasCoordinate(value: number | undefined): number | undefined {
if (value == null) return undefined; if (value == null) return undefined;
return Math.abs(value) > 1000 ? value / 1e6 : value; return Math.abs(value) > 1000 ? value / 1e6 : value;
} }
/**
* Client for the TimeToLeave server API. Proxy requests to HAFAS, OSRM,
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
*/
export class ApiClient { export class ApiClient {
private readonly baseUrl: string; private readonly baseUrl: string;
+10 -1
View File
@@ -1,6 +1,15 @@
// Countdown utilities for TimeToLeave
import { CountdownInfo } from "./types"; import { CountdownInfo } from "./types";
/**
* Determine the countdown label, color and urgency for a target time.
*
* Thresholds (minutes until target):
* - 0 → red, "Now"
* - ≤10 → orange (urgent)
* - ≤30 → yellow
* - ≤60 → green
* - >60 → blue ("Xh Ymin")
*/
export function calculateCountdown(targetDate: Date): CountdownInfo { export function calculateCountdown(targetDate: Date): CountdownInfo {
const now = new Date(); const now = new Date();
const diffMs = targetDate.getTime() - now.getTime(); const diffMs = targetDate.getTime() - now.getTime();
+5
View File
@@ -1,3 +1,7 @@
// ── Defaults ──
// Fallback origin station (Mödling, Austria) used when geolocation is
// unavailable or the user hasn't set a custom origin.
import type { Station } from "./types"; import type { Station } from "./types";
export const DEFAULT_ORIGIN_ADDRESS = "Goethegasse 36, 2340 Moedling"; export const DEFAULT_ORIGIN_ADDRESS = "Goethegasse 36, 2340 Moedling";
@@ -6,6 +10,7 @@ export const DEFAULT_ORIGIN_LNG = 16.2908052;
export const DEFAULT_ORIGIN_STATION_NAME = "Mödling Bahnhof"; export const DEFAULT_ORIGIN_STATION_NAME = "Mödling Bahnhof";
export const DEFAULT_ORIGIN_STATION_EXT_ID = "1231701"; export const DEFAULT_ORIGIN_STATION_EXT_ID = "1231701";
/** Pre-assembled default station object for convenience. */
export const DEFAULT_ORIGIN_STATION: Station = { export const DEFAULT_ORIGIN_STATION: Station = {
name: DEFAULT_ORIGIN_ADDRESS, name: DEFAULT_ORIGIN_ADDRESS,
extId: DEFAULT_ORIGIN_STATION_EXT_ID, extId: DEFAULT_ORIGIN_STATION_EXT_ID,
+7 -1
View File
@@ -1,9 +1,12 @@
// Formatting utilities for TimeToLeave // ── Formatting utilities ──
// All formatters use Austrian locale (de-AT) for consistency with the target market.
/** Format a Date as a 24h clock time string (HH:mm). */
export function formatTime(date: Date): string { export function formatTime(date: Date): string {
return date.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" }); return date.toLocaleTimeString("de-AT", { hour: "2-digit", minute: "2-digit" });
} }
/** Format a Date as a localized date string (weekday, day, month, year). */
export function formatDate(date: Date): string { export function formatDate(date: Date): string {
return date.toLocaleDateString("de-AT", { return date.toLocaleDateString("de-AT", {
weekday: "short", weekday: "short",
@@ -13,6 +16,7 @@ export function formatDate(date: Date): string {
}); });
} }
/** Format a Date as a full date+time string for display. */
export function formatDateTime(date: Date): string { export function formatDateTime(date: Date): string {
return date.toLocaleString("de-AT", { return date.toLocaleString("de-AT", {
day: "numeric", day: "numeric",
@@ -23,6 +27,7 @@ export function formatDateTime(date: Date): string {
}); });
} }
/** Convert seconds to a human-readable duration (e.g. "1h 30min"). */
export function formatDuration(seconds: number): string { export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
@@ -33,6 +38,7 @@ export function formatDuration(seconds: number): string {
return `${minutes}min`; return `${minutes}min`;
} }
/** Convert meters to a human-readable distance (meters or km). */
export function formatDistance(meters: number): string { export function formatDistance(meters: number): string {
if (meters < 1000) { if (meters < 1000) {
return `${meters}m`; return `${meters}m`;
+16
View File
@@ -1,9 +1,25 @@
// ── HAFAS Response Parser ──
// Transforms raw HAFAS protocol JSON into clean `Journey` objects.
// HAFAS encodes times relative to a date boundary (day wraps at 00:00),
// so the caller must provide the original query date for correct parsing.
import type { Journey } from "./types"; import type { Journey } from "./types";
import { parseHafasTime } from "./hafas-time"; import { parseHafasTime } from "./hafas-time";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type RawJson = any; type RawJson = any;
/**
* Parse the `outConL` array from a HAFAS TripSearch response into Journey objects.
*
* Each connection (`con`) contains a list of sections (`secL`). The first section's
* departure and the last section's arrival define the journey boundaries. Train names
* are extracted from the first stop of each train leg.
*
* @param json - Raw HAFAS response body
* @param hafasDate - The HAFAS date string ("YYYYMMDD") used for the query
* @param queryDate - The original JavaScript Date used for the query (fallback for missing times)
*/
export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: Date): Journey[] { export function parseHafasJourneys(json: RawJson, hafasDate: string, queryDate: Date): Journey[] {
const outConL: RawJson[] = json?.svcResL?.[0]?.res?.outConL ?? []; const outConL: RawJson[] = json?.svcResL?.[0]?.res?.outConL ?? [];
+3 -1
View File
@@ -1,4 +1,6 @@
// Re-export everything for clean imports // ── Re-exports ──
// Barrel file: import everything from `@timetoleave/core` in one go.
export * from './types'; export * from './types';
export * from './countdown-utils'; export * from './countdown-utils';
export * from './formatting'; export * from './formatting';
+2 -1
View File
@@ -1,6 +1,7 @@
// Status utilities for TimeToLeave // ── Status utilities ──
import type { ServerStatus, Event, Journey } from "./types"; import type { ServerStatus, Event, Journey } from "./types";
/** Utility class for server health checks. */
export class StatusUtils { export class StatusUtils {
static async checkServerStatus(url: string): Promise<ServerStatus> { static async checkServerStatus(url: string): Promise<ServerStatus> {
try { try {
+21 -2
View File
@@ -1,5 +1,6 @@
// Core domain types // Core domain types for TimeToLeave
/** Event stored in the application (eventTime as Date). */
export interface Event { export interface Event {
id: string; id: string;
title: string; title: string;
@@ -8,6 +9,7 @@ export interface Event {
source: string; source: string;
} }
/** Calendar event from an ICS feed (eventTime as ISO string for serialization). */
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
title: string; title: string;
@@ -16,6 +18,7 @@ export interface CalendarEvent {
source: string; source: string;
} }
/** HAFAS station with optional GPS coordinates. */
export interface Station { export interface Station {
name: string; name: string;
extId: string; extId: string;
@@ -23,6 +26,7 @@ export interface Station {
lng?: number; lng?: number;
} }
/** A single transit journey with schedule vs. real-time data. */
export interface Journey { export interface Journey {
id: string; id: string;
sD: Date; // scheduled departure sD: Date; // scheduled departure
@@ -36,6 +40,7 @@ export interface Journey {
cancelled: boolean; cancelled: boolean;
} }
/** Bike routing step from OSRM. */
export interface BikeStep { export interface BikeStep {
name: string; name: string;
distance: number; distance: number;
@@ -43,18 +48,21 @@ export interface BikeStep {
instruction: string; instruction: string;
} }
/** Complete bike route with step-by-step directions. */
export interface BikeRoute { export interface BikeRoute {
distance: number; distance: number;
duration: number; duration: number;
steps: BikeStep[]; steps: BikeStep[];
} }
/** Walking route from OSRM with turn-by-turn steps. */
export interface WalkRoute { export interface WalkRoute {
distance: number; distance: number;
duration: number; duration: number;
steps: WalkStep[]; steps: WalkStep[];
} }
/** Walking step from OSRM route data. */
export interface WalkStep { export interface WalkStep {
name: string; name: string;
distance: number; distance: number;
@@ -62,17 +70,21 @@ export interface WalkStep {
instruction: string; instruction: string;
} }
/** Countdown display info derived from the time remaining until an event. */
export interface CountdownInfo { export interface CountdownInfo {
label: string; label: string;
color: string; color: string;
urgent: boolean; urgent: boolean;
} }
/** Geolocation permission state. */
export type LocState = "pending" | "granted" | "denied"; export type LocState = "pending" | "granted" | "denied";
/** Calendar sync status. */
export type CalStatus = null | "loading" | "ok" | "error"; export type CalStatus = null | "loading" | "ok" | "error";
// ── Reminder Settings ────────────────────────────────── // ── Reminder Settings ──────────────────────────────────
/** Persistent reminder preferences stored in localStorage. */
export interface ReminderSettings { export interface ReminderSettings {
bufferMinutes: number; bufferMinutes: number;
enabled: boolean; enabled: boolean;
@@ -81,21 +93,25 @@ export interface ReminderSettings {
showBikeOption: boolean; // show bike route section (default true) showBikeOption: boolean; // show bike route section (default true)
} }
/** Server health check result. */
export type ServerStatus = boolean | null; export type ServerStatus = boolean | null;
/** Result from Nominatim geocoding. */
export interface GeocodeResult { export interface GeocodeResult {
lat: number; lat: number;
lng: number; lng: number;
display_name: string; display_name: string;
} }
// WienerLinien types // ── WienerLinien (Vienna public transport) types ──
/** A WienerLinien vehicle line. */
export interface WienerLinienLine { export interface WienerLinienLine {
name: string; name: string;
type?: string; type?: string;
} }
/** A WienerLinien stop with GPS coordinates. */
export interface WienerLinienStop { export interface WienerLinienStop {
id: string; id: string;
name: string; name: string;
@@ -103,6 +119,7 @@ export interface WienerLinienStop {
lng: number; lng: number;
} }
/** Departure from a WienerLinien stop. */
export interface WienerLinienDeparture { export interface WienerLinienDeparture {
stopId: string; stopId: string;
line: WienerLinienLine; line: WienerLinienLine;
@@ -111,6 +128,7 @@ export interface WienerLinienDeparture {
delay?: number; delay?: number;
} }
/** Response from the WienerLinien monitor endpoint. */
export interface WienerLinienMonitorResponse { export interface WienerLinienMonitorResponse {
stops: { stops: {
stopId: string; stopId: string;
@@ -118,6 +136,7 @@ export interface WienerLinienMonitorResponse {
}[]; }[];
} }
/** Stop found nearby the user's location. */
export interface NearbyStop { export interface NearbyStop {
id: string; id: string;
name: string; name: string;