Files
time_to_leave/add_features.md.
T
fegger eac4f6e216 Revamp branding, UI styling, and HAFAS integration
- Update BRAND_GUIDELINES.md with new melting clock logo concept
- Rebrand UI components with new violet-to-pink gradient color scheme
- Implement HAFAS authentication headers and GET support in route
- Update logo SVGs to match new brand guidelines
- Fix HAFAS time format to exclude millisecond padding
- Update default station to Mödling Bahnhof
- Bump workspace package versions to 1.0.0
2026-05-12 09:44:37 +02:00

2435 lines
83 KiB
Plaintext

## User
please add the following to the settings:
- an option to chose how long before the event-time the user wants to arrive
- if the user prefers to walk from/to the station (if within a reasonable distance and the duration is compareable to using public transit)
- if biking distance and duration to the event-location should also be shown, next to the train connections.
other changes:
- add the necessary features to implement the above
- time to leave should change based on the mode of transportation selected by the user ( train/bike)
## Assistant
<thinking>
This is a significant feature request. Let me break it down:
1. **Settings additions:**
- "Arrive X minutes before event" - a time buffer setting
- "Walk from/to station if reasonable" - a walking preference toggle
- "Show biking alternative" - toggle to show bike duration alongside trains
2. **Feature implementations:**
- The "time to leave" calculation should factor in the arrival buffer
- Walking distance/duration should be checked when the user prefers walking
- Bike section should be shown next to train connections (not separately below)
3. **Time to leave changes:**
- The countdown currently just shows when the event starts, but it needs to adapt based on transportation mode—for trains, it should display a "leave by" time based on the departure time minus the buffer, while for biking it should show "leave now" accounting for the bike duration plus buffer
Now I need to explore the codebase to understand how settings are currently managed, how the countdown is calculated, the structure of the event card, and what the settings panel looks like.
</thinking>
Let me explore the current settings, countdown, and event card structure before planning.
**Tool Call: Codebase exploration for settings and event features**
Status: Completed
Perfect! Now let me create a comprehensive summary of all the files you requested. Here's the complete report:
## Summary Report: TimeToLeave Web App Source Code
### Directory Listings
**src/app/ directory structure:**
- add-event/ (folder)
- api/ (folder with bike-route, calendar, geocode, hafas, wienerlinien routes)
- calendar/ (folder)
- event/ (folder)
- layout/ (folder)
- ui/ (folder)
- layout.tsx
- page.tsx
- favicon.ico
- globals.css
- icon.svg
- opengraph-image.svg
- timetoleave_app_icon.svg
- timetoleave_dark_logo.svg
**src/hooks/ directory structure:**
- useBikeRoute.ts
- useCalendar.ts
- useClock.ts
- useDestinationStation.ts
- useEventsStore.tsx
- useGeocode.ts
- useGeolocation.ts
- useJourneys.ts
- useOriginStation.ts
- useReminderSettings.tsx
- useReminder.ts
- useServerHealth.ts
- useTheme.ts
- useWienerLinien.ts
- __tests__/ (folder)
**src/app/ui/ directory structure:**
- Button.tsx
- Chip.tsx
- CountdownBadge.tsx
- LoadingSpinner.tsx
- LogoIcon.tsx
- LogoHorizontal.tsx
- ReminderSettingsPanel.tsx
- logos.ts
---
### Complete File Contents
**1. /home/fegger/Code/TimeToLeave/apps/web/src/app/ui/ReminderSettingsPanel.tsx**
A client component managing the UI for reminder settings. Features:
- Toggle switch for enabling/disabling leave reminders
- Range slider for buffer minutes (1-120 minutes)
- Notification permission status display
- Permission request button
- Dark mode support with custom color scheme
**2. /home/fegger/Code/TimeToLeave/apps/web/src/hooks/useClock.ts**
Custom hook that:
- Tracks current time with 10-second update intervals
- Calculates countdown to a target date
- Returns countdown info and status ("upcoming", "now", or "past")
- Uses memoization to prevent unnecessary recalculations
- Considers events within 10 minutes as "now" status
**3. /home/fegger/Code/TimeToLeave/apps/web/src/hooks/useReminder.ts**
Reminder system hook that:
- Polls for upcoming events every 30 seconds
- Tracks which reminders have been fired using a Set reference
- Fires browser notifications at the configured buffer time before events
- Automatically requests notification permission if not denied
- Cleans up fired reminders for events that no longer exist
- Respects the reminder enabled/disabled setting
**4. /home/fegger/Code/TimeToLeave/apps/web/src/app/event/LeaveByBadge.tsx**
Simple badge component that:
- Displays countdown information with color-coded status
- Supports red, orange, yellow, green, and blue colors
- Adds pulse animation for urgent events
- Uses the Chip component for styling
**5. /home/fegger/Code/TimeToLeave/apps/web/src/app/event/BikeSection.tsx**
Displays bike route information including:
- Distance and duration
- Step-by-step directions
- Refresh button functionality
- Loading and error states
- Dark mode support
**6. /home/fegger/Code/TimeToLeave/apps/web/src/app/event/WienerLinienSection.tsx**
Vienna public transport section that:
- Displays nearby stops and their departures
- Groups departures by stop ID
- Shows line numbers, directions, and minutes until departure
- Handles loading and error states
- Returns null if no stops found
**7. /home/fegger/Code/TimeToLeave/apps/web/src/app/event/EventCard.tsx**
Main event card component that:
- Displays event title, destination, and time
- Integrates multiple data sources (trains, bikes, public transport)
- Uses origin and destination stations
- Shows countdown badge with status
- Displays TrainSection, BikeSection, and WienerLinienSection conditionally
- Uses multiple custom hooks for data fetching
**8. /home/fegger/Code/TimeToLeave/apps/web/src/app/page.tsx**
Home page component that:
- Displays upcoming events sorted by time
- Filters out past events
- Shows empty state message when no events
- Renders EventCard for each upcoming event
- Uses max-width container centered on page
**9. /home/fegger/Code/TimeToLeave/apps/web/src/app/layout/Navbar.tsx**
Navigation bar component with:
- Two main links: "Home" (/) and "Calendar" (/calendar)
- Active link highlighting with purple color
- Dark mode support
- Responsive spacing
**10. /home/fegger/Code/TimeToLeave/apps/web/src/app/layout/Header.tsx**
Header component featuring:
- Logo with link to home
- Event count display (hidden on mobile)
- Online/offline status indicator
- "Add Event" button opening AddEventModal
- Dark mode toggle button
- Settings button opening a modal with ReminderSettingsPanel
- Server health status check
**11. /home/fegger/Code/TimeToLeave/apps/web/src/hooks/useReminderSettings.tsx**
Settings context and hook that:
- Stores reminder settings in localStorage (key: "ttl_reminder_settings")
- Maintains bufferMinutes (1-120, default 15) and enabled (default true)
- Provides setter functions with validation
- Automatically syncs to localStorage on changes
- Falls back to defaults if storage is unavailable
---
### Additional Important Files Found
**useEventsStore.tsx** - Context-based state management for events
- Stores in localStorage (key: "ttl_events")
- Supports add, update, remove, clear, merge operations
- Handles Date serialization/deserialization
**useTheme.ts** - Dark mode management hook
- Stores theme preference in localStorage (key: "ttl_theme")
- Respects system prefers-color-scheme
- Toggles dark class on document root
**layout.tsx** - Root layout
- Provides ReminderSettingsProvider and EventsProvider
- Wraps app with ReminderEngine for reminder polling
- Includes Header, Navbar, and content area
**ReminderEngine.tsx** - Zero-UI component
- Simply calls useReminder() hook to enable reminder polling throughout the app
---
### Key Observations
1. **Settings Storage**: Uses localStorage exclusively for persistence (reminder settings, events, theme)
2. **Reminder Flow**: Settings Panel (UI) → useReminderSettings (context) → useReminder (polling logic) → Browser Notifications
3. **Color Scheme**: Dark mode colors use custom hex values (#17112A, #0F0E1A, #F4F1EA, #B23CFF, #FF2D8D)
4. **Architecture**: Context-based state management for global settings and events
5. **Polling Intervals**: useClock updates every 10 seconds, useReminder checks every 30 seconds
**Tool Call: find /home/fegger/Code/TimeToLeave/apps/web/src/app -type f -name "*.tsx" -o -name "*.ts" | head -50**
Status: Completed
Terminal:
```
/home/fegger/Code/TimeToLeave/apps/web/src/app/add-event/AddEventModal.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/__tests__/bike-route.test.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/__tests__/geocode.test.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/bike-route/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/calendar/parse/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/calendar/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/geocode/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/hafas/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/health/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/wienerlinien/monitor/__tests__/route.test.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/wienerlinien/monitor/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/wienerlinien/stops/__tests__/route.test.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/api/wienerlinien/stops/route.ts
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/CalendarPanel.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/CalendarView.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/DayEvents.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/FileTab.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/UrlTab.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/__tests__/CalendarView.test.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/calendar/page.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/BikeSection.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/EventCard.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/JourneyList.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/LeaveByBadge.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/TrainSection.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/WienerLinienSection.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/__tests__/EventCard.test.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/event/__tests__/WienerLinienSection.test.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/layout.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/layout/Header.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/layout/Navbar.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/layout/ReminderEngine.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/page.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/Button.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/Chip.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/CountdownBadge.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/LoadingSpinner.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/ReminderSettingsPanel.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/LogoIcon.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/LogoHorizontal.tsx
/home/fegger/Code/TimeToLeave/apps/web/src/app/ui/logos.ts
```
**Tool Call: ls -la /home/fegger/Code/TimeToLeave/apps/web/src/app/**
Status: Completed
Terminal:
```
total 56
drwxr-xr-x 1 fegger fegger 298 May 11 22:32 .
drwxr-xr-x 1 fegger fegger 64 May 11 20:09 ..
drwxr-xr-x 1 fegger fegger 34 May 11 20:09 add-event
drwxr-xr-x 1 fegger fegger 114 May 11 20:09 api
drwxr-xr-x 1 fegger fegger 168 May 11 20:09 calendar
drwxr-xr-x 1 fegger fegger 214 May 11 20:09 event
-rw-r--r-- 1 fegger fegger 25931 May 11 20:09 favicon.ico
-rw-r--r-- 1 fegger fegger 1087 May 12 00:30 globals.css
-rw-r--r-- 1 fegger fegger 3043 May 12 00:00 icon.svg
drwxr-xr-x 1 fegger fegger 76 May 11 20:09 layout
-rw-r--r-- 1 fegger fegger 1335 May 12 00:44 layout.tsx
-rw-r--r-- 1 fegger fegger 3748 May 12 00:02 opengraph-image.svg
-rw-r--r-- 1 fegger fegger 1068 May 12 00:35 page.tsx
-rw-r--r-- 1 fegger fegger 3148 May 11 22:32 timetoleave_app_icon.svg
-rw-r--r-- 1 fegger fegger 3675 May 11 22:32 timetoleave_dark_logo.svg
drwxr-xr-x 1 fegger fegger 234 May 11 22:10 ui
```
**Tool Call: ls -la /home/fegger/Code/TimeToLeave/apps/web/src/hooks/**
Status: Completed
Terminal:
```
total 60
drwxr-xr-x 1 fegger fegger 476 May 12 08:52 .
drwxr-xr-x 1 fegger fegger 64 May 11 20:09 ..
drwxr-xr-x 1 fegger fegger 164 May 11 20:09 __tests__
-rw-r--r-- 1 fegger fegger 1337 May 11 20:09 useBikeRoute.ts
-rw-r--r-- 1 fegger fegger 1832 May 11 20:09 useCalendar.ts
-rw-r--r-- 1 fegger fegger 912 May 11 20:09 useClock.ts
-rw-r--r-- 1 fegger fegger 2289 May 12 08:52 useDestinationStation.ts
-rw-r--r-- 1 fegger fegger 2837 May 11 20:09 useEventsStore.tsx
-rw-r--r-- 1 fegger fegger 1305 May 11 20:09 useGeocode.ts
-rw-r--r-- 1 fegger fegger 1414 May 11 20:09 useGeolocation.ts
-rw-r--r-- 1 fegger fegger 1926 May 11 20:09 useJourneys.ts
-rw-r--r-- 1 fegger fegger 2825 May 12 08:52 useOriginStation.ts
-rw-r--r-- 1 fegger fegger 1735 May 11 20:09 useReminderSettings.tsx
-rw-r--r-- 1 fegger fegger 1975 May 11 20:09 useReminder.ts
-rw-r--r-- 1 fegger fegger 932 May 11 20:09 useServerHealth.ts
-rw-r--r-- 1 fegger fegger 1084 May 11 23:50 useTheme.ts
-rw-r--r-- 1 fegger fegger 5138 May 11 20:09 useWienerLinien.ts
```
**Tool Call: ls -la /home/fegger/Code/TimeToLeave/apps/web/src/app/ui/**
Status: Completed
Terminal:
```
total 32
drwxr-xr-x 1 fegger fegger 234 May 11 22:10 .
drwxr-xr-x 1 fegger fegger 298 May 11 22:32 ..
-rw-r--r-- 1 fegger fegger 1244 May 12 00:30 Button.tsx
-rw-r--r-- 1 fegger fegger 447 May 12 00:41 Chip.tsx
-rw-r--r-- 1 fegger fegger 994 May 12 00:36 CountdownBadge.tsx
-rw-r--r-- 1 fegger fegger 595 May 12 00:40 LoadingSpinner.tsx
-rw-r--r-- 1 fegger fegger 3905 May 12 00:32 LogoHorizontal.tsx
-rw-r--r-- 1 fegger fegger 2565 May 11 23:58 LogoIcon.tsx
-rw-r--r-- 1 fegger fegger 112 May 11 22:19 logos.ts
-rw-r--r-- 1 fegger fegger 3138 May 12 00:47 ReminderSettingsPanel.tsx
```
**Tool Call: Read apps/web/src/app/ui/ReminderSettingsPanel.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { useReminderSettings } from "@/hooks/useReminderSettings";
5
6 type ReminderSettingsPanelProps = {
7 className?: string;
8 };
9
10 export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
11 const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings();
12
13 const permission =
14 typeof window !== "undefined" && "Notification" in window
15 ? Notification.permission
16 : "unavailable";
17
18 const statusLabel =
19 permission === "granted"
20 ? "Notifications enabled"
21 : permission === "denied"
22 ? "Notifications blocked — check browser settings"
23 : "Notifications not yet requested";
24
25 return (
26 <div className={`space-y-4 ${className}`}>
27 {/* Toggle */}
28 <div className="flex items-center justify-between">
29 <span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
30 Leave reminders
31 </span>
32 <button
33 role="switch"
34 aria-checked={enabled}
35 onClick={() => setEnabled(!enabled)}
36 className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
37 enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
38 }`}
39 >
40 <span
41 className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
42 enabled ? "translate-x-6" : "translate-x-1"
43 }`}
44 />
45 </button>
46 </div>
47
48 {/* Buffer minutes */}
49 {enabled && (
50 <div className="space-y-2">
51 <label
52 htmlFor="buffer-minutes"
53 className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
54 >
55 Remind me{" "}
56 <span className="text-gray-500 dark:text-gray-400">
57 (minutes before event)
58 </span>
59 </label>
60 <div className="flex items-center gap-3">
61 <input
62 id="buffer-minutes"
63 type="range"
64 min={1}
65 max={120}
66 step={1}
67 value={bufferMinutes}
68 onChange={(e) => setBufferMinutes(Number(e.target.value))}
69 className="flex-1 accent-[#B23CFF]"
70 />
71 <output
72 htmlFor="buffer-minutes"
73 className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
74 >
75 {bufferMinutes}
76 </output>
77 </div>
78 </div>
79 )}
80
81 {/* Permission status */}
82 <div className="pt-2 border-t border-gray-200 dark:border-white/10">
83 <p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
84 {permission !== "granted" && permission !== "denied" && (
85 <button
86 onClick={() => Notification.requestPermission()}
87 className="mt-2 text-xs text-[#B23CFF] hover:underline"
88 >
89 Request permission now
90 </button>
91 )}
92 </div>
93 </div>
94 );
95 }
96
```
**Tool Call: Read apps/web/src/hooks/useClock.ts**
Status: Completed
```
1 "use client";
2
3 import { useState, useEffect, useMemo } from "react";
4 import { calculateCountdown } from "@timetoleave/core";
5
6 interface ClockResult {
7 countdown: ReturnType<typeof calculateCountdown>;
8 status: "upcoming" | "now" | "past";
9 }
10
11 export function useClock(targetDate: Date): ClockResult {
12 const [now, setNow] = useState<Date>(new Date());
13
14 useEffect(() => {
15 const interval = setInterval(() => {
16 setNow(new Date());
17 }, 10_000);
18
19 return () => clearInterval(interval);
20 }, []);
21
22 return useMemo(() => {
23 const countdown = calculateCountdown(targetDate);
24 const diffMs = targetDate.getTime() - now.getTime();
25
26 let status: "upcoming" | "now" | "past";
27 if (diffMs <= 0) {
28 status = "now";
29 } else if (diffMs <= 10 * 60 * 1000) {
30 status = "now";
31 } else {
32 status = "upcoming";
33 }
34
35 return { countdown, status };
36 }, [targetDate, now]);
37 }
38
```
**Tool Call: Read apps/web/src/hooks/useReminder.ts**
Status: Completed
```
1 import { useEffect, useRef, useCallback } from "react";
2 import { useEventsStore } from "./useEventsStore";
3 import { useReminderSettings } from "./useReminderSettings";
4
5 const POLLS_MS = 30_000; // 30s — matches useClock cadence
6
7 export function useReminder() {
8 const { events } = useEventsStore();
9 const { bufferMinutes, enabled } = useReminderSettings();
10 const firedRef = useRef(new Set<string>());
11
12 const check = useCallback(() => {
13 if (!enabled) return;
14 if (typeof window === "undefined") return;
15 if (typeof Notification === "undefined") return;
16
17 const now = new Date();
18 const upcoming = events
19 .filter((e) => e.eventTime > now)
20 .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
21
22 for (const event of upcoming) {
23 const reminderTime = new Date(event.eventTime.getTime() - bufferMinutes * 60_000);
24 if (now >= reminderTime && !firedRef.current.has(event.id)) {
25 firedRef.current.add(event.id);
26
27 if (Notification.permission === "granted") {
28 new Notification("Time to leave!", {
29 body: `${event.title} starts in ${bufferMinutes} minutes`,
30 tag: `reminder-${event.id}`,
31 });
32 } else if (Notification.permission !== "denied") {
33 Notification.requestPermission().then((perm) => {
34 if (perm === "granted") {
35 new Notification("Time to leave!", {
36 body: `${event.title} starts in ${bufferMinutes} minutes`,
37 tag: `reminder-${event.id}`,
38 });
39 }
40 });
41 }
42 }
43 }
44
45 // Clear entries for events that no longer exist
46 const ids = new Set(upcoming.map((e) => e.id));
47 for (const id of firedRef.current) {
48 if (!ids.has(id)) firedRef.current.delete(id);
49 }
50 }, [events, bufferMinutes, enabled]);
51
52 useEffect(() => {
53 check();
54 const id = setInterval(check, POLLS_MS);
55 return () => clearInterval(id);
56 }, [check]);
57 }
58
```
**Tool Call: Read apps/web/src/app/event/LeaveByBadge.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { CountdownInfo } from "@timetoleave/core";
5 import Chip from "@/app/ui/Chip";
6
7 const colorMap: Record<string, string> = {
8 red: "text-[#FF2D8D]",
9 orange: "text-orange-600",
10 yellow: "text-yellow-600",
11 green: "text-green-600",
12 blue: "text-[#B23CFF]",
13 };
14
15 type LeaveByBadgeProps = {
16 countdown: CountdownInfo;
17 className?: string;
18 };
19
20 const LeaveByBadge: React.FC<LeaveByBadgeProps> = ({ countdown, className = "" }) => {
21 return (
22 <Chip className={`${className} ${colorMap[countdown.color] || ""} ${countdown.urgent ? "animate-pulse" : ""}`}>
23 {countdown.label}
24 </Chip>
25 );
26 };
27
28 export default LeaveByBadge;
29
```
**Tool Call: Read apps/web/src/app/event/BikeSection.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { BikeRoute } from "@timetoleave/core";
5 import LoadingSpinner from "@/app/ui/LoadingSpinner";
6 import Button from "@/app/ui/Button";
7
8 type BikeSectionProps = {
9 bikeRoute: BikeRoute | null | undefined;
10 bikeLoading: boolean;
11 bikeError: string | null | undefined;
12 onRefresh?: () => void;
13 className?: string;
14 };
15
16 const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => {
17 if (!bikeRoute && !bikeLoading && !bikeError) {
18 return null;
19 }
20
21 return (
22 <div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden mt-4 border border-gray-200 dark:border-white/10 ${className}`}>
23 <div className="p-4 border-b border-gray-200 dark:border-white/10">
24 <div className="flex items-center justify-between">
25 <div>
26 <h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Bicycle Route</h3>
27 </div>
28 {onRefresh && (
29 <Button variant="secondary" size="sm" onClick={onRefresh}>
30 Refresh
31 </Button>
32 )}
33 </div>
34 </div>
35 <div className="p-4">
36 {bikeLoading ? (
37 <div className="text-center py-4">
38 <LoadingSpinner size="md" />
39 </div>
40 ) : bikeError ? (
41 <div className="text-center py-4 text-[#FF2D8D]">Error: {bikeError}</div>
42 ) : bikeRoute ? (
43 <div className="space-y-2">
44 <div className="flex justify-between">
45 <span className="text-gray-600 dark:text-[#F4F1EA]/80">Distance</span>
46 <span className="font-medium">
47 {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
48 </span>
49 </div>
50 <div className="flex justify-between">
51 <span className="text-gray-600 dark:text-[#F4F1EA]/80">Duration</span>
52 <span className="font-medium">
53 {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
54 </span>
55 </div>
56 {bikeRoute.steps && bikeRoute.steps.length > 0 && (
57 <div className="mt-4">
58 <h4 className="font-medium text-gray-800 dark:text-[#F4F1EA] mb-2">Steps</h4>
59 <ul className="space-y-2">
60 {bikeRoute.steps.map((step, index) => (
61 <li key={index} className="text-sm">
62 <span className="font-medium text-[#B23CFF] dark:text-[#D946EF]">{step.name}</span>
63 <span className="ml-2 text-gray-600 dark:text-[#F4F1EA]/80">{step.instruction}</span>
64 </li>
65 ))}
66 </ul>
67 </div>
68 )}
69 </div>
70 ) : null}
71 </div>
72 </div>
73 );
74 };
75
76 export default BikeSection;
77
```
**Tool Call: Read apps/web/src/app/event/WienerLinienSection.tsx**
Status: Completed
```
1 "use client";
2
3 import type { WienerLinienStop } from "@timetoleave/core";
4 import LoadingSpinner from "@/app/ui/LoadingSpinner";
5 import Chip from "@/app/ui/Chip";
6
7 interface DepartureRow {
8 stopId: string;
9 lineName: string;
10 direction: string;
11 minutes: number;
12 }
13
14 type WienerLinienSectionProps = {
15 stops: WienerLinienStop[];
16 departures: DepartureRow[];
17 loading: boolean;
18 error: string | null;
19 className?: string;
20 };
21
22 export default function WienerLinienSection({
23 stops,
24 departures,
25 loading,
26 error,
27 className = "",
28 }: WienerLinienSectionProps) {
29 if (loading) {
30 return (
31 <div className={className}>
32 <LoadingSpinner />
33 <p className="mt-2 text-sm text-gray-500 dark:text-[#F4F1EA]/60">Loading nearby stops...</p>
34 </div>
35 );
36 }
37
38 if (error) {
39 return (
40 <div className={className}>
41 <p className="text-sm text-[#FF2D8D] dark:text-[#FF2D8D]">{error}</p>
42 </div>
43 );
44 }
45
46 if (stops.length === 0) {
47 return (
48 <div className={className}>
49 <p className="text-sm text-gray-500 dark:text-[#F4F1EA]/60">No nearby stops found.</p>
50 </div>
51 );
52 }
53
54 const departuresByStop = new Map<string, DepartureRow[]>();
55 for (const departure of departures) {
56 const stopId = departure.stopId;
57 const existing = departuresByStop.get(stopId) ?? [];
58 existing.push(departure);
59 departuresByStop.set(stopId, existing);
60 }
61
62 return (
63 <div className={className}>
64 {stops.map((stop) => {
65 const stopDepartures = departuresByStop.get(stop.id) ?? [];
66
67 return (
68 <div key={stop.id} className="mb-4 last:mb-0">
69 <h3 className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">{stop.name}</h3>
70
71 {stopDepartures.length === 0 ? (
72 <p className="mt-1 text-xs text-gray-400 dark:text-[#F4F1EA]/40">No departures available</p>
73 ) : (
74 <ul className="mt-1 space-y-1">
75 {stopDepartures.map((departure, index) => (
76 <li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
77 <Chip>{departure.lineName}</Chip>
78 <span className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">{departure.direction}</span>
79 <span className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">
80 {departure.minutes} min
81 </span>
82 </li>
83 ))}
84 </ul>
85 )}
86 </div>
87 );
88 })}
89 </div>
90 );
91 }
92
```
**Tool Call: Read apps/web/src/app/event/EventCard.tsx**
Status: Completed
```
1 "use client";
2
3 import { format } from "date-fns";
4 import { useJourneys } from "@/hooks/useJourneys";
5 import { useDestinationStation } from "@/hooks/useDestinationStation";
6 import { useBikeRoute } from "@/hooks/useBikeRoute";
7 import { useGeocode } from "@/hooks/useGeocode";
8 import { useClock } from "@/hooks/useClock";
9 import { useWienerLinien } from "@/hooks/useWienerLinien";
10 import type { Event, Station } from "@timetoleave/core";
11 import TrainSection from "./TrainSection";
12 import BikeSection from "./BikeSection";
13 import WienerLinienSection from "./WienerLinienSection";
14 import CountdownBadge from "@/app/ui/CountdownBadge";
15
16 interface EventCardProps {
17 event: Event;
18 originStation: Station | null;
19 }
20
21 export default function EventCard({ event, originStation }: EventCardProps) {
22 const destStation = useDestinationStation(event.destination);
23
24 const {
25 journeys,
26 loading: journeysLoading,
27 error: journeysError,
28 } = useJourneys(originStation?.extId ?? null, destStation.station?.extId ?? null, event.eventTime, 0);
29
30 const destCoords = useGeocode(event.destination);
31
32 const {
33 bikeRoute,
34 loading: bikeLoading,
35 error: bikeError,
36 } = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
37
38 const {
39 stops,
40 departures,
41 loading: wlLoading,
42 error: wlError,
43 } = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
44
45 const { countdown, status } = useClock(event.eventTime);
46
47 return (
48 <div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-[#17112A]">
49 <div className="mb-3 flex items-center justify-between">
50 <h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">{event.title}</h3>
51 <CountdownBadge countdown={countdown} status={status} />
52 </div>
53
54 <div className="mb-2 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
55 <span className="font-medium">Destination:</span> {event.destination}
56 </div>
57 <div className="mb-4 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
58 <span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
59 </div>
60
61 <div className="space-y-4">
62 <TrainSection
63 journeys={journeys}
64 eventTime={event.eventTime}
65 destName={event.destination}
66 loading={journeysLoading}
67 error={journeysError}
68 />
69
70 <BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
71
72 {stops.length > 0 && (
73 <WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
74 )}
75 </div>
76 </div>
77 );
78 }
79
```
**Tool Call: Read apps/web/src/app/page.tsx**
Status: Completed
```
1 "use client";
2
3 import { useEventsStore } from "@/hooks/useEventsStore";
4 import { useOriginStation } from "@/hooks/useOriginStation";
5 import EventCard from "@/app/event/EventCard";
6
7 export default function Home() {
8 const { events } = useEventsStore();
9 const { station: originStation } = useOriginStation();
10
11 const upcoming = events
12 .filter((e) => e.eventTime >= new Date())
13 .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
14
15 return (
16 <main className="max-w-3xl mx-auto w-full px-4 py-6">
17 {upcoming.length === 0 ? (
18 <div className="text-center py-16 text-gray-500 dark:text-[#F4F1EA]/60">
19 <p className="text-2xl font-bold brand-gradient-text mb-2">No upcoming events</p>
20 <p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
21 </div>
22 ) : (
23 <div className="space-y-4">
24 {upcoming.map((event) => (
25 <EventCard key={event.id} event={event} originStation={originStation} />
26 ))}
27 </div>
28 )}
29 </main>
30 );
31 }
32
```
**Tool Call: Read apps/web/src/app/layout/Navbar.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import Link from "next/link";
5 import { usePathname } from "next/navigation";
6
7 type NavbarProps = {
8 className?: string;
9 };
10
11 const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
12 const pathname = usePathname();
13
14 const navLinks = [
15 { name: "Home", href: "/" },
16 { name: "Calendar", href: "/calendar" },
17 ];
18
19 return (
20 <nav className={`bg-white dark:bg-[#0F0E1A] border-t border-gray-200 dark:border-white/10 ${className}`}>
21 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
22 <div className="flex items-center justify-between h-16">
23 <div className="flex space-x-8">
24 {navLinks.map((link) => (
25 <Link
26 key={link.href}
27 href={link.href}
28 className={`text-sm font-medium transition-colors ${
29 pathname === link.href
30 ? "text-[#B23CFF] border-b-2 border-[#B23CFF]"
31 : "text-gray-500 hover:text-[#D946EF] hover:border-gray-300"
32 }`}
33 >
34 {link.name}
35 </Link>
36 ))}
37 </div>
38 </div>
39 </div>
40 </nav>
41 );
42 };
43
44 export default Navbar;
45
```
**Tool Call: Read apps/web/src/app/layout/Header.tsx**
Status: Completed
```
1 "use client";
2
3 import React, { useState } from "react";
4 import Button from "@/app/ui/Button";
5 import AddEventModal from "@/app/add-event/AddEventModal";
6 import ReminderSettingsPanel from "@/app/ui/ReminderSettingsPanel";
7 import { useServerHealth } from "@/hooks/useServerHealth";
8 import { useEventsStore } from "@/hooks/useEventsStore";
9 import { useTheme } from "@/hooks/useTheme";
10 import Link from "next/link";
11 import Image from "next/image";
12
13 type HeaderProps = {
14 className?: string;
15 };
16
17 const Header: React.FC<HeaderProps> = ({ className = "" }) => {
18 const [showAddEventModal, setShowAddEventModal] = useState(false);
19 const [showSettingsModal, setShowSettingsModal] = useState(false);
20 const { status } = useServerHealth();
21 const { events } = useEventsStore();
22 const { dark, toggle, mounted } = useTheme();
23
24 return (
25 <header className={`bg-white dark:bg-[#0F0E1A] border-b border-gray-200 dark:border-white/10 shadow-sm ${className}`}>
26 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
27 <div className="flex items-center justify-between h-16">
28 <div className="flex items-center">
29 <Link href="/">
30 <Image
31 src="/timetoleave_logo_text_right.png"
32 alt="TimeToLeave"
33 width={192}
34 height={48}
35 className="object-cover h-12 w-auto"
36 priority
37 />
38 </Link>
39 </div>
40 <div className="flex items-center space-x-4">
41 <div className="hidden md:flex items-center text-sm text-gray-600 dark:text-[#F4F1EA]/80">
42 <span>
43 <strong>{events.length}</strong>
44 </span>
45 <span className="ml-1">events</span>
46 {status === true ? (
47 <span className="ml-2 text-green-500">Online</span>
48 ) : status === false ? (
49 <span className="ml-2 text-red-500">Offline</span>
50 ) : null}
51 </div>
52 <Button variant="primary" size="sm" onClick={() => setShowAddEventModal(true)}>
53 Add Event
54 </Button>
55 <button
56 onClick={toggle}
57 aria-label="Toggle dark mode"
58 className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
59 >
60 {mounted ? (dark ? "☀️" : "🌙") : "🌙"}
61 </button>
62 <button
63 onClick={() => setShowSettingsModal(true)}
64 aria-label="Settings"
65 className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
66 >
67 ⚙️
68 </button>
69 </div>
70 </div>
71 </div>
72 <AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
73 {showSettingsModal && (
74 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
75 <div className="bg-white dark:bg-[#17112A] rounded-lg shadow-lg p-6 w-full max-w-sm relative border border-gray-200 dark:border-white/10">
76 <button
77 onClick={() => setShowSettingsModal(false)}
78 className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-[#F4F1EA] text-lg"
79 aria-label="Close settings"
80 >
81 ✕
82 </button>
83 <h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-[#F4F1EA]">Settings</h2>
84 <ReminderSettingsPanel />
85 </div>
86 </div>
87 )}
88 </header>
89 );
90 };
91
92 export default Header;
93
```
**Tool Call: Read apps/web/src/hooks/useReminderSettings.tsx**
Status: Completed
```
1 "use client";
2
3 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
4 import { ReminderSettings } from "@timetoleave/core";
5
6 const STORAGE_KEY = "ttl_reminder_settings";
7 const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true };
8
9 function loadFromStorage(): ReminderSettings {
10 if (typeof window === "undefined") return DEFAULTS;
11 try {
12 const raw = localStorage.getItem(STORAGE_KEY);
13 if (!raw) return DEFAULTS;
14 return { ...DEFAULTS, ...JSON.parse(raw) };
15 } catch {
16 return DEFAULTS;
17 }
18 }
19
20 interface ReminderContextType extends ReminderSettings {
21 setBufferMinutes: (minutes: number) => void;
22 setEnabled: (enabled: boolean) => void;
23 }
24
25 const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
26
27 export function ReminderSettingsProvider({ children }: { children: ReactNode }) {
28 const [settings, setSettings] = useState<ReminderSettings>(loadFromStorage);
29
30 useEffect(() => {
31 localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
32 }, [settings]);
33
34 const setBufferMinutes = useCallback((minutes: number) => {
35 setSettings((prev) => ({ ...prev, bufferMinutes: Math.max(1, Math.min(120, minutes)) }));
36 }, []);
37
38 const setEnabled = useCallback((enabled: boolean) => {
39 setSettings((prev) => ({ ...prev, enabled }));
40 }, []);
41
42 return (
43 <ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}>
44 {children}
45 </ReminderContext.Provider>
46 );
47 }
48
49 export function useReminderSettings() {
50 const context = useContext(ReminderContext);
51 if (context === undefined) {
52 throw new Error("useReminderSettings must be used within a ReminderSettingsProvider");
53 }
54 return context;
55 }
56
```
**Tool Call: Find `/home/fegger/Code/TimeToLeave/apps/web/src/**/*[Ss]etting*`**
Status: Completed
apps/web/src/hooks/useReminderSettings.tsx
apps/web/src/app/ui/ReminderSettingsPanel.tsx
**Tool Call: grep -l --type=tsx "localStorage|useContext|Context|Provider" /home/fegger/Code/TimeToLeave/apps/web/src**
Status: Completed
No files found
**Tool Call: Read apps/web/src/app/layout.tsx**
Status: Completed
```
1 import type { Metadata } from "next";
2 import "./globals.css";
3 import { EventsProvider } from "@/hooks/useEventsStore";
4 import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
5 import Header from "@/app/layout/Header";
6 import Navbar from "@/app/layout/Navbar";
7 import ReminderEngine from "@/app/layout/ReminderEngine";
8
9 export const metadata: Metadata = {
10 title: "TimeToLeave — Smart Departure Planner",
11 description:
12 "Plan your train journeys, sync with your calendar, and know exactly when to leave for your appointments.",
13 icons: {
14 icon: "/icon.svg",
15 apple: "/icon.svg",
16 },
17 };
18
19 export default function RootLayout({
20 children,
21 }: Readonly<{
22 children: React.ReactNode;
23 }>) {
24 return (
25 <html lang="en" className="h-full antialiased">
26 <head>
27 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
28 </head>
29 <body className="min-h-full flex flex-col bg-[#FAFAF9] dark:bg-[#090816] text-[#1a1a2e] dark:text-[#F4F1EA]">
30 <ReminderSettingsProvider>
31 <EventsProvider>
32 <ReminderEngine />
33 <Header />
34 <div className="flex-1">{children}</div>
35 <Navbar />
36 </EventsProvider>
37 </ReminderSettingsProvider>
38 </body>
39 </html>
40 );
41 }
42
```
**Tool Call: Read apps/web/src/app/layout/ReminderEngine.tsx**
Status: Completed
```
1 "use client";
2
3 import { useReminder } from "@/hooks/useReminder";
4
5 export default function ReminderEngine() {
6 useReminder();
7 return null;
8 }
9
```
**Tool Call: Read apps/web/src/hooks/useEventsStore.tsx**
Status: Completed
```
1 "use client";
2
3 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
4 import type { Event, CalendarEvent } from "@timetoleave/core";
5
6 const STORAGE_KEY = "ttl_events";
7
8 function loadFromStorage(): Event[] {
9 if (typeof window === "undefined") return [];
10 try {
11 const raw = localStorage.getItem(STORAGE_KEY);
12 if (!raw) return [];
13 const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
14 return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime as string) })) as Event[];
15 } catch {
16 return [];
17 }
18 }
19
20 interface EventsContextType {
21 events: Event[];
22 addEvent: (event: Event) => void;
23 updateEvent: (id: string, updates: Partial<Event>) => void;
24 removeEvent: (id: string) => void;
25 clearEvents: () => void;
26 setEvents: (events: Event[]) => void;
27 mergeEvents: (events: CalendarEvent[]) => void;
28 }
29
30 const EventsContext = createContext<EventsContextType | undefined>(undefined);
31
32 export function EventsProvider({ children }: { children: ReactNode }) {
33 const [events, setEventsState] = useState<Event[]>(loadFromStorage);
34
35 useEffect(() => {
36 localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
37 }, [events]);
38
39 const addEvent = useCallback((event: Event) => {
40 setEventsState((prev) => {
41 if (prev.some((e) => e.id === event.id)) return prev;
42 return [...prev, event];
43 });
44 }, []);
45
46 const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
47 setEventsState((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
48 }, []);
49
50 const removeEvent = useCallback((id: string) => {
51 setEventsState((prev) => prev.filter((event) => event.id !== id));
52 }, []);
53
54 const clearEvents = useCallback(() => {
55 setEventsState([]);
56 }, []);
57
58 const setEvents = useCallback((evts: Event[]) => {
59 setEventsState(evts);
60 }, []);
61
62 const mergeEvents = useCallback((calendarEvents: CalendarEvent[]) => {
63 const converted: Event[] = calendarEvents.map((e) => ({
64 id: e.id,
65 title: e.title,
66 destination: e.destination,
67 eventTime: new Date(e.eventTime),
68 source: e.source,
69 }));
70
71 setEventsState((prev) => {
72 const merged = new Map<string, Event>();
73 prev.forEach((event) => merged.set(event.id, event));
74 converted.forEach((event) => merged.set(event.id, event));
75 return Array.from(merged.values());
76 });
77 }, []);
78
79 return (
80 <EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
81 {children}
82 </EventsContext.Provider>
83 );
84 }
85
86 export function useEventsStore() {
87 const context = useContext(EventsContext);
88 if (context === undefined) {
89 throw new Error("useEventsStore must be used within an EventsProvider");
90 }
91 return context;
92 }
93
```
**Tool Call: Read apps/web/src/hooks/useTheme.ts**
Status: Completed
```
1 "use client";
2
3 import { useState, useCallback, useEffect, useRef } from "react";
4
5 const THEME_KEY = "ttl_theme";
6
7 function getServerTheme(): "dark" | "light" {
8 return "light";
9 }
10
11 function getClientTheme(): "dark" | "light" {
12 if (typeof window === "undefined") return getServerTheme();
13 const stored = localStorage.getItem(THEME_KEY);
14 if (stored) return stored as "dark" | "light";
15 return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
16 }
17
18 export function useTheme() {
19 const [dark, setDark] = useState(() => getClientTheme() === "dark");
20 const [mounted, setMounted] = useState(false);
21 const initialized = useRef(false);
22
23 useEffect(() => {
24 setMounted(true);
25 if (!initialized.current) {
26 initialized.current = true;
27 document.documentElement.classList.toggle("dark", dark);
28 }
29 }, [dark]);
30
31 const toggle = useCallback(() => {
32 setDark((prev) => {
33 const next = !prev;
34 localStorage.setItem(THEME_KEY, next ? "dark" : "light");
35 return next;
36 });
37 }, []);
38
39 return { dark, toggle, mounted };
40 }
41
```
**Tool Call: Read apps/web/src/app/ui/CountdownBadge.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import Chip from "./Chip";
5
6 const colorMap: Record<string, string> = {
7 red: "text-red-600 bg-red-50 dark:bg-red-900/30",
8 orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
9 yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
10 green: "text-green-600 bg-green-50 dark:bg-green-900/30",
11 blue: "text-[#B23CFF] bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20",
12 };
13
14 type CountdownBadgeProps = {
15 countdown: {
16 label: string;
17 color: string;
18 urgent: boolean;
19 };
20 status: "upcoming" | "now" | "past";
21 className?: string;
22 };
23
24 const CountdownBadge: React.FC<CountdownBadgeProps> = ({ countdown, status, className = "" }) => {
25 const bgColor = colorMap[countdown.color] || colorMap.blue;
26 const isUrgent = countdown.urgent || status === "now";
27
28 return (
29 <Chip
30 className={`${className} ${bgColor} ${isUrgent ? "animate-pulse" : ""}`}
31 >
32 {countdown.label}
33 </Chip>
34 );
35 };
36
37 export default CountdownBadge;
38
```
## Assistant
<thinking>
Good, now I have a clear picture of the codebase. Let me read the actual file contents of the key files before planning:
1. `useReminderSettings.tsx` - to understand the current settings structure
2. `ReminderSettingsPanel.tsx` - to understand the current UI
3. `useClock.ts` - to understand countdown calculation
4. `EventCard.tsx` - to understand the full layout
5. `LeaveByBadge.tsx` - for the leave-by badge
6. `BikeSection.tsx` - for bike display
Let me pull up these files to see what I'm working with.
</thinking>
Let me read the actual file contents before implementing:
**Tool Call: Read apps/web/src/hooks/useReminderSettings.tsx**
Status: Completed
```
1 "use client";
2
3 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
4 import { ReminderSettings } from "@timetoleave/core";
5
6 const STORAGE_KEY = "ttl_reminder_settings";
7 const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true };
8
9 function loadFromStorage(): ReminderSettings {
10 if (typeof window === "undefined") return DEFAULTS;
11 try {
12 const raw = localStorage.getItem(STORAGE_KEY);
13 if (!raw) return DEFAULTS;
14 return { ...DEFAULTS, ...JSON.parse(raw) };
15 } catch {
16 return DEFAULTS;
17 }
18 }
19
20 interface ReminderContextType extends ReminderSettings {
21 setBufferMinutes: (minutes: number) => void;
22 setEnabled: (enabled: boolean) => void;
23 }
24
25 const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
26
27 export function ReminderSettingsProvider({ children }: { children: ReactNode }) {
28 const [settings, setSettings] = useState<ReminderSettings>(loadFromStorage);
29
30 useEffect(() => {
31 localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
32 }, [settings]);
33
34 const setBufferMinutes = useCallback((minutes: number) => {
35 setSettings((prev) => ({ ...prev, bufferMinutes: Math.max(1, Math.min(120, minutes)) }));
36 }, []);
37
38 const setEnabled = useCallback((enabled: boolean) => {
39 setSettings((prev) => ({ ...prev, enabled }));
40 }, []);
41
42 return (
43 <ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}>
44 {children}
45 </ReminderContext.Provider>
46 );
47 }
48
49 export function useReminderSettings() {
50 const context = useContext(ReminderContext);
51 if (context === undefined) {
52 throw new Error("useReminderSettings must be used within a ReminderSettingsProvider");
53 }
54 return context;
55 }
56
```
**Tool Call: Read apps/web/src/app/ui/ReminderSettingsPanel.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { useReminderSettings } from "@/hooks/useReminderSettings";
5
6 type ReminderSettingsPanelProps = {
7 className?: string;
8 };
9
10 export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
11 const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings();
12
13 const permission =
14 typeof window !== "undefined" && "Notification" in window
15 ? Notification.permission
16 : "unavailable";
17
18 const statusLabel =
19 permission === "granted"
20 ? "Notifications enabled"
21 : permission === "denied"
22 ? "Notifications blocked — check browser settings"
23 : "Notifications not yet requested";
24
25 return (
26 <div className={`space-y-4 ${className}`}>
27 {/* Toggle */}
28 <div className="flex items-center justify-between">
29 <span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
30 Leave reminders
31 </span>
32 <button
33 role="switch"
34 aria-checked={enabled}
35 onClick={() => setEnabled(!enabled)}
36 className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
37 enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
38 }`}
39 >
40 <span
41 className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
42 enabled ? "translate-x-6" : "translate-x-1"
43 }`}
44 />
45 </button>
46 </div>
47
48 {/* Buffer minutes */}
49 {enabled && (
50 <div className="space-y-2">
51 <label
52 htmlFor="buffer-minutes"
53 className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
54 >
55 Remind me{" "}
56 <span className="text-gray-500 dark:text-gray-400">
57 (minutes before event)
58 </span>
59 </label>
60 <div className="flex items-center gap-3">
61 <input
62 id="buffer-minutes"
63 type="range"
64 min={1}
65 max={120}
66 step={1}
67 value={bufferMinutes}
68 onChange={(e) => setBufferMinutes(Number(e.target.value))}
69 className="flex-1 accent-[#B23CFF]"
70 />
71 <output
72 htmlFor="buffer-minutes"
73 className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
74 >
75 {bufferMinutes}
76 </output>
77 </div>
78 </div>
79 )}
80
81 {/* Permission status */}
82 <div className="pt-2 border-t border-gray-200 dark:border-white/10">
83 <p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
84 {permission !== "granted" && permission !== "denied" && (
85 <button
86 onClick={() => Notification.requestPermission()}
87 className="mt-2 text-xs text-[#B23CFF] hover:underline"
88 >
89 Request permission now
90 </button>
91 )}
92 </div>
93 </div>
94 );
95 }
96
```
**Tool Call: Read apps/web/src/hooks/useClock.ts**
Status: Completed
```
1 "use client";
2
3 import { useState, useEffect, useMemo } from "react";
4 import { calculateCountdown } from "@timetoleave/core";
5
6 interface ClockResult {
7 countdown: ReturnType<typeof calculateCountdown>;
8 status: "upcoming" | "now" | "past";
9 }
10
11 export function useClock(targetDate: Date): ClockResult {
12 const [now, setNow] = useState<Date>(new Date());
13
14 useEffect(() => {
15 const interval = setInterval(() => {
16 setNow(new Date());
17 }, 10_000);
18
19 return () => clearInterval(interval);
20 }, []);
21
22 return useMemo(() => {
23 const countdown = calculateCountdown(targetDate);
24 const diffMs = targetDate.getTime() - now.getTime();
25
26 let status: "upcoming" | "now" | "past";
27 if (diffMs <= 0) {
28 status = "now";
29 } else if (diffMs <= 10 * 60 * 1000) {
30 status = "now";
31 } else {
32 status = "upcoming";
33 }
34
35 return { countdown, status };
36 }, [targetDate, now]);
37 }
38
```
**Tool Call: Read apps/web/src/app/event/LeaveByBadge.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { CountdownInfo } from "@timetoleave/core";
5 import Chip from "@/app/ui/Chip";
6
7 const colorMap: Record<string, string> = {
8 red: "text-[#FF2D8D]",
9 orange: "text-orange-600",
10 yellow: "text-yellow-600",
11 green: "text-green-600",
12 blue: "text-[#B23CFF]",
13 };
14
15 type LeaveByBadgeProps = {
16 countdown: CountdownInfo;
17 className?: string;
18 };
19
20 const LeaveByBadge: React.FC<LeaveByBadgeProps> = ({ countdown, className = "" }) => {
21 return (
22 <Chip className={`${className} ${colorMap[countdown.color] || ""} ${countdown.urgent ? "animate-pulse" : ""}`}>
23 {countdown.label}
24 </Chip>
25 );
26 };
27
28 export default LeaveByBadge;
29
```
**Tool Call: Read apps/web/src/app/event/BikeSection.tsx**
Status: Completed
```
1 "use client";
2
3 import React from "react";
4 import { BikeRoute } from "@timetoleave/core";
5 import LoadingSpinner from "@/app/ui/LoadingSpinner";
6 import Button from "@/app/ui/Button";
7
8 type BikeSectionProps = {
9 bikeRoute: BikeRoute | null | undefined;
10 bikeLoading: boolean;
11 bikeError: string | null | undefined;
12 onRefresh?: () => void;
13 className?: string;
14 };
15
16 const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => {
17 if (!bikeRoute && !bikeLoading && !bikeError) {
18 return null;
19 }
20
21 return (
22 <div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden mt-4 border border-gray-200 dark:border-white/10 ${className}`}>
23 <div className="p-4 border-b border-gray-200 dark:border-white/10">
24 <div className="flex items-center justify-between">
25 <div>
26 <h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Bicycle Route</h3>
27 </div>
28 {onRefresh && (
29 <Button variant="secondary" size="sm" onClick={onRefresh}>
30 Refresh
31 </Button>
32 )}
33 </div>
34 </div>
35 <div className="p-4">
36 {bikeLoading ? (
37 <div className="text-center py-4">
38 <LoadingSpinner size="md" />
39 </div>
40 ) : bikeError ? (
41 <div className="text-center py-4 text-[#FF2D8D]">Error: {bikeError}</div>
42 ) : bikeRoute ? (
43 <div className="space-y-2">
44 <div className="flex justify-between">
45 <span className="text-gray-600 dark:text-[#F4F1EA]/80">Distance</span>
46 <span className="font-medium">
47 {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
48 </span>
49 </div>
50 <div className="flex justify-between">
51 <span className="text-gray-600 dark:text-[#F4F1EA]/80">Duration</span>
52 <span className="font-medium">
53 {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
54 </span>
55 </div>
56 {bikeRoute.steps && bikeRoute.steps.length > 0 && (
57 <div className="mt-4">
58 <h4 className="font-medium text-gray-800 dark:text-[#F4F1EA] mb-2">Steps</h4>
59 <ul className="space-y-2">
60 {bikeRoute.steps.map((step, index) => (
61 <li key={index} className="text-sm">
62 <span className="font-medium text-[#B23CFF] dark:text-[#D946EF]">{step.name}</span>
63 <span className="ml-2 text-gray-600 dark:text-[#F4F1EA]/80">{step.instruction}</span>
64 </li>
65 ))}
66 </ul>
67 </div>
68 )}
69 </div>
70 ) : null}
71 </div>
72 </div>
73 );
74 };
75
76 export default BikeSection;
77
```
**Tool Call: Read apps/web/src/hooks/useReminder.ts**
Status: Completed
```
1 import { useEffect, useRef, useCallback } from "react";
2 import { useEventsStore } from "./useEventsStore";
3 import { useReminderSettings } from "./useReminderSettings";
4
5 const POLLS_MS = 30_000; // 30s — matches useClock cadence
6
7 export function useReminder() {
8 const { events } = useEventsStore();
9 const { bufferMinutes, enabled } = useReminderSettings();
10 const firedRef = useRef(new Set<string>());
11
12 const check = useCallback(() => {
13 if (!enabled) return;
14 if (typeof window === "undefined") return;
15 if (typeof Notification === "undefined") return;
16
17 const now = new Date();
18 const upcoming = events
19 .filter((e) => e.eventTime > now)
20 .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
21
22 for (const event of upcoming) {
23 const reminderTime = new Date(event.eventTime.getTime() - bufferMinutes * 60_000);
24 if (now >= reminderTime && !firedRef.current.has(event.id)) {
25 firedRef.current.add(event.id);
26
27 if (Notification.permission === "granted") {
28 new Notification("Time to leave!", {
29 body: `${event.title} starts in ${bufferMinutes} minutes`,
30 tag: `reminder-${event.id}`,
31 });
32 } else if (Notification.permission !== "denied") {
33 Notification.requestPermission().then((perm) => {
34 if (perm === "granted") {
35 new Notification("Time to leave!", {
36 body: `${event.title} starts in ${bufferMinutes} minutes`,
37 tag: `reminder-${event.id}`,
38 });
39 }
40 });
41 }
42 }
43 }
44
45 // Clear entries for events that no longer exist
46 const ids = new Set(upcoming.map((e) => e.id));
47 for (const id of firedRef.current) {
48 if (!ids.has(id)) firedRef.current.delete(id);
49 }
50 }, [events, bufferMinutes, enabled]);
51
52 useEffect(() => {
53 check();
54 const id = setInterval(check, POLLS_MS);
55 return () => clearInterval(id);
56 }, [check]);
57 }
58
```
## Assistant
<thinking>
Now I have a full picture of the codebase. Let me also check the core types to understand `ReminderSettings`, `BikeRoute`, `CountdownInfo`, and `calculateCountdown`.
</thinking>
Let me check the core types and `calculateCountdown`:
**Tool Call: Read packages/core/src/types.ts**
Status: Completed
```
1 // Core domain types
2
3 export interface Event {
4 id: string;
5 title: string;
6 destination: string;
7 eventTime: Date;
8 source: string;
9 }
10
11 export interface CalendarEvent {
12 id: string;
13 title: string;
14 destination: string;
15 eventTime: string;
16 source: string;
17 }
18
19 export interface Station {
20 name: string;
21 extId: string;
22 lat?: number;
23 lng?: number;
24 }
25
26 export interface Journey {
27 id: string;
28 sD: Date; // scheduled departure
29 rD: Date; // real departure
30 sA: Date; // scheduled arrival
31 rA: Date; // real arrival
32 delay: number; // delay in minutes
33 platform: string;
34 changes: number;
35 trains: string[];
36 cancelled: boolean;
37 }
38
39 export interface BikeStep {
40 name: string;
41 distance: number;
42 duration: number;
43 instruction: string;
44 }
45
46 export interface BikeRoute {
47 distance: number;
48 duration: number;
49 steps: BikeStep[];
50 }
51
52 export interface CountdownInfo {
53 label: string;
54 color: string;
55 urgent: boolean;
56 }
57
58 export type LocState = "pending" | "granted" | "denied";
59 export type CalStatus = null | "loading" | "ok" | "error";
60
61 // ── Reminder Settings ──────────────────────────────────
62
63 export interface ReminderSettings {
64 bufferMinutes: number;
65 enabled: boolean;
66 }
67
68 export type ServerStatus = boolean | null;
69
70 export interface GeocodeResult {
71 lat: number;
72 lng: number;
73 display_name: string;
74 }
75
76 // WienerLinien types
77
78 export interface WienerLinienLine {
79 name: string;
80 type?: string;
81 }
82
83 export interface WienerLinienStop {
84 id: string;
85 name: string;
86 lat: number;
87 lng: number;
88 }
89
90 export interface WienerLinienDeparture {
91 stopId: string;
92 line: WienerLinienLine;
93 direction: string;
94 departureTime: number;
95 delay?: number;
96 }
97
98 export interface WienerLinienMonitorResponse {
99 stops: {
100 stopId: string;
101 departures: WienerLinienDeparture[];
102 }[];
103 }
104
105 export interface NearbyStop {
106 id: string;
107 name: string;
108 lat: number;
109 lng: number;
110 }
111
```
**Tool Call: Find `packages/core/src/*.ts`**
Status: Completed
packages/core/src/countdown-utils.ts
packages/core/src/formatting.ts
packages/core/src/index.ts
packages/core/src/status-utils.ts
packages/core/src/types.ts
packages/core/src/hafas-time.ts
**Tool Call: Read packages/core/src/countdown-utils.ts**
Status: Completed
```
1 // Countdown utilities for TimeToLeave
2 import { CountdownInfo } from "./types";
3
4 export function calculateCountdown(targetDate: Date): CountdownInfo {
5 const now = new Date();
6 const diffMs = targetDate.getTime() - now.getTime();
7 const diffMin = Math.floor(diffMs / 60000);
8
9 if (diffMin <= 0) {
10 return {
11 label: "Now",
12 color: "red",
13 urgent: true,
14 };
15 }
16
17 if (diffMin <= 10) {
18 return {
19 label: `${diffMin}min`,
20 color: "orange",
21 urgent: true,
22 };
23 }
24
25 if (diffMin <= 30) {
26 return {
27 label: `${diffMin}min`,
28 color: "yellow",
29 urgent: false,
30 };
31 }
32
33 if (diffMin <= 60) {
34 return {
35 label: `${diffMin}min`,
36 color: "green",
37 urgent: false,
38 };
39 }
40
41 const hours = Math.floor(diffMin / 60);
42 const remainingMin = diffMin % 60;
43 return {
44 label: `${hours}h ${remainingMin}min`,
45 color: "blue",
46 urgent: false,
47 };
48 }
49
```
**Tool Call: Read packages/core/src/index.ts**
Status: Completed
```
1 // Re-export everything for clean imports
2 export * from './types';
3 export * from './countdown-utils';
4 export * from './formatting';
5 export * from './status-utils';
6 export * from './hafas-time';
7
```
## Assistant
<thinking>
Now I have a complete picture of the codebase. Let me plan the implementation:
## What needs to be done:
### New Settings:
1. **`arrivalBufferMinutes`** - How many minutes before the event to arrive (default 10 min)
2. **`showWalkingOption`** - Whether to show walking suggestions when feasible (default true)
3. **`showBikeOption`** - Whether to show bike route (default true)
### Core changes:
1. **`ReminderSettings` type** in `packages/core/src/types.ts` - add new fields
2. **`useReminderSettings.tsx`** - add setters for new fields, update DEFAULTS
3. **`ReminderSettingsPanel.tsx`** - add UI for the new settings
For the settings architecture, I'm debating whether to extend the existing `useReminderSettings` hook or create a separate `useAppSettings`. Since renaming would be disruptive, I'll just expand the current system by adding these new fields directly to the existing settings structure.
Now I'm clarifying the distinction between the two buffer concepts: `bufferMinutes` controls when you get a reminder notification before the event, while the new `arrivalBufferMinutes` determines how early you want to physically arrive, which drives the "time to leave" calculation. These are separate concerns that both need to live in settings.
For the UI, I'll add a slider for arrival buffer (0-30 minutes, defaulting to 5) and a toggle for the walking option.
The key insight is that `CountdownBadge` currently just counts down to the event time, but it should actually show when you need to leave based on your transport mode—for trains that's the departure time, and for bikes it's the event time minus travel duration, both adjusted by the arrival buffer.
For the walking option, I'm thinking through whether it makes sense to walk the entire way from origin to destination, or if it's really about walking to/from stations as part of a transit journey. Since we don't have pedestrian routing, I'd need to estimate walking time or use straight-line distance as a proxy.
But actually, the real requirement is simpler—it's about the first/last mile problem: should users walk to the train station instead of taking another transit option? We already have bike routing data, so I could use that as a baseline and adjust for walking speed to estimate whether walking to a nearby station is feasible.
Let me check what OSRM profile the bike route API is actually using.
</thinking>
Let me also check `useBikeRoute` and the walk-related API routes:
**Tool Call: Read apps/web/src/hooks/useBikeRoute.ts**
Status: Completed
```
1 import { useState, useEffect } from "react";
2 import type { BikeRoute } from "@timetoleave/core";
3 import { ApiClient } from "@timetoleave/api-client";
4
5 const client = new ApiClient();
6
7 export function useBikeRoute(
8 fromLat: number | undefined,
9 fromLng: number | undefined,
10 toLat: number | undefined,
11 toLng: number | undefined,
12 ) {
13 const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
14 const [loading, setLoading] = useState<boolean>(false);
15 const [error, setError] = useState<string | null>(null);
16
17 useEffect(() => {
18 let isMounted = true;
19
20 const fetchRoute = async () => {
21 if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
22 return;
23 }
24
25 setLoading(true);
26 setError(null);
27
28 try {
29 const data = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
30
31 if (isMounted) {
32 setBikeRoute(data);
33 setLoading(false);
34 }
35 } catch (err: unknown) {
36 if (isMounted) {
37 const message = err instanceof Error ? err.message : "Failed to fetch bike route";
38 setError(message);
39 setLoading(false);
40 }
41 }
42 };
43
44 fetchRoute();
45
46 return () => {
47 isMounted = false;
48 };
49 }, [fromLat, fromLng, toLat, toLng]);
50
51 return { bikeRoute, loading, error };
52 }
53
```
**Tool Call: Find `apps/web/src/app/api/bike-route/**`**
Status: Completed
apps/web/src/app/api/bike-route/route.ts
**Tool Call: Read apps/web/src/app/api/bike-route/route.ts**
Status: Completed
```
1 import { randomUUID } from "crypto";
2 import { NextRequest, NextResponse } from "next/server";
3 import { BikeRoutingClient } from "@/lib/bike-routing-client";
4
5 // Module-level singleton — cache persists across requests
6 const client = new BikeRoutingClient();
7
8 export async function GET(request: NextRequest) {
9 try {
10 const { searchParams } = new URL(request.url);
11 const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
12 const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
13 const toLat = parseFloat(searchParams.get("toLat") ?? "");
14 const toLng = parseFloat(searchParams.get("toLng") ?? "");
15
16 if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
17 return NextResponse.json(
18 { error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
19 { status: 400 },
20 );
21 }
22
23 const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
24
25 if (!route) {
26 return NextResponse.json({ error: "No route found" }, { status: 404 });
27 }
28
29 return NextResponse.json(route);
30 } catch (error) {
31 const corrId = randomUUID().slice(0, 8);
32 console.error(`[${corrId}] Bike route API error:`, error);
33 return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
34 }
35 }
36
```
**Tool Call: Find `apps/web/src/lib/bike-routing-client*`**
Status: Completed
apps/web/src/lib/bike-routing-client.ts
**Tool Call: Read apps/web/src/lib/bike-routing-client.ts**
Status: Completed
```
1 import type { BikeRoute, BikeStep } from "@timetoleave/core";
2 import { OSRM_URL } from "./constants";
3 import { ApiClient } from "./api-service";
4
5 // ---------------------------------------------------------------------------
6 // OSRM response types (internal, not exported)
7 // ---------------------------------------------------------------------------
8
9 interface OsrmStep {
10 name: string;
11 distance: number;
12 duration: number;
13 maneuver: { instruction?: string; type: string; modifier?: string };
14 }
15
16 interface OsrmRoute {
17 distance: number;
18 duration: number;
19 legs: Array<{ steps: OsrmStep[] }>;
20 }
21
22 interface OsrmResponse {
23 code: string;
24 routes: OsrmRoute[];
25 }
26
27 // ---------------------------------------------------------------------------
28 // Helper: Build human-readable instruction from OSRM step data
29 // ---------------------------------------------------------------------------
30
31 function stepInstruction(step: OsrmStep): string {
32 if (step.maneuver.instruction) return step.maneuver.instruction;
33 const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
34 return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
35 }
36
37 // ---------------------------------------------------------------------------
38 // BikeRoutingClient — Wraps the OSRM bike routing API
39 //
40 // OSRM routes are cached for 5 minutes. Bike routes between the same
41 // coordinates rarely change, and caching significantly reduces API load.
42 // ---------------------------------------------------------------------------
43
44 export class BikeRoutingClient {
45 private client: ApiClient;
46 private readonly defaultTtlMs: number;
47
48 constructor(
49 baseUrl: string = OSRM_URL,
50 ttlMs: number = 5 * 60 * 1000, // 5 minutes — routes are very stable
51 ) {
52 this.client = new ApiClient({
53 baseUrl,
54 defaultTimeoutMs: 10_000,
55 defaultTtlMs: ttlMs,
56 maxRetries: 3,
57 });
58 this.defaultTtlMs = ttlMs;
59 }
60
61 async getBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
62 const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
63 const path = `/route/v1/bicycle/${coords}`;
64
65 const cacheKey = `osrm:bike:${coords}`;
66
67 const res = await this.client.get<OsrmResponse>(
68 path,
69 { overview: "false", steps: "true" },
70 {
71 cacheKey,
72 ttl: this.defaultTtlMs,
73 },
74 );
75
76 if (res.code !== "Ok" || !res.routes.length) return null;
77
78 const route = res.routes[0];
79 const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
80 name: s.name,
81 distance: s.distance,
82 duration: s.duration,
83 instruction: stepInstruction(s),
84 }));
85
86 return {
87 distance: route.distance,
88 duration: route.duration,
89 steps,
90 };
91 }
92
93 /**
94 * Expose cache statistics for debugging/monitoring.
95 */
96 public cacheStats() {
97 return this.client.cacheStats();
98 }
99
100 /**
101 * Clear the cache (useful for testing or forced refresh).
102 */
103 public clearCache() {
104 this.client.clearCache();
105 }
106 }
107
```
## Assistant
<thinking>
Now I have a full picture. Let me plan the implementation:
## Plan
### Settings:
The settings panel should have these new options:
1. **Arrival buffer** - "Arrive X minutes early" (slider 0-30 min, default 5). This is how many minutes before the event time you want to arrive. This AFFECTS:
- The "time to leave" calculation (subtract arrival buffer from event time to get target arrival time)
- The departure recommendation (leave earlier)
2. **Show walking option** - toggle whether to show walking as an option if within reasonable distance. This means:
- If walk from station to destination < some threshold (e.g., 20 min), highlight that walking from the arrival station is feasible
- I can estimate walking time from bike duration multiplied by roughly 3
3. **Show bike route** - toggle to show/hide the BikeSection entirely
### Time to leave changes:
For train mode, I need to calculate the countdown to the earliest journey's departure time that arrives before the event time minus the arrival buffer. For bike mode, the countdown should be to the event time minus the bike duration.
The main countdown badge in the EventCard header should reflect whichever mode the user has selected.
### UI changes:
The BikeSection should appear alongside the TrainSection rather than below it, and I should add a mode selector to let users choose between train and bike. The "time to leave" badge will update based on their selection.
### Approach:
I need to extend the `ReminderSettings` type to include `arrivalBufferMinutes` and a `showWalkingOption` flag...
Implementing the mode selector in EventCard so users can toggle between transportation types, with the countdown badge updating accordingly. For the time-to-leave calculation, I'm finding the best train that arrives before the event minus the arrival buffer, then showing the countdown to its departure. For biking, I'll calculate the countdown to the event time minus bike duration minus the arrival buffer. I should also add a walking option that appears when the station-to-destination walk is under 20 minutes.
For the walking time estimation, I can use OSRM's foot profile endpoint to get accurate times, or estimate based on a standard 5 km/h walking speed. I'll check if walking from the arrival station is feasible by comparing it against the train journey duration to avoid suggesting walks that take too long. I'm settling on the core changes needed: adding three new settings for arrival buffer, walking, and biking options, then updating the EventCard to let users switch between transport modes and recalculate the departure time accordingly. For the walking feature specifically, I'll keep it straightforward by showing walking duration from the arrival station to the destination using foot routing, and only display it if it's under a reasonable threshold like 20 minutes and within 1.5km. Now I need to map out which files to update starting with the types and settings hooks. /ReminderSettingsPanel.tsx` - Add new settings UI
4. `apps/web/src/app/event/EventCard.tsx` - Add mode selector, update countdown
5. `apps/web/src/app/event/JourneyList.tsx` - Use arrival buffer in countdown
6. `apps/web/src/app/event/BikeSection.tsx` - Update to work in side-by-side layout
7. New: `apps/web/src/hooks/useWalkRoute.ts` - Walk route hook (OSRM foot)
8. Maybe: Update `useClock.ts` to accept a "departure time" override
### Key design decisions:
**Mode selector**: I'll add a tab/toggle on EventCard for Train vs Bike. Default selection based on which mode is better (train if journey exists and is faster, bike otherwise).
**Countdown badge in EventCard header**: For the selected mode, this shows "leave in X" — counting down to the next train departure (accounting for arrival buffer) or to when I need to leave for the bike ride (event time minus bike duration and arrival buffer).
**Walking from station**: When `showWalkingOption` is enabled and the walk from station to destination is feasible, I'll display a small walk duration badge next to each journey in the list.
**Arrival buffer**: Journeys that arrive after the event time minus the buffer get filtered out in the train section, and the bike section adjusts its departure time calculation accordingly. The countdown badge in the header reflects this for whichever mode is active.
Now I'm ready to start implementing. I'll begin with extending the settings type and updating the hooks, then move to the UI components.
For the routing, I think the cleanest approach is to add a `profile` parameter to the existing bike-route endpoint so I can reuse the same hook for both walking and biking, defaulting to "bicycle" but allowing "foot" when needed. clean. I should also calculate walking time from the user's current location to the origin station so they know if it's feasible to walk there and catch the train. Both the origin and destination stations have latitude and longitude from the HAFAS response, so I can use those coordinates with the walk routing API to show walking distances for both legs of the journey.
Now I'm checking what data is available in the EventCard component—I have access to the origin station coordinates and the geocoded event location, but I realize I need the user's actual GPS coordinates to calculate the walk from their current position to the station. That data comes from the geolocation hook, but it's not currently being passed down to EventCard, so I'd need to add that as a prop.
</thinking>
Let me check `page.tsx` and the events store to complete the picture:
**Tool Call: Read apps/web/src/app/page.tsx**
Status: Completed
```
1 "use client";
2
3 import { useEventsStore } from "@/hooks/useEventsStore";
4 import { useOriginStation } from "@/hooks/useOriginStation";
5 import EventCard from "@/app/event/EventCard";
6
7 export default function Home() {
8 const { events } = useEventsStore();
9 const { station: originStation } = useOriginStation();
10
11 const upcoming = events
12 .filter((e) => e.eventTime >= new Date())
13 .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
14
15 return (
16 <main className="max-w-3xl mx-auto w-full px-4 py-6">
17 {upcoming.length === 0 ? (
18 <div className="text-center py-16 text-gray-500 dark:text-[#F4F1EA]/60">
19 <p className="text-2xl font-bold brand-gradient-text mb-2">No upcoming events</p>
20 <p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
21 </div>
22 ) : (
23 <div className="space-y-4">
24 {upcoming.map((event) => (
25 <EventCard key={event.id} event={event} originStation={originStation} />
26 ))}
27 </div>
28 )}
29 </main>
30 );
31 }
32
```
**Tool Call: Read apps/web/src/hooks/useEventsStore.tsx**
Status: Completed
```
1 "use client";
2
3 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
4 import type { Event, CalendarEvent } from "@timetoleave/core";
5
6 const STORAGE_KEY = "ttl_events";
7
8 function loadFromStorage(): Event[] {
9 if (typeof window === "undefined") return [];
10 try {
11 const raw = localStorage.getItem(STORAGE_KEY);
12 if (!raw) return [];
13 const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
14 return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime as string) })) as Event[];
15 } catch {
16 return [];
17 }
18 }
19
20 interface EventsContextType {
21 events: Event[];
22 addEvent: (event: Event) => void;
23 updateEvent: (id: string, updates: Partial<Event>) => void;
24 removeEvent: (id: string) => void;
25 clearEvents: () => void;
26 setEvents: (events: Event[]) => void;
27 mergeEvents: (events: CalendarEvent[]) => void;
28 }
29
30 const EventsContext = createContext<EventsContextType | undefined>(undefined);
31
32 export function EventsProvider({ children }: { children: ReactNode }) {
33 const [events, setEventsState] = useState<Event[]>(loadFromStorage);
34
35 useEffect(() => {
36 localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
37 }, [events]);
38
39 const addEvent = useCallback((event: Event) => {
40 setEventsState((prev) => {
41 if (prev.some((e) => e.id === event.id)) return prev;
42 return [...prev, event];
43 });
44 }, []);
45
46 const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
47 setEventsState((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
48 }, []);
49
50 const removeEvent = useCallback((id: string) => {
51 setEventsState((prev) => prev.filter((event) => event.id !== id));
52 }, []);
53
54 const clearEvents = useCallback(() => {
55 setEventsState([]);
56 }, []);
57
58 const setEvents = useCallback((evts: Event[]) => {
59 setEventsState(evts);
60 }, []);
61
62 const mergeEvents = useCallback((calendarEvents: CalendarEvent[]) => {
63 const converted: Event[] = calendarEvents.map((e) => ({
64 id: e.id,
65 title: e.title,
66 destination: e.destination,
67 eventTime: new Date(e.eventTime),
68 source: e.source,
69 }));
70
71 setEventsState((prev) => {
72 const merged = new Map<string, Event>();
73 prev.forEach((event) => merged.set(event.id, event));
74 converted.forEach((event) => merged.set(event.id, event));
75 return Array.from(merged.values());
76 });
77 }, []);
78
79 return (
80 <EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
81 {children}
82 </EventsContext.Provider>
83 );
84 }
85
86 export function useEventsStore() {
87 const context = useContext(EventsContext);
88 if (context === undefined) {
89 throw new Error("useEventsStore must be used within an EventsProvider");
90 }
91 return context;
92 }
93
```