diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..254c71e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# EditorConfig for TimeToLeave +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 +max_line_length = 100 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a71755 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + lint-typecheck-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build web + run: npm run build diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/POST_MVP_PLAN.md b/POST_MVP_PLAN.md index 5555dd7..dcea0aa 100644 --- a/POST_MVP_PLAN.md +++ b/POST_MVP_PLAN.md @@ -1,79 +1,87 @@ # TimeToLeave - Post-MVP Improvements Plan +> **Last updated:** 2026-05-19 +> This plan reflects the current state of the codebase after the initial MVP and mobile rewrite. + +## Already Implemented (No Longer TODO) + +The following items from earlier versions of this plan have been completed and are in production: + +- **Native Calendar Import** — `expo-calendar` integration with device calendar sync, permission requests, and multiple calendar source selection. +- **Push / Local Notifications** — `expo-notifications` with local notification scheduling. Server-side push (FCM/APNs) for live journey updates remains deferred. +- **Dark / Light Theming** — System theme following and manual dark/light toggle on both web and mobile. +- **Auto-Refresh** — `useFocusEffect`-based refresh on mobile when returning to foreground. + +--- + ## High Priority -### 1. Native Calendar Import -- Integrate with expo-calendar or react-native-calendar-events -- Request calendar permissions -- Auto-sync events from Google/Apple calendars -- Support multiple calendar sources +### 1. Offline-First Architecture +- Cache journey and route data in `AsyncStorage` / `localStorage` for offline viewing. +- Background sync when connection is restored. +- Add explicit "offline mode" UI indicators. +- Conflict resolution for concurrent event edits. -### 2. Push Notifications -- Implement FCM for Android and APNs for iOS -- Server-side notification triggers for journey changes -- Real-time updates when train status changes -- Fallback to local notifications when offline +### 2. Real Map Integration +- Integrate `expo-maps` / `@vis.gl/react-google-maps` for visual route display. +- Show origin, destination, and train stations on a map. +- Display bike route with turn-by-turn directions. +- Alternative route suggestions (e.g., faster vs. fewer changes). -### 3. Offline-First Architecture -- Use expo-sqlite for local caching -- Cache journey data for offline access -- Background sync when connection restored -- Conflict resolution for concurrent edits +### 3. Multiple Origins Support +- Allow different origins per event (Home, Work, Custom presets). +- Quick origin switching in event detail and settings. +- Store origin presets in persistent settings. + +--- ## Medium Priority -### 4. Real Map Integration -- Integrate expo-maps for visual route display -- Show train stations on map -- Display bike route with turn-by-turn directions -- Alternative route suggestions +### 4. Server-Side Push Notifications +- Implement FCM for Android and APNs for iOS for real-time journey disruption alerts. +- Real-time delay/cancellation push notifications. +- Fallback to local notifications when the server is unreachable. -### 5. Multiple Origins Support -- Allow different origins per event -- Home/Work/Custom origin presets -- Quick origin switching in event detail +### 5. Accessibility +- TalkBack / VoiceOver screen reader support on mobile. +- Dynamic type scaling. +- High contrast mode. +- WCAG 2.1 AA compliance audit on web. -### 6. Auto-Refresh -- Refresh journey data when returning to app -- Background refresh for active events -- Configurable refresh intervals +### 6. Analytics & Crash Reporting +- Integrate Sentry for error tracking on web and mobile. +- Opt-in usage analytics. +- Performance monitoring (Web Vitals, React Native startup time). +- In-app user feedback collection. + +--- ## Lower Priority -### 7. Accessibility -- TalkBack/VoiceOver support -- Dynamic type scaling -- High contrast mode -- Screen reader optimizations +### 7. Advanced Features +- Shared events with friends / family (collaborative departure planning). +- Recurring event templates. +- Journey history and statistics dashboard. +- Export / import event data (ICS, JSON). -### 8. Theming -- Dark mode support -- System theme following -- Custom color schemes -- Accessibility-compliant color contrasts +--- -### 9. Analytics & Crash Reporting -- Sentry or similar for error tracking -- Usage analytics (opt-in) -- Performance monitoring -- User feedback collection +## Technical Debt & Quality -### 10. Advanced Features -- Shared events with friends/family -- Recurring event templates -- Journey history and statistics -- Export/import event data +### 8. Code Quality +- Extract shared hooks to `packages/hooks` (deduplicate web and mobile hook implementations). +- Increase unit test coverage across all packages. +- Add Playwright E2E tests for web critical flows. +- Add Detox E2E tests for mobile critical flows. +- Bundle size analysis and reduction. -## Technical Debt +### 9. Developer Experience +- Consolidate ESLint to Flat Config everywhere. +- Add pre-commit hooks (`husky` + `lint-staged`). +- Add GitHub Actions CI pipeline. +- Architecture Decision Records (ADRs) in `docs/adr/`. -### 11. Code Quality -- More comprehensive test coverage -- E2E tests for critical flows -- Performance optimization -- Bundle size reduction - -### 12. Documentation -- User documentation -- API documentation -- Contributing guidelines -- Architecture decisions (ADRs) +### 10. Documentation +- Keep `POST_MVP_PLAN.md` and `CHECKLIST.md` in sync with reality. +- Contributing guidelines (`CONTRIBUTING.md`). +- API versioning policy once the backend grows. diff --git a/add_features.md. b/add_features.md. deleted file mode 100644 index c91f7b6..0000000 --- a/add_features.md. +++ /dev/null @@ -1,2434 +0,0 @@ -## 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 - - -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. - - -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
-27 {/* Toggle */} -28
-29 -30 Leave reminders -31 -32 -46
-47 -48 {/* Buffer minutes */} -49 {enabled && ( -50
-51 -60
-61 setBufferMinutes(Number(e.target.value))} -69 className="flex-1 accent-[#B23CFF]" -70 /> -71 -75 {bufferMinutes} -76 -77
-78
-79 )} -80 -81 {/* Permission status */} -82
-83

{statusLabel}

-84 {permission !== "granted" && permission !== "denied" && ( -85 -91 )} -92
-93
-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; -8 status: "upcoming" | "now" | "past"; -9 } -10 -11 export function useClock(targetDate: Date): ClockResult { -12 const [now, setNow] = useState(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()); -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 = { -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 = ({ countdown, className = "" }) => { -21 return ( -22 -23 {countdown.label} -24 -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 = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => { -17 if (!bikeRoute && !bikeLoading && !bikeError) { -18 return null; -19 } -20 -21 return ( -22
-23
-24
-25
-26

Bicycle Route

-27
-28 {onRefresh && ( -29 -32 )} -33
-34
-35
-36 {bikeLoading ? ( -37
-38 -39
-40 ) : bikeError ? ( -41
Error: {bikeError}
-42 ) : bikeRoute ? ( -43
-44
-45 Distance -46 -47 {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m) -48 -49
-50
-51 Duration -52 -53 {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s -54 -55
-56 {bikeRoute.steps && bikeRoute.steps.length > 0 && ( -57
-58

Steps

-59
    -60 {bikeRoute.steps.map((step, index) => ( -61
  • -62 {step.name} -63 {step.instruction} -64
  • -65 ))} -66
-67
-68 )} -69
-70 ) : null} -71
-72
-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
-32 -33

Loading nearby stops...

-34
-35 ); -36 } -37 -38 if (error) { -39 return ( -40
-41

{error}

-42
-43 ); -44 } -45 -46 if (stops.length === 0) { -47 return ( -48
-49

No nearby stops found.

-50
-51 ); -52 } -53 -54 const departuresByStop = new Map(); -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
-64 {stops.map((stop) => { -65 const stopDepartures = departuresByStop.get(stop.id) ?? []; -66 -67 return ( -68
-69

{stop.name}

-70 -71 {stopDepartures.length === 0 ? ( -72

No departures available

-73 ) : ( -74
    -75 {stopDepartures.map((departure, index) => ( -76
  • -77 {departure.lineName} -78 {departure.direction} -79 -80 {departure.minutes} min -81 -82
  • -83 ))} -84
-85 )} -86
-87 ); -88 })} -89
-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
-49
-50

{event.title}

-51 -52
-53 -54
-55 Destination: {event.destination} -56
-57
-58 Time: {format(event.eventTime, "EEE dd MMM yyyy HH:mm")} -59
-60 -61
-62 -69 -70 -71 -72 {stops.length > 0 && ( -73 -74 )} -75
-76
-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
-17 {upcoming.length === 0 ? ( -18
-19

No upcoming events

-20

Use "Add Event" to add one, or import from the Calendar.

-21
-22 ) : ( -23
-24 {upcoming.map((event) => ( -25 -26 ))} -27
-28 )} -29
-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 = ({ className = "" }) => { -12 const pathname = usePathname(); -13 -14 const navLinks = [ -15 { name: "Home", href: "/" }, -16 { name: "Calendar", href: "/calendar" }, -17 ]; -18 -19 return ( -20 -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 = ({ 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
-26
-27
-28
-29 -30 TimeToLeave -38 -39
-40
-41
-42 -43 {events.length} -44 -45 events -46 {status === true ? ( -47 Online -48 ) : status === false ? ( -49 Offline -50 ) : null} -51
-52 -55 -62 -69
-70
-71
-72 setShowAddEventModal(false)} /> -73 {showSettingsModal && ( -74
-75
-76 -83

Settings

-84 -85
-86
-87 )} -88
-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(undefined); -26 -27 export function ReminderSettingsProvider({ children }: { children: ReactNode }) { -28 const [settings, setSettings] = useState(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 -44 {children} -45 -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 -26 -27 -28 -29 -30 -31 -32 -33
-34
{children}
-35 -36 -37 -38 -39 -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>; -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) => 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(undefined); -31 -32 export function EventsProvider({ children }: { children: ReactNode }) { -33 const [events, setEventsState] = useState(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) => { -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(); -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 -81 {children} -82 -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 = { -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 = ({ countdown, status, className = "" }) => { -25 const bgColor = colorMap[countdown.color] || colorMap.blue; -26 const isUrgent = countdown.urgent || status === "now"; -27 -28 return ( -29 -32 {countdown.label} -33 -34 ); -35 }; -36 -37 export default CountdownBadge; -38 -``` - -## Assistant - - -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. - - -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(undefined); -26 -27 export function ReminderSettingsProvider({ children }: { children: ReactNode }) { -28 const [settings, setSettings] = useState(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 -44 {children} -45 -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
-27 {/* Toggle */} -28
-29 -30 Leave reminders -31 -32 -46
-47 -48 {/* Buffer minutes */} -49 {enabled && ( -50
-51 -60
-61 setBufferMinutes(Number(e.target.value))} -69 className="flex-1 accent-[#B23CFF]" -70 /> -71 -75 {bufferMinutes} -76 -77
-78
-79 )} -80 -81 {/* Permission status */} -82
-83

{statusLabel}

-84 {permission !== "granted" && permission !== "denied" && ( -85 -91 )} -92
-93
-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; -8 status: "upcoming" | "now" | "past"; -9 } -10 -11 export function useClock(targetDate: Date): ClockResult { -12 const [now, setNow] = useState(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 = { -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 = ({ countdown, className = "" }) => { -21 return ( -22 -23 {countdown.label} -24 -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 = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => { -17 if (!bikeRoute && !bikeLoading && !bikeError) { -18 return null; -19 } -20 -21 return ( -22
-23
-24
-25
-26

Bicycle Route

-27
-28 {onRefresh && ( -29 -32 )} -33
-34
-35
-36 {bikeLoading ? ( -37
-38 -39
-40 ) : bikeError ? ( -41
Error: {bikeError}
-42 ) : bikeRoute ? ( -43
-44
-45 Distance -46 -47 {Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m) -48 -49
-50
-51 Duration -52 -53 {Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s -54 -55
-56 {bikeRoute.steps && bikeRoute.steps.length > 0 && ( -57
-58

Steps

-59
    -60 {bikeRoute.steps.map((step, index) => ( -61
  • -62 {step.name} -63 {step.instruction} -64
  • -65 ))} -66
-67
-68 )} -69
-70 ) : null} -71
-72
-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()); -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 - - -Now I have a full picture of the codebase. Let me also check the core types to understand `ReminderSettings`, `BikeRoute`, `CountdownInfo`, and `calculateCountdown`. - - -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 - - -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. - - -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(null); -14 const [loading, setLoading] = useState(false); -15 const [error, setError] = useState(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 { -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( -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 - - -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. - - -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
-17 {upcoming.length === 0 ? ( -18
-19

No upcoming events

-20

Use "Add Event" to add one, or import from the Calendar.

-21
-22 ) : ( -23
-24 {upcoming.map((event) => ( -25 -26 ))} -27
-28 )} -29
-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>; -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) => 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(undefined); -31 -32 export function EventsProvider({ children }: { children: ReactNode }) { -33 const [events, setEventsState] = useState(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) => { -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(); -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 -81 {children} -82 -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 -``` diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js deleted file mode 100644 index 7c38038..0000000 --- a/apps/mobile/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - root: true, - extends: ['expo'], - rules: { - 'react-native/no-inline-styles': 'off', - }, - ignorePatterns: ['node_modules/', '.expo/', 'dist/'], -}; diff --git a/apps/mobile/jest.config.cjs b/apps/mobile/jest.config.cjs index 592817f..175b6b8 100644 --- a/apps/mobile/jest.config.cjs +++ b/apps/mobile/jest.config.cjs @@ -5,4 +5,7 @@ module.exports = { '^@react-native-async-storage/async-storage$': '@react-native-async-storage/async-storage/jest/async-storage-mock', }, + transformIgnorePatterns: [ + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|@fortawesome/.*)', + ], }; diff --git a/apps/mobile/src/screens/EventDetailScreen.tsx b/apps/mobile/src/screens/EventDetailScreen.tsx index 9ac3e18..6e19151 100644 --- a/apps/mobile/src/screens/EventDetailScreen.tsx +++ b/apps/mobile/src/screens/EventDetailScreen.tsx @@ -12,6 +12,14 @@ import { faArrowsRotate, faBicycle, faTrain, faTriangleExclamation } from '@fort import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RouteProp } from '@react-navigation/native'; import { loadEvents, loadOriginStation, loadNotificationSettings } from '../store/eventStore'; +import { + getCachedJourneys, + getCachedBikeRoute, + getCachedWalkRoute, + setCachedJourneys, + setCachedBikeRoute, + setCachedWalkRoute, +} from '../store/apiCache'; import { api } from '../services/api'; import type { Journey, BikeRoute, Station, Event as CalendarEvent, WalkRoute } from '@timetoleave/core'; import { useDestinationStation } from '../hooks/useDestinationStation'; @@ -79,6 +87,15 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { const fetchData = useCallback(async () => { setLoading(true); setError(null); + + // Preload cached data immediately so the UI isn't empty + const cachedJourneys = await getCachedJourneys(eventId); + const cachedBike = await getCachedBikeRoute(eventId); + const cachedWalk = await getCachedWalkRoute(eventId); + if (cachedJourneys) setJourneys(cachedJourneys); + if (cachedBike) setBikeRoute(cachedBike); + if (cachedWalk) setWalkRoute(cachedWalk); + try { const [events, originStation, settings] = await Promise.all([ loadEvents(), @@ -111,8 +128,14 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { const walkDurationSeconds = settings.showWalkingOption ? (walkHook.walkRoute?.duration ?? 0) : 0; const target = stationArrivalTarget(found.eventTime, settings.arrivalBufferMinutes, walkDurationSeconds); - const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true }); - setJourneys(results); + try { + const results = await api.searchJourneys(originStation.extId, destExtId, target, { arriveBy: true }); + setJourneys(results); + await setCachedJourneys(eventId, results); + } catch { + if (!cachedJourneys) throw new Error('Failed to load journeys'); + // Keep stale data, mark as offline + } } else if (destStation.error) { setError(`Destination station not resolvable: ${destStation.error}`); } @@ -125,15 +148,22 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { destCoords.coords.lat, destCoords.coords.lng, ); setBikeRoute(bike); + await setCachedBikeRoute(eventId, bike); } } catch { - setBikeRoute(null); + if (!cachedBike) setBikeRoute(null); } finally { setLoadingBike(false); } } } catch (err) { - setError(err instanceof Error ? err.message : 'Error loading'); + const msg = err instanceof Error ? err.message : 'Error loading'; + // If we have any cached data, show a soft offline warning instead of a hard error + if (cachedJourneys || cachedBike || cachedWalk) { + setError(`${msg} (showing cached data)`); + } else { + setError(msg); + } } finally { setLoading(false); } @@ -144,7 +174,10 @@ export function EventDetailScreen({ navigation, route }: ScreenProps) { useEffect(() => { setWalkRoute(walkHook.walkRoute); setLoadingWalk(walkHook.loading); - }, [walkHook.walkRoute, walkHook.loading]); + if (walkHook.walkRoute) { + setCachedWalkRoute(eventId, walkHook.walkRoute).catch(() => {}); + } + }, [walkHook.walkRoute, walkHook.loading, eventId]); const handleRefresh = () => { setBikeRoute(null); diff --git a/apps/mobile/src/store/apiCache.ts b/apps/mobile/src/store/apiCache.ts new file mode 100644 index 0000000..0cbcc14 --- /dev/null +++ b/apps/mobile/src/store/apiCache.ts @@ -0,0 +1,84 @@ +/** + * Offline cache for API responses. + * + * Stores the last successful journey, bike-route, and walk-route results + * per event (keyed by event id) so the detail screen can show stale data + * when the device is offline. + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { Journey, BikeRoute, WalkRoute } from '@timetoleave/core'; + +const CACHE_PREFIX = '@timetoleave_cache_'; +const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes + +interface CacheEntry { + data: T; + ts: number; +} + +async function getCache(key: string): Promise { + try { + const raw = await AsyncStorage.getItem(CACHE_PREFIX + key); + if (!raw) return null; + const entry: CacheEntry = JSON.parse(raw); + if (Date.now() - entry.ts > MAX_AGE_MS) return null; + return entry.data; + } catch { + return null; + } +} + +async function setCache(key: string, data: T): Promise { + try { + const entry: CacheEntry = { data, ts: Date.now() }; + await AsyncStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry)); + } catch { + // Silently fail on quota exceeded / privacy mode + } +} + +export async function getCachedJourneys(eventId: string): Promise { + const raw = await getCache & { sD: string; rD: string; sA: string; rA: string }>>(`journeys_${eventId}`); + if (!raw) return null; + return raw.map((j) => ({ + ...j, + sD: new Date(j.sD), + rD: new Date(j.rD), + sA: new Date(j.sA), + rA: new Date(j.rA), + })); +} + +export async function setCachedJourneys(eventId: string, journeys: Journey[]): Promise { + const serializable = journeys.map((j) => ({ + ...j, + sD: j.sD.toISOString(), + rD: j.rD.toISOString(), + sA: j.sA.toISOString(), + rA: j.rA.toISOString(), + })); + await setCache(`journeys_${eventId}`, serializable); +} + +export async function getCachedBikeRoute(eventId: string): Promise { + return getCache(`bike_${eventId}`); +} + +export async function setCachedBikeRoute(eventId: string, route: BikeRoute): Promise { + await setCache(`bike_${eventId}`, route); +} + +export async function getCachedWalkRoute(eventId: string): Promise { + return getCache(`walk_${eventId}`); +} + +export async function setCachedWalkRoute(eventId: string, route: WalkRoute): Promise { + await setCache(`walk_${eventId}`, route); +} + +export async function clearApiCache(): Promise { + const keys = await AsyncStorage.getAllKeys(); + const cacheKeys = keys.filter((k) => k.startsWith(CACHE_PREFIX)); + await AsyncStorage.multiRemove(cacheKeys); +} diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index 9a899f0..0000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - root: true, - extends: ['next/core-web-vitals'], - rules: { - '@next/next/no-html-link-for-pages': 'off', - }, - ignorePatterns: ['node_modules/', '.next/', 'out/', 'dist/'], -}; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index dcd0669..8ea0b7a 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { useEventsStore } from "@/hooks/useEventsStore"; import { useOriginStation } from "@/hooks/useOriginStation"; import EventCard from "@/app/event/EventCard"; @@ -15,9 +16,13 @@ export default function Home() { const { events } = useEventsStore(); const { station: originStation } = useOriginStation(); - const upcoming = events - .filter((e) => e.eventTime >= new Date()) - .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()); + const upcoming = useMemo( + () => + events + .filter((e) => e.eventTime >= new Date()) + .sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime()), + [events] + ); const nextEvent = upcoming[0] ?? null; return ( diff --git a/FEATURES_PLAN.md b/docs/archive/FEATURES_PLAN.md similarity index 100% rename from FEATURES_PLAN.md rename to docs/archive/FEATURES_PLAN.md diff --git a/package-lock.json b/package-lock.json index 77c7738..b090112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,19 +11,9 @@ "apps/*", "packages/*" ], - "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~54.0.34", - "expo-calendar": "~15.0.8", - "expo-dev-client": "~6.0.21", - "expo-location": "~19.0.8", - "expo-notifications": "~0.32.17", - "expo-status-bar": "~3.0.9", - "react": "19.1.0", - "react-native": "0.81.5", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-svg": "^15.15.5" + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^17.0.5" } }, "apps/mobile": { @@ -7464,6 +7454,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -8312,6 +8365,19 @@ "node": ">=8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -9018,6 +9084,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -9859,6 +9932,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -10324,6 +10410,22 @@ "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", "license": "MIT" }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13151,6 +13253,125 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.5.tgz", + "integrity": "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.1.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -13283,6 +13504,222 @@ "node": ">=4" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13855,6 +14292,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -15758,6 +16208,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -16392,6 +16849,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slugify": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", @@ -16597,6 +17100,16 @@ "node": ">=4" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -18410,7 +18923,8 @@ "date-fns": "^4.1.0" }, "devDependencies": { - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } } } diff --git a/package.json b/package.json index 5d55907..a19b74a 100644 --- a/package.json +++ b/package.json @@ -12,29 +12,24 @@ "start": "npm run start -w apps/web", "lint": "npm run lint -w apps/web && npm run lint -w apps/mobile && npm run lint -w packages/core && npm run lint -w packages/api-client", "typecheck": "npm run typecheck -w apps/web && npm run typecheck -w apps/mobile && npm run typecheck -w packages/core && npm run typecheck -w packages/api-client", - "test": "npm run test -w apps/web && npm run test -w apps/mobile", + "test": "npm run test -w apps/web && npm run test -w apps/mobile && npm run test -w packages/core", "dev:mobile": "npm run start -w apps/mobile", "typecheck:mobile": "npm run typecheck -w apps/mobile", - "android": "expo run:android", - "ios": "expo run:ios" + "prepare": "husky" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "bash -c 'npm run typecheck'" + ] }, "overrides": { "react": "19.1.0", "react-dom": "19.1.0", "react-test-renderer": "19.1.0" }, - "dependencies": { - "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~54.0.34", - "expo-calendar": "~15.0.8", - "expo-dev-client": "~6.0.21", - "expo-location": "~19.0.8", - "expo-notifications": "~0.32.17", - "expo-status-bar": "~3.0.9", - "react": "19.1.0", - "react-native": "0.81.5", - "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0", - "react-native-svg": "^15.15.5" + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^17.0.5" } } diff --git a/packages/core/package.json b/packages/core/package.json index 65588cd..869ea50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,12 +6,14 @@ "types": "src/index.ts", "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src/" + "lint": "eslint src/", + "test": "vitest run" }, "dependencies": { "date-fns": "^4.1.0" }, "devDependencies": { - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } } diff --git a/apps/web/src/lib/__tests__/countdown-utils.test.ts b/packages/core/src/__tests__/countdown-utils.test.ts similarity index 97% rename from apps/web/src/lib/__tests__/countdown-utils.test.ts rename to packages/core/src/__tests__/countdown-utils.test.ts index cfb0810..c6f0f66 100644 --- a/apps/web/src/lib/__tests__/countdown-utils.test.ts +++ b/packages/core/src/__tests__/countdown-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { calculateCountdown } from "@timetoleave/core"; +import { calculateCountdown } from "../countdown-utils"; describe("countdown-utils", () => { describe("calculateCountdown", () => { diff --git a/apps/web/src/lib/__tests__/hafas-time.test.ts b/packages/core/src/__tests__/hafas-time.test.ts similarity index 99% rename from apps/web/src/lib/__tests__/hafas-time.test.ts rename to packages/core/src/__tests__/hafas-time.test.ts index 2b47f1a..f81cfaa 100644 --- a/apps/web/src/lib/__tests__/hafas-time.test.ts +++ b/packages/core/src/__tests__/hafas-time.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseHafasTime, hafasDateTime } from "@timetoleave/core"; +import { parseHafasTime, hafasDateTime } from "../hafas-time"; describe("getTimezoneOffsetMinutes (via parseHafasTime)", () => { /* diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..c55c5cc --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: true, + }, +});