diff --git a/apps/mobile/src/components/BikeSection.tsx b/apps/mobile/src/components/BikeSection.tsx
index 3c1cebf..36627cf 100644
--- a/apps/mobile/src/components/BikeSection.tsx
+++ b/apps/mobile/src/components/BikeSection.tsx
@@ -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 (
diff --git a/apps/mobile/src/components/EventHeader.tsx b/apps/mobile/src/components/EventHeader.tsx
index e7005bb..a01d09c 100644
--- a/apps/mobile/src/components/EventHeader.tsx
+++ b/apps/mobile/src/components/EventHeader.tsx
@@ -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);
diff --git a/apps/mobile/src/components/JourneyList.tsx b/apps/mobile/src/components/JourneyList.tsx
index b094d25..e26676c 100644
--- a/apps/mobile/src/components/JourneyList.tsx
+++ b/apps/mobile/src/components/JourneyList.tsx
@@ -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,
diff --git a/apps/mobile/src/components/NearbyStops.tsx b/apps/mobile/src/components/NearbyStops.tsx
index a5daf5e..7c3e6c5 100644
--- a/apps/mobile/src/components/NearbyStops.tsx
+++ b/apps/mobile/src/components/NearbyStops.tsx
@@ -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;
diff --git a/apps/mobile/src/hooks/useColors.ts b/apps/mobile/src/hooks/useColors.ts
index a1a2d4a..c77b0dd 100644
--- a/apps/mobile/src/hooks/useColors.ts
+++ b/apps/mobile/src/hooks/useColors.ts
@@ -32,8 +32,13 @@ const LIGHT = {
highlight: '#e8f4fd',
} as const;
+/** Color token type — every theme variant shares the same keys. */
export type AppColors = Record;
+/**
+ * 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;
diff --git a/apps/mobile/src/hooks/useDepartureTime.ts b/apps/mobile/src/hooks/useDepartureTime.ts
index 58673b3..e34a3b8 100644
--- a/apps/mobile/src/hooks/useDepartureTime.ts
+++ b/apps/mobile/src/hooks/useDepartureTime.ts
@@ -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,
diff --git a/apps/mobile/src/hooks/useDestinationStation.ts b/apps/mobile/src/hooks/useDestinationStation.ts
index 7ec02fa..318e270 100644
--- a/apps/mobile/src/hooks/useDestinationStation.ts
+++ b/apps/mobile/src/hooks/useDestinationStation.ts
@@ -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;
diff --git a/apps/mobile/src/hooks/useGeocode.ts b/apps/mobile/src/hooks/useGeocode.ts
index db9e726..6033363 100644
--- a/apps/mobile/src/hooks/useGeocode.ts
+++ b/apps/mobile/src/hooks/useGeocode.ts
@@ -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) {
diff --git a/apps/mobile/src/hooks/useTheme.ts b/apps/mobile/src/hooks/useTheme.ts
index 8875211..368dbac 100644
--- a/apps/mobile/src/hooks/useTheme.ts
+++ b/apps/mobile/src/hooks/useTheme.ts
@@ -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() {
diff --git a/apps/mobile/src/hooks/useWalkRoute.ts b/apps/mobile/src/hooks/useWalkRoute.ts
index 8b97512..9af6b5c 100644
--- a/apps/mobile/src/hooks/useWalkRoute.ts
+++ b/apps/mobile/src/hooks/useWalkRoute.ts
@@ -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(
diff --git a/apps/mobile/src/hooks/useWienerLinien.ts b/apps/mobile/src/hooks/useWienerLinien.ts
index 2a72f40..23c7a1c 100644
--- a/apps/mobile/src/hooks/useWienerLinien.ts
+++ b/apps/mobile/src/hooks/useWienerLinien.ts
@@ -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(null);
+ // Stopped by the cleanup effect when coordinates change, so in-flight
+ // monitor requests don't overwrite stale stop lists.
const stopIdsRef = useRef([]);
const cancelledRef = useRef(false);
+ /** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
const fetchMonitor = useCallback(async (stopIds: string[]): Promise => {
if (stopIds.length === 0 || cancelledRef.current) return;
try {
diff --git a/apps/mobile/src/navigation/AppNavigator.tsx b/apps/mobile/src/navigation/AppNavigator.tsx
index 8db0d22..5fada58 100644
--- a/apps/mobile/src/navigation/AppNavigator.tsx
+++ b/apps/mobile/src/navigation/AppNavigator.tsx
@@ -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();
+/**
+ * Root native stack navigator for the app.
+ * Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
+ */
export default function AppNavigator() {
return (
@@ -21,13 +26,22 @@ export default function AppNavigator() {
(
+
+
+ Time To Leave
+
+ ),
+ }}
>
-
-
-
-
-
+
+
+
+
+
diff --git a/apps/mobile/src/polyfills/sharedArrayBuffer.ts b/apps/mobile/src/polyfills/sharedArrayBuffer.ts
index e1e8551..26c7f44 100644
--- a/apps/mobile/src/polyfills/sharedArrayBuffer.ts
+++ b/apps/mobile/src/polyfills/sharedArrayBuffer.ts
@@ -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;
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 = '';
diff --git a/apps/mobile/src/screens/AddEventScreen.tsx b/apps/mobile/src/screens/AddEventScreen.tsx
index 3f67e57..d2e09be 100644
--- a/apps/mobile/src/screens/AddEventScreen.tsx
+++ b/apps/mobile/src/screens/AddEventScreen.tsx
@@ -18,6 +18,13 @@ type ScreenProps = {
route: RouteProp;
};
+/**
+ * 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('');
diff --git a/apps/mobile/src/screens/CalendarImportScreen.tsx b/apps/mobile/src/screens/CalendarImportScreen.tsx
index e668322..ef09058 100644
--- a/apps/mobile/src/screens/CalendarImportScreen.tsx
+++ b/apps/mobile/src/screens/CalendarImportScreen.tsx
@@ -22,6 +22,10 @@ type ScreenProps = {
route: RouteProp;
};
+/**
+ * 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('');
diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx
index 24016a4..69d87c0 100644
--- a/apps/mobile/src/screens/EventDetailScreen.tsx
+++ b/apps/mobile/src/screens/EventDetailScreen.tsx
@@ -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,
diff --git a/apps/mobile/src/screens/EventListScreen.tsx b/apps/mobile/src/screens/EventListScreen.tsx
index 40073f1..ebec576 100644
--- a/apps/mobile/src/screens/EventListScreen.tsx
+++ b/apps/mobile/src/screens/EventListScreen.tsx
@@ -21,6 +21,11 @@ type ScreenProps = {
route: RouteProp;
};
+/**
+ * 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([]);
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index 66affc6..1952bdc 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -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;
};
+/**
+ * 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 (
-
+
{/* Appearance */}
Erscheinungsbild
@@ -322,12 +334,13 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
)}
-
+
);
}
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: {
diff --git a/apps/mobile/src/services/api.ts b/apps/mobile/src/services/api.ts
index 69fbeb0..8607cbf 100644
--- a/apps/mobile/src/services/api.ts
+++ b/apps/mobile/src/services/api.ts
@@ -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);
diff --git a/apps/mobile/src/services/calendar.ts b/apps/mobile/src/services/calendar.ts
index 2419504..448c5ca 100644
--- a/apps/mobile/src/services/calendar.ts
+++ b/apps/mobile/src/services/calendar.ts
@@ -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 {
const { status } = await Calendar.requestCalendarPermissionsAsync();
if (status !== 'granted')
diff --git a/apps/mobile/src/services/expoNotifications.ts b/apps/mobile/src/services/expoNotifications.ts
index b144e9c..5d4b827 100644
--- a/apps/mobile/src/services/expoNotifications.ts
+++ b/apps/mobile/src/services/expoNotifications.ts
@@ -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
diff --git a/apps/mobile/src/store/eventStore.ts b/apps/mobile/src/store/eventStore.ts
index ab39561..fbae81a 100644
--- a/apps/mobile/src/store/eventStore.ts
+++ b/apps/mobile/src/store/eventStore.ts
@@ -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;
@@ -39,6 +51,11 @@ async function getNotificationSettings(): Promise {
// 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 {
// 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 {
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 {
const settings = await getNotificationSettings();
if (!settings.enabled) return;
diff --git a/apps/web/src/app/add-event/AddEventModal.tsx b/apps/web/src/app/add-event/AddEventModal.tsx
index 26d9487..1206697 100644
--- a/apps/web/src/app/add-event/AddEventModal.tsx
+++ b/apps/web/src/app/add-event/AddEventModal.tsx
@@ -6,6 +6,15 @@ import Button from "@/app/ui/Button";
import { useEventsStore } from "@/hooks/useEventsStore";
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 = {
isOpen: boolean;
onClose: () => void;
diff --git a/apps/web/src/app/api/auth/google/callback/route.ts b/apps/web/src/app/api/auth/google/callback/route.ts
index 7b7c890..fb89998 100644
--- a/apps/web/src/app/api/auth/google/callback/route.ts
+++ b/apps/web/src/app/api/auth/google/callback/route.ts
@@ -1,6 +1,16 @@
import { cookies } from "next/headers";
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 {
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
}
diff --git a/apps/web/src/app/api/auth/google/disconnect/route.ts b/apps/web/src/app/api/auth/google/disconnect/route.ts
index 0a7fb13..7f7f1ca 100644
--- a/apps/web/src/app/api/auth/google/disconnect/route.ts
+++ b/apps/web/src/app/api/auth/google/disconnect/route.ts
@@ -1,6 +1,9 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
+/**
+ * Disconnects the user's Google Calendar integration by clearing stored tokens.
+ */
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete("google_tokens");
diff --git a/apps/web/src/app/api/auth/google/route.ts b/apps/web/src/app/api/auth/google/route.ts
index 1308df7..5505489 100644
--- a/apps/web/src/app/api/auth/google/route.ts
+++ b/apps/web/src/app/api/auth/google/route.ts
@@ -2,6 +2,16 @@ import { randomBytes } from "crypto";
import { cookies } from "next/headers";
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 {
return process.env.DEPLOYMENT_URL || "http://localhost:3000";
}
diff --git a/apps/web/src/app/api/auth/google/status/route.ts b/apps/web/src/app/api/auth/google/status/route.ts
index 74c8dd9..03dab9c 100644
--- a/apps/web/src/app/api/auth/google/status/route.ts
+++ b/apps/web/src/app/api/auth/google/status/route.ts
@@ -1,6 +1,13 @@
import { cookies } from "next/headers";
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() {
const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
if (!configured) {
diff --git a/apps/web/src/app/api/bike-route/route.ts b/apps/web/src/app/api/bike-route/route.ts
index 5d923ba..1743c32 100644
--- a/apps/web/src/app/api/bike-route/route.ts
+++ b/apps/web/src/app/api/bike-route/route.ts
@@ -3,6 +3,17 @@ import { NextRequest, NextResponse } from "next/server";
import { BikeRoutingClient } from "@/lib/bike-routing-client";
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
const client = new BikeRoutingClient();
diff --git a/apps/web/src/app/api/calendar/google/route.ts b/apps/web/src/app/api/calendar/google/route.ts
index 2a5ce79..fb78e7f 100644
--- a/apps/web/src/app/api/calendar/google/route.ts
+++ b/apps/web/src/app/api/calendar/google/route.ts
@@ -4,12 +4,14 @@ import type { CalendarEvent } from "@timetoleave/core";
import { cleanLocation } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
+/** Stored Google OAuth tokens, persisted in an httpOnly cookie. */
interface GoogleTokens {
access_token: string;
refresh_token: string | null;
expires_at: number;
}
+/** Raw event shape from the Google Calendar API response. */
interface GoogleEventItem {
id: string;
summary?: string;
@@ -17,6 +19,11 @@ interface GoogleEventItem {
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 {
if (!tokens.refresh_token) return null;
diff --git a/apps/web/src/app/api/calendar/parse/route.ts b/apps/web/src/app/api/calendar/parse/route.ts
index a763e14..d557d71 100644
--- a/apps/web/src/app/api/calendar/parse/route.ts
+++ b/apps/web/src/app/api/calendar/parse/route.ts
@@ -4,6 +4,13 @@ import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
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) {
try {
const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES);
diff --git a/apps/web/src/app/api/calendar/route.ts b/apps/web/src/app/api/calendar/route.ts
index 6acf9d9..9b14fd7 100644
--- a/apps/web/src/app/api/calendar/route.ts
+++ b/apps/web/src/app/api/calendar/route.ts
@@ -4,6 +4,22 @@ import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
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
const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024;
diff --git a/apps/web/src/app/api/geocode/route.ts b/apps/web/src/app/api/geocode/route.ts
index 904d6fb..071fce1 100644
--- a/apps/web/src/app/api/geocode/route.ts
+++ b/apps/web/src/app/api/geocode/route.ts
@@ -2,6 +2,17 @@ import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
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
const client = new GeocodingClient();
diff --git a/apps/web/src/app/api/hafas/route.ts b/apps/web/src/app/api/hafas/route.ts
index 955d771..4ac328c 100644
--- a/apps/web/src/app/api/hafas/route.ts
+++ b/apps/web/src/app/api/hafas/route.ts
@@ -4,27 +4,40 @@ import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
import { hafasDateTime } from "@timetoleave/core";
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_LANG = process.env.HAFAS_LANG || "eng";
+/** Application ID registered with ÖBB for HAFAS access. */
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
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
+/** Only these HAFAS service methods are allowed through the proxy. */
const ALLOWED_METHODS = ["TripSearch", "LocMatch"] as const;
type HafasMethod = (typeof ALLOWED_METHODS)[number];
+/** Single service request entry inside a HAFAS envelope. */
interface HafasServiceRequest {
meth: string;
req?: Record;
}
+/** Full HAFAS request body with a list of service requests. */
interface HafasBody extends Record {
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(
body: Record | null | undefined,
): Record {
@@ -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) {
try {
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) {
try {
// Body size guard before parsing
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
index 615b4b4..835facd 100644
--- a/apps/web/src/app/api/health/route.ts
+++ b/apps/web/src/app/api/health/route.ts
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
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() {
return NextResponse.json({
ok: true,
diff --git a/apps/web/src/app/api/walk-route/route.ts b/apps/web/src/app/api/walk-route/route.ts
index 8cc9f6b..9c181f0 100644
--- a/apps/web/src/app/api/walk-route/route.ts
+++ b/apps/web/src/app/api/walk-route/route.ts
@@ -3,6 +3,17 @@ import { NextRequest, NextResponse } from "next/server";
import { WalkRoutingClient } from "@/lib/walk-routing-client";
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
const client = new WalkRoutingClient();
diff --git a/apps/web/src/app/api/wienerlinien/monitor/route.ts b/apps/web/src/app/api/wienerlinien/monitor/route.ts
index d8641e4..c27b398 100644
--- a/apps/web/src/app/api/wienerlinien/monitor/route.ts
+++ b/apps/web/src/app/api/wienerlinien/monitor/route.ts
@@ -2,6 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "crypto";
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();
/** Wiener Linien stop IDs can be numeric or in WL:format */
diff --git a/apps/web/src/app/api/wienerlinien/stops/route.ts b/apps/web/src/app/api/wienerlinien/stops/route.ts
index e76d185..fbbaead 100644
--- a/apps/web/src/app/api/wienerlinien/stops/route.ts
+++ b/apps/web/src/app/api/wienerlinien/stops/route.ts
@@ -3,6 +3,15 @@ import { randomUUID } from "crypto";
import { WienerLinienClient } from "@/lib/wienerlinien-client";
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();
export async function GET(request: NextRequest) {
diff --git a/apps/web/src/app/calendar/BatchEditPanel.tsx b/apps/web/src/app/calendar/BatchEditPanel.tsx
index 67b0b4f..6378bd7 100644
--- a/apps/web/src/app/calendar/BatchEditPanel.tsx
+++ b/apps/web/src/app/calendar/BatchEditPanel.tsx
@@ -5,6 +5,10 @@ import { format } from "date-fns";
import { useEventsStore } from "@/hooks/useEventsStore";
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 {
if (source === "manual") return "Manually added";
if (source === "google_calendar") return "Google Calendar";
@@ -20,6 +24,13 @@ function sourceLabel(source: string): string {
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() {
const { events, updateEvent } = useEventsStore();
diff --git a/apps/web/src/app/calendar/CalendarPanel.tsx b/apps/web/src/app/calendar/CalendarPanel.tsx
index 3c7c29e..691df4a 100644
--- a/apps/web/src/app/calendar/CalendarPanel.tsx
+++ b/apps/web/src/app/calendar/CalendarPanel.tsx
@@ -8,6 +8,16 @@ import UrlTab from "./UrlTab";
import FileTab from "./FileTab";
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 = {
className?: string;
};
diff --git a/apps/web/src/app/calendar/CalendarView.tsx b/apps/web/src/app/calendar/CalendarView.tsx
index 9684a34..4c8adeb 100644
--- a/apps/web/src/app/calendar/CalendarView.tsx
+++ b/apps/web/src/app/calendar/CalendarView.tsx
@@ -4,6 +4,13 @@ import React, { useState } from "react";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
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 = {
events: Event[];
onDateSelect: (date: Date) => void;
diff --git a/apps/web/src/app/calendar/DayEvents.tsx b/apps/web/src/app/calendar/DayEvents.tsx
index c58cd89..e2781c6 100644
--- a/apps/web/src/app/calendar/DayEvents.tsx
+++ b/apps/web/src/app/calendar/DayEvents.tsx
@@ -5,6 +5,10 @@ import { format } from "date-fns";
import { Event, Station } from "@timetoleave/core";
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 = {
events: Event[];
date: Date;
diff --git a/apps/web/src/app/calendar/FileTab.tsx b/apps/web/src/app/calendar/FileTab.tsx
index 7cc4eee..c71d074 100644
--- a/apps/web/src/app/calendar/FileTab.tsx
+++ b/apps/web/src/app/calendar/FileTab.tsx
@@ -3,6 +3,12 @@
import React, { useRef, useState } from "react";
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 = {
onLoadCalendar: (file: File) => Promise;
loading: boolean;
diff --git a/apps/web/src/app/calendar/GoogleTab.tsx b/apps/web/src/app/calendar/GoogleTab.tsx
index 31ff358..23e6556 100644
--- a/apps/web/src/app/calendar/GoogleTab.tsx
+++ b/apps/web/src/app/calendar/GoogleTab.tsx
@@ -4,8 +4,20 @@ import React, { useState, useEffect, useCallback } from "react";
import Button from "@/app/ui/Button";
import type { CalendarEvent } from "@timetoleave/core";
+/** OAuth connection states for the Google Calendar integration. */
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 = {
onEventsLoaded: (events: CalendarEvent[]) => void;
className?: string;
diff --git a/apps/web/src/app/calendar/UrlTab.tsx b/apps/web/src/app/calendar/UrlTab.tsx
index 159580f..f087443 100644
--- a/apps/web/src/app/calendar/UrlTab.tsx
+++ b/apps/web/src/app/calendar/UrlTab.tsx
@@ -3,6 +3,12 @@
import React, { useState } from "react";
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 = {
onLoadCalendar: (url: string) => Promise;
loading: boolean;
diff --git a/apps/web/src/app/calendar/page.tsx b/apps/web/src/app/calendar/page.tsx
index 1f087dd..d886360 100644
--- a/apps/web/src/app/calendar/page.tsx
+++ b/apps/web/src/app/calendar/page.tsx
@@ -8,6 +8,12 @@ import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel";
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() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
diff --git a/apps/web/src/app/event/BikeSection.tsx b/apps/web/src/app/event/BikeSection.tsx
index 051c0c3..00b036e 100644
--- a/apps/web/src/app/event/BikeSection.tsx
+++ b/apps/web/src/app/event/BikeSection.tsx
@@ -5,6 +5,12 @@ import { BikeRoute } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
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 = {
bikeRoute: BikeRoute | null | undefined;
bikeLoading: boolean;
diff --git a/apps/web/src/app/event/EventCard.tsx b/apps/web/src/app/event/EventCard.tsx
index 7d786a9..7ea6823 100644
--- a/apps/web/src/app/event/EventCard.tsx
+++ b/apps/web/src/app/event/EventCard.tsx
@@ -19,6 +19,20 @@ import WienerLinienSection from "./WienerLinienSection";
import CountdownBadge from "@/app/ui/CountdownBadge";
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 {
event: Event;
originStation: Station | null;
diff --git a/apps/web/src/app/event/JourneyList.tsx b/apps/web/src/app/event/JourneyList.tsx
index 3b6d586..8995e1c 100644
--- a/apps/web/src/app/event/JourneyList.tsx
+++ b/apps/web/src/app/event/JourneyList.tsx
@@ -6,6 +6,18 @@ import { formatTime } from "@timetoleave/core";
import LeaveByBadge from "./LeaveByBadge";
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 = {
journeys: Journey[];
eventTime: Date;
diff --git a/apps/web/src/app/event/LeaveByBadge.tsx b/apps/web/src/app/event/LeaveByBadge.tsx
index 4d4327f..79ec6e6 100644
--- a/apps/web/src/app/event/LeaveByBadge.tsx
+++ b/apps/web/src/app/event/LeaveByBadge.tsx
@@ -4,6 +4,7 @@ import React from "react";
import { CountdownInfo } from "@timetoleave/core";
import Chip from "@/app/ui/Chip";
+/** Color variants matching the countdown engine's severity levels. */
const colorMap: Record = {
red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100",
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
@@ -12,6 +13,10 @@ const colorMap: Record = {
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 = {
countdown: CountdownInfo;
className?: string;
diff --git a/apps/web/src/app/event/TrainSection.tsx b/apps/web/src/app/event/TrainSection.tsx
index f2b8f1c..9205ae6 100644
--- a/apps/web/src/app/event/TrainSection.tsx
+++ b/apps/web/src/app/event/TrainSection.tsx
@@ -8,6 +8,13 @@ import JourneyList from "./JourneyList";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
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 = {
journeys: Journey[];
eventTime: Date;
diff --git a/apps/web/src/app/event/WalkingOption.tsx b/apps/web/src/app/event/WalkingOption.tsx
index 55db2dc..07b31fa 100644
--- a/apps/web/src/app/event/WalkingOption.tsx
+++ b/apps/web/src/app/event/WalkingOption.tsx
@@ -5,6 +5,12 @@ import { WalkRoute } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
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 = {
walkRoute: WalkRoute | null | undefined;
walkLoading?: boolean;
diff --git a/apps/web/src/app/event/WienerLinienSection.tsx b/apps/web/src/app/event/WienerLinienSection.tsx
index d0ce92b..16078cf 100644
--- a/apps/web/src/app/event/WienerLinienSection.tsx
+++ b/apps/web/src/app/event/WienerLinienSection.tsx
@@ -4,6 +4,7 @@ import type { WienerLinienStop } from "@timetoleave/core";
import LoadingSpinner from "@/app/ui/LoadingSpinner";
import Chip from "@/app/ui/Chip";
+/** Individual departure entry from a WienerLinien stop. */
interface DepartureRow {
stopId: string;
lineName: string;
@@ -11,6 +12,12 @@ interface DepartureRow {
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 = {
stops: WienerLinienStop[];
departures: DepartureRow[];
diff --git a/apps/web/src/app/layout/Header.tsx b/apps/web/src/app/layout/Header.tsx
index bf4cf1e..f3d331e 100644
--- a/apps/web/src/app/layout/Header.tsx
+++ b/apps/web/src/app/layout/Header.tsx
@@ -10,6 +10,16 @@ import { useTheme } from "@/hooks/useTheme";
import Link from "next/link";
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 = {
className?: string;
};
diff --git a/apps/web/src/app/layout/Navbar.tsx b/apps/web/src/app/layout/Navbar.tsx
index 471476f..744190b 100644
--- a/apps/web/src/app/layout/Navbar.tsx
+++ b/apps/web/src/app/layout/Navbar.tsx
@@ -4,6 +4,10 @@ import React from "react";
import Link from "next/link";
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 = {
className?: string;
};
diff --git a/apps/web/src/app/layout/ReminderEngine.tsx b/apps/web/src/app/layout/ReminderEngine.tsx
index 456a0f3..eb146ef 100644
--- a/apps/web/src/app/layout/ReminderEngine.tsx
+++ b/apps/web/src/app/layout/ReminderEngine.tsx
@@ -2,6 +2,12 @@
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() {
useReminder();
return null;
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index ad88c81..2fa003b 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -4,6 +4,13 @@ import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
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() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
diff --git a/apps/web/src/app/ui/Button.tsx b/apps/web/src/app/ui/Button.tsx
index 47983f8..9e34576 100644
--- a/apps/web/src/app/ui/Button.tsx
+++ b/apps/web/src/app/ui/Button.tsx
@@ -2,6 +2,14 @@
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 & {
variant?: "primary" | "secondary" | "accent" | "danger";
size?: "sm" | "md" | "lg";
diff --git a/apps/web/src/app/ui/Chip.tsx b/apps/web/src/app/ui/Chip.tsx
index ee48e00..0451ec2 100644
--- a/apps/web/src/app/ui/Chip.tsx
+++ b/apps/web/src/app/ui/Chip.tsx
@@ -2,6 +2,10 @@
import React from 'react';
+/**
+ * Pill-shaped badge component used for status indicators,
+ * transport line labels, and compact metadata display.
+ */
type ChipProps = {
children: React.ReactNode;
className?: string;
diff --git a/apps/web/src/app/ui/CountdownBadge.tsx b/apps/web/src/app/ui/CountdownBadge.tsx
index d5882e5..ce3a71f 100644
--- a/apps/web/src/app/ui/CountdownBadge.tsx
+++ b/apps/web/src/app/ui/CountdownBadge.tsx
@@ -3,6 +3,7 @@
import React from "react";
import Chip from "./Chip";
+/** Color variants mapped from the countdown engine output to Tailwind classes. */
const colorMap: Record = {
red: "border-red-400/30 bg-red-500/16 text-red-100",
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
diff --git a/apps/web/src/app/ui/LoadingSpinner.tsx b/apps/web/src/app/ui/LoadingSpinner.tsx
index 3673bc2..85e393c 100644
--- a/apps/web/src/app/ui/LoadingSpinner.tsx
+++ b/apps/web/src/app/ui/LoadingSpinner.tsx
@@ -2,6 +2,10 @@
import React from 'react';
+/**
+ * Animated spinning indicator shown during data fetching.
+ * Includes an accessible screen-reader-only label.
+ */
type LoadingSpinnerProps = {
size?: 'sm' | 'md' | 'lg';
className?: string;
diff --git a/apps/web/src/app/ui/LogoHorizontal.tsx b/apps/web/src/app/ui/LogoHorizontal.tsx
index cc79003..981c69f 100644
--- a/apps/web/src/app/ui/LogoHorizontal.tsx
+++ b/apps/web/src/app/ui/LogoHorizontal.tsx
@@ -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({
height = 44,
className = "",
diff --git a/apps/web/src/app/ui/LogoIcon.tsx b/apps/web/src/app/ui/LogoIcon.tsx
index 49cdc83..9f2823e 100644
--- a/apps/web/src/app/ui/LogoIcon.tsx
+++ b/apps/web/src/app/ui/LogoIcon.tsx
@@ -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";
export default function LogoIcon({
diff --git a/apps/web/src/app/ui/ReminderSettingsPanel.tsx b/apps/web/src/app/ui/ReminderSettingsPanel.tsx
index 43f7101..4a09624 100644
--- a/apps/web/src/app/ui/ReminderSettingsPanel.tsx
+++ b/apps/web/src/app/ui/ReminderSettingsPanel.tsx
@@ -3,6 +3,16 @@
import React from "react";
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 = {
className?: string;
};
diff --git a/apps/web/src/hooks/useBikeRoute.ts b/apps/web/src/hooks/useBikeRoute.ts
index 4ad747a..49316c0 100644
--- a/apps/web/src/hooks/useBikeRoute.ts
+++ b/apps/web/src/hooks/useBikeRoute.ts
@@ -4,6 +4,10 @@ import { ApiClient } from "@timetoleave/api-client";
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(
fromLat: number | undefined,
fromLng: number | undefined,
diff --git a/apps/web/src/hooks/useCalendar.ts b/apps/web/src/hooks/useCalendar.ts
index 58cc803..e6f0043 100644
--- a/apps/web/src/hooks/useCalendar.ts
+++ b/apps/web/src/hooks/useCalendar.ts
@@ -2,6 +2,10 @@ import { useState, useCallback } from "react";
import type { CalendarEvent } from "@timetoleave/core";
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) {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(false);
diff --git a/apps/web/src/hooks/useClock.ts b/apps/web/src/hooks/useClock.ts
index 9338f83..60453b2 100644
--- a/apps/web/src/hooks/useClock.ts
+++ b/apps/web/src/hooks/useClock.ts
@@ -8,6 +8,11 @@ interface ClockResult {
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 {
const [now, setNow] = useState(new Date());
diff --git a/apps/web/src/hooks/useDepartureTime.ts b/apps/web/src/hooks/useDepartureTime.ts
index 86054cb..f306fdc 100644
--- a/apps/web/src/hooks/useDepartureTime.ts
+++ b/apps/web/src/hooks/useDepartureTime.ts
@@ -10,6 +10,15 @@ interface DepartureTimeResult {
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(
eventTime: Date,
journeys: Journey[] | null,
diff --git a/apps/web/src/hooks/useDestinationStation.ts b/apps/web/src/hooks/useDestinationStation.ts
index c5853ae..dbe570a 100644
--- a/apps/web/src/hooks/useDestinationStation.ts
+++ b/apps/web/src/hooks/useDestinationStation.ts
@@ -15,6 +15,13 @@ function normalizeHafasCoordinate(value: number | undefined): number | undefined
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) {
const [station, setStation] = useState(null);
const [loading, setLoading] = useState(false);
diff --git a/apps/web/src/hooks/useEventsStore.tsx b/apps/web/src/hooks/useEventsStore.tsx
index 55bd0fa..0d56cf0 100644
--- a/apps/web/src/hooks/useEventsStore.tsx
+++ b/apps/web/src/hooks/useEventsStore.tsx
@@ -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";
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";
+/**
+ * Load events from localStorage, converting ISO-string eventTime back
+ * to Date objects. Returns empty array on SSR or parse failure.
+ */
function loadFromStorage(): Event[] {
if (typeof window === "undefined") return [];
try {
@@ -17,6 +25,9 @@ function loadFromStorage(): Event[] {
}
}
+/**
+ * Context value shape for the events store.
+ */
interface EventsContextType {
events: Event[];
addEvent: (event: Event) => void;
@@ -29,6 +40,10 @@ interface EventsContextType {
const EventsContext = createContext(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 }) {
const [events, setEventsState] = useState([]);
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() {
const context = useContext(EventsContext);
if (context === undefined) {
diff --git a/apps/web/src/hooks/useGeocode.ts b/apps/web/src/hooks/useGeocode.ts
index 9a99ae9..b7155a4 100644
--- a/apps/web/src/hooks/useGeocode.ts
+++ b/apps/web/src/hooks/useGeocode.ts
@@ -1,6 +1,10 @@
import { useState, useEffect } from "react";
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) {
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
const [loading, setLoading] = useState(false);
diff --git a/apps/web/src/hooks/useJourneys.ts b/apps/web/src/hooks/useJourneys.ts
index ab360d2..f5dfcc9 100644
--- a/apps/web/src/hooks/useJourneys.ts
+++ b/apps/web/src/hooks/useJourneys.ts
@@ -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(
fromStationExtId: string | null,
toStationExtId: string | null,
diff --git a/apps/web/src/hooks/useOriginStation.ts b/apps/web/src/hooks/useOriginStation.ts
index 16b37f3..fa5edbe 100644
--- a/apps/web/src/hooks/useOriginStation.ts
+++ b/apps/web/src/hooks/useOriginStation.ts
@@ -11,6 +11,12 @@ interface HafasLocation {
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() {
const [station, setStation] = useState(null);
const [loading, setLoading] = useState(false);
diff --git a/apps/web/src/hooks/useReminder.ts b/apps/web/src/hooks/useReminder.ts
index 3efcf19..8e52e77 100644
--- a/apps/web/src/hooks/useReminder.ts
+++ b/apps/web/src/hooks/useReminder.ts
@@ -4,6 +4,12 @@ import { useReminderSettings } from "./useReminderSettings";
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() {
const { events } = useEventsStore();
const { bufferMinutes, enabled } = useReminderSettings();
diff --git a/apps/web/src/hooks/useReminderSettings.tsx b/apps/web/src/hooks/useReminderSettings.tsx
index f694972..7213725 100644
--- a/apps/web/src/hooks/useReminderSettings.tsx
+++ b/apps/web/src/hooks/useReminderSettings.tsx
@@ -3,6 +3,10 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
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 DEFAULTS: ReminderSettings = {
bufferMinutes: 15,
diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts
index 6aa16f9..3207efa 100644
--- a/apps/web/src/hooks/useTheme.ts
+++ b/apps/web/src/hooks/useTheme.ts
@@ -15,6 +15,12 @@ function getClientTheme(): "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() {
const [dark, setDark] = useState(() => getClientTheme() === "dark");
const [mounted] = useState(() => typeof window !== "undefined");
diff --git a/apps/web/src/hooks/useWalkRoute.ts b/apps/web/src/hooks/useWalkRoute.ts
index 1e851ee..aaae7e9 100644
--- a/apps/web/src/hooks/useWalkRoute.ts
+++ b/apps/web/src/hooks/useWalkRoute.ts
@@ -2,6 +2,10 @@ import { useState, useEffect } from "react";
import type { WalkRoute } from "@timetoleave/core";
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(
fromLat: number | undefined,
fromLng: number | undefined,
diff --git a/apps/web/src/lib/calendar-utils.ts b/apps/web/src/lib/calendar-utils.ts
index 822db21..658f9ee 100644
--- a/apps/web/src/lib/calendar-utils.ts
+++ b/apps/web/src/lib/calendar-utils.ts
@@ -1,6 +1,7 @@
import * as ical from "node-ical";
import type { CalendarEvent } from "@timetoleave/core";
+/** ICS component shape extracted by node-ical. */
type IcalComponent = {
type?: string;
uid?: string;
@@ -12,6 +13,13 @@ type IcalComponent = {
[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[] {
const _now = now ?? new Date();
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;
}
+/** Normalise Austrian station names so HAFAS lookup succeeds. */
export function cleanLocation(location: string): string {
const knownStations: Record = {
"Graz Hauptbahnhof": "Graz Hbf",
diff --git a/apps/web/src/lib/demo.ts b/apps/web/src/lib/demo.ts
index 21ccce8..30791d0 100644
--- a/apps/web/src/lib/demo.ts
+++ b/apps/web/src/lib/demo.ts
@@ -1,6 +1,10 @@
// Demo data for TimeToLeave
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[] = [
{ name: "Wien Hbf", extId: "0WB0F0001500" },
{ name: "Graz Hbf", extId: "0WB0F0000600" },
@@ -11,6 +15,13 @@ export const DEMO_STATIONS: Station[] = [
{ 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 {
const now = new Date();
return {
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index 7532ded..0cc5d51 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -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 {
GeocodeResult,
NearbyStop,
@@ -16,6 +20,11 @@ type SearchJourneyOptions = {
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;
function buildUrl(base: string, path: string, params: Record = {}): 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 {
if (value == null) return undefined;
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 {
private readonly baseUrl: string;
diff --git a/packages/core/src/countdown-utils.ts b/packages/core/src/countdown-utils.ts
index 85ff1ac..6cf6411 100644
--- a/packages/core/src/countdown-utils.ts
+++ b/packages/core/src/countdown-utils.ts
@@ -1,6 +1,15 @@
-// Countdown utilities for TimeToLeave
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 {
const now = new Date();
const diffMs = targetDate.getTime() - now.getTime();
diff --git a/packages/core/src/defaults.ts b/packages/core/src/defaults.ts
index 4195c7b..3866b58 100644
--- a/packages/core/src/defaults.ts
+++ b/packages/core/src/defaults.ts
@@ -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";
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_EXT_ID = "1231701";
+/** Pre-assembled default station object for convenience. */
export const DEFAULT_ORIGIN_STATION: Station = {
name: DEFAULT_ORIGIN_ADDRESS,
extId: DEFAULT_ORIGIN_STATION_EXT_ID,
diff --git a/packages/core/src/formatting.ts b/packages/core/src/formatting.ts
index e661af1..2302729 100644
--- a/packages/core/src/formatting.ts
+++ b/packages/core/src/formatting.ts
@@ -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 {
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 {
return date.toLocaleDateString("de-AT", {
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 {
return date.toLocaleString("de-AT", {
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 {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
@@ -33,6 +38,7 @@ export function formatDuration(seconds: number): string {
return `${minutes}min`;
}
+/** Convert meters to a human-readable distance (meters or km). */
export function formatDistance(meters: number): string {
if (meters < 1000) {
return `${meters}m`;
diff --git a/packages/core/src/hafas-parser.ts b/packages/core/src/hafas-parser.ts
index dd78c44..ac03761 100644
--- a/packages/core/src/hafas-parser.ts
+++ b/packages/core/src/hafas-parser.ts
@@ -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 { parseHafasTime } from "./hafas-time";
// eslint-disable-next-line @typescript-eslint/no-explicit-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[] {
const outConL: RawJson[] = json?.svcResL?.[0]?.res?.outConL ?? [];
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8d79680..ea5dae0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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 './countdown-utils';
export * from './formatting';
diff --git a/packages/core/src/status-utils.ts b/packages/core/src/status-utils.ts
index c1f6d6f..dcd919a 100644
--- a/packages/core/src/status-utils.ts
+++ b/packages/core/src/status-utils.ts
@@ -1,6 +1,7 @@
-// Status utilities for TimeToLeave
+// ── Status utilities ──
import type { ServerStatus, Event, Journey } from "./types";
+/** Utility class for server health checks. */
export class StatusUtils {
static async checkServerStatus(url: string): Promise {
try {
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index b43c228..60447b6 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,5 +1,6 @@
-// Core domain types
+// Core domain types for TimeToLeave
+/** Event stored in the application (eventTime as Date). */
export interface Event {
id: string;
title: string;
@@ -8,6 +9,7 @@ export interface Event {
source: string;
}
+/** Calendar event from an ICS feed (eventTime as ISO string for serialization). */
export interface CalendarEvent {
id: string;
title: string;
@@ -16,6 +18,7 @@ export interface CalendarEvent {
source: string;
}
+/** HAFAS station with optional GPS coordinates. */
export interface Station {
name: string;
extId: string;
@@ -23,6 +26,7 @@ export interface Station {
lng?: number;
}
+/** A single transit journey with schedule vs. real-time data. */
export interface Journey {
id: string;
sD: Date; // scheduled departure
@@ -36,6 +40,7 @@ export interface Journey {
cancelled: boolean;
}
+/** Bike routing step from OSRM. */
export interface BikeStep {
name: string;
distance: number;
@@ -43,18 +48,21 @@ export interface BikeStep {
instruction: string;
}
+/** Complete bike route with step-by-step directions. */
export interface BikeRoute {
distance: number;
duration: number;
steps: BikeStep[];
}
+/** Walking route from OSRM with turn-by-turn steps. */
export interface WalkRoute {
distance: number;
duration: number;
steps: WalkStep[];
}
+/** Walking step from OSRM route data. */
export interface WalkStep {
name: string;
distance: number;
@@ -62,17 +70,21 @@ export interface WalkStep {
instruction: string;
}
+/** Countdown display info derived from the time remaining until an event. */
export interface CountdownInfo {
label: string;
color: string;
urgent: boolean;
}
+/** Geolocation permission state. */
export type LocState = "pending" | "granted" | "denied";
+/** Calendar sync status. */
export type CalStatus = null | "loading" | "ok" | "error";
// ── Reminder Settings ──────────────────────────────────
+/** Persistent reminder preferences stored in localStorage. */
export interface ReminderSettings {
bufferMinutes: number;
enabled: boolean;
@@ -81,21 +93,25 @@ export interface ReminderSettings {
showBikeOption: boolean; // show bike route section (default true)
}
+/** Server health check result. */
export type ServerStatus = boolean | null;
+/** Result from Nominatim geocoding. */
export interface GeocodeResult {
lat: number;
lng: number;
display_name: string;
}
-// WienerLinien types
+// ── WienerLinien (Vienna public transport) types ──
+/** A WienerLinien vehicle line. */
export interface WienerLinienLine {
name: string;
type?: string;
}
+/** A WienerLinien stop with GPS coordinates. */
export interface WienerLinienStop {
id: string;
name: string;
@@ -103,6 +119,7 @@ export interface WienerLinienStop {
lng: number;
}
+/** Departure from a WienerLinien stop. */
export interface WienerLinienDeparture {
stopId: string;
line: WienerLinienLine;
@@ -111,6 +128,7 @@ export interface WienerLinienDeparture {
delay?: number;
}
+/** Response from the WienerLinien monitor endpoint. */
export interface WienerLinienMonitorResponse {
stops: {
stopId: string;
@@ -118,6 +136,7 @@ export interface WienerLinienMonitorResponse {
}[];
}
+/** Stop found nearby the user's location. */
export interface NearbyStop {
id: string;
name: string;