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 { AppColors } from '../hooks/useColors';
/** Props for the bike route information section shown on event detail. */
interface Props {
bikeRoute: BikeRoute | null;
loading: boolean;
@@ -10,6 +11,11 @@ interface Props {
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) {
return (
<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 { AppColors } from '../hooks/useColors';
/** Props for the event detail header showing title, destination, times, and buffers. */
interface Props {
event: Event;
leaveByTime: Date | null;
@@ -9,6 +10,10 @@ interface Props {
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) {
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 { AppColors } from '../hooks/useColors';
/** Props for the train journey list and optional final walk leg. */
interface Props {
journeys: Journey[];
destStationLoading: boolean;
@@ -15,6 +16,12 @@ interface Props {
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({
journeys,
destStationLoading,
@@ -3,6 +3,7 @@ import type { WienerLinienStop } from '@timetoleave/core';
import type { DepartureRow } from '../hooks/useWienerLinien';
import type { AppColors } from '../hooks/useColors';
/** Props for the nearby public transport stops section. */
interface Props {
stops: WienerLinienStop[];
departures: DepartureRow[];
@@ -11,6 +12,11 @@ interface Props {
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) {
if (!loading && stops.length === 0 && !error) return null;
+5
View File
@@ -32,8 +32,13 @@ const LIGHT = {
highlight: '#e8f4fd',
} as const;
/** Color token type — every theme variant shares the same keys. */
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 {
const { dark } = useTheme();
return dark ? DARK : LIGHT;
+12
View File
@@ -9,7 +9,19 @@ interface DepartureTimeResult {
/**
* 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.
*
* @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(
eventTime: Date,
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
import type { Station } from '@timetoleave/core';
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 {
type: string;
name: string;
@@ -10,6 +18,7 @@ interface HafasLocation {
lon: number;
}
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
if (value == null) return undefined;
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.
* 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.
*/
export function useGeocode(destination: string | undefined) {
+7
View File
@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
/** Theme storage key in AsyncStorage. */
const THEME_KEY = '@timetoleave_theme';
type Theme = 'dark' | 'light';
/** Returns the default theme. Mobile defaults to dark to match the web app. */
function getDefaultTheme(): Theme {
// 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
@@ -13,6 +15,11 @@ function getDefaultTheme(): Theme {
/**
* 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.
*/
export function useTheme() {
+2 -1
View File
@@ -3,7 +3,8 @@ import type { WalkRoute } from '@timetoleave/core';
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.
*/
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 { api } from '../services/api';
/** Flattened departure row shown in the NearbyStops UI. */
export interface DepartureRow {
stopId: string;
lineName: string;
@@ -12,6 +13,7 @@ export interface DepartureRow {
const DEBOUNCE_MS = 400;
const REFRESH_INTERVAL_MS = 60_000;
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
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(
lat: number | undefined,
lng: number | undefined,
@@ -32,9 +44,12 @@ export function useWienerLinien(
const [loading, setLoading] = useState(false);
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 cancelledRef = useRef(false);
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
if (stopIds.length === 0 || cancelledRef.current) return;
try {
+20 -6
View File
@@ -1,5 +1,6 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Image, Text, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { EventListScreen } from '../screens/EventListScreen';
@@ -13,6 +14,10 @@ import type { RootStack } from '../types/navigation';
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() {
return (
<SafeAreaProvider>
@@ -21,13 +26,22 @@ export default function AppNavigator() {
<NavigationContainer>
<Root.Navigator
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="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
<Root.Screen name="EventList" component={EventListScreen} />
<Root.Screen name="AddEvent" component={AddEventScreen} />
<Root.Screen name="EventDetail" component={EventDetailScreen} />
<Root.Screen name="Settings" component={SettingsScreen} />
<Root.Screen name="CalendarImport" component={CalendarImportScreen} />
</Root.Navigator>
</NavigationContainer>
</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 stringPrototype = String.prototype as typeof String.prototype & {
isWellFormed?: () => boolean;
@@ -6,6 +17,11 @@ const stringPrototype = String.prototype as typeof String.prototype & {
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 {
let result = '';
@@ -18,6 +18,13 @@ type ScreenProps = {
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) {
const colors = useColors();
const [title, setTitle] = useState('');
@@ -22,6 +22,10 @@ type ScreenProps = {
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) {
const colors = useColors();
const [url, setUrl] = useState('');
@@ -31,6 +31,11 @@ type ScreenProps = {
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(
eventTime: Date,
arrivalBufferMinutes: number,
@@ -21,6 +21,11 @@ type ScreenProps = {
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) {
const colors = useColors();
const [events, setEvents] = useState<CalendarEvent[]>([]);
+16 -3
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
ActivityIndicator,
ScrollView,
StyleSheet,
Switch,
Text,
@@ -24,6 +25,13 @@ type ScreenProps = {
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) {
const { dark, toggle: toggleTheme } = useTheme();
const colors = useColors();
@@ -209,7 +217,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.contentContainer}
keyboardShouldPersistTaps="handled"
>
{/* Appearance */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
@@ -322,12 +334,13 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
</View>
)}
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
container: { flex: 1 },
contentContainer: { flexGrow: 1, padding: 20 },
section: { marginBottom: 24 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
input: {
+4
View File
@@ -1,4 +1,8 @@
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 ?? '';
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.
* 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> {
const { status } = await Calendar.requestCalendarPermissionsAsync();
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
// 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 { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
import * as Notifications from '../services/expoNotifications';
@@ -21,6 +29,10 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
// ── 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[] {
try {
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
@@ -39,6 +51,11 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
// 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> {
// Calculate target arrival time (event time minus arrival buffer)
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);
}
/**
* 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> {
const REMINDERS_MIN = [30, 10, 0];
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> {
const settings = await getNotificationSettings();
if (!settings.enabled) return;