Add JSDoc documentation to all TypeScript files
This commit is contained in:
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { BikeRoute, Station } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the bike route information section shown on event detail. */
|
||||
interface Props {
|
||||
bikeRoute: BikeRoute | null;
|
||||
loading: boolean;
|
||||
@@ -10,6 +11,11 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays cycling route duration, distance, and a map placeholder for the
|
||||
* event's origin → destination leg. Shows contextual empty states when no
|
||||
* origin is set or no route is available.
|
||||
*/
|
||||
export function BikeSection({ bikeRoute, loading, origin, colors }: Props) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { StyleSheet, Text, View } from 'react-native';
|
||||
import type { Event } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the event detail header showing title, destination, times, and buffers. */
|
||||
interface Props {
|
||||
event: Event;
|
||||
leaveByTime: Date | null;
|
||||
@@ -9,6 +10,10 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the event title, destination, formatted date/time, data source,
|
||||
* and a three-column grid with leave-by time, arrive-by time, and buffer.
|
||||
*/
|
||||
export function EventHeader({ event, leaveByTime, arrivalBufferMinutes, colors }: Props) {
|
||||
const arriveByTime = new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, Station, WalkRoute } from '@timetoleave/core';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the train journey list and optional final walk leg. */
|
||||
interface Props {
|
||||
journeys: Journey[];
|
||||
destStationLoading: boolean;
|
||||
@@ -15,6 +16,12 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders each journey card with train lines, departure/arrival times, platform,
|
||||
* transfer count, and delay/cancellation badges. Cards that arrive too late are
|
||||
* visually dimmed and bordered in red. Optionally shows the final walking leg
|
||||
* from the destination station to the event location.
|
||||
*/
|
||||
export function JourneyList({
|
||||
journeys,
|
||||
destStationLoading,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { WienerLinienStop } from '@timetoleave/core';
|
||||
import type { DepartureRow } from '../hooks/useWienerLinien';
|
||||
import type { AppColors } from '../hooks/useColors';
|
||||
|
||||
/** Props for the nearby public transport stops section. */
|
||||
interface Props {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
@@ -11,6 +12,11 @@ interface Props {
|
||||
colors: AppColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows public transport stops near the event destination and their upcoming
|
||||
* departures. Caps the departure list at 8 items to avoid overwhelming the UI.
|
||||
* Falls back to showing plain stop names when no departure data is available.
|
||||
*/
|
||||
export function NearbyStops({ stops, departures, loading, error, colors }: Props) {
|
||||
if (!loading && stops.length === 0 && !error) return null;
|
||||
|
||||
|
||||
@@ -32,8 +32,13 @@ const LIGHT = {
|
||||
highlight: '#e8f4fd',
|
||||
} as const;
|
||||
|
||||
/** Color token type — every theme variant shares the same keys. */
|
||||
export type AppColors = Record<keyof typeof DARK, string>;
|
||||
|
||||
/**
|
||||
* Returns the full color palette (dark or light) based on the current theme.
|
||||
* Derives from {@link useTheme} so it stays in sync with user preferences.
|
||||
*/
|
||||
export function useColors(): AppColors {
|
||||
const { dark } = useTheme();
|
||||
return dark ? DARK : LIGHT;
|
||||
|
||||
@@ -9,7 +9,19 @@ interface DepartureTimeResult {
|
||||
|
||||
/**
|
||||
* Calculate departure time based on selected transport mode.
|
||||
*
|
||||
* For train mode, picks the latest-departing journey that still arrives on time
|
||||
* (factoring in the final walking leg). For bike mode, simply subtracts the
|
||||
* cycling duration from the target arrival time.
|
||||
*
|
||||
* Mirrors the web app's useDepartureTime hook.
|
||||
*
|
||||
* @param eventTime - The scheduled event start time.
|
||||
* @param journeys - Available train journeys (may be null if not loaded yet).
|
||||
* @param bikeDurationSeconds - Cycling duration in seconds, or null if unavailable.
|
||||
* @param activeMode - The currently selected transport mode.
|
||||
* @param arrivalBufferMinutes - Minutes to arrive before the event starts.
|
||||
* @param trainWalkDurationSeconds - Walking time from destination station to event (seconds).
|
||||
*/
|
||||
export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
|
||||
import type { Station } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/**
|
||||
* Geocodes a destination string to the nearest HAFAS station.
|
||||
*
|
||||
* Two-step process: first geocodes the address to lat/lng via Nominatim,
|
||||
* then sends those coordinates to HAFAS LocMatch to find the closest station.
|
||||
* Debounces lookups by 400 ms to avoid excessive API calls while typing.
|
||||
*/
|
||||
/** Intermediate shape returned by HAFAS LocMatch before we pick the best station. */
|
||||
interface HafasLocation {
|
||||
type: string;
|
||||
name: string;
|
||||
@@ -10,6 +18,7 @@ interface HafasLocation {
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** HAFAS can return coordinates in either raw degrees or micro-degrees (×1e6). */
|
||||
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
return Math.abs(value) > 1000 ? value / 1e6 : value;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { WienerLinienStop, WienerLinienDeparture } from '@timetoleave/core';
|
||||
import { api } from '../services/api';
|
||||
|
||||
/** Flattened departure row shown in the NearbyStops UI. */
|
||||
export interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
@@ -12,6 +13,7 @@ export interface DepartureRow {
|
||||
const DEBOUNCE_MS = 400;
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Convert a raw WienerLinien departure to the simplified DepartureRow shape. */
|
||||
function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
const minutes = Math.max(0, Math.round((dep.departureTime - Date.now()) / 60_000));
|
||||
return {
|
||||
@@ -22,6 +24,16 @@ function transformDeparture(dep: WienerLinienDeparture): DepartureRow {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches nearby WienerLinien stops around given coordinates and their live
|
||||
* departures. Debounces initial lookups (400 ms) and refreshes departures
|
||||
* every 60 seconds. Uses a cancellation ref to prevent stale overwrites when
|
||||
* coordinates change mid-request.
|
||||
*
|
||||
* @param lat - Latitude of the point of interest.
|
||||
* @param lng - Longitude of the point of interest.
|
||||
* @param radius - Search radius in meters (defaults to 500).
|
||||
*/
|
||||
export function useWienerLinien(
|
||||
lat: number | undefined,
|
||||
lng: number | undefined,
|
||||
@@ -32,9 +44,12 @@ export function useWienerLinien(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stopped by the cleanup effect when coordinates change, so in-flight
|
||||
// monitor requests don't overwrite stale stop lists.
|
||||
const stopIdsRef = useRef<string[]>([]);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
/** Fetch live departure data for a batch of stop IDs. Silently ignores errors. */
|
||||
const fetchMonitor = useCallback(async (stopIds: string[]): Promise<void> => {
|
||||
if (stopIds.length === 0 || cancelledRef.current) return;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
@@ -13,6 +14,10 @@ import type { RootStack } from '../types/navigation';
|
||||
|
||||
const Root = createNativeStackNavigator<RootStack>();
|
||||
|
||||
/**
|
||||
* Root native stack navigator for the app.
|
||||
* Wraps all screens in SafeAreaProvider/SafeAreaView with a dark header bar.
|
||||
*/
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
@@ -21,13 +26,22 @@ export default function AppNavigator() {
|
||||
<NavigationContainer>
|
||||
<Root.Navigator
|
||||
initialRouteName="EventList"
|
||||
screenOptions={{ headerStyle: { backgroundColor: '#17112A' }, headerTintColor: '#F4F1EA' }}
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#17112A' },
|
||||
headerTintColor: '#F4F1EA',
|
||||
headerTitle: () => (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Image source={require('../../assets/icon.png')} style={{ width: 28, height: 28 }} resizeMode="contain" />
|
||||
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time To Leave</Text>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Root.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
<Root.Screen name="EventList" component={EventListScreen} />
|
||||
<Root.Screen name="AddEvent" component={AddEventScreen} />
|
||||
<Root.Screen name="EventDetail" component={EventDetailScreen} />
|
||||
<Root.Screen name="Settings" component={SettingsScreen} />
|
||||
<Root.Screen name="CalendarImport" component={CalendarImportScreen} />
|
||||
</Root.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/**
|
||||
* Polyfills for newer JavaScript features that React Native's Hermes engine
|
||||
* doesn't provide out of the box.
|
||||
*
|
||||
* - `String.prototype.toWellFormed` / `isWellFormed` — handles lone surrogates
|
||||
* by replacing them with the Unicode replacement character (U+FFFD).
|
||||
* - `ArrayBuffer.prototype.resizable` — required by newer Intl APIs.
|
||||
* - `SharedArrayBuffer` — stubbed so that libraries which check for its
|
||||
* presence (e.g. the Intl locale data) don't crash at runtime.
|
||||
*/
|
||||
|
||||
const globalScope = globalThis as Record<string, unknown>;
|
||||
const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
isWellFormed?: () => boolean;
|
||||
@@ -6,6 +17,11 @@ const stringPrototype = String.prototype as typeof String.prototype & {
|
||||
|
||||
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get;
|
||||
|
||||
/**
|
||||
* Replace lone surrogates (U+D800…U+DBFF without a partner, or U+DC00…U+DFFF
|
||||
* without a lead) with the Unicode replacement character U+FFFD. Valid
|
||||
* surrogate pairs are kept as-is. Used by both `toWellFormed` and `isWellFormed`.
|
||||
*/
|
||||
function toWellFormedString(value: string): string {
|
||||
let result = '';
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Form screen for creating or editing an event.
|
||||
*
|
||||
* When navigated with `editEventId`, loads the existing event and populates
|
||||
* the form fields. Validates that the event time is in the future before saving.
|
||||
* Shows a transient success overlay for 1.5 s before popping back.
|
||||
*/
|
||||
export function AddEventScreen({ navigation, route }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [title, setTitle] = useState('');
|
||||
|
||||
@@ -22,6 +22,10 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'CalendarImport'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Screen for importing events from either an ICS calendar URL or the device's
|
||||
* native calendar. Deduplicates against already-imported events by ID.
|
||||
*/
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
@@ -31,6 +31,11 @@ type ScreenProps = {
|
||||
|
||||
type TransportMode = 'train' | 'bike';
|
||||
|
||||
/**
|
||||
* Compute the target arrival time at the destination station.
|
||||
* Subtracts both the arrival buffer (time before event) and the walking
|
||||
* duration from station to event location.
|
||||
*/
|
||||
function stationArrivalTarget(
|
||||
eventTime: Date,
|
||||
arrivalBufferMinutes: number,
|
||||
|
||||
@@ -21,6 +21,11 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'EventList'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Home screen showing a scrollable list of upcoming events with countdown
|
||||
* badges. Pull-to-refresh reloads events from storage. Reloads automatically
|
||||
* when the screen gains focus (so edits on other screens are reflected).
|
||||
*/
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const colors = useColors();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -24,6 +25,13 @@ type ScreenProps = {
|
||||
route: RouteProp<RootStack, 'Settings'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings screen for managing the origin station, notification preferences,
|
||||
* appearance (dark/light mode), and advanced options (walk/bike toggles).
|
||||
*
|
||||
* Station search is debounced by 400 ms. When the origin changes, all
|
||||
* scheduled push notifications are recalculated to use the new departure station.
|
||||
*/
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const { dark, toggle: toggleTheme } = useTheme();
|
||||
const colors = useColors();
|
||||
@@ -209,7 +217,11 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Erscheinungsbild</Text>
|
||||
@@ -322,12 +334,13 @@ export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20 },
|
||||
container: { flex: 1 },
|
||||
contentContainer: { flexGrow: 1, padding: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 10 },
|
||||
input: {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ApiClient } from '@timetoleave/api-client';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the shared `ApiClient` class.
|
||||
* Reads the base URL from the Expo environment variable `EXPO_PUBLIC_API_BASE_URL`.
|
||||
*/
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
|
||||
export const api = new ApiClient(baseUrl);
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { Event as CoreEvent } from '@timetoleave/core';
|
||||
/**
|
||||
* Native calendar integration for the mobile app.
|
||||
* Reads events from device calendars and converts them to our internal format.
|
||||
*
|
||||
* Only events that have a non-empty `location` field are included, since the
|
||||
* app requires a destination to compute routes.
|
||||
*/
|
||||
|
||||
/** Requests calendar read permission and verifies the calendar service is available. */
|
||||
export async function ensureCalendarPermission(): Promise<boolean> {
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
||||
if (status !== 'granted')
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Re-exports the stable public API from `expo-notifications`.
|
||||
* Using public exports instead of internal `/build/` paths to avoid
|
||||
* breaking when the Expo package is updated.
|
||||
*/
|
||||
|
||||
// Public API re-exports from expo-notifications
|
||||
// Using stable public exports instead of internal /build/ paths
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Persistent event store backed by AsyncStorage.
|
||||
*
|
||||
* Every mutation (add, update, remove) also reschedules push notifications
|
||||
* so the user is reminded at the correct departure time. Settings changes
|
||||
* (origin station, buffer) trigger a full notification reschedule.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { DEFAULT_ORIGIN_STATION, type Event, type Station, type ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from '../services/expoNotifications';
|
||||
@@ -21,6 +29,10 @@ const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Revives `eventTime` strings from JSON storage back into Date objects.
|
||||
* Returns an empty array if the stored data is corrupted.
|
||||
*/
|
||||
function reviveDates(json: string): Event[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
|
||||
@@ -39,6 +51,11 @@ async function getNotificationSettings(): Promise<ReminderSettings> {
|
||||
// Notification scheduling utilities
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calculate the leave-by time using a pessimistic fallback.
|
||||
* Because the store doesn't hold live journey data, it subtracts
|
||||
* both the arrival buffer and the notification buffer from the event time.
|
||||
*/
|
||||
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
|
||||
// Calculate target arrival time (event time minus arrival buffer)
|
||||
const targetArrivalTime = new Date(event.eventTime);
|
||||
@@ -48,6 +65,12 @@ async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number,
|
||||
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule three reminder notifications for an event:
|
||||
* 30 min, 10 min, and 0 min before the leave-by time.
|
||||
* Skips notifications that would fire in the past or more than 2 hours before
|
||||
* the event (push notifications are unreliable beyond that window).
|
||||
*/
|
||||
async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promise<void> {
|
||||
const REMINDERS_MIN = [30, 10, 0];
|
||||
const twoHoursBefore = new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000);
|
||||
@@ -70,6 +93,11 @@ async function fireNotificationsForEvent(event: Event, leaveByTime: Date): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule (or re-schedule) notifications for a single event.
|
||||
* Cancels any existing notifications for this event first to avoid duplicates.
|
||||
* No-ops if notifications are globally disabled in settings.
|
||||
*/
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<GoogleTokens | null> {
|
||||
if (!tokens.refresh_token) return null;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** Full HAFAS request body with a list of service requests. */
|
||||
interface HafasBody extends Record<string, unknown> {
|
||||
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<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
@@ -40,6 +53,15 @@ function injectHafasAuth(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET handler — convenience endpoint for simple trip searches.
|
||||
*
|
||||
* Accepts `from`, `to` (numeric ÖBB station IDs) and `date` as query params.
|
||||
* Constructs a TripSearch request, injects auth, and forwards it to HAFAS.
|
||||
*
|
||||
* This is a simplified interface; the POST handler below supports arbitrary
|
||||
* HAFAS envelopes (TripSearch, LocMatch).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
loading: boolean;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
loading: boolean;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<HTMLButtonElement> & {
|
||||
variant?: "primary" | "secondary" | "accent" | "danger";
|
||||
size?: "sm" | "md" | "lg";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
red: "border-red-400/30 bg-red-500/16 text-red-100",
|
||||
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
@@ -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<Date>(new Date());
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Station | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -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<EventsContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provider component that wraps the app with event state management.
|
||||
* Persists events to localStorage on every change (skips initial hydration write).
|
||||
*/
|
||||
export function EventsProvider({ children }: { children: ReactNode }) {
|
||||
const [events, setEventsState] = useState<Event[]>([]);
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Station | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"Graz Hauptbahnhof": "Graz Hbf",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, string> = {}): string {
|
||||
@@ -51,11 +60,19 @@ function buildTripSearchBody(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize HAFAS coordinates. HAFAS may return coordinates in either
|
||||
* decimal degrees or micro-degrees (1e6 factor). This detects and fixes it.
|
||||
*/
|
||||
function normalizeHafasCoordinate(value: number | undefined): number | undefined {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ServerStatus> {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user