From 498958a27cbbf6091ba13117f2935840cff36817 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 14 May 2026 13:53:12 +0200 Subject: [PATCH] Add JSDoc documentation to all TypeScript files --- apps/mobile/src/components/BikeSection.tsx | 6 ++++ apps/mobile/src/components/EventHeader.tsx | 5 +++ apps/mobile/src/components/JourneyList.tsx | 7 ++++ apps/mobile/src/components/NearbyStops.tsx | 6 ++++ apps/mobile/src/hooks/useColors.ts | 5 +++ apps/mobile/src/hooks/useDepartureTime.ts | 12 +++++++ .../mobile/src/hooks/useDestinationStation.ts | 9 ++++++ apps/mobile/src/hooks/useGeocode.ts | 2 ++ apps/mobile/src/hooks/useTheme.ts | 7 ++++ apps/mobile/src/hooks/useWalkRoute.ts | 3 +- apps/mobile/src/hooks/useWienerLinien.ts | 15 +++++++++ apps/mobile/src/navigation/AppNavigator.tsx | 26 +++++++++++---- .../mobile/src/polyfills/sharedArrayBuffer.ts | 16 ++++++++++ apps/mobile/src/screens/AddEventScreen.tsx | 7 ++++ .../src/screens/CalendarImportScreen.tsx | 4 +++ apps/mobile/src/screens/EventDetailScreen.tsx | 5 +++ apps/mobile/src/screens/EventListScreen.tsx | 5 +++ apps/mobile/src/screens/SettingsScreen.tsx | 19 +++++++++-- apps/mobile/src/services/api.ts | 4 +++ apps/mobile/src/services/calendar.ts | 4 +++ apps/mobile/src/services/expoNotifications.ts | 6 ++++ apps/mobile/src/store/eventStore.ts | 28 ++++++++++++++++ apps/web/src/app/add-event/AddEventModal.tsx | 9 ++++++ .../src/app/api/auth/google/callback/route.ts | 10 ++++++ .../app/api/auth/google/disconnect/route.ts | 3 ++ apps/web/src/app/api/auth/google/route.ts | 10 ++++++ .../src/app/api/auth/google/status/route.ts | 7 ++++ apps/web/src/app/api/bike-route/route.ts | 11 +++++++ apps/web/src/app/api/calendar/google/route.ts | 7 ++++ apps/web/src/app/api/calendar/parse/route.ts | 7 ++++ apps/web/src/app/api/calendar/route.ts | 16 ++++++++++ apps/web/src/app/api/geocode/route.ts | 11 +++++++ apps/web/src/app/api/hafas/route.ts | 32 +++++++++++++++++++ apps/web/src/app/api/health/route.ts | 6 ++++ apps/web/src/app/api/walk-route/route.ts | 11 +++++++ .../src/app/api/wienerlinien/monitor/route.ts | 10 ++++++ .../src/app/api/wienerlinien/stops/route.ts | 9 ++++++ apps/web/src/app/calendar/BatchEditPanel.tsx | 11 +++++++ apps/web/src/app/calendar/CalendarPanel.tsx | 10 ++++++ apps/web/src/app/calendar/CalendarView.tsx | 7 ++++ apps/web/src/app/calendar/DayEvents.tsx | 4 +++ apps/web/src/app/calendar/FileTab.tsx | 6 ++++ apps/web/src/app/calendar/GoogleTab.tsx | 12 +++++++ apps/web/src/app/calendar/UrlTab.tsx | 6 ++++ apps/web/src/app/calendar/page.tsx | 6 ++++ apps/web/src/app/event/BikeSection.tsx | 6 ++++ apps/web/src/app/event/EventCard.tsx | 14 ++++++++ apps/web/src/app/event/JourneyList.tsx | 12 +++++++ apps/web/src/app/event/LeaveByBadge.tsx | 5 +++ apps/web/src/app/event/TrainSection.tsx | 7 ++++ apps/web/src/app/event/WalkingOption.tsx | 6 ++++ .../web/src/app/event/WienerLinienSection.tsx | 7 ++++ apps/web/src/app/layout/Header.tsx | 10 ++++++ apps/web/src/app/layout/Navbar.tsx | 4 +++ apps/web/src/app/layout/ReminderEngine.tsx | 6 ++++ apps/web/src/app/page.tsx | 7 ++++ apps/web/src/app/ui/Button.tsx | 8 +++++ apps/web/src/app/ui/Chip.tsx | 4 +++ apps/web/src/app/ui/CountdownBadge.tsx | 1 + apps/web/src/app/ui/LoadingSpinner.tsx | 4 +++ apps/web/src/app/ui/LogoHorizontal.tsx | 8 +++++ apps/web/src/app/ui/LogoIcon.tsx | 10 ++++++ apps/web/src/app/ui/ReminderSettingsPanel.tsx | 10 ++++++ apps/web/src/hooks/useBikeRoute.ts | 4 +++ apps/web/src/hooks/useCalendar.ts | 4 +++ apps/web/src/hooks/useClock.ts | 5 +++ apps/web/src/hooks/useDepartureTime.ts | 9 ++++++ apps/web/src/hooks/useDestinationStation.ts | 7 ++++ apps/web/src/hooks/useEventsStore.tsx | 19 +++++++++++ apps/web/src/hooks/useGeocode.ts | 4 +++ apps/web/src/hooks/useJourneys.ts | 8 +++++ apps/web/src/hooks/useOriginStation.ts | 6 ++++ apps/web/src/hooks/useReminder.ts | 6 ++++ apps/web/src/hooks/useReminderSettings.tsx | 4 +++ apps/web/src/hooks/useTheme.ts | 6 ++++ apps/web/src/hooks/useWalkRoute.ts | 4 +++ apps/web/src/lib/calendar-utils.ts | 9 ++++++ apps/web/src/lib/demo.ts | 11 +++++++ packages/api-client/src/client.ts | 17 ++++++++++ packages/core/src/countdown-utils.ts | 11 ++++++- packages/core/src/defaults.ts | 5 +++ packages/core/src/formatting.ts | 8 ++++- packages/core/src/hafas-parser.ts | 16 ++++++++++ packages/core/src/index.ts | 4 ++- packages/core/src/status-utils.ts | 3 +- packages/core/src/types.ts | 23 +++++++++++-- 86 files changed, 728 insertions(+), 16 deletions(-) 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;