Compare commits
36 Commits
rewrite/next
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c6df95a86 | |||
| 98e74ee48d | |||
| 2eeae9a27b | |||
| 35971596b3 | |||
| 863996f06c | |||
| 1851d2ed47 | |||
| eac4f6e216 | |||
| 2c65dc1d5a | |||
| b87acfd0e1 | |||
| 08794eae05 | |||
| dc5b40ff6e | |||
| 4366f781d3 | |||
| ea88e9d34a | |||
| b541c809b2 | |||
| 014fe789f8 | |||
| ef36d227e9 | |||
| 6fb7941d56 | |||
| 55c77c7572 | |||
| fff5700132 | |||
| 5bcfafcbaf | |||
| 20159262c1 | |||
| d08afc1dcb | |||
| 442a00dbbe | |||
| 330cbb6b37 | |||
| de9c16430b | |||
| b2608a4a60 | |||
| 7901368971 | |||
| 48e6f5c7fd | |||
| c0f34b9e2a | |||
| ce2a900545 | |||
| 7abfddd607 | |||
| 28c25c32ab | |||
| 77b8c6db98 | |||
| c869d4958e | |||
| a7fcbd811e | |||
| 7c345785a7 |
@@ -12,3 +12,12 @@ OSRM_URL=https://router.project-osrm.org
|
||||
|
||||
# Nominatim user-agent / referer (required by their ToS)
|
||||
NOMINATIM_USER_AGENT=OebbPlanner/1.0
|
||||
|
||||
# Wiener Linien Open Data API
|
||||
WIENER_LINIEN_API_URL=https://api.wienerlinien.at/darwin-v2
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://timetoleave.app
|
||||
|
||||
# Deployment URL
|
||||
DEPLOYMENT_URL=https://timetoleave.app
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
**/node_modules
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -27,3 +28,24 @@ npm-debug.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.aider*
|
||||
|
||||
# editor / agent tooling
|
||||
.claude/
|
||||
.idea/
|
||||
.zed/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
AIDER_RULES.md
|
||||
.aider.chat.history.md
|
||||
.aider.input.history
|
||||
|
||||
# agent loop
|
||||
agent_loop/
|
||||
|
||||
# agent loop generated output
|
||||
logs/
|
||||
runs/
|
||||
|
||||
# next.js build output (apps)
|
||||
apps/web/.next/
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/oebb_planner.iml" filepath="$PROJECT_DIR$/.idea/oebb_planner.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/oebb-planner-app" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,114 +0,0 @@
|
||||
# Rewrite Agent Rules
|
||||
|
||||
These rules apply when implementing the Next.js rewrite on the `rewrite/next` branch.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and **required**; blocks the next phase
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; **never blocks phase advancement**
|
||||
|
||||
Rules:
|
||||
- The ✅ column is yours; the ✔️ column belongs to the review agent.
|
||||
- After completing each numbered item, mark its ✅ box by changing `[ ]` to `[x]`.
|
||||
- If you complete an optional item (`[~]`), change it to `[x]`. If you skip it, leave it as `[~]`.
|
||||
- Before starting any item in a new phase, read `CHECKLIST.md` and confirm that every **required** (`[ ]`/`[x]`) item in all preceding phases has `[x]` in both ✅ and ✔️. Items marked `[~]` in both columns do not need to be completed first.
|
||||
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
|
||||
|
||||
## Rewrite Context
|
||||
|
||||
- `REWRITE_PLAN.md` is the guiding plan for the migration from the current CRA + Express app to a Next.js App Router + TypeScript app.
|
||||
- When working on the rewrite, follow the migration phases in `REWRITE_PLAN.md` unless the user explicitly asks for a different order.
|
||||
- Treat each numbered migration item as a checkpoint: implement it, update its ✅ box in `CHECKLIST.md`, add or update tests, run the relevant verification, then continue.
|
||||
- Prefer building the new Next.js structure in parallel until feature parity is proven. Do not delete `server/`, `oebb-planner-app/`, or `oebb-planner.jsx` before equivalent Next.js behavior is implemented, tested, and the user has clearly asked for cleanup.
|
||||
- Preserve existing API contracts and user-visible behavior during migration unless the rewrite plan or user request explicitly changes them.
|
||||
- Use `npm` consistently because the existing project uses `package-lock.json`.
|
||||
|
||||
## Work Step By Step
|
||||
|
||||
- Start by reading the relevant files and identifying the smallest safe next step.
|
||||
- State the plan before making non-trivial changes.
|
||||
- Implement one coherent change at a time.
|
||||
- After each step, review the diff and check whether it still matches the intended behavior.
|
||||
- Do not move on to the next step while the current step has unresolved compile errors, failing tests, or obvious regressions.
|
||||
- Prefer small, targeted edits over broad rewrites.
|
||||
- Preserve existing behavior unless the user explicitly asks to change it.
|
||||
- When a task spans multiple rewrite phases, complete one vertical slice at a time where practical: type or library code, route or hook, UI integration, tests, then verification.
|
||||
- Keep reusable logic in `src/lib`, side effects in hooks or route handlers, and shared contracts in `src/types`.
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- Add or update tests for every new feature, bug fix, and behavior change.
|
||||
- Put tests near the code they cover and follow the existing test style.
|
||||
- Cover the main success path, important edge cases, and failure behavior.
|
||||
- Do not remove or weaken tests just to make the suite pass.
|
||||
- If a change cannot reasonably be tested, explain why and add the closest practical verification.
|
||||
- For the Next.js rewrite, prefer unit tests for `src/lib`, route tests for `src/app/api`, and component smoke or behavior tests for UI components.
|
||||
- Mock external services in automated tests, including ÖBB HAFAS, Nominatim, OSRM, geolocation, time, and calendar downloads. Do not make tests depend on live network availability.
|
||||
- Test TypeScript data shapes and boundary parsing where API responses are transformed into app types.
|
||||
|
||||
## Verification Before Moving On
|
||||
|
||||
- Run the narrowest relevant tests after each meaningful change.
|
||||
- Run the broader project checks before finishing.
|
||||
- For server changes, run:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm test
|
||||
```
|
||||
|
||||
- For React app changes, run:
|
||||
|
||||
```bash
|
||||
cd oebb-planner-app
|
||||
CI=true npm test -- --watchAll=false
|
||||
npm run build
|
||||
```
|
||||
|
||||
- For the Next.js rewrite, once the root Next.js project exists, run the relevant root checks instead:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
- If available, also run type-checking and linting scripts before finishing:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- If a change touches both server and app behavior, run both sets of checks.
|
||||
- If a command fails, stop, inspect the failure, fix the cause, and rerun the command.
|
||||
- Do not claim the work is complete until the relevant checks pass, or until the remaining blocker is clearly reported.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- Make sure additions do not introduce compile errors, lint errors, runtime crashes, or broken imports.
|
||||
- Check that public APIs, endpoint contracts, props, and data shapes remain compatible with existing callers.
|
||||
- Keep error handling explicit and user-facing failures understandable.
|
||||
- Avoid hidden global state, timing assumptions, and network-dependent tests unless the project already uses that pattern.
|
||||
- Keep dependencies unchanged unless they are necessary for the task and justified.
|
||||
- Do not commit generated artifacts, caches, logs, or local environment files.
|
||||
- Keep TypeScript strictness intact once introduced. Do not use `any` as a shortcut around unclear domain types.
|
||||
- Keep server-only code out of client components. Route handlers and `src/lib` clients that use secrets, privileged headers, or upstream service details must not be imported into browser-only code.
|
||||
- Respect Nominatim usage requirements when implementing geocoding: configurable base URL, clear user agent, rate-limit-aware caching, and no direct browser calls to the public service.
|
||||
- Keep OSRM and HAFAS clients behind API routes or server-side utilities so failures can be normalized and tested.
|
||||
- For UI work, preserve accessibility basics: semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
Before finishing a step, confirm:
|
||||
|
||||
- The requested behavior is implemented.
|
||||
- The ✅ box for the corresponding item in `CHECKLIST.md` is checked.
|
||||
- The change matches the relevant phase or numbered item in `REWRITE_PLAN.md`, when applicable.
|
||||
- Tests were added or updated where appropriate.
|
||||
- Relevant tests and build checks pass.
|
||||
- The change is scoped to the request.
|
||||
- No unrelated user changes were overwritten.
|
||||
- Old implementation files were not removed unless parity is tested and cleanup was requested.
|
||||
- Any limitations or skipped checks are reported clearly.
|
||||
@@ -1,5 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1,84 @@
|
||||
# TimeToLeave Brand Guidelines
|
||||
|
||||
## Logo
|
||||
|
||||
The TimeToLeave logo features a **melting clock** flowing into a right-pointing departure arrow, symbolizing "it's time to go" — time literally dripping away as you head out the door.
|
||||
|
||||
### Meaning
|
||||
- **Melting clock** = Time awareness, urgency, fluidity — like Dali's persistence of memory
|
||||
- **Violet→Magenta gradient** = Creativity, energy, modernity
|
||||
- **Hot pink arrow** (#FF2D8D) = Departure, leaving, forward motion
|
||||
- **Dark background** = Premium, sleek, focused
|
||||
|
||||
## Colors
|
||||
|
||||
### Primary Gradient
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Violet | `#8B5CF6` | Gradient start |
|
||||
| Magenta | `#B23CFF` | Gradient mid |
|
||||
| Pink | `#D946EF` | Gradient end |
|
||||
| Hot Pink | `#FF2D8D` | Accents, arrows, "To" in wordmark |
|
||||
|
||||
### Text
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Off-White | `#F4F1EA` | Primary text on dark backgrounds |
|
||||
|
||||
### Background
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Deep Space | `#03030A` | Outer background |
|
||||
| Night | `#090816` | Inner background |
|
||||
| Twilight | `#17112A` | Highlights, glows |
|
||||
|
||||
### Status Colors (Countdown)
|
||||
| Status | Color | Meaning |
|
||||
|--------|-------|---------|
|
||||
| Red | `#FF3B30` | Leave now / Late |
|
||||
| Orange | `#FF9500` | Getting close |
|
||||
| Yellow | `#FFCC00` | On track |
|
||||
| Green | `#34C759` | Plenty of time |
|
||||
| Blue | `#5AC8FA` | Confirmed / Done |
|
||||
|
||||
## Typography
|
||||
|
||||
- **Primary:** Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif
|
||||
- **Weights:** 800 for headlines, 700 for headings, 600 for semibold, 400 for body, 300 for captions
|
||||
- **Letter spacing:** -0.02em for headings (tighter, more modern)
|
||||
|
||||
## Logo Variants
|
||||
|
||||
### Icon Only
|
||||
Use `LogoIcon` component for favicons, app icons, loading states.
|
||||
|
||||
### Horizontal Logo
|
||||
Use `LogoHorizontal` component for headers, navigation, about pages.
|
||||
|
||||
### Full Logo (SVG)
|
||||
Use `timetoleave_dark_logo.svg` for downloads, print, marketing materials.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { LogoIcon, LogoHorizontal } from "@/app/ui/logos";
|
||||
|
||||
// Icon only (48px)
|
||||
<LogoIcon size={48} />
|
||||
|
||||
// Horizontal header logo
|
||||
<LogoHorizontal height={32} />
|
||||
|
||||
// Custom sizing
|
||||
<LogoIcon size={128} className="drop-shadow-lg" />
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/web/src/app/ui/LogoIcon.tsx` | React icon component |
|
||||
| `apps/web/src/app/ui/LogoHorizontal.tsx` | React horizontal logo |
|
||||
| `apps/web/src/app/icon.svg` | Web favicon (auto-generated by Next.js) |
|
||||
| `apps/web/src/app/opengraph-image.svg` | Social sharing image |
|
||||
| `apps/web/src/app/timetoleave_dark_logo.svg` | Master SVG with full wordmark |
|
||||
@@ -0,0 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] — 2025-01-27
|
||||
|
||||
### 🎉 Initial MVP Release
|
||||
|
||||
First stable release of TimeToLeave: a smart departure planner that integrates
|
||||
your calendar with real-time public transport data to tell you exactly when to leave.
|
||||
|
||||
### Web Application
|
||||
|
||||
- Next.js 16 web dashboard with Tailwind CSS 4
|
||||
- Calendar sync via `.ics` file import or URL
|
||||
- Station search and journey planning for upcoming events
|
||||
- Real-time departures with dynamic leave-status countdown
|
||||
- Browser-based leave reminders with push notifications
|
||||
- Dark/light theme toggle
|
||||
- Debounced search and optimized calendar loading
|
||||
- Docker support with multi-stage build and standalone output
|
||||
|
||||
### Mobile Application
|
||||
|
||||
- React Native 0.81 / Expo 54 mobile client
|
||||
- Five-screen navigation: Event List, Add Event, Event Detail, Origin Setup, Settings
|
||||
- Native calendar import via `expo-calendar`
|
||||
- Geolocation-based origin detection via `expo-location`
|
||||
- Local push notifications via `expo-notifications`
|
||||
- Persistent settings and state via AsyncStorage
|
||||
|
||||
### Public Transport Integration
|
||||
|
||||
- HAFAS protocol support for Austrian railway (ÖBB) real-time departures
|
||||
- WienerLinien integration for Vienna U-Bahn, tram, and bus departures
|
||||
- Accurate timezone-aware HAFAS time parsing with DST transition handling for `Europe/Vienna`
|
||||
- Support for repeated stop IDs and multiple connections
|
||||
|
||||
### Routing
|
||||
|
||||
- Bike route calculation from origin to departure station via OSRM
|
||||
- Geocoding API integration for station lookups
|
||||
- Fallback and caching logic for API failures
|
||||
|
||||
### Shared Infrastructure
|
||||
|
||||
- Monorepo structure with npm workspaces
|
||||
- `@timetoleave/core` — shared types, countdown utilities, and leave-status logic
|
||||
- `@timetoleave/api-client` — typed client for HAFAS, calendar, geocoding, and bike routing
|
||||
- Correlation IDs for request tracing and monitoring
|
||||
- Comprehensive test coverage across web (Vitest) and mobile (Jest)
|
||||
- Strict TypeScript type-checking across all workspaces
|
||||
- ESLint linting across the codebase
|
||||
@@ -1,23 +1,47 @@
|
||||
## Phase 9 — Fix Broken Tests (~1 hour)
|
||||
# TimeToLeave — Implementation Checklist
|
||||
|
||||
The API route tests were written against an earlier interface and will fail as-is.
|
||||
## Phase 1 — Workspace + Shared Packages (Steps 1-9)
|
||||
|
||||
| # | Item | ✅ | ✔️ |
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 53 | `geocode.test.ts`: change `?q=` → `?name=` to match the actual route parameter | [x] | [x] |
|
||||
| 54 | `geocode.test.ts`: fix expected error strings (`"Failed to geocode location"` → `"Internal server error"` / `"No results found"`) | [x] | [x] |
|
||||
| 55 | `bike-route.test.ts`: change `?start=` / `?end=` → `?fromLat=&fromLng=&toLat=&toLng=` to match actual route | [x] | [x] |
|
||||
| 56 | `bike-route.test.ts`: fix expected error strings (`"Missing 'start' or 'end' parameter"` → `"Missing required parameters ..."` and `"Failed to fetch bike route"` → `"Internal server error"`) | [x] | [x] |
|
||||
| 57 | `calendar-utils.test.ts` (`extractEvents`): replace hardcoded past dates (2020-01-01, 2023-01-01) with `vi.setSystemTime` + dates relative to the frozen clock so filters behave as expected | [x] | [x] |
|
||||
| 1 | Create safety baseline (typecheck, lint, test, build) | [x] | [x] |
|
||||
| 2 | Add workspace support to root `package.json` | [x] | [x] |
|
||||
| 3 | Move web app into `apps/web/` | [x] | [x] |
|
||||
| 4 | Create `packages/core/` with shared types + utilities | [x] | [x] |
|
||||
| 5 | Update web imports to use `@timetoleave/core` | [x] | [x] |
|
||||
| 6 | Create `packages/api-client/` with typed API wrapper | [x] | [x] |
|
||||
| 7 | Refactor web hooks to use `api-client` | [x] | [x] |
|
||||
| 8 | Run web verification again (typecheck, lint, test, build) | [x] | [x] |
|
||||
| 9 | Phase 1 summary verification (all packages compile) | [x] | [x] |
|
||||
|
||||
## Phase 10 — Fix Architecture & Critical Bugs (~2 hours)
|
||||
## Phase 2 — Expo Mobile MVP (Steps 10-18)
|
||||
|
||||
| # | Item | ✅ | ✔️ |
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [x] | [x] |
|
||||
| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [x] | [x] |
|
||||
| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [x] | [x] |
|
||||
| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [x] | [x] |
|
||||
| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [x] | [x] |
|
||||
| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [x] | [x] |
|
||||
| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [x] | [x] |
|
||||
| 10 | Scaffold Expo mobile app with TypeScript | [x] | [x] |
|
||||
| 11 | Configure mobile API base URL (`.env` + singleton) | [x] | [x] |
|
||||
| 12 | Add mobile app shell (navigation + 5 screens) | [x] | [x] |
|
||||
| 13 | Add mobile event store (AsyncStorage) | [x] | [x] |
|
||||
| 14 | Build Event List screen | [x] | [x] |
|
||||
| 15 | Build Add Event screen with validation | [x] | [x] |
|
||||
| 16 | Build Origin Setup in Settings | [x] | [x] |
|
||||
| 17 | Build Event Detail screen (trains + bike + errors) | [x] | [x] |
|
||||
| 18 | Phase 2 summary verification | [x] | [x] |
|
||||
|
||||
## Phase 3 — Notifications, Calendar, Deployment (Steps 19-24)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 19 | Add local notifications (expo-notifications) | [x] | [x] |
|
||||
| 20 | Add native calendar import (post-MVP) | [~] | [~] |
|
||||
| 21 | Add mobile tests (core + API + store + screens) | [x] | [x] |
|
||||
| 22 | Prepare deployment (web backend + EAS mobile) | [x] | [x] |
|
||||
| 23 | Release MVP (verify acceptance criteria) | [x] | [x] |
|
||||
| 24 | Plan post-MVP improvements | [x] | [x] |
|
||||
|
||||
---
|
||||
|
||||
**Legend:**
|
||||
- ✅ = Done (code written)
|
||||
- ✔️ = Verified (tests/builds pass)
|
||||
- `[~]` = Optional or deferred (never blocks phase advancement)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# TimeToLeave — Features Implementation Checklist
|
||||
|
||||
## Phase 1 — New Settings Infrastructure (Steps 1-3)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 1 | Extend ReminderSettings type with 3 new fields | [x] | [x] |
|
||||
| 2 | Update useReminderSettings hook with defaults + setters | [x] | [x] |
|
||||
| 3 | Update ReminderSettingsPanel UI (slider + 2 toggles) | [x] | [x] |
|
||||
|
||||
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 4 | Create WalkRoutingClient (OSRM foot profile) | [x] | [x] |
|
||||
| 5 | Create /api/walk-route endpoint | [x] | [x] |
|
||||
| 6 | Add getWalkRoute to api-client package | [x] | [x] |
|
||||
|
||||
## Phase 3 — Departure Time Calculation (Steps 7-8)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 7 | Create useDepartureTime hook | [x] | [x] |
|
||||
| 8 | Update useClock to accept departureTime override | [x] | [x] |
|
||||
|
||||
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 9 | Create useWalkRoute hook | [x] | [x] |
|
||||
| 10 | Create WalkingOption component | [x] | [x] |
|
||||
| 11 | Update EventCard with mode selector + conditional rendering | [x] | [x] |
|
||||
|
||||
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 12 | Update TrainSection props (arrival buffer + walk option) | [x] | [x] |
|
||||
| 13 | Update JourneyList with arrival buffer filtering | [x] | [x] |
|
||||
|
||||
## Phase 6 — Verification & Testing (Steps 14-16)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 14 | Integration verification (manual testing) | [ ] | [ ] |
|
||||
| 15 | Build verification (typecheck, lint, test, build) | [x] | [x] |
|
||||
| 16 | Update api-client exports | [x] | [x] |
|
||||
|
||||
---
|
||||
|
||||
**Legend:**
|
||||
- ✅ = Done (code written)
|
||||
- ✔️ = Verified (tests/builds pass)
|
||||
- `[~]` = Optional or deferred (never blocks phase advancement)
|
||||
@@ -0,0 +1,604 @@
|
||||
## Phase 1 — New Settings Infrastructure (Steps 1-3)
|
||||
|
||||
Extend the `ReminderSettings` type, hook, and UI panel with three new options.
|
||||
|
||||
---
|
||||
|
||||
#### Step 1: Extend ReminderSettings Type (~5 min)
|
||||
|
||||
**File:** `packages/core/src/types.ts`
|
||||
|
||||
**Goal:** Add three new fields to `ReminderSettings` for arrival buffer, walking, and bike toggles.
|
||||
|
||||
**Change `ReminderSettings` interface:**
|
||||
|
||||
```typescript
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
arrivalBufferMinutes: number; // arrive X minutes before event (default 5)
|
||||
showWalkingOption: boolean; // show walk-from-station option (default true)
|
||||
showBikeOption: boolean; // show bike route section (default true)
|
||||
}
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
1. Add the three new fields to the interface
|
||||
2. No changes needed to other types
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `packages/core` compiles without errors
|
||||
- [ ] `npm run typecheck -w packages/core` passes
|
||||
|
||||
---
|
||||
|
||||
#### Step 2: Update useReminderSettings Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useReminderSettings.tsx`
|
||||
|
||||
**Goal:** Add default values and setters for the three new settings fields.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update DEFAULTS:
|
||||
```typescript
|
||||
const DEFAULTS: ReminderSettings = {
|
||||
bufferMinutes: 15,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
};
|
||||
```
|
||||
|
||||
Update context interface:
|
||||
```typescript
|
||||
interface ReminderContextType extends ReminderSettings {
|
||||
setBufferMinutes: (minutes: number) => void;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
setArrivalBufferMinutes: (minutes: number) => void;
|
||||
setShowWalkingOption: (show: boolean) => void;
|
||||
setShowBikeOption: (show: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Add setter implementations:
|
||||
```typescript
|
||||
const setArrivalBufferMinutes = useCallback((minutes: number) => {
|
||||
setSettings((prev) => ({ ...prev, arrivalBufferMinutes: Math.max(0, Math.min(30, minutes)) }));
|
||||
}, []);
|
||||
|
||||
const setShowWalkingOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showWalkingOption: show }));
|
||||
}, []);
|
||||
|
||||
const setShowBikeOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showBikeOption: show }));
|
||||
}, []);
|
||||
```
|
||||
|
||||
Update Provider value to include new setters.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook exports all three new setters
|
||||
- [ ] localStorage serialization includes new fields
|
||||
- [ ] `npm run typecheck -w apps/web` passes
|
||||
|
||||
---
|
||||
|
||||
#### Step 3: Update ReminderSettingsPanel UI (~15 min)
|
||||
|
||||
**File:** `apps/web/src/app/ui/ReminderSettingsPanel.tsx`
|
||||
|
||||
**Goal:** Add UI controls for the three new settings.
|
||||
|
||||
**Add after the buffer minutes slider, before the permission status section:**
|
||||
|
||||
```tsx
|
||||
{/* Arrival buffer slider */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="arrival-buffer"
|
||||
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
Arrive early
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
(minutes before event)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="arrival-buffer"
|
||||
type="range"
|
||||
min={0}
|
||||
max={30}
|
||||
step={1}
|
||||
value={arrivalBufferMinutes}
|
||||
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
|
||||
className="flex-1 accent-[#B23CFF]"
|
||||
/>
|
||||
<output
|
||||
htmlFor="arrival-buffer"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
{arrivalBufferMinutes}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show walking option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
Show walking option
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showWalkingOption}
|
||||
onClick={() => setShowWalkingOption(!showWalkingOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showWalkingOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show bike option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
Show bike route
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showBikeOption}
|
||||
onClick={() => setShowBikeOption(!showBikeOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showBikeOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Destructure new values from the hook at the top of the component:**
|
||||
```typescript
|
||||
const {
|
||||
bufferMinutes, enabled, setBufferMinutes, setEnabled,
|
||||
arrivalBufferMinutes, showWalkingOption, showBikeOption,
|
||||
setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption,
|
||||
} = useReminderSettings();
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Panel renders all new controls
|
||||
- [ ] Slider range is 0-30 for arrival buffer
|
||||
- [ ] Toggles match existing visual style
|
||||
- [ ] `npm run build -w apps/web` succeeds
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
|
||||
|
||||
Add the ability to calculate walking routes using OSRM's foot profile.
|
||||
|
||||
---
|
||||
|
||||
#### Step 4: Create WalkRoutingClient (~10 min)
|
||||
|
||||
**File:** `apps/web/src/lib/walk-routing-client.ts` (create new)
|
||||
|
||||
**Goal:** Clone `BikeRoutingClient` but targeting the OSRM foot profile.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/lib/bike-routing-client.ts` to `walk-routing-client.ts`
|
||||
2. Rename class to `WalkRoutingClient`
|
||||
3. Change OSRM path from `/route/v1/bicycle/` to `/route/v1/foot/`
|
||||
4. Change cache key prefix from `osrm:bike:` to `osrm:walk:`
|
||||
5. Rename `getBikeRoute` to `getWalkRoute`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] File compiles without errors
|
||||
- [ ] Class mirrors BikeRoutingClient structure exactly
|
||||
- [ ] Cache keys are namespaced separately
|
||||
|
||||
---
|
||||
|
||||
#### Step 5: Create Walk Route API Endpoint (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/api/walk-route/route.ts` (create new)
|
||||
|
||||
**Goal:** Server-side handler that delegates to WalkRoutingClient.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/app/api/bike-route/route.ts` to `walk-route/route.ts`
|
||||
2. Import `WalkRoutingClient` instead of `BikeRoutingClient`
|
||||
3. Update error log messages to reference "Walk route"
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Endpoint responds at `/api/walk-route`
|
||||
- [ ] Accepts same query params as bike-route
|
||||
- [ ] Returns same `BikeRoute` shape (reused type)
|
||||
|
||||
---
|
||||
|
||||
#### Step 6: Add getWalkRoute to ApiClient (~5 min)
|
||||
|
||||
**File:** `packages/api-client/src/client.ts`
|
||||
|
||||
**Goal:** Add `getWalkRoute` method mirroring `getBikeRoute`.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `getBikeRoute` method
|
||||
2. Rename to `getWalkRoute`
|
||||
3. Change URL from `/api/bike-route` to `/api/walk-route`
|
||||
4. Export from `packages/api-client/src/index.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Method accepts same signature as `getBikeRoute`
|
||||
- [ ] Returns `Promise<BikeRoute>`
|
||||
- [ ] `npm run typecheck -w packages/api-client` passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Departure Time Calculation (Steps 7-8)
|
||||
|
||||
Build the logic for calculating when you should leave based on transport mode.
|
||||
|
||||
---
|
||||
|
||||
#### Step 7: Create useDepartureTime Hook (~15 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useDepartureTime.ts` (create new)
|
||||
|
||||
**Goal:** Calculate the optimal departure time based on selected mode, journeys, bike route, and arrival buffer.
|
||||
|
||||
**Actions:**
|
||||
1. Create hook accepting `eventTime`, `journeys`, `bikeRoute`, `activeMode`
|
||||
2. Import `useReminderSettings` for `arrivalBufferMinutes`
|
||||
3. Calculate `targetArrivalTime = eventTime - arrivalBufferMinutes`
|
||||
4. For train mode: find journeys arriving before target, pick latest departure
|
||||
5. For bike mode: calculate departure from target minus bike duration
|
||||
6. Return `{ departureTime, arrivalTime, mode }`
|
||||
|
||||
**Key implementation notes:**
|
||||
- Bike duration from OSRM is in seconds, convert to milliseconds for Date math
|
||||
- Filter cancelled journeys
|
||||
- Return null values if no valid option exists for the mode
|
||||
- Use `useMemo` to avoid recalculating on unrelated renders
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook returns correct departure time for train mode
|
||||
- [ ] Hook returns correct departure time for bike mode
|
||||
- [ ] Returns null when no valid journey/route exists
|
||||
- [ ] Respects arrival buffer setting
|
||||
|
||||
---
|
||||
|
||||
#### Step 8: Update useClock Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useClock.ts`
|
||||
|
||||
**Goal:** Accept optional departure time override so countdown reflects "time to leave" instead of "time to event".
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update signature:
|
||||
```typescript
|
||||
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
|
||||
```
|
||||
|
||||
Update memoized logic:
|
||||
```typescript
|
||||
const effectiveTarget = departureTime ?? targetDate;
|
||||
|
||||
return useMemo(() => {
|
||||
const countdown = calculateCountdown(effectiveTarget);
|
||||
const diffMs = effectiveTarget.getTime() - now.getTime();
|
||||
// ... rest of status logic using effectiveTarget
|
||||
}, [targetDate, departureTime, now]);
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] When departureTime is provided, countdown is to departure time
|
||||
- [ ] When departureTime is null/undefined, countdown is to event time (backward compatible)
|
||||
- [ ] `npm run typecheck -w apps/web` passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
|
||||
|
||||
Wire everything together in the EventCard with a mode selector and conditional rendering.
|
||||
|
||||
---
|
||||
|
||||
#### Step 9: Add Walk Route Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useWalkRoute.ts` (create new)
|
||||
|
||||
**Goal:** Create hook for fetching walk routes, similar to `useBikeRoute`.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/hooks/useBikeRoute.ts` to `useWalkRoute.ts`
|
||||
2. Rename state variables from `bikeRoute` to `walkRoute`
|
||||
3. Call `client.getWalkRoute` instead of `client.getBikeRoute`
|
||||
4. Return `{ walkRoute, loading, error }`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook fetches walk route via the walk-route API
|
||||
- [ ] Follows same pattern as useBikeRoute
|
||||
- [ ] Compiles without errors
|
||||
|
||||
---
|
||||
|
||||
#### Step 10: Create WalkingOption Component (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/WalkingOption.tsx` (create new)
|
||||
|
||||
**Goal:** Show walking duration from the arrival station to the destination.
|
||||
|
||||
**Actions:**
|
||||
1. Create component accepting `walkRoute`, `walkLoading`, `walkError` props
|
||||
2. Show loading spinner while fetching
|
||||
3. When route exists, show walk time in minutes and distance in km
|
||||
4. Use a pedestrian icon (inline SVG or emoji)
|
||||
5. Return null if no route or error
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Component renders walk duration badge
|
||||
- [ ] Matches existing visual style
|
||||
- [ ] Graceful handling of loading/error states
|
||||
|
||||
---
|
||||
|
||||
#### Step 11: Update EventCard with Mode Selector (~20 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/EventCard.tsx`
|
||||
|
||||
**Goal:** Add transport mode selector, wire departure time to countdown, conditionally render sections.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Add mode state:
|
||||
```typescript
|
||||
type TransportMode = "train" | "bike";
|
||||
const [activeMode, setActiveMode] = useState<TransportMode>("train");
|
||||
```
|
||||
|
||||
2. Use settings:
|
||||
```typescript
|
||||
const { arrivalBufferMinutes, showWalkingOption, showBikeOption } = useReminderSettings();
|
||||
```
|
||||
|
||||
3. Fetch walk route from destination station to destination:
|
||||
```typescript
|
||||
const { walkRoute, loading: walkLoading, error: walkError } = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
```
|
||||
|
||||
4. Use departure time hook:
|
||||
```typescript
|
||||
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute, activeMode);
|
||||
```
|
||||
|
||||
5. Pass departureTime to useClock:
|
||||
```typescript
|
||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||
```
|
||||
|
||||
6. Add mode selector UI between the header info and the sections:
|
||||
```tsx
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "train"
|
||||
? "bg-[#B23CFF] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("train")}
|
||||
>
|
||||
Train
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "bike"
|
||||
? "bg-[#B23CFF] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("bike")}
|
||||
>
|
||||
Bike
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
7. Conditional section rendering:
|
||||
- TrainSection renders when `activeMode === "train"` (pass arrival buffer + walk option props)
|
||||
- BikeSection renders when `showBikeOption && activeMode === "bike"`
|
||||
- WienerLinienSection stays unchanged (always visible)
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Mode selector toggles between Train and Bike
|
||||
- [ ] Countdown badge updates to reflect departure time for selected mode
|
||||
- [ ] Bike section hidden when `showBikeOption` is false
|
||||
- [ ] Train section receives arrival buffer for filtering
|
||||
- [ ] Walk route fetched from station to destination
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
|
||||
|
||||
Pass arrival buffer through the component tree and use it for filtering/countdown.
|
||||
|
||||
---
|
||||
|
||||
#### Step 12: Update TrainSection Props (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/TrainSection.tsx`
|
||||
|
||||
**Goal:** Accept and forward new props for walking option and arrival buffer.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Extend props interface:
|
||||
```typescript
|
||||
type TrainSectionProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
destName: string;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
arrivalBufferMinutes?: number;
|
||||
showWalkingOption?: boolean;
|
||||
walkRoute?: import("@timetoleave/core").BikeRoute | null;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
Pass through to JourneyList:
|
||||
```tsx
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes ?? 0}
|
||||
/>
|
||||
```
|
||||
|
||||
Add WalkingOption at bottom when enabled:
|
||||
```tsx
|
||||
{showWalkingOption && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-white/10">
|
||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Props are optional with defaults
|
||||
- [ ] WalkingOption renders below journey list
|
||||
- [ ] Arrival buffer forwarded to JourneyList
|
||||
|
||||
---
|
||||
|
||||
#### Step 13: Update JourneyList with Arrival Buffer (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/JourneyList.tsx`
|
||||
|
||||
**Goal:** Use arrival buffer when calculating countdown and filtering invalid journeys.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update props:
|
||||
```typescript
|
||||
type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
className?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Calculate adjusted target:
|
||||
```typescript
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
```
|
||||
|
||||
Filter out journeys arriving after target:
|
||||
```typescript
|
||||
const validJourneys = journeys.filter(j => !j.cancelled);
|
||||
```
|
||||
|
||||
Dim journeys that arrive too late:
|
||||
```tsx
|
||||
const arrivesTooLate = j.rA.getTime() > targetArrivalTime.getTime();
|
||||
```
|
||||
|
||||
Apply dimmed styling to late journeys:
|
||||
```tsx
|
||||
<li key={j.id} className={`... ${arrivesTooLate ? "opacity-40 line-through" : ""}`}>
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Late journeys are visually dimmed
|
||||
- [ ] Countdown reflects arrival buffer
|
||||
- [ ] Late journeys are not removed, just dimmed (user can still see them)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Verification & Testing (Steps 14-16)
|
||||
|
||||
Ensure everything works together correctly.
|
||||
|
||||
---
|
||||
|
||||
#### Step 14: Integration Verification (~10 min)
|
||||
|
||||
**Goal:** Verify the full flow from settings through to the UI.
|
||||
|
||||
**Manual test steps:**
|
||||
1. Open settings, set arrival buffer to 10 minutes
|
||||
2. Verify countdown badge shows earlier departure time than event time
|
||||
3. Switch to bike mode, verify countdown updates to bike departure time
|
||||
4. Enable walking option, verify walk duration appears under train section
|
||||
5. Toggle bike option off, verify bike section disappears
|
||||
6. Toggle bike option on, verify bike section reappears
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] All settings persist in localStorage
|
||||
- [ ] Countdown reflects selected mode and arrival buffer
|
||||
- [ ] Walk option shows correctly when enabled
|
||||
- [ ] Bike section toggles correctly
|
||||
|
||||
---
|
||||
|
||||
#### Step 15: Run Build Verification (~5 min)
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
npm run typecheck -w packages/core
|
||||
npm run typecheck -w packages/api-client
|
||||
npm run typecheck -w apps/web
|
||||
npm run lint -w apps/web
|
||||
npm run build -w apps/web
|
||||
npm test
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] All typechecks pass with zero errors
|
||||
- [ ] Lint passes with zero errors
|
||||
- [ ] Build completes successfully
|
||||
- [ ] All existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
#### Step 16: Update api-client exports (~5 min)
|
||||
|
||||
**File:** `packages/api-client/src/index.ts`
|
||||
|
||||
**Goal:** Ensure `getWalkRoute` is exported from the package.
|
||||
|
||||
**Actions:**
|
||||
1. Check current exports in index.ts
|
||||
2. Add `getWalkRoute` method to the exported class if not already present
|
||||
3. Verify the method is accessible from web app imports
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `getWalkRoute` is importable from `@timetoleave/api-client`
|
||||
- [ ] No breaking changes to existing exports
|
||||
|
||||
---
|
||||
@@ -0,0 +1,216 @@
|
||||
# TimeToLeave - Manual Integration Testing Checklist
|
||||
|
||||
## Overview
|
||||
This checklist guides you through manual testing of the TimeToLeave application to ensure all features work correctly in the browser.
|
||||
|
||||
## Prerequisites
|
||||
- [ ] Application is running locally or deployed
|
||||
- [ ] All required environment variables are set
|
||||
- [ ] Network connection is available for external API calls
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings Infrastructure Testing
|
||||
|
||||
### Arrival Buffer Settings
|
||||
- [ ] Navigate to Settings panel
|
||||
- [ ] Set arrival buffer to 10 minutes
|
||||
- [ ] Verify buffer value is displayed correctly
|
||||
- [ ] Test different buffer values (0, 5, 15, 30 minutes)
|
||||
- [ ] Verify buffer value persists after page refresh
|
||||
|
||||
### Walking Option Toggle
|
||||
- [ ] Enable "Show walking option" toggle
|
||||
- [ ] Verify toggle state is saved
|
||||
- [ ] Disable "Show walking option" toggle
|
||||
- [ ] Verify toggle state persists after page refresh
|
||||
|
||||
### Bike Option Toggle
|
||||
- [ ] Enable "Show bike option" toggle
|
||||
- [ ] Verify toggle state is saved
|
||||
- [ ] Disable "Show bike option" toggle
|
||||
- [ ] Verify toggle state persists after page refresh
|
||||
|
||||
---
|
||||
|
||||
## 2. Walk Routing Testing
|
||||
|
||||
### Walk Route API
|
||||
- [ ] Open Developer Tools (F12) → Network tab
|
||||
- [ ] Trigger a walk route calculation (e.g., by loading an event with walk mode)
|
||||
- [ ] Verify `/api/walk-route` request appears in network log
|
||||
- [ ] Check request contains correct query parameters (fromLat, fromLng, toLat, toLng)
|
||||
- [ ] Verify response contains distance, duration, and steps array
|
||||
- [ ] Test with different coordinate pairs
|
||||
|
||||
### Walk Route Display
|
||||
- [ ] Enable walking option in settings
|
||||
- [ ] Load an event that should show walk route
|
||||
- [ ] Verify walk duration appears under train section
|
||||
- [ ] Verify walk distance is displayed
|
||||
- [ ] Verify step-by-step instructions are shown
|
||||
- [ ] Test with events at different locations
|
||||
|
||||
---
|
||||
|
||||
## 3. Departure Time Calculation Testing
|
||||
|
||||
### Countdown Badge
|
||||
- [ ] Set arrival buffer to 10 minutes
|
||||
- [ ] Verify countdown badge shows earlier departure time than event time
|
||||
- [ ] Test with different event times (now, in 1 hour, in 3 hours)
|
||||
- [ ] Verify countdown updates in real-time
|
||||
|
||||
### Departure Time Override
|
||||
- [ ] Switch between transport modes (train, bike, walk)
|
||||
- [ ] Verify countdown updates to reflect selected mode
|
||||
- [ ] Test mode switching multiple times
|
||||
- [ ] Verify departure time calculation is consistent
|
||||
|
||||
---
|
||||
|
||||
## 4. Mode Selector Testing
|
||||
|
||||
### Transport Mode Selection
|
||||
- [ ] Verify "Train" mode is selected by default
|
||||
- [ ] Click "Bike" mode button
|
||||
- [ ] Verify "Bike" mode is now active
|
||||
- [ ] Click "Walk" mode button
|
||||
- [ ] Verify "Walk" mode is now active
|
||||
- [ ] Test switching between all modes multiple times
|
||||
|
||||
### Conditional Rendering
|
||||
- [ ] With walking option disabled: verify walk section is hidden
|
||||
- [ ] With walking option enabled: verify walk section appears
|
||||
- [ ] With bike option disabled: verify bike section is hidden
|
||||
- [ ] With bike option enabled: verify bike section appears
|
||||
- [ ] Test all combinations of toggle states
|
||||
|
||||
---
|
||||
|
||||
## 5. JourneyList Filtering Testing
|
||||
|
||||
### Arrival Buffer Filtering
|
||||
- [ ] Set arrival buffer to 5 minutes
|
||||
- [ ] Load multiple journeys with different arrival times
|
||||
- [ ] Verify journeys arriving too late are filtered out
|
||||
- [ ] Increase arrival buffer to 15 minutes
|
||||
- [ ] Verify previously filtered journeys now appear
|
||||
- [ ] Test filtering with real-world journey data
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Feature Integration Testing
|
||||
|
||||
### Complete Workflow
|
||||
- [ ] Open settings and set arrival buffer to 10 minutes
|
||||
- [ ] Enable walking option
|
||||
- [ ] Enable bike option
|
||||
- [ ] Load an event with multiple journey options
|
||||
- [ ] Verify countdown badge shows earlier departure time
|
||||
- [ ] Switch to bike mode and verify countdown updates
|
||||
- [ ] Verify walk duration appears under train section
|
||||
- [ ] Disable bike option and verify bike section disappears
|
||||
- [ ] Re-enable bike option and verify bike section reappears
|
||||
- [ ] Test complete workflow with different events
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases Testing
|
||||
|
||||
### Empty States
|
||||
- [ ] Test with no walk route available (remote location)
|
||||
- [ ] Verify appropriate error message is displayed
|
||||
- [ ] Test with missing coordinates
|
||||
- [ ] Verify graceful handling of missing data
|
||||
|
||||
### Network Errors
|
||||
- [ ] Disable network connection (offline mode in DevTools)
|
||||
- [ ] Attempt to load walk route
|
||||
- [ ] Verify error state is displayed
|
||||
- [ ] Re-enable network and verify retry works
|
||||
|
||||
### Invalid Data
|
||||
- [ ] Test with invalid coordinate values
|
||||
- [ ] Test with zero or negative buffer times
|
||||
- [ ] Verify application handles invalid data gracefully
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility Testing
|
||||
|
||||
### Keyboard Navigation
|
||||
- [ ] Tab through all settings controls
|
||||
- [ ] Verify all buttons and toggles are keyboard accessible
|
||||
- [ ] Test mode selector with keyboard only
|
||||
|
||||
### Screen Reader Compatibility
|
||||
- [ ] Use Chrome's accessibility inspector or a screen reader
|
||||
- [ ] Verify all settings have proper labels
|
||||
- [ ] Verify all interactive elements are announced correctly
|
||||
|
||||
### High Contrast Mode
|
||||
- [ ] Enable high contrast mode in OS settings
|
||||
- [ ] Verify all UI elements remain visible and readable
|
||||
|
||||
---
|
||||
|
||||
## 9. Performance Testing
|
||||
|
||||
### Loading Times
|
||||
- [ ] Measure time to load walk route for nearby location (< 5km)
|
||||
- [ ] Measure time to load walk route for farther location (10-20km)
|
||||
- [ ] Verify loading spinner appears during API calls
|
||||
- [ ] Verify loading spinner disappears when complete
|
||||
|
||||
### Memory Usage
|
||||
- [ ] Open Developer Tools → Memory tab
|
||||
- [ ] Perform multiple walk route calculations
|
||||
- [ ] Verify no memory leaks (memory usage should stabilize)
|
||||
|
||||
---
|
||||
|
||||
## 10. Responsive Design Testing
|
||||
|
||||
### Mobile
|
||||
- [ ] Test on mobile device (iPhone/Android)
|
||||
- [ ] Verify settings panel is usable on small screens
|
||||
|
||||
### Tablet
|
||||
- [ ] Test on tablet device
|
||||
- [ ] Verify all controls are properly sized
|
||||
|
||||
### Desktop
|
||||
- [ ] Test on various desktop screen sizes
|
||||
- [ ] Verify layout does not break
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When you encounter an issue during testing:
|
||||
|
||||
1. Note the exact steps to reproduce
|
||||
2. Record browser/device information
|
||||
3. Capture any error messages or console logs
|
||||
4. Take screenshots if UI is affected
|
||||
5. Test with latest code after reporting
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [ ] All required tests passed successfully
|
||||
- [ ] No critical bugs found
|
||||
- [ ] Application ready for production deployment
|
||||
|
||||
**Tested by:** ________________________
|
||||
**Date:** ________________________
|
||||
**Browser/Device:** ________________________
|
||||
**Build Version:** ________________________
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes
|
||||
|
||||
_Add any observations, workarounds, or special test conditions here._
|
||||
@@ -0,0 +1,79 @@
|
||||
# TimeToLeave - Post-MVP Improvements Plan
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
|
||||
### 5. Multiple Origins Support
|
||||
- Allow different origins per event
|
||||
- Home/Work/Custom origin presets
|
||||
- Quick origin switching in event detail
|
||||
|
||||
### 6. Auto-Refresh
|
||||
- Refresh journey data when returning to app
|
||||
- Background refresh for active events
|
||||
- Configurable refresh intervals
|
||||
|
||||
## Lower Priority
|
||||
|
||||
### 7. Accessibility
|
||||
- TalkBack/VoiceOver support
|
||||
- Dynamic type scaling
|
||||
- High contrast mode
|
||||
- Screen reader optimizations
|
||||
|
||||
### 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
|
||||
|
||||
### 10. Advanced Features
|
||||
- Shared events with friends/family
|
||||
- Recurring event templates
|
||||
- Journey history and statistics
|
||||
- Export/import event data
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### 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)
|
||||
@@ -0,0 +1,27 @@
|
||||
# Privacy Policy
|
||||
|
||||
## Information We Collect
|
||||
|
||||
We do not collect any personal information or data from users. All data is stored locally on your device.
|
||||
|
||||
## Data Usage
|
||||
|
||||
- **Location Data**: We use your device's location to find nearby stations and calculate travel times. This data is only used for the app's functionality and is not stored or transmitted.
|
||||
- **Calendar Data**: If you choose to import calendar events, we only read the events from your calendar and do not store or transmit them.
|
||||
- **Notifications**: We use local notifications to remind you about events, which are stored locally on your device.
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored locally on your device and never leaves your device. We do not use any third-party analytics or tracking services.
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
We do not use any third-party services that might collect or process your data. All processing happens locally on your device.
|
||||
|
||||
## Changes to This Privacy Policy
|
||||
|
||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page.
|
||||
|
||||
## Contact Us
|
||||
|
||||
If you have any questions about this Privacy Policy, please contact us at [contact email].
|
||||
@@ -1,21 +1,147 @@
|
||||
# TimeToLeave - Next.js Rewrite
|
||||
# ⏱️ TimeToLeave
|
||||
|
||||
This is a rewrite of the TimeToLeave application using Next.js App Router with TypeScript.
|
||||

|
||||
|
||||
## Development
|
||||
> **TimeToLeave** is a smart departure planner that tells you exactly when to leave home to catch your public transport for upcoming appointments. It syncs with your personal calendar, checks real-time train/bus departures (HAFAS & WienerLinien), and provides a live "Leave Status" based on real-time delays.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
   
|
||||
|
||||
## 🚀 How It Works
|
||||
|
||||
1. **Sync Your Calendar:** Import your `.ics` file or provide a calendar URL. The app extracts your upcoming events and destinations.
|
||||
2. **Set Your Origin:** Define your home station or let the app use your current geolocation.
|
||||
3. **Journey Calculation:** The app queries the HAFAS protocol and WienerLinien APIs to find the best public transport connections to your event destination.
|
||||
4. **Real-Time Monitoring:** It monitors your train's real-time departure time, accounts for delays, and adds your local travel time (e.g., biking to the station) to calculate a dynamic countdown.
|
||||
5. **Leave Status:** You get a clear status: `Leave now`, `On time`, `Delayed +X min`, or `Departure missed`.
|
||||
|
||||
## 🧱 Project Structure
|
||||
|
||||
This project uses a monorepo setup (npm workspaces) to manage multiple, interconnected parts:
|
||||
|
||||
| Directory | Description |
|
||||
| :--- | :--- |
|
||||
| `apps/web/` | The main web dashboard built with **Next.js 16**, React 19, and Tailwind CSS 4. |
|
||||
| `apps/mobile/` | The on-the-go mobile client built with **React Native 0.81** and **Expo 54**. |
|
||||
| `packages/core/` | Shared domain logic, types (`Event`, `Journey`, `Station`), countdown utilities, and status calculators. |
|
||||
| `packages/api-client/` | A lightweight client that handles API proxies for HAFAS requests, calendar parsing, geocoding, and bike routing. |
|
||||
|
||||
## 🛠 Development & Running the Application
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Node.js (version 20.x or higher)
|
||||
* npm (version 9.x or higher)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd TimeToLeave
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Environment variables:**
|
||||
* For `apps/web` and `apps/mobile`, copy `.env.example` to `.env` in each app directory and update the backend API URL and any required keys.
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Script | Command | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `dev` | `npm run dev` | Starts the Next.js development server for the Web dashboard. |
|
||||
| `dev:mobile` | `npm run dev:mobile` | Starts the Expo development server for the Mobile client. |
|
||||
| `build` | `npm run build` | Builds the production bundle for the Web application. |
|
||||
| `test` | `npm run test` | Runs Vitest for the Web app and Jest for the Mobile app. |
|
||||
| `lint` | `npm run lint` | Runs ESLint across both web and mobile clients. |
|
||||
| `typecheck` | `npm run typecheck` | Runs TypeScript type checking across all workspaces. |
|
||||
|
||||
## 📝 Key Features & Tech Stack
|
||||
|
||||
### Web Application (`apps/web`)
|
||||
* **Framework:** Next.js 16.2.6 (App Router)
|
||||
* **UI:** React 19.2.4 with Tailwind CSS 4
|
||||
* **State Management:** React Context (via `EventsProvider` and `ReminderSettingsProvider`)
|
||||
* **Routing:** Next.js built-in routing for `/` (event list) and `/calendar` views.
|
||||
|
||||
### Mobile Application (`apps/mobile`)
|
||||
* **Framework:** React Native 0.81 via Expo 54
|
||||
* **Navigation:** React Navigation 7 (Native Stack)
|
||||
* **Device APIs:**
|
||||
* `expo-location`: For geocoding your current position.
|
||||
* `expo-calendar`: For native calendar event integration.
|
||||
* `expo-notifications`: For native push notifications when it's time to leave.
|
||||
* `@react-native-async-storage/async-storage`: For persisting settings and local state.
|
||||
|
||||
### Core Logic (`packages/core`)
|
||||
* **Countdown Utilities:** Calculates time-deltas and assigns color codes (Red/Orange/Yellow/Green/Blue) based on urgency.
|
||||
* **HAFAS Time Parsing:** Highly accurate timezone-aware parsing for HAFAS timestamps, specifically handling `Europe/Vienna` (CET/CEST) and DST transitions.
|
||||
* **WienerLinien Support:** Native types and handling for Vienna public transport departures.
|
||||
* **Leave Status:** Derives human-readable statuses (`Leave now`, `Delayed +10 min`, etc.) by comparing the best non-cancelled journey's real departure time against the current time.
|
||||
|
||||
## 📄 API Client Usage
|
||||
|
||||
The `@timetoleave/api-client` package provides a clean interface to interact with your backend proxy, which handles the heavy lifting of HAFAS protocol communication and calendar parsing.
|
||||
|
||||
```typescript
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
// Initialize with your backend URL
|
||||
const api = new ApiClient("http://localhost:3000");
|
||||
|
||||
// 1. Sync your calendar
|
||||
const events = await api.fetchCalendar("https://example.com/calendar.ics", 7);
|
||||
|
||||
// 2. Search for a station via the HAFAS LocMatch endpoint
|
||||
const stationResult = await api.hafasRequest({
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "LocMatch",
|
||||
req: { searchTxt: "Wien Mitte", maxMatches: 5 },
|
||||
},
|
||||
],
|
||||
});
|
||||
const stations = stationResult?.svcReqL?.[0]?.res?.locL ?? [];
|
||||
|
||||
// 3. Find journeys between stations for a specific date
|
||||
const journeys = await api.searchJourneys(
|
||||
stations[0].extId, // From
|
||||
"dest:extId", // To
|
||||
new Date() // Date
|
||||
);
|
||||
|
||||
// 4. Get a bike route from your current location to the station
|
||||
const bikeRoute = await api.getBikeRoute(
|
||||
48.2082, 16.3738, // From lat/lng
|
||||
48.1850, 16.3780 // To lat/lng
|
||||
);
|
||||
```
|
||||
|
||||
## Building
|
||||
## 🛡️ Testing & Quality Assurance
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
The project provides comprehensive scripts for maintaining code quality:
|
||||
|
||||
* **Linting:** Use `npm run lint` to catch stylistic and structural errors via ESLint 9.
|
||||
* **Type Checking:** Use `npm run typecheck` to ensure strict type safety across the codebase via TypeScript 5.
|
||||
* **Testing:**
|
||||
* The web application uses **Vitest** (v4.1.5) with **jsdom** and **@testing-library/react**.
|
||||
* The mobile application uses **Jest** (v29.7.0) with **jest-expo** and **react-test-renderer**.
|
||||
|
||||
## 📂 File Structure
|
||||
|
||||
```text
|
||||
├── apps/
|
||||
│ ├── mobile/ # Mobile application using React Native and Expo
|
||||
│ └── web/ # Web application using Next.js and Tailwind CSS
|
||||
├── packages/
|
||||
│ ├── api-client/ # API client for HAFAS, Calendar, and Routing proxies
|
||||
│ └── core/ # Shared domain types, countdowns, and HAFAS time utilities
|
||||
├── node_modules/ # Third-party dependencies
|
||||
└── README.md # The file you're reading now
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
---
|
||||
*Built for developers who bike to the train and hate missing their connections.*
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
# Review Agent Rules
|
||||
|
||||
These rules apply when reviewing completed implementation steps on the `rewrite/next` branch.
|
||||
These rules apply when reviewing completed implementation steps on the `main` branch.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
`FEATURES_CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and **required**; blocks the next phase
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; **never blocks phase advancement**
|
||||
|
||||
Rules:
|
||||
- The ✅ column belongs to the rewrite agent; the ✔️ column is yours.
|
||||
- The ✅ column belongs to the implementation agent; the ✔️ column is yours.
|
||||
- Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items.
|
||||
- If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items.
|
||||
- After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`.
|
||||
- Before reviewing any item in a new phase, read `CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
|
||||
- Before reviewing any item in a new phase, read `FEATURES_CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
|
||||
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
|
||||
|
||||
## Review Scope
|
||||
|
||||
- Review one phase at a time. Within a phase, review items in the order they appear in `CHECKLIST.md`.
|
||||
- For each item, cross-reference the implementation against `REWRITE_PLAN.md` and the quality criteria below.
|
||||
- Review one phase at a time. Within a phase, review items in the order they appear in `FEATURES_CHECKLIST.md`.
|
||||
- For each item, cross-reference the implementation against `FEATURES_PLAN.md` and the quality criteria below.
|
||||
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline.
|
||||
|
||||
## What to Check
|
||||
|
||||
**Correctness**
|
||||
- The behavior matches the intent described in `REWRITE_PLAN.md` and the checklist item.
|
||||
- The behavior matches the intent described in `FEATURES_PLAN.md` and the checklist item.
|
||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
||||
- No regressions are introduced in previously working behavior.
|
||||
|
||||
@@ -43,10 +43,11 @@ Rules:
|
||||
- Error handling is explicit and user-facing failures are understandable.
|
||||
- No generated artifacts, caches, logs, or local environment files are committed.
|
||||
- Dependencies are unchanged unless necessary and justified.
|
||||
- Package boundaries are respected — shared types in `packages/core`, API wrappers in `packages/api-client`, app code in `apps/web`.
|
||||
|
||||
**Scope**
|
||||
- The change is scoped to the checklist item — no unrelated modifications.
|
||||
- Old implementation files (`server/`, `oebb-planner-app/`, `oebb-planner.jsx`) were not removed unless parity is tested and cleanup was explicitly requested.
|
||||
- Existing implementation files were not removed unless parity is tested and cleanup was explicitly requested.
|
||||
|
||||
**Accessibility (UI items only)**
|
||||
- Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
@@ -56,21 +57,23 @@ Rules:
|
||||
- Run the relevant test and build checks to confirm the implementation passes before marking ✔️:
|
||||
|
||||
```bash
|
||||
npm run typecheck -w packages/core
|
||||
npm run typecheck -w packages/api-client
|
||||
npm run typecheck -w apps/web
|
||||
npm run lint -w apps/web
|
||||
npm run build -w apps/web
|
||||
npm test
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the rewrite agent to fix.
|
||||
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the implementation agent to fix.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
Before marking a ✔️ box, confirm:
|
||||
|
||||
- The ✅ box for this item is already checked by the rewrite agent.
|
||||
- The ✅ box for this item is already checked by the implementation agent.
|
||||
- All preceding phase items have both ✅ and ✔️ checked.
|
||||
- The implementation matches the intent in `REWRITE_PLAN.md`.
|
||||
- The implementation matches the intent in `FEATURES_PLAN.md`.
|
||||
- Tests exist, are meaningful, and pass.
|
||||
- Build and type checks pass.
|
||||
- No quality issues from the criteria above remain unresolved.
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['expo'],
|
||||
rules: {
|
||||
'react-native/no-inline-styles': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
.claude/
|
||||
.idea/
|
||||
.zed/
|
||||
agent_loop
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import AppNavigator from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
// Request notification permissions on app start
|
||||
Notifications.requestPermissionsAsync();
|
||||
|
||||
// Set up notification handler
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <AppNavigator />;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Time To Leave",
|
||||
"slug": "timetoleave",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#007AFF"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.timetoleave.app",
|
||||
"infoPlist": {
|
||||
"NSLocationWhenInUseUsageDescription": "This app uses your location to find nearby stations and calculate travel times.",
|
||||
"NSUserNotificationUsageDescription": "This app uses notifications to remind you about events."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#007AFF"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.timetoleave.app",
|
||||
"permissions": [
|
||||
"android.permission.ACCESS_FINE_LOCATION",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.INTERNET"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-location",
|
||||
"expo-notifications"
|
||||
],
|
||||
"privacyPolicyUrl": "https://timetoleave.app/privacy-policy"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,58 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
||||
<title id="title">TimeToLeave app icon</title>
|
||||
<desc id="desc">An icon-only TimeToLeave mark: a violet and magenta melting clock on a dark rounded-square background, flowing into a right arrow.</desc>
|
||||
<defs>
|
||||
<radialGradient id="iconBg" cx="50%" cy="36%" r="76%">
|
||||
<stop offset="0%" stop-color="#17112A"/>
|
||||
<stop offset="55%" stop-color="#090816"/>
|
||||
<stop offset="100%" stop-color="#03030A"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="iconMelt" x1="205" y1="170" x2="840" y2="745" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#8B5CF6"/>
|
||||
<stop offset="40%" stop-color="#B23CFF"/>
|
||||
<stop offset="72%" stop-color="#D946EF"/>
|
||||
<stop offset="100%" stop-color="#FF2D8D"/>
|
||||
</linearGradient>
|
||||
<filter id="iconGlow" x="-25%" y="-25%" width="150%" height="150%">
|
||||
<feGaussianBlur stdDeviation="9" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.35 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<style>
|
||||
.iconStroke { stroke: url(#iconMelt); stroke-width: 56; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.iconTick { stroke: #F4F1EA; stroke-width: 16; stroke-linecap: round; opacity: 0.96; }
|
||||
.iconHand { stroke: #F4F1EA; stroke-width: 22; stroke-linecap: round; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" rx="218" fill="url(#iconBg)"/>
|
||||
|
||||
<g filter="url(#iconGlow)">
|
||||
<!-- Simplified app-icon melting clock -->
|
||||
<path class="iconStroke" d="M266 544 C254 478 271 406 316 350 C372 280 456 246 543 256 C644 268 724 349 738 451 C744 495 737 535 731 559 C726 585 738 604 762 612 C790 621 793 582 815 578 C842 574 850 611 836 642 C826 664 811 674 790 671"/>
|
||||
<path class="iconStroke" d="M266 544 C251 589 263 628 303 632 C339 635 324 578 361 578 C397 578 385 653 430 653 C473 653 463 590 508 590 C551 590 548 671 602 698 C671 734 757 712 821 668"/>
|
||||
<path class="iconStroke" d="M430 653 C428 707 429 753 449 753 C470 753 469 708 474 662"/>
|
||||
<path class="iconStroke" d="M821 668 C855 643 890 626 928 615"/>
|
||||
<path d="M897 564 L1010 609 L916 687 L928 636 Z" fill="#FF2D8D"/>
|
||||
</g>
|
||||
|
||||
<!-- Minimal face details for small-size legibility -->
|
||||
<g>
|
||||
<line class="iconTick" x1="512" y1="326" x2="512" y2="354"/>
|
||||
<line class="iconTick" x1="640" y1="381" x2="663" y2="358"/>
|
||||
<line class="iconTick" x1="704" y1="514" x2="737" y2="514"/>
|
||||
<line class="iconTick" x1="512" y1="649" x2="512" y2="680"/>
|
||||
<line class="iconTick" x1="388" y1="640" x2="411" y2="617"/>
|
||||
<line class="iconTick" x1="318" y1="514" x2="351" y2="514"/>
|
||||
<line class="iconTick" x1="388" y1="388" x2="411" y2="411"/>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<line class="iconHand" x1="512" y1="514" x2="512" y2="394"/>
|
||||
<line class="iconHand" x1="512" y1="514" x2="631" y2="590"/>
|
||||
<circle cx="512" cy="514" r="29" fill="#F4F1EA"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "app-bundle",
|
||||
"distribution": "store"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./google-service-account.json",
|
||||
"track": "production"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import js from "@eslint/js";
|
||||
import ts from "typescript-eslint";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// We have no .eslintrc, so we must define everything here.
|
||||
|
||||
export default ts.config(
|
||||
{
|
||||
ignores: ["dist/**"],
|
||||
},
|
||||
{
|
||||
extends: [js.configs.recommended, ts.configs.recommended],
|
||||
files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
// React Native globals
|
||||
__DEV__: "readonly",
|
||||
alert: "readonly",
|
||||
console: "readonly",
|
||||
document: "readonly",
|
||||
navigator: "readonly",
|
||||
window: "readonly",
|
||||
require: "readonly",
|
||||
module: "readonly",
|
||||
exports: "readonly",
|
||||
process: "readonly",
|
||||
jest: "readonly",
|
||||
describe: "readonly",
|
||||
it: "readonly",
|
||||
test: "readonly",
|
||||
expect: "readonly",
|
||||
beforeEach: "readonly",
|
||||
afterEach: "readonly",
|
||||
beforeAll: "readonly",
|
||||
afterAll: "readonly",
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
tsconfigRootDir: path.resolve(__dirname),
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...ts.configs.recommended.rules,
|
||||
// TypeScript handles type-related issues
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
// Allow _-prefixed parameters and variables to signal intentionally unused
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
varsIgnorePattern: "^_",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
varsIgnorePattern: "^_",
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
// React Native uses require() heavily
|
||||
"no-var-requires": "off",
|
||||
// We use React Native's StyleSheet
|
||||
"react/no-unknown-property": [
|
||||
"error",
|
||||
{
|
||||
ignore: ["flex", "justifyContent", "alignItems", "width", "height", "margin", "padding"],
|
||||
},
|
||||
],
|
||||
// Allow console.log for debugging
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@timetoleave/mobile",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"lint": "eslint src/",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^3.0.2",
|
||||
"@react-navigation/native": "^7.2.4",
|
||||
"@react-navigation/native-stack": "^7.14.14",
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"expo": "~54.0.33",
|
||||
"expo-calendar": "^55.0.14",
|
||||
"expo-location": "^55.1.9",
|
||||
"expo-notifications": "^55.0.22",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.2.4",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-screens": "^4.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.0",
|
||||
"react-test-renderer": "19.2.4",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as Calendar from 'expo-calendar';
|
||||
import { ensureCalendarPermission, fetchNativeEvents } from '../services/calendar';
|
||||
|
||||
// Mock expo-calendar
|
||||
jest.mock('expo-calendar', () => ({
|
||||
requestCalendarPermissionsAsync: jest.fn(),
|
||||
isAvailableAsync: jest.fn(),
|
||||
getCalendarsAsync: jest.fn(),
|
||||
getEventsAsync: jest.fn(),
|
||||
EntityTypes: {
|
||||
EVENTS: 'EVENTS',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockCalendar = Calendar as jest.Mocked<typeof Calendar>;
|
||||
|
||||
describe('calendar service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ensureCalendarPermission', () => {
|
||||
it('returns true when permission granted and calendar available', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
|
||||
const result = await ensureCalendarPermission();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when permission denied', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
|
||||
|
||||
const result = await ensureCalendarPermission();
|
||||
expect(result).toBe(false);
|
||||
expect(mockCalendar.isAvailableAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when calendar not available', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(false);
|
||||
|
||||
const result = await ensureCalendarPermission();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNativeEvents', () => {
|
||||
it('returns empty array when no permission', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'denied' as Calendar.PermissionStatus, granted: false, expires: 'never' as const, canAskAgain: true });
|
||||
|
||||
const result = await fetchNativeEvents(new Date(), new Date());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no calendars', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
mockCalendar.getCalendarsAsync.mockResolvedValue([]);
|
||||
|
||||
const result = await fetchNativeEvents(new Date(), new Date());
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns mapped events from native calendar', async () => {
|
||||
const startDate = new Date('2025-01-01');
|
||||
const endDate = new Date('2025-01-31');
|
||||
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
mockCalendar.getCalendarsAsync.mockResolvedValue([
|
||||
{ id: 'cal1', title: 'Work' },
|
||||
{ id: 'cal2', title: 'Personal' },
|
||||
] as Calendar.Calendar[]);
|
||||
mockCalendar.getEventsAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'evt1',
|
||||
calendarId: 'cal1',
|
||||
title: 'Team Meeting',
|
||||
location: 'Berlin',
|
||||
startDate: new Date('2025-01-15T10:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'evt2',
|
||||
calendarId: 'cal2',
|
||||
title: 'Dentist',
|
||||
location: null,
|
||||
startDate: new Date('2025-01-20T14:00:00'),
|
||||
},
|
||||
] as Calendar.Event[]);
|
||||
|
||||
const result = await fetchNativeEvents(startDate, endDate);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
id: 'evt1',
|
||||
title: 'Team Meeting',
|
||||
destination: 'Berlin',
|
||||
eventTime: new Date('2025-01-15T10:00:00'),
|
||||
source: 'native:cal1',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: 'evt2',
|
||||
title: 'Dentist',
|
||||
destination: '',
|
||||
eventTime: new Date('2025-01-20T14:00:00'),
|
||||
source: 'native:cal2',
|
||||
});
|
||||
|
||||
expect(mockCalendar.getEventsAsync).toHaveBeenCalledWith(
|
||||
['cal1', 'cal2'],
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles events with missing title or startDate', async () => {
|
||||
mockCalendar.requestCalendarPermissionsAsync.mockResolvedValue({ status: 'granted' as Calendar.PermissionStatus, granted: true, expires: 'never' as const, canAskAgain: true });
|
||||
mockCalendar.isAvailableAsync.mockResolvedValue(true);
|
||||
mockCalendar.getCalendarsAsync.mockResolvedValue([{ id: 'cal1', title: 'Default' }] as Calendar.Calendar[]);
|
||||
mockCalendar.getEventsAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'evt1',
|
||||
calendarId: 'cal1',
|
||||
title: null as unknown as string,
|
||||
location: null,
|
||||
startDate: null as unknown as string | Date,
|
||||
},
|
||||
] as Calendar.Event[]);
|
||||
|
||||
const result = await fetchNativeEvents(new Date(), new Date());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Untitled Event');
|
||||
expect(result[0].destination).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Tests for core utilities
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
|
||||
describe('core utilities', () => {
|
||||
describe('calculateCountdown', () => {
|
||||
beforeEach(() => {
|
||||
// Mock Date for consistent tests
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2025-01-01T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return urgent status for past events', () => {
|
||||
const targetDate = new Date('2025-01-01T11:00:00Z');
|
||||
const result = calculateCountdown(targetDate);
|
||||
|
||||
expect(result.label).toBe('Now');
|
||||
expect(result.color).toBe('red');
|
||||
expect(result.urgent).toBe(true);
|
||||
});
|
||||
|
||||
it('should return urgent status for events within 10 minutes', () => {
|
||||
const targetDate = new Date('2025-01-01T12:05:00Z');
|
||||
const result = calculateCountdown(targetDate);
|
||||
|
||||
expect(result.label).toBe('5min');
|
||||
expect(result.color).toBe('orange');
|
||||
expect(result.urgent).toBe(true);
|
||||
});
|
||||
|
||||
it('should return yellow for events within 30 minutes', () => {
|
||||
const targetDate = new Date('2025-01-01T12:20:00Z');
|
||||
const result = calculateCountdown(targetDate);
|
||||
|
||||
expect(result.label).toBe('20min');
|
||||
expect(result.color).toBe('yellow');
|
||||
expect(result.urgent).toBe(false);
|
||||
});
|
||||
|
||||
it('should return green for events within 60 minutes', () => {
|
||||
const targetDate = new Date('2025-01-01T12:45:00Z');
|
||||
const result = calculateCountdown(targetDate);
|
||||
|
||||
expect(result.label).toBe('45min');
|
||||
expect(result.color).toBe('green');
|
||||
expect(result.urgent).toBe(false);
|
||||
});
|
||||
|
||||
it('should return hours and minutes for events more than 1 hour away', () => {
|
||||
const targetDate = new Date('2025-01-01T15:30:00Z');
|
||||
const result = calculateCountdown(targetDate);
|
||||
|
||||
expect(result.label).toBe('3h 30min');
|
||||
expect(result.color).toBe('blue');
|
||||
expect(result.urgent).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle exact boundaries correctly', () => {
|
||||
// Exactly 10 minutes
|
||||
let targetDate = new Date('2025-01-01T12:10:00Z');
|
||||
let result = calculateCountdown(targetDate);
|
||||
expect(result.urgent).toBe(true);
|
||||
|
||||
// Exactly 30 minutes
|
||||
targetDate = new Date('2025-01-01T12:30:00Z');
|
||||
result = calculateCountdown(targetDate);
|
||||
expect(result.urgent).toBe(false);
|
||||
expect(result.color).toBe('yellow');
|
||||
|
||||
// Exactly 60 minutes
|
||||
targetDate = new Date('2025-01-01T13:00:00Z');
|
||||
result = calculateCountdown(targetDate);
|
||||
expect(result.label).toBe('60min');
|
||||
expect(result.color).toBe('green');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
// Tests for event store persistence and behavior
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
loadEvents,
|
||||
saveEvents,
|
||||
addEvent,
|
||||
removeEvent,
|
||||
loadOriginStation,
|
||||
saveOriginStation,
|
||||
loadNotificationSettings,
|
||||
saveNotificationSettings,
|
||||
rescheduleAllNotifications
|
||||
} from '../store/eventStore';
|
||||
import { calculateLeaveByTime } from '../services/notifications';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
// Mock AsyncStorage
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock expo-notifications
|
||||
jest.mock('expo-notifications', () => ({
|
||||
getAllScheduledNotificationsAsync: jest.fn(),
|
||||
cancelScheduledNotificationAsync: jest.fn(),
|
||||
cancelAllScheduledNotificationsAsync: jest.fn(),
|
||||
scheduleNotificationAsync: jest.fn(),
|
||||
SchedulableTriggerInputTypes: {
|
||||
DATE: 'date',
|
||||
},
|
||||
setNotificationHandler: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock calculateLeaveByTime
|
||||
jest.mock('../services/notifications', () => ({
|
||||
...jest.requireActual('../services/notifications'),
|
||||
calculateLeaveByTime: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('eventStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
it('should load empty events when none exist', async () => {
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const events = await loadEvents();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load events from AsyncStorage', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockEvents));
|
||||
|
||||
const events = await loadEvents();
|
||||
expect(events).toEqual(mockEvents);
|
||||
expect(events[0].eventTime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should save events to AsyncStorage', async () => {
|
||||
const events = [
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
|
||||
await saveEvents(events);
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'@timetoleave_events',
|
||||
JSON.stringify(events)
|
||||
);
|
||||
});
|
||||
|
||||
it('should add event and schedule notification', async () => {
|
||||
const event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue('[]');
|
||||
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
|
||||
|
||||
await addEvent(event);
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'@timetoleave_events',
|
||||
JSON.stringify([event])
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove event and cancel notifications', async () => {
|
||||
const event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify([event]));
|
||||
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
identifier: 'notif-1',
|
||||
content: { data: { eventId: 'test-1' } }
|
||||
}
|
||||
]);
|
||||
|
||||
await removeEvent('test-1');
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'@timetoleave_events',
|
||||
'[]'
|
||||
);
|
||||
expect(Notifications.cancelScheduledNotificationAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin station', () => {
|
||||
it('should load null when no origin exists', async () => {
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const station = await loadOriginStation();
|
||||
expect(station).toBeNull();
|
||||
});
|
||||
|
||||
it('should load origin station from AsyncStorage', async () => {
|
||||
const mockStation = {
|
||||
extId: 'station-1',
|
||||
name: 'Test Station',
|
||||
lat: 48.2,
|
||||
lng: 16.3,
|
||||
};
|
||||
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockStation));
|
||||
|
||||
const station = await loadOriginStation();
|
||||
expect(station).toEqual(mockStation);
|
||||
});
|
||||
|
||||
it('should save origin station to AsyncStorage', async () => {
|
||||
const station = {
|
||||
extId: 'station-1',
|
||||
name: 'Test Station',
|
||||
lat: 48.2,
|
||||
lng: 16.3,
|
||||
};
|
||||
|
||||
await saveOriginStation(station);
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'@timetoleave_origin',
|
||||
JSON.stringify(station)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification settings', () => {
|
||||
it('should load default settings when none exist', async () => {
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const settings = await loadNotificationSettings();
|
||||
expect(settings).toEqual({
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load notification settings from AsyncStorage', async () => {
|
||||
const mockSettings = {
|
||||
bufferMinutes: 45,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(mockSettings));
|
||||
|
||||
const settings = await loadNotificationSettings();
|
||||
expect(settings).toEqual(mockSettings);
|
||||
});
|
||||
|
||||
it('should save notification settings to AsyncStorage', async () => {
|
||||
const settings = {
|
||||
bufferMinutes: 45,
|
||||
enabled: false,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
};
|
||||
|
||||
await saveNotificationSettings(settings);
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'@timetoleave_notifications',
|
||||
JSON.stringify(settings)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rescheduleAllNotifications', () => {
|
||||
it('should cancel all existing notifications and schedule new ones', async () => {
|
||||
(AsyncStorage.getItem as jest.Mock)
|
||||
.mockResolvedValueOnce(JSON.stringify([])) // events
|
||||
.mockResolvedValueOnce(JSON.stringify({ bufferMinutes: 30, enabled: true })); // settings
|
||||
|
||||
(Notifications.getAllScheduledNotificationsAsync as jest.Mock).mockResolvedValue([]);
|
||||
(calculateLeaveByTime as jest.Mock).mockResolvedValue(new Date('2025-01-01T09:30:00Z'));
|
||||
|
||||
await rescheduleAllNotifications();
|
||||
|
||||
expect(Notifications.cancelAllScheduledNotificationsAsync).toHaveBeenCalled();
|
||||
// We can't easily verify scheduling due to complex mocks, but we can check it was called
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// Tests for notification service
|
||||
// Mock expo-notifications before importing
|
||||
jest.mock('expo-notifications', () => ({
|
||||
setNotificationHandler: jest.fn(),
|
||||
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||
scheduleNotificationAsync: jest.fn().mockResolvedValue({ identifier: 'mock-id' }),
|
||||
cancelScheduledNotificationAsync: jest.fn().mockResolvedValue(undefined),
|
||||
getAllScheduledNotificationsAsync: jest.fn().mockResolvedValue([]),
|
||||
cancelAllScheduledNotificationsAsync: jest.fn().mockResolvedValue(undefined),
|
||||
SchedulableTriggerInputTypes: {
|
||||
DATE: 'date',
|
||||
CALENDAR: 'calendar',
|
||||
DAILY: 'daily',
|
||||
WEEKLY: 'weekly',
|
||||
MONTHLY: 'monthly',
|
||||
YEARLY: 'yearly',
|
||||
TIME_INTERVAL: 'timeInterval',
|
||||
},
|
||||
}));
|
||||
|
||||
import { calculateLeaveByTime } from '../services/notifications';
|
||||
import type { Event, Journey } from '@timetoleave/core';
|
||||
|
||||
describe('notifications service', () => {
|
||||
describe('calculateLeaveByTime', () => {
|
||||
it('should calculate leave-by time from event time minus arrival buffer minus reminder buffer', () => {
|
||||
const event: Event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const leaveByTime = calculateLeaveByTime(event, [], 30, 5);
|
||||
|
||||
// Leave-by time should be 30 minutes before event time
|
||||
// (arrival buffer) minus 5 minutes reminder buffer
|
||||
// = event time - 35 minutes total
|
||||
const expectedTime = new Date('2025-01-01T09:25:00Z');
|
||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||
});
|
||||
|
||||
it('should use earliest journey departure time if available', () => {
|
||||
const event: Event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const journeys: Journey[] = [
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2025-01-01T08:00:00Z'),
|
||||
rD: new Date('2025-01-01T08:00:00Z'),
|
||||
sA: new Date('2025-01-01T09:00:00Z'),
|
||||
rA: new Date('2025-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1'],
|
||||
cancelled: false,
|
||||
},
|
||||
{
|
||||
id: 'journey-2',
|
||||
sD: new Date('2025-01-01T07:00:00Z'),
|
||||
rD: new Date('2025-01-01T07:00:00Z'),
|
||||
sA: new Date('2025-01-01T08:00:00Z'),
|
||||
rA: new Date('2025-01-01T08:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '2',
|
||||
changes: 1,
|
||||
trains: ['U3', 'S2'],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||
|
||||
// Should use earliest non-cancelled journey (journey-2 at 07:00)
|
||||
// Leave-by time = journey departure (07:00) - reminder buffer (5 min)
|
||||
const expectedTime = new Date('2025-01-01T06:55:00Z');
|
||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||
});
|
||||
|
||||
it('should skip cancelled journeys', () => {
|
||||
const event: Event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const journeys: Journey[] = [
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2025-01-01T08:00:00Z'),
|
||||
rD: new Date('2025-01-01T08:00:00Z'),
|
||||
sA: new Date('2025-01-01T09:00:00Z'),
|
||||
rA: new Date('2025-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1'],
|
||||
cancelled: true,
|
||||
},
|
||||
{
|
||||
id: 'journey-2',
|
||||
sD: new Date('2025-01-01T07:00:00Z'),
|
||||
rD: new Date('2025-01-01T07:00:00Z'),
|
||||
sA: new Date('2025-01-01T08:00:00Z'),
|
||||
rA: new Date('2025-01-01T08:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '2',
|
||||
changes: 1,
|
||||
trains: ['U3', 'S2'],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||
|
||||
// Should use journey-2 since journey-1 is cancelled
|
||||
// Leave-by time = journey departure (07:00) - reminder buffer (5 min)
|
||||
const expectedTime = new Date('2025-01-01T06:55:00Z');
|
||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||
});
|
||||
|
||||
it('should fall back to event time minus buffer when all journeys cancelled', () => {
|
||||
const event: Event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const journeys: Journey[] = [
|
||||
{
|
||||
id: 'journey-1',
|
||||
sD: new Date('2025-01-01T08:00:00Z'),
|
||||
rD: new Date('2025-01-01T08:00:00Z'),
|
||||
sA: new Date('2025-01-01T09:00:00Z'),
|
||||
rA: new Date('2025-01-01T09:00:00Z'),
|
||||
delay: 0,
|
||||
platform: '1',
|
||||
changes: 0,
|
||||
trains: ['S1'],
|
||||
cancelled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const leaveByTime = calculateLeaveByTime(event, journeys, 30, 5);
|
||||
|
||||
// All journeys cancelled, fall back to event time minus arrival buffer minus reminder buffer
|
||||
// = 10:00 - 30 min - 5 min = 09:25
|
||||
const expectedTime = new Date('2025-01-01T09:25:00Z');
|
||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||
});
|
||||
|
||||
it('should handle zero buffer correctly', () => {
|
||||
const event: Event = {
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
const leaveByTime = calculateLeaveByTime(event, [], 30, 0);
|
||||
|
||||
// With zero reminder buffer, leave-by time = event time - arrival buffer
|
||||
// = 10:00 AM - 30 minutes = 09:30 AM
|
||||
const expectedTime = new Date('2025-01-01T09:30:00Z');
|
||||
expect(leaveByTime.getTime()).toBe(expectedTime.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// Tests for UI screens
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { loadEvents } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
|
||||
// Mock the store and utilities
|
||||
jest.mock('../store/eventStore', () => ({
|
||||
loadEvents: jest.fn(),
|
||||
removeEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@timetoleave/core', () => ({
|
||||
...jest.requireActual('@timetoleave/core'),
|
||||
calculateCountdown: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useFocusEffect so EventListScreen can render without NavigationContainer
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
...jest.requireActual('@react-navigation/native'),
|
||||
useFocusEffect: (callback: () => void) => {
|
||||
// Execute the callback immediately so the component loads data
|
||||
callback();
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Mock navigation factories ---
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
function createMockNavigationProp<
|
||||
S extends Record<string, undefined | Record<string, unknown>>,
|
||||
T extends keyof S
|
||||
>(overrides?: Partial<NativeStackNavigationProp<S, T>>): NativeStackNavigationProp<S, T> {
|
||||
const mocks: Partial<NativeStackNavigationProp<S, T>> = {
|
||||
navigate: jest.fn(),
|
||||
dispatch: jest.fn(() => {}),
|
||||
goBack: jest.fn(),
|
||||
isFocused: jest.fn(() => true),
|
||||
setParams: jest.fn(),
|
||||
setOptions: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
pop: jest.fn(),
|
||||
preload: jest.fn(),
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
canGoBack: jest.fn(() => false),
|
||||
...overrides,
|
||||
};
|
||||
return mocks as NativeStackNavigationProp<S, T>;
|
||||
}
|
||||
|
||||
const mockRouteEventList = { name: 'EventList' as const, params: undefined } as unknown as RouteProp<RootStack, 'EventList'>;
|
||||
const mockRouteAddEvent = { name: 'AddEvent' as const, params: undefined } as unknown as RouteProp<RootStack, 'AddEvent'>;
|
||||
|
||||
// --- End mock factories ---
|
||||
|
||||
describe('EventListScreen', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render empty state when no events', async () => {
|
||||
(loadEvents as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const { getByText } = render(
|
||||
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Keine Termine')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render events when they exist', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
|
||||
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
|
||||
(calculateCountdown as jest.Mock).mockReturnValue({
|
||||
label: '30min',
|
||||
color: 'green',
|
||||
urgent: false
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Event')).toBeTruthy();
|
||||
expect(getByText('Test Destination')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle refresh correctly', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Test Event',
|
||||
destination: 'Test Destination',
|
||||
eventTime: new Date('2025-01-01T10:00:00Z'),
|
||||
source: 'manual',
|
||||
}
|
||||
];
|
||||
|
||||
(loadEvents as jest.Mock).mockResolvedValue(mockEvents);
|
||||
(calculateCountdown as jest.Mock).mockReturnValue({
|
||||
label: '30min',
|
||||
color: 'green',
|
||||
urgent: false
|
||||
});
|
||||
|
||||
const { getByText } = render(
|
||||
<EventListScreen navigation={createMockNavigationProp<RootStack, 'EventList'>()} route={mockRouteEventList} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Event')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddEventScreen', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render form correctly', () => {
|
||||
const { getByPlaceholderText, getByText } = render(
|
||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||
);
|
||||
|
||||
expect(getByPlaceholderText('z.B. Team Meeting')).toBeTruthy();
|
||||
expect(getByPlaceholderText('z.B. Wien, Donau-City')).toBeTruthy();
|
||||
expect(getByPlaceholderText('JJJJ-MM-TT')).toBeTruthy();
|
||||
expect(getByPlaceholderText('SS:MM')).toBeTruthy();
|
||||
expect(getByText('Speichern')).toBeTruthy();
|
||||
expect(getByText('Abbrechen')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show validation errors', () => {
|
||||
const { getByText } = render(
|
||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||
);
|
||||
|
||||
// Try to save without filling form
|
||||
const saveButton = getByText('Speichern');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
// Should show error text
|
||||
expect(getByText('Titel erforderlich')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate date format', () => {
|
||||
const { getByPlaceholderText, getByText } = render(
|
||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||
);
|
||||
|
||||
// Fill in all required fields except date format is invalid
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), 'invalid-date');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
|
||||
const saveButton = getByText('Speichern');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
expect(getByText('Ungültiges Datum')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should validate future date', () => {
|
||||
const { getByPlaceholderText, getByText } = render(
|
||||
<AddEventScreen navigation={createMockNavigationProp<RootStack, 'AddEvent'>()} route={mockRouteAddEvent} />
|
||||
);
|
||||
|
||||
// Fill in all required fields with a past date
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Team Meeting'), 'Meeting');
|
||||
fireEvent.changeText(getByPlaceholderText('z.B. Wien, Donau-City'), 'Wien');
|
||||
fireEvent.changeText(getByPlaceholderText('JJJJ-MM-TT'), '2020-01-01');
|
||||
fireEvent.changeText(getByPlaceholderText('SS:MM'), '12:00');
|
||||
|
||||
const saveButton = getByText('Speichern');
|
||||
fireEvent.press(saveButton);
|
||||
|
||||
expect(getByText('Datum muss in der Zukunft liegen')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { EventListScreen } from '../screens/EventListScreen';
|
||||
import { EventDetailScreen } from '../screens/EventDetailScreen';
|
||||
import { AddEventScreen } from '../screens/AddEventScreen';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||
import { CalendarImportScreen } from '../screens/CalendarImportScreen';
|
||||
|
||||
// ── Root Stack ────────────────────────────────────────
|
||||
|
||||
const RootStack = createNativeStackNavigator<{
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
}>();
|
||||
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: '#f2f2f7' }}>
|
||||
<StatusBar style="auto" />
|
||||
<NavigationContainer>
|
||||
<RootStack.Navigator
|
||||
initialRouteName="EventList"
|
||||
screenOptions={{ headerStyle: { backgroundColor: '#007AFF' }, headerTintColor: '#fff' }}
|
||||
>
|
||||
<RootStack.Screen name="EventList" component={EventListScreen} options={{ title: 'Time To Leave' }} />
|
||||
<RootStack.Screen name="AddEvent" component={AddEventScreen} options={{ title: 'Add Event' }} />
|
||||
<RootStack.Screen name="EventDetail" component={EventDetailScreen} options={{ title: 'Event Details' }} />
|
||||
<RootStack.Screen name="Settings" component={SettingsScreen} options={{ title: 'Settings' }} />
|
||||
<RootStack.Screen name="CalendarImport" component={CalendarImportScreen} options={{ title: 'Import Calendar' }} />
|
||||
</RootStack.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { addEvent } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'AddEvent'>;
|
||||
route: RouteProp<RootStack, 'AddEvent'>;
|
||||
};
|
||||
|
||||
export function AddEventScreen({ navigation }: ScreenProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [dateStr, setDateStr] = useState('');
|
||||
const [timeStr, setTimeStr] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const validate = (): boolean => {
|
||||
if (!title.trim()) { setError('Titel erforderlich'); return false; }
|
||||
if (!destination.trim()) { setError('Ziel erforderlich'); return false; }
|
||||
if (!dateStr || !timeStr) { setError('Datum und Zeit erforderlich'); return false; }
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
if (isNaN(eventTime.getTime())) { setError('Ungültiges Datum'); return false; }
|
||||
if (eventTime <= new Date()) { setError('Datum muss in der Zukunft liegen'); return false; }
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const eventTime = new Date(`${dateStr}T${timeStr}`);
|
||||
const event: CalendarEvent = {
|
||||
id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: title.trim(),
|
||||
destination: destination.trim(),
|
||||
eventTime,
|
||||
source: 'manual',
|
||||
};
|
||||
|
||||
await addEvent(event);
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>Titel</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Team Meeting"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Ziel</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="z.B. Wien, Donau-City"
|
||||
value={destination}
|
||||
onChangeText={setDestination}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Datum</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="JJJJ-MM-TT"
|
||||
value={dateStr}
|
||||
onChangeText={setDateStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Zeit</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="SS:MM"
|
||||
value={timeStr}
|
||||
onChangeText={setTimeStr}
|
||||
keyboardType="numbers-and-punctuation"
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>Speichern</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, styles.cancelBtn]}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={[styles.saveBtnText, styles.cancelText]}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
form: { padding: 20 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#1c1c1e', marginBottom: 6 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
errorText: { color: '#FF3B30', fontSize: 14, marginBottom: 8 },
|
||||
saveBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
cancelBtn: { marginTop: 12, backgroundColor: '#e5e5ea' },
|
||||
cancelText: { color: '#1c1c1e' },
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { api } from '../services/api';
|
||||
import { fetchNativeEvents } from '../services/calendar';
|
||||
import { addEvent, loadEvents } from '../store/eventStore';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'CalendarImport'>;
|
||||
route: RouteProp<RootStack, 'CalendarImport'>;
|
||||
};
|
||||
|
||||
export function CalendarImportScreen({ navigation }: ScreenProps) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte ICS-URL eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
const events = await api.fetchCalendar(url.trim());
|
||||
// Add imported events to local store
|
||||
for (const evt of events) {
|
||||
const localEvent: CalendarEvent = {
|
||||
id: evt.id,
|
||||
title: evt.title,
|
||||
destination: evt.destination,
|
||||
eventTime: new Date(evt.eventTime),
|
||||
source: `calendar:${url.trim().slice(0, 40)}`,
|
||||
};
|
||||
await addEvent(localEvent);
|
||||
}
|
||||
setCount(events.length);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncNative = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCount(null);
|
||||
|
||||
try {
|
||||
// Fetch events from the next 30 days
|
||||
const now = new Date();
|
||||
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater);
|
||||
|
||||
// Load existing events to avoid duplicates
|
||||
const existing = await loadEvents();
|
||||
const existingIds = new Set(existing.map((e) => e.id));
|
||||
|
||||
let added = 0;
|
||||
for (const evt of nativeEvents) {
|
||||
if (!existingIds.has(evt.id)) {
|
||||
await addEvent(evt);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
setCount(added);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sync fehlgeschlagen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.heading}>Kalender-Import</Text>
|
||||
<Text style={styles.description}>
|
||||
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
|
||||
</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>ICS-URL Import</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.importBtn, loading && styles.importBtnDisabled]}
|
||||
onPress={handleImport}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.importBtnText}>ICS Importieren</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Geräte-Kalender Sync</Text>
|
||||
<Text style={styles.sectionDesc}>
|
||||
Hole Termine der nächsten 30 Tage aus den kalendern auf deinem Gerät.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.importBtn, styles.nativeBtn, loading && styles.importBtnDisabled]}
|
||||
onPress={handleSyncNative}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.importBtnText}>📅 Kalender Sync</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{count !== null && (
|
||||
<View style={styles.successBanner}>
|
||||
<Text style={styles.successText}>
|
||||
✓ {count} Termin(e) erfolgreich importiert!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backBtn}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Text style={styles.backBtnText}>← Zurück</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
content: { padding: 20 },
|
||||
heading: { fontSize: 22, fontWeight: '700', color: '#1c1c1e', marginBottom: 4 },
|
||||
description: { fontSize: 14, color: '#8e8e93', marginBottom: 20, lineHeight: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '600', color: '#1c1c1e', marginBottom: 8 },
|
||||
sectionDesc: { fontSize: 13, color: '#8e8e93', marginBottom: 12, lineHeight: 18 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorBanner: { backgroundColor: '#FF3B30', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
errorText: { color: '#fff', fontSize: 14 },
|
||||
successBanner: { backgroundColor: '#34C759', borderRadius: 8, padding: 12, marginBottom: 12 },
|
||||
successText: { color: '#fff', fontSize: 14 },
|
||||
importBtn: {
|
||||
backgroundColor: '#007AFF',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
nativeBtn: {
|
||||
backgroundColor: '#5856D6',
|
||||
},
|
||||
importBtnDisabled: { opacity: 0.6 },
|
||||
importBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
backBtn: {
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
backBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, loadOriginStation } from '../store/eventStore';
|
||||
import { api } from '../services/api';
|
||||
import { formatDuration, formatDistance } from '@timetoleave/core';
|
||||
import type { Journey, BikeRoute, Station, Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventDetail'>;
|
||||
route: RouteProp<RootStack, 'EventDetail'>;
|
||||
};
|
||||
|
||||
export function EventDetailScreen({ navigation, route }: ScreenProps) {
|
||||
const { eventId } = route.params;
|
||||
|
||||
const [event, setEvent] = useState<CalendarEvent | null>(null);
|
||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||
const [bikeRoute, setBikeRoute] = useState<BikeRoute | null>(null);
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingBike, setLoadingBike] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [events, originStation] = await Promise.all([loadEvents(), loadOriginStation()]);
|
||||
setOrigin(originStation);
|
||||
const found = events.find((e) => e.id === eventId);
|
||||
if (!found) {
|
||||
setError('Termin nicht gefunden');
|
||||
return;
|
||||
}
|
||||
setEvent(found);
|
||||
|
||||
if (originStation) {
|
||||
const results = await api.searchJourneys(
|
||||
originStation.extId,
|
||||
found.destination,
|
||||
found.eventTime,
|
||||
);
|
||||
setJourneys(results);
|
||||
|
||||
// Fetch bike route if we have a destination station
|
||||
// We need destination coordinates; for MVP we geocode the destination name
|
||||
try {
|
||||
setLoadingBike(true);
|
||||
const geo = await api.geocode(found.destination);
|
||||
if (geo.length > 0 && originStation.lat && originStation.lng) {
|
||||
const bike = await api.getBikeRoute(
|
||||
originStation.lat,
|
||||
originStation.lng,
|
||||
geo[0].lat,
|
||||
geo[0].lng,
|
||||
);
|
||||
setBikeRoute(bike);
|
||||
}
|
||||
} catch {
|
||||
// Bike route is optional — don't fail the whole screen
|
||||
setBikeRoute(null);
|
||||
} finally {
|
||||
setLoadingBike(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setBikeRoute(null);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Termine werden geladen…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Event header */}
|
||||
{event && (
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||
<Text style={styles.eventDest}>{event.destination}</Text>
|
||||
<Text style={styles.eventTime}>
|
||||
{event.eventTime.toLocaleString('de-AT', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.source}>Quelle: {event.source}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorBannerText}>⚠ {error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Origin status */}
|
||||
{!origin && !error && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Text style={styles.warningBannerText}>
|
||||
Keine Ursprungstation festgelegt.
|
||||
{' '}
|
||||
<Text style={styles.warningLink} onPress={() => navigation.navigate('Settings')}>
|
||||
Einstellungen öffnen
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Journeys list */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Zugverbindungen</Text>
|
||||
{journeys.length === 0 ? (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Verbindungen gefunden' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
) : (
|
||||
journeys.map((j) => (
|
||||
<View key={j.id} style={styles.journeyCard}>
|
||||
<View style={styles.journeyRow}>
|
||||
<Text style={styles.lineText}>
|
||||
{j.trains.length > 0 ? j.trains.join(', ') : '—'}
|
||||
</Text>
|
||||
{j.delay > 0 && <Text style={styles.delayBadge}>+{j.delay} min</Text>}
|
||||
{j.cancelled && <Text style={styles.cancelBadge}>Storniert</Text>}
|
||||
</View>
|
||||
<Text style={styles.departure}>
|
||||
Abfahrt: {new Date(j.sD).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
(Plattform {j.platform || '—'})
|
||||
</Text>
|
||||
<Text style={styles.arrival}>
|
||||
Ankunft: {new Date(j.sA).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}
|
||||
{' '}
|
||||
({j.changes === 0 ? 'Direkt' : `${j.changes} Umst.`})
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bike route section */}
|
||||
<View style={styles.journeys}>
|
||||
<Text style={styles.sectionTitle}>Radroute</Text>
|
||||
{loadingBike ? (
|
||||
<View style={styles.centerBike}>
|
||||
<ActivityIndicator size="small" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Radroute wird geladen…</Text>
|
||||
</View>
|
||||
) : bikeRoute ? (
|
||||
<View style={styles.bikeCard}>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>⏱ Dauer</Text>
|
||||
<Text style={styles.bikeValue}>{formatDuration(bikeRoute.duration)}</Text>
|
||||
</View>
|
||||
<View style={styles.bikeRow}>
|
||||
<Text style={styles.bikeLabel}>📏 Distanz</Text>
|
||||
<Text style={styles.bikeValue}>{formatDistance(bikeRoute.distance)}</Text>
|
||||
</View>
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>🗺 Karte (post-MVP)</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
{origin ? 'Keine Radroute verfügbar' : 'Ursprungstation festlegen'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Refresh */}
|
||||
<TouchableOpacity style={styles.refreshBtn} onPress={handleRefresh}>
|
||||
<Text style={styles.refreshBtnText}>🔄 Neu laden</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
loadingText: { color: '#8e8e93', marginTop: 12, fontSize: 15 },
|
||||
header: { padding: 20, backgroundColor: '#fff', marginBottom: 12 },
|
||||
eventTitle: { fontSize: 22, fontWeight: '700', color: '#1c1c1e' },
|
||||
eventDest: { fontSize: 16, color: '#8e8e93', marginTop: 4 },
|
||||
eventTime: { fontSize: 14, color: '#007AFF', marginTop: 8 },
|
||||
source: { fontSize: 12, color: '#8e8e93', marginTop: 4 },
|
||||
errorBanner: { backgroundColor: '#FF3B30', padding: 12, marginBottom: 12 },
|
||||
errorBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningBanner: { backgroundColor: '#FF9500', padding: 12, marginBottom: 12 },
|
||||
warningBannerText: { color: '#fff', fontSize: 14 },
|
||||
warningLink: { color: '#fff', fontWeight: '700', textDecorationLine: 'underline' },
|
||||
journeys: { padding: 20 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 12 },
|
||||
emptyText: { color: '#8e8e93', fontSize: 14 },
|
||||
journeyCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
journeyRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
lineText: { fontSize: 16, fontWeight: '600', color: '#1c1c1e' },
|
||||
delayBadge: { backgroundColor: '#FF3B30', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
cancelBadge: { backgroundColor: '#000', color: '#fff', fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6 },
|
||||
departure: { fontSize: 13, color: '#1c1c1e', marginTop: 6 },
|
||||
arrival: { fontSize: 13, color: '#8e8e93', marginTop: 2 },
|
||||
centerBike: { alignItems: 'center', gap: 8 },
|
||||
bikeCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
bikeRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 6 },
|
||||
bikeLabel: { fontSize: 15, color: '#1c1c1e', fontWeight: '500' },
|
||||
bikeValue: { fontSize: 15, color: '#007AFF', fontWeight: '600' },
|
||||
mapPlaceholder: {
|
||||
marginTop: 10,
|
||||
height: 100,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#f2f2f7',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#c7c7cc',
|
||||
},
|
||||
mapPlaceholderText: { fontSize: 14, color: '#8e8e93' },
|
||||
refreshBtn: {
|
||||
alignSelf: 'center',
|
||||
marginTop: 20,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: '#e5e5ea',
|
||||
borderRadius: 12,
|
||||
},
|
||||
refreshBtnText: { fontSize: 15, color: '#1c1c1e', fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { loadEvents, removeEvent } from '../store/eventStore';
|
||||
import { calculateCountdown } from '@timetoleave/core';
|
||||
import type { Event as CalendarEvent } from '@timetoleave/core';
|
||||
|
||||
// Color map for countdown urgency
|
||||
const urgencyColor = (urgent: boolean): string => {
|
||||
if (urgent) return '#FF3B30';
|
||||
return '#34C759';
|
||||
};
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'EventList'>;
|
||||
route: RouteProp<RootStack, 'EventList'>;
|
||||
};
|
||||
|
||||
export function EventListScreen({ navigation }: ScreenProps) {
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await loadEvents();
|
||||
setEvents(list);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
useFocusEffect(
|
||||
useCallback(() => { reload(); }, [reload]),
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await reload();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: CalendarEvent }) => {
|
||||
const countdown = calculateCountdown(item.eventTime);
|
||||
|
||||
// Derive a simple status — journeys aren't loaded on the list screen for MVP
|
||||
// so we show countdown-based status instead
|
||||
const status = countdown.urgent ? 'Bald!' : countdown.label;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('EventDetail', { eventId: item.id })}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.dotRow}>
|
||||
<View style={[styles.dot, { backgroundColor: urgencyColor(countdown.urgent) }]} />
|
||||
<Text style={styles.title}>{item.title}</Text>
|
||||
<Text style={[styles.badge, { color: countdown.color === 'red' || countdown.color === 'orange' ? '#FF3B30' : '#007AFF' }]}>
|
||||
{countdown.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.subtitle}>{item.destination}</Text>
|
||||
<Text style={styles.time}>
|
||||
{item.eventTime.toLocaleString('de-AT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.status}>{status}</Text>
|
||||
<TouchableOpacity onPress={() => removeEvent(item.id, reload)} style={styles.deleteBtn}>
|
||||
<Text style={styles.deleteText}>Entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.empty}>Keine Termine</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addBtn}
|
||||
onPress={() => navigation.navigate('AddEvent')}
|
||||
>
|
||||
<Text style={styles.addBtnText}>+ Termin hinzufügen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('CalendarImport')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>📅 Kalender</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
style={styles.topBtn}
|
||||
>
|
||||
<Text style={styles.topBtnText}>⚙️ Einstellungen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={events}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#007AFF" />
|
||||
}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.fab}
|
||||
onPress={() => navigation.navigate('AddEvent')}
|
||||
>
|
||||
<Text style={styles.fabText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7' },
|
||||
topBar: { flexDirection: 'row', justifyContent: 'flex-end', padding: 8, gap: 8 },
|
||||
topBtn: { paddingHorizontal: 12, paddingVertical: 6 },
|
||||
topBtnText: { color: '#007AFF', fontSize: 15 },
|
||||
list: { padding: 12 },
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
dotRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 4 },
|
||||
dot: { width: 10, height: 10, borderRadius: 5 },
|
||||
title: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', flex: 1 },
|
||||
badge: { fontSize: 12, fontWeight: '600' },
|
||||
subtitle: { fontSize: 14, color: '#8e8e93', marginBottom: 4 },
|
||||
time: { fontSize: 13, color: '#007AFF' },
|
||||
status: { fontSize: 13, color: '#34C759', marginTop: 2, fontWeight: '500' },
|
||||
deleteBtn: { alignSelf: 'flex-start', marginTop: 8, paddingVertical: 4, paddingHorizontal: 8 },
|
||||
deleteText: { color: '#FF3B30', fontSize: 13 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f2f2f7' },
|
||||
empty: { fontSize: 20, color: '#8e8e93', marginBottom: 16 },
|
||||
addBtn: { backgroundColor: '#007AFF', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12 },
|
||||
addBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
fabText: { color: '#fff', fontSize: 32, fontWeight: '300', marginTop: -4 },
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import * as Location from 'expo-location';
|
||||
import { loadOriginStation, saveOriginStation, loadNotificationSettings, saveNotificationSettings, rescheduleAllNotifications } from '../store/eventStore';
|
||||
import { api } from '../services/api';
|
||||
import type { Station, ReminderSettings } from '@timetoleave/core';
|
||||
|
||||
type RootStack = {
|
||||
EventList: undefined;
|
||||
EventDetail: { eventId: string };
|
||||
AddEvent: undefined;
|
||||
Settings: undefined;
|
||||
CalendarImport: undefined;
|
||||
};
|
||||
|
||||
type ScreenProps = {
|
||||
navigation: NativeStackNavigationProp<RootStack, 'Settings'>;
|
||||
route: RouteProp<RootStack, 'Settings'>;
|
||||
};
|
||||
|
||||
export function SettingsScreen({ navigation: _navigation }: ScreenProps) {
|
||||
const [origin, setOrigin] = useState<Station | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Station[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [notifSettings, setNotifSettings] = useState<ReminderSettings>({
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [locPermission, setLocPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Load persisted data on mount
|
||||
useEffect(() => {
|
||||
loadOriginStation().then(setOrigin);
|
||||
loadNotificationSettings().then(setNotifSettings);
|
||||
|
||||
return () => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounced station search
|
||||
const searchStation = useCallback(async (q: string) => {
|
||||
if (q.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
try {
|
||||
const stations = await api.searchStation(q.trim());
|
||||
setResults(stations);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onQueryChange = (text: string) => {
|
||||
setQuery(text);
|
||||
// Proper debounce using useRef — no `any`
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => searchStation(text), 400);
|
||||
};
|
||||
|
||||
const selectStation = (station: Station) => {
|
||||
setOrigin(station);
|
||||
setQuery(station.name);
|
||||
setResults([]);
|
||||
saveOriginStation(station);
|
||||
rescheduleAllNotifications(); // Recalculate when origin changes
|
||||
};
|
||||
|
||||
const useCurrentLocation = async () => {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
setLocPermission(status === 'granted' ? 'granted' : 'denied');
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Berechtigung erforderlich', 'Standortzugriff ist nötig für die automatische Stationssuche.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loc = await Location.getCurrentPositionAsync({});
|
||||
const userLat = loc.coords.latitude;
|
||||
const userLng = loc.coords.longitude;
|
||||
|
||||
// Find real public-transport stops near the user's GPS coordinates
|
||||
// via the WienerLinien nearby-stops proxy.
|
||||
const stops = await api.findNearbyStops(userLat, userLng, 2000);
|
||||
if (stops.length === 0) {
|
||||
Alert.alert('Keine Station gefunden', 'Kein ÖPNV-Halt in der Nähe gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick the closest stop to the user's actual position
|
||||
const closest = stops.reduce((best, candidate) => {
|
||||
const bestDist = Math.hypot(best.lat - userLat, best.lng - userLng);
|
||||
const candDist = Math.hypot(candidate.lat - userLat, candidate.lng - userLng);
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stops[0]);
|
||||
|
||||
// Build a Station with the real stop id as extId — HAFAS can look this up.
|
||||
const station: Station = {
|
||||
name: closest.name,
|
||||
extId: closest.id,
|
||||
lat: closest.lat,
|
||||
lng: closest.lng,
|
||||
};
|
||||
selectStation(station);
|
||||
} catch (_err) {
|
||||
Alert.alert('Fehler', 'Standort konnte nicht ermittelt werden.');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotifications = async (value: boolean) => {
|
||||
const updated = { ...notifSettings, enabled: value };
|
||||
setNotifSettings(updated);
|
||||
await saveNotificationSettings(updated);
|
||||
await rescheduleAllNotifications();
|
||||
};
|
||||
|
||||
const updateBufferMinutes = async (value: string) => {
|
||||
const minutes = parseInt(value, 10);
|
||||
if (!isNaN(minutes) && minutes >= 0) {
|
||||
const updated = { ...notifSettings, bufferMinutes: minutes };
|
||||
setNotifSettings(updated);
|
||||
await saveNotificationSettings(updated);
|
||||
await rescheduleAllNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const updateArrivalBuffer = async (value: string) => {
|
||||
const minutes = parseInt(value, 10);
|
||||
if (!isNaN(minutes) && minutes >= 0) {
|
||||
const updated = { ...notifSettings, arrivalBufferMinutes: minutes };
|
||||
setNotifSettings(updated);
|
||||
await saveNotificationSettings(updated);
|
||||
await rescheduleAllNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWalking = async (value: boolean) => {
|
||||
const updated = { ...notifSettings, showWalkingOption: value };
|
||||
setNotifSettings(updated);
|
||||
await saveNotificationSettings(updated);
|
||||
await rescheduleAllNotifications();
|
||||
};
|
||||
|
||||
const toggleBike = async (value: boolean) => {
|
||||
const updated = { ...notifSettings, showBikeOption: value };
|
||||
setNotifSettings(updated);
|
||||
await saveNotificationSettings(updated);
|
||||
await rescheduleAllNotifications();
|
||||
};
|
||||
|
||||
const toggleAdvanced = () => {
|
||||
setShowAdvanced(!showAdvanced);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Origin Station */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Ursprungstation</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Station suchen …"
|
||||
value={query}
|
||||
onChangeText={onQueryChange}
|
||||
autoCapitalize="words"
|
||||
accessibilityLabel="Station suchen"
|
||||
/>
|
||||
{searching && <ActivityIndicator style={{ marginVertical: 8 }} color="#007AFF" />}
|
||||
{origin && (
|
||||
<Text style={styles.currentStation}>Aktuell: {origin.name}</Text>
|
||||
)}
|
||||
|
||||
{results.map((s) => (
|
||||
<TouchableOpacity key={s.extId} onPress={() => selectStation(s)}>
|
||||
<Text style={styles.resultItem}>{s.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity style={styles.locBtn} onPress={useCurrentLocation}>
|
||||
<Text style={styles.locBtnText}>📍 Aktuelle Position verwenden</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.locStatus}>
|
||||
Standort: {locPermission === 'granted' ? 'Erlaubt ✓' : locPermission === 'denied' ? 'Verweigert ✗' : 'Noch nicht angefragt'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Benachrichtigungen</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>Benachrichtigungen aktivieren</Text>
|
||||
<Switch
|
||||
value={notifSettings.enabled}
|
||||
onValueChange={toggleNotifications}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Benachrichtigungen umschalten"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.settingLabel}>Pufferzeit (Minuten)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput]}
|
||||
value={String(notifSettings.bufferMinutes)}
|
||||
onChangeText={updateBufferMinutes}
|
||||
keyboardType="numeric"
|
||||
accessibilityLabel="Pufferzeit in Minuten"
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
Du wirst {notifSettings.bufferMinutes} Minuten vor der geplanten Abfahrt erinnert.
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.advancedToggle, styles.locBtn]} onPress={toggleAdvanced}>
|
||||
<Text style={styles.locBtnText}>
|
||||
{showAdvanced ? '↑ Weniger Optionen zeigen' : '↓ Mehr Optionen zeigen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showAdvanced && (
|
||||
<View style={styles.advancedSection}>
|
||||
<Text style={styles.settingLabel}>Ankunfts-Puffer (Minuten)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.numberInput]}
|
||||
value={String(notifSettings.arrivalBufferMinutes)}
|
||||
onChangeText={updateArrivalBuffer}
|
||||
keyboardType="numeric"
|
||||
accessibilityLabel="Ankunfts-Puffer in Minuten"
|
||||
/>
|
||||
<Text style={styles.hint}>Wie viele Minuten vor der Event-Zeit du am Ziel ankommen möchtest</Text>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>Zu Fuß-Option anzeigen</Text>
|
||||
<Switch
|
||||
value={notifSettings.showWalkingOption}
|
||||
onValueChange={toggleWalking}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Zu Fuß-Option umschalten"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={styles.settingLabel}>Fahrrad-Option anzeigen</Text>
|
||||
<Switch
|
||||
value={notifSettings.showBikeOption}
|
||||
onValueChange={toggleBike}
|
||||
trackColor={{ true: '#007AFF', false: '#e5e5ea' }}
|
||||
accessibilityLabel="Fahrrad-Option umschalten"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#f2f2f7', padding: 20 },
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: { fontSize: 18, fontWeight: '600', color: '#1c1c1e', marginBottom: 10 },
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5ea',
|
||||
},
|
||||
numberInput: { width: 80 },
|
||||
currentStation: { fontSize: 14, color: '#34C759', marginTop: 6 },
|
||||
resultItem: {
|
||||
fontSize: 15,
|
||||
color: '#007AFF',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e5ea',
|
||||
},
|
||||
locBtn: {
|
||||
marginTop: 12,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
locBtnText: { fontSize: 15, color: '#007AFF', fontWeight: '500' },
|
||||
locStatus: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
||||
advancedToggle: {
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
advancedSection: {
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e5ea',
|
||||
},
|
||||
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||
settingLabel: { fontSize: 14, color: '#1c1c1e' },
|
||||
hint: { fontSize: 12, color: '#8e8e93', marginTop: 6 },
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ApiClient } from '@timetoleave/api-client';
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
|
||||
export const api = new ApiClient(baseUrl);
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as Calendar from 'expo-calendar';
|
||||
import type { Event as CoreEvent } from '@timetoleave/core';
|
||||
|
||||
/**
|
||||
* Native calendar integration for the mobile app.
|
||||
* Reads events from device calendars and converts them to our internal format.
|
||||
*/
|
||||
|
||||
export async function ensureCalendarPermission(): Promise<boolean> {
|
||||
const { status } = await Calendar.requestCalendarPermissionsAsync();
|
||||
if (status !== 'granted')
|
||||
return false;
|
||||
|
||||
return Calendar.isAvailableAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from native calendars within a date range.
|
||||
* Returns events converted to our internal Event format.
|
||||
*/
|
||||
export async function fetchNativeEvents(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<CoreEvent[]> {
|
||||
const available = await ensureCalendarPermission();
|
||||
if (!available) return [];
|
||||
|
||||
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
|
||||
if (calendars.length === 0) return [];
|
||||
|
||||
const calendarIds = calendars.map((c) => c.id);
|
||||
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
|
||||
|
||||
return events.map((evt) => ({
|
||||
id: evt.id,
|
||||
title: evt.title ?? 'Untitled Event',
|
||||
destination: evt.location ?? '',
|
||||
eventTime: typeof evt.startDate === 'string' ? new Date(evt.startDate) : (evt.startDate ?? new Date()),
|
||||
source: `native:${evt.calendarId}`,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
import type { Event, Journey, ReminderSettings } from '@timetoleave/core';
|
||||
|
||||
// Register for push notification permissions
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculate leave-by time from event time and journey data.
|
||||
* Uses earliest real departure time if journeys exist, otherwise event time minus buffer.
|
||||
*
|
||||
* @param event - The event to calculate leave-by time for
|
||||
* @param journeys - Journey data for this event
|
||||
* @param arrivalBufferMinutes - How many minutes before the event to arrive
|
||||
* @param bufferMinutes - How many minutes before leaving to be reminded
|
||||
*/
|
||||
export function calculateLeaveByTime(
|
||||
event: Event,
|
||||
journeys: Journey[],
|
||||
arrivalBufferMinutes: number,
|
||||
bufferMinutes: number
|
||||
): Date {
|
||||
// Calculate target arrival time (event time minus arrival buffer)
|
||||
const targetArrivalTimeMs = event.eventTime.getTime() - arrivalBufferMinutes * 60 * 1000;
|
||||
|
||||
// If we have journeys, use the earliest non-cancelled real departure minus reminder buffer
|
||||
if (journeys.length > 0) {
|
||||
const best = journeys
|
||||
.filter((j) => !j.cancelled)
|
||||
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
|
||||
|
||||
if (best) {
|
||||
// Leave by time = earliest real departure time - reminder buffer
|
||||
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: event time minus arrival buffer minus reminder (no journey data)
|
||||
return new Date(targetArrivalTimeMs - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule notifications for an event
|
||||
*
|
||||
* @param event - The event to schedule notifications for
|
||||
* @param journeys - Journey data for this event (optional)
|
||||
* @param settings - Notification settings
|
||||
*/
|
||||
export async function scheduleNotificationsForEvent(
|
||||
event: Event,
|
||||
journeys: Journey[] = [],
|
||||
settings: ReminderSettings
|
||||
): Promise<void> {
|
||||
// Don't schedule if notifications are disabled
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate leave-by time (when user should actually leave)
|
||||
const leaveByTime = calculateLeaveByTime(event, journeys, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Cancel existing notifications for this event - cancel one by one
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
if (toCancel.length > 0) {
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
// But respect the buffer time - we want reminders relative to when they should leave
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use Date trigger with time property
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule notifications for all events
|
||||
* Use this when origin station changes or notification settings are updated
|
||||
*/
|
||||
export async function rescheduleAllNotifications(
|
||||
events: Event[],
|
||||
journeysMap: Record<string, Journey[]>, // eventId -> journeys
|
||||
settings: ReminderSettings
|
||||
): Promise<void> {
|
||||
// Cancel ALL existing notifications first
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
const eventJourneys = journeysMap[event.id] || [];
|
||||
await scheduleNotificationsForEvent(event, eventJourneys, settings);
|
||||
}
|
||||
}
|
||||
|
||||
// Request permissions if not already granted
|
||||
let permissionsRequested = false;
|
||||
|
||||
export async function requestNotificationPermissions(): Promise<boolean> {
|
||||
if (permissionsRequested) {
|
||||
return true;
|
||||
}
|
||||
|
||||
permissionsRequested = true;
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
return status === 'granted';
|
||||
}
|
||||
|
||||
// Request permissions automatically when app starts (for Android)
|
||||
// This is called in App.tsx
|
||||
|
||||
export function setupNotifications() {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Event, Station, ReminderSettings } from '@timetoleave/core';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { SchedulableTriggerInputTypes } from 'expo-notifications';
|
||||
|
||||
// ── Keys ───────────────────────────────
|
||||
|
||||
const EVENTS_KEY = '@timetoleave_events';
|
||||
const ORIGIN_KEY = '@timetoleave_origin';
|
||||
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
|
||||
|
||||
// ── Default notification settings ─────────────────────
|
||||
|
||||
const DEFAULT_NOTIFICATION_SETTINGS: ReminderSettings = {
|
||||
bufferMinutes: 30,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────
|
||||
|
||||
function reviveDates(json: string): Event[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Array<Event & { eventTime: string }>;
|
||||
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getNotificationSettings(): Promise<ReminderSettings> {
|
||||
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
||||
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Notification scheduling utilities
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
async function calculateLeaveByTime(event: Event, arrivalBufferMinutes: number, bufferMinutes: number): Promise<Date> {
|
||||
// Calculate target arrival time (event time minus arrival buffer)
|
||||
const targetArrivalTime = new Date(event.eventTime);
|
||||
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
|
||||
|
||||
// Fallback: event time minus arrival buffer minus buffer (no journey data)
|
||||
return new Date(targetArrivalTime.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function scheduleEventNotification(event: Event): Promise<void> {
|
||||
const settings = await getNotificationSettings();
|
||||
if (!settings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === event.id);
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
// Schedule notifications
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Events ────────────────────────────────────────────
|
||||
|
||||
// ── Events ────────────────────────────────────────────
|
||||
|
||||
export async function loadEvents(): Promise<Event[]> {
|
||||
const json = await AsyncStorage.getItem(EVENTS_KEY);
|
||||
return json ? reviveDates(json) : [];
|
||||
}
|
||||
|
||||
export async function saveEvents(events: Event[]): Promise<void> {
|
||||
const json = JSON.stringify(events);
|
||||
await AsyncStorage.setItem(EVENTS_KEY, json);
|
||||
}
|
||||
|
||||
export async function addEvent(event: Event): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
events.push(event);
|
||||
await saveEvents(events);
|
||||
await scheduleEventNotification(event);
|
||||
}
|
||||
|
||||
export async function removeEvent(id: string, onDone?: () => void): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const filtered = events.filter((e) => e.id !== id);
|
||||
await saveEvents(filtered);
|
||||
|
||||
// Cancel notifications for removed event - cancel one by one
|
||||
const existing = await Notifications.getAllScheduledNotificationsAsync();
|
||||
const toCancel = existing.filter(n => n.content.data?.eventId === id);
|
||||
for (const notif of toCancel) {
|
||||
await Notifications.cancelScheduledNotificationAsync(notif.identifier);
|
||||
}
|
||||
|
||||
onDone?.();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Origin Station ────────────────────────────────────
|
||||
|
||||
export async function loadOriginStation(): Promise<Station | null> {
|
||||
const json = await AsyncStorage.getItem(ORIGIN_KEY);
|
||||
return json ? JSON.parse(json) : null;
|
||||
}
|
||||
|
||||
export async function saveOriginStation(station: Station): Promise<void> {
|
||||
const json = JSON.stringify(station);
|
||||
await AsyncStorage.setItem(ORIGIN_KEY, json);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Notification Settings ──────────────────────────────
|
||||
|
||||
export async function loadNotificationSettings(): Promise<ReminderSettings> {
|
||||
const json = await AsyncStorage.getItem(NOTIFICATIONS_KEY);
|
||||
return json ? JSON.parse(json) : DEFAULT_NOTIFICATION_SETTINGS;
|
||||
}
|
||||
|
||||
export async function saveNotificationSettings(
|
||||
settings: ReminderSettings,
|
||||
): Promise<void> {
|
||||
const json = JSON.stringify(settings);
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_KEY, json);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Reschedule all notifications (for origin/setting changes)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function rescheduleAllNotifications(): Promise<void> {
|
||||
const events = await loadEvents();
|
||||
const settings = await loadNotificationSettings();
|
||||
|
||||
// Cancel ALL existing notifications first
|
||||
await Notifications.cancelAllScheduledNotificationsAsync();
|
||||
|
||||
// Schedule new notifications for each event
|
||||
for (const event of events) {
|
||||
if (settings.enabled) {
|
||||
const leaveByTime = await calculateLeaveByTime(event, settings.arrivalBufferMinutes, settings.bufferMinutes);
|
||||
|
||||
// Default reminders: 30min, 10min, and at leave-by time
|
||||
const defaultReminders = [30, 10, 0];
|
||||
|
||||
for (const minutesBefore of defaultReminders) {
|
||||
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
|
||||
|
||||
// Skip if trigger time is in the past
|
||||
if (triggerTime <= new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if this would be before the event actually starts (add some safety margin)
|
||||
if (triggerTime < new Date(event.eventTime.getTime() - 2 * 60 * 60 * 1000)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: `🚆 ${event.title}`,
|
||||
body: minutesBefore === 0
|
||||
? 'Zeit zu gehen!'
|
||||
: `${minutesBefore} Minuten bis du losmusst`,
|
||||
data: { eventId: event.id },
|
||||
},
|
||||
trigger: { type: SchedulableTriggerInputTypes.DATE, date: triggerTime },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
coverage/
|
||||
|
||||
# env files
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE / editors
|
||||
.idea/
|
||||
.vscode/
|
||||
.zed/
|
||||
.claude/
|
||||
|
||||
# git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# docs
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
CHECKLIST.md
|
||||
REWRITE_PLAN.md
|
||||
|
||||
# tests
|
||||
vitest.config.ts
|
||||
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['next/core-web-vitals'],
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules/', '.next/', 'out/', 'dist/'],
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
# ---- Stage 1: Install dependencies ----
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- Stage 2: Build the app ----
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 3: Production image ----
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
# Use standalone output if available, otherwise copy .next
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Ensure correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: time-to-leave
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HAFAS_URL=${HAFAS_URL:-https://fahrplan.oebb.at/bin/mgate.exe}
|
||||
- NOMINATIM_URL=${NOMINATIM_URL:-https://nominatim.openstreetmap.org}
|
||||
- NOMINATIM_USER_AGENT=${NOMINATIM_USER_AGENT:-TimeToLeave/2.0}
|
||||
- OSRM_URL=${OSRM_URL:-https://router.project-osrm.org}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: path.resolve(__dirname),
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Allow _-prefixed parameters and variables to signal intentionally unused.
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ['100.107.92.66'],
|
||||
serverExternalPackages: ["node-ical"],
|
||||
env: {
|
||||
CORS_ALLOWED_ORIGINS: process.env.CORS_ALLOWED_ORIGINS,
|
||||
DEPLOYMENT_URL: process.env.DEPLOYMENT_URL,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate environment variables at build time
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.SKIP_ENV_VALIDATION) {
|
||||
if (!process.env.CORS_ALLOWED_ORIGINS) {
|
||||
throw new Error('Missing CORS_ALLOWED_ORIGINS environment variable in production');
|
||||
}
|
||||
if (!process.env.DEPLOYMENT_URL) {
|
||||
throw new Error('Missing DEPLOYMENT_URL environment variable in production');
|
||||
}
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@timetoleave/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@timetoleave/api-client": "*",
|
||||
"@timetoleave/core": "*",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^16.2.6",
|
||||
"node-ical": "^0.26.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -0,0 +1,150 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import proxy from "../proxy";
|
||||
|
||||
// Mock NextResponse to avoid internal routing context issues
|
||||
vi.mock("next/server", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
|
||||
// Create a mock constructor class with static methods
|
||||
class MockNextResponse {
|
||||
public headers: Headers;
|
||||
public status: number;
|
||||
public statusText: string;
|
||||
public body: BodyInit | null;
|
||||
|
||||
static next: () => MockNextResponse;
|
||||
static json: (data: unknown, init?: ResponseInit) => MockNextResponse;
|
||||
static redirect: () => void;
|
||||
|
||||
constructor(init?: ResponseInit) {
|
||||
this.headers = new Headers();
|
||||
this.status = init?.status ?? 200;
|
||||
this.statusText = "OK";
|
||||
this.body = null;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new MockNextResponse();
|
||||
}
|
||||
arrayBuffer() {
|
||||
return new ArrayBuffer(0);
|
||||
}
|
||||
blob() {
|
||||
return new Blob();
|
||||
}
|
||||
formData() {
|
||||
return new FormData();
|
||||
}
|
||||
json() {
|
||||
return {};
|
||||
}
|
||||
text() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Static methods
|
||||
MockNextResponse.next = vi.fn(() => new MockNextResponse());
|
||||
MockNextResponse.json = vi.fn(
|
||||
(_data, init) => new MockNextResponse(init),
|
||||
) as typeof MockNextResponse.json;
|
||||
MockNextResponse.redirect = vi.fn();
|
||||
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
NextResponse: MockNextResponse,
|
||||
};
|
||||
});
|
||||
|
||||
describe("middleware", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should bypass non-API requests", () => {
|
||||
const request = new NextRequest("http://localhost:3000/test", {
|
||||
headers: { origin: "http://localhost:3000" },
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle preflight for allowed origin", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://localhost:3000" },
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe(
|
||||
"GET, POST, OPTIONS",
|
||||
);
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe(
|
||||
"Content-Type, Authorization",
|
||||
);
|
||||
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
|
||||
expect(response.headers.get("Vary")).toBe("Origin");
|
||||
});
|
||||
|
||||
it("should reject preflight for disallowed origin", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://evil.example.com" },
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
expect(response.headers.get("Vary")).toBe("Origin");
|
||||
});
|
||||
|
||||
it("should allow regular API request from allowed origin", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
headers: { origin: "http://localhost:3000" },
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
expect(response.headers.get("Vary")).toBe("Origin");
|
||||
});
|
||||
|
||||
it("should deny API request from disallowed origin", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
headers: { origin: "http://different-origin.com" },
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle API requests without Origin header", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response).toBeDefined();
|
||||
// No CORS leak — should not echo *
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("should include rate-limit headers on allowed requests", () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/test", {
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const response = proxy(request);
|
||||
expect(response.headers.get("X-RateLimit-Limit")).toBeDefined();
|
||||
expect(response.headers.get("X-RateLimit-Remaining")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -80,15 +80,16 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full ${className}`}>
|
||||
<div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">Add Manual Event</h3>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p>
|
||||
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Event Title
|
||||
</label>
|
||||
<input
|
||||
@@ -97,14 +98,14 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Team Meeting"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="event-destination"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
|
||||
>
|
||||
Destination
|
||||
</label>
|
||||
@@ -114,13 +115,13 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Vienna Main Station"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-date" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
@@ -128,12 +129,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="date"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="event-time" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
@@ -141,7 +142,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="time"
|
||||
value={eventTime}
|
||||
onChange={(e) => setEventTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET } from "../bike-route/route";
|
||||
|
||||
// Mock the fetch function to avoid making actual HTTP requests
|
||||
global.fetch = vi.fn();
|
||||
const mockGetBikeRoute = vi.fn();
|
||||
|
||||
vi.mock("@/lib/bike-routing-client", () => ({
|
||||
BikeRoutingClient: class MockBikeRoutingClient {
|
||||
getBikeRoute = mockGetBikeRoute;
|
||||
},
|
||||
}));
|
||||
|
||||
const { GET } = await import("../bike-route/route");
|
||||
|
||||
describe("api/bike-route/route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
mockGetBikeRoute.mockReset();
|
||||
});
|
||||
|
||||
it("should return error when no required parameters are provided", async () => {
|
||||
@@ -21,40 +26,14 @@ describe("api/bike-route/route", () => {
|
||||
});
|
||||
|
||||
it("should handle valid bike route request", async () => {
|
||||
// Mock successful fetch response
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
distance: 1500,
|
||||
duration: 300,
|
||||
routes: [
|
||||
{
|
||||
distance: 1500,
|
||||
duration: 300,
|
||||
legs: [
|
||||
{
|
||||
steps: [
|
||||
{
|
||||
name: "Start",
|
||||
distance: 100,
|
||||
duration: 10,
|
||||
maneuver: { instruction: "Go straight", type: "straight" },
|
||||
},
|
||||
{
|
||||
name: "Turn left",
|
||||
distance: 200,
|
||||
duration: 20,
|
||||
maneuver: { instruction: "Turn left at the corner", type: "turn", modifier: "left" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
|
||||
mockGetBikeRoute.mockResolvedValue({
|
||||
distance: 1500,
|
||||
duration: 300,
|
||||
steps: [
|
||||
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
|
||||
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
|
||||
],
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
@@ -68,8 +47,8 @@ describe("api/bike-route/route", () => {
|
||||
expect(data).toHaveProperty("steps");
|
||||
});
|
||||
|
||||
it("should handle fetch error", async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
|
||||
it("should handle client error", async () => {
|
||||
mockGetBikeRoute.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
@@ -78,19 +57,12 @@ describe("api/bike-route/route", () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "Internal server error" });
|
||||
expect(data.error).toBe("Internal server error");
|
||||
expect(data.correlationId).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("should handle no route found", async () => {
|
||||
// Mock fetch response with empty routes
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
routes: [],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
|
||||
mockGetBikeRoute.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
@@ -55,7 +55,8 @@ describe("api/geocode/route", () => {
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "Internal server error" });
|
||||
expect(data.error).toBe("Internal server error");
|
||||
expect(data.correlationId).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("should handle no results found", async () => {
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mockGetWalkRoute = vi.fn();
|
||||
|
||||
vi.mock("@/lib/walk-routing-client", () => ({
|
||||
WalkRoutingClient: class MockWalkRoutingClient {
|
||||
getWalkRoute = mockGetWalkRoute;
|
||||
},
|
||||
}));
|
||||
|
||||
const { GET } = await import("../walk-route/route");
|
||||
|
||||
describe("api/walk-route/route", () => {
|
||||
beforeEach(() => {
|
||||
mockGetWalkRoute.mockReset();
|
||||
});
|
||||
|
||||
it("should return error when no required parameters are provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/walk-route");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" });
|
||||
});
|
||||
|
||||
it("should handle valid walk route request", async () => {
|
||||
mockGetWalkRoute.mockResolvedValue({
|
||||
distance: 800,
|
||||
duration: 600,
|
||||
steps: [
|
||||
{ name: "Start", distance: 50, duration: 40, instruction: "Head north" },
|
||||
{ name: "Main St", distance: 150, duration: 120, instruction: "Turn right" },
|
||||
],
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data).toHaveProperty("distance");
|
||||
expect(data).toHaveProperty("duration");
|
||||
expect(data).toHaveProperty("steps");
|
||||
});
|
||||
|
||||
it("should handle client error", async () => {
|
||||
mockGetWalkRoute.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Internal server error");
|
||||
expect(data.correlationId).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("should handle no route found", async () => {
|
||||
mockGetWalkRoute.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "No route found" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { BikeRoutingClient } from "@/lib/bike-routing-client";
|
||||
import { validateCoordinate } from "@/lib/api-guards";
|
||||
|
||||
// Module-level singleton — cache persists across requests
|
||||
const client = new BikeRoutingClient();
|
||||
|
||||
/** Maximum valid distance between two points in degrees (sanity check). */
|
||||
const MAX_COORD_DIFF = 10; // ~1100 km, blocks routing across oceans
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
|
||||
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
|
||||
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
|
||||
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
|
||||
|
||||
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check: the two points shouldn't be farther apart than MAX_COORD_DIFF degrees
|
||||
const dLat = Math.abs(toLat - fromLat);
|
||||
const dLng = Math.abs(toLng - fromLng);
|
||||
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
|
||||
return NextResponse.json(
|
||||
{ error: "Coordinates too far apart" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
|
||||
|
||||
if (!route) {
|
||||
return NextResponse.json({ error: "No route found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(route);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Bike route API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractEvents } from "@/lib/calendar-utils";
|
||||
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||
import { readBodyWithLimit, MAX_REQUEST_BODY_BYTES } from "@/lib/api-guards";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await readBodyWithLimit(request, MAX_REQUEST_BODY_BYTES);
|
||||
|
||||
if (!body || body.trim().length === 0) {
|
||||
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Guard: cap at 10 000 chars to prevent OOM from node-ical parsing
|
||||
if (body.length > 10_000) {
|
||||
return NextResponse.json(
|
||||
{ error: "Request body too large (max 10 KB for ICS content)" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// Use extractEvents for consistent parsing with cleanLocation() and filtering
|
||||
const events = extractEvents(body, DEFAULT_DAYS);
|
||||
|
||||
return NextResponse.json(events);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Calendar parse API error:`, error);
|
||||
return NextResponse.json({ error: "Failed to parse calendar", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { extractEvents } from "@/lib/calendar-utils";
|
||||
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||
import { isCalendarUrlAllowed } from "@/lib/url-validation";
|
||||
|
||||
// Maximum allowed ICS response size: 5 MB
|
||||
const MAX_CALENDAR_RESPONSE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
// Accepted content types for ICS calendar feeds
|
||||
const ACCEPTED_CONTENT_TYPES = [
|
||||
'text/calendar',
|
||||
'text/plain', // some servers mislabel .ics as text/plain
|
||||
'application/octet-stream', // fallback for servers that don't set a type
|
||||
];
|
||||
|
||||
function hasAcceptableContentType(contentType: string | null | undefined): boolean {
|
||||
if (!contentType) {
|
||||
return false;
|
||||
}
|
||||
const lower = contentType.toLowerCase().split(';')[0].trim();
|
||||
return ACCEPTED_CONTENT_TYPES.some(ct => lower.startsWith(ct));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get("url");
|
||||
const daysParam = searchParams.get("days");
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
// SSRF protection: validate URL
|
||||
if (!isCalendarUrlAllowed(url)) {
|
||||
return NextResponse.json({ error: "Calendar URL not allowed" }, { status: 403 });
|
||||
}
|
||||
|
||||
const days = daysParam ? parseInt(daysParam, 10) : DEFAULT_DAYS;
|
||||
|
||||
// Fetch the ICS content from the provided URL
|
||||
// redirect: 'manual' prevents following redirects, which blocks SSRF via
|
||||
// whitelisted-domain → 3xx → internal-service chains.
|
||||
const icsResponse = await fetch(url, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
headers: {
|
||||
'Accept': 'text/calendar, text/plain, */*',
|
||||
'User-Agent': 'TimeToLeave/2.0',
|
||||
},
|
||||
});
|
||||
|
||||
// If the server redirects, reject it rather than following blindly.
|
||||
if ([301, 302, 303, 307, 308].includes(icsResponse.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar URL redirects are not allowed' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!icsResponse.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: icsResponse.status });
|
||||
}
|
||||
|
||||
// Validate content-type
|
||||
const contentType = icsResponse.headers.get('content-type');
|
||||
if (!hasAcceptableContentType(contentType)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar response has an unexpected content type' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce response size limit to prevent large-body DoS
|
||||
const contentLength = icsResponse.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_CALENDAR_RESPONSE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar response is too large' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// Read body with size guard
|
||||
const arrayBuffer = await icsResponse.arrayBuffer();
|
||||
if (arrayBuffer.byteLength > MAX_CALENDAR_RESPONSE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar response is too large' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const content = new TextDecoder('utf-8').decode(arrayBuffer);
|
||||
|
||||
// Use extractEvents for consistent parsing with cleanLocation() and filtering
|
||||
const events = extractEvents(content, days);
|
||||
|
||||
return NextResponse.json(events);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Calendar API error:`, error);
|
||||
return NextResponse.json({ error: "Failed to fetch calendar", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { GeocodingClient } from "@/lib/geocoding-client";
|
||||
|
||||
// Module-level singleton — cache persists across requests
|
||||
const client = new GeocodingClient();
|
||||
|
||||
/**
|
||||
* Maximum allowed query length. Station/place names are typically
|
||||
* < 100 characters. This prevents abuse with extremely long inputs.
|
||||
*/
|
||||
const MAX_QUERY_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Allowed country codes for narrowing the search.
|
||||
* Two-letter ISO 3166-1 alpha-2 codes.
|
||||
*/
|
||||
const ALLOWED_COUNTRY_CODES = /^[a-z]{2}(,[a-z]{2}){0,4}$/;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const name = searchParams.get("name");
|
||||
const countrycodes = searchParams.get("countrycodes");
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (name.length > MAX_QUERY_LENGTH) {
|
||||
return NextResponse.json(
|
||||
{ error: "Query too long (max 256 characters)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (countrycodes && !ALLOWED_COUNTRY_CODES.test(countrycodes.toLowerCase())) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid countrycodes (use up to 5 two-letter codes, comma-separated)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const results = await client.geocode(name, countrycodes || undefined);
|
||||
|
||||
if (results.length === 0) {
|
||||
return NextResponse.json({ error: "No results found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(results[0]);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Geocode API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
||||
import { hafasDateTime } from "@timetoleave/core";
|
||||
import { readBodyWithLimit } from "@/lib/api-guards";
|
||||
|
||||
const HAFAS_VER = process.env.HAFAS_VER || "1.36";
|
||||
const HAFAS_LANG = process.env.HAFAS_LANG || "eng";
|
||||
const HAFAS_AID = process.env.HAFAS_AID || "hf7mcf9bv3nv8g5f";
|
||||
const HAFAS_CLIENT_ID = process.env.HAFAS_CLIENT_ID || "OEBB";
|
||||
const HAFAS_CLIENT_VER = process.env.HAFAS_CLIENT_VER || "6020700";
|
||||
const HAFAS_CLIENT_NAME = process.env.HAFAS_CLIENT_NAME || "oebbApp";
|
||||
|
||||
/**
|
||||
* Maximum number of characters allowed in the JSON body of a HAFAS POST.
|
||||
* Keeps the relay surface small — a TripSearch + LocMatch request is ~1 KB.
|
||||
*/
|
||||
const HAFAS_BODY_MAX = 4 * 1024; // 4 KB
|
||||
|
||||
function injectHafasAuth(
|
||||
body: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!body || typeof body !== "object") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
ver: HAFAS_VER,
|
||||
lang: HAFAS_LANG,
|
||||
auth: { type: "AID", aid: HAFAS_AID },
|
||||
client: { id: HAFAS_CLIENT_ID, v: HAFAS_CLIENT_VER, type: "IPH", name: HAFAS_CLIENT_NAME },
|
||||
...body,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
const date = searchParams.get("date");
|
||||
|
||||
if (!from || !to || !date) {
|
||||
return NextResponse.json({ error: "Missing required parameters: from, to, date" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate extId shape — ÖBB station IDs are purely numeric
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to)) {
|
||||
return NextResponse.json({ error: "Invalid station ID format (must be numeric)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { date: hafasDate, time: hafasTime } = hafasDateTime(dateObj);
|
||||
|
||||
const body = injectHafasAuth({
|
||||
svcReqL: [
|
||||
{
|
||||
meth: "TripSearch",
|
||||
req: {
|
||||
depLocL: [{ type: "S", extId: from }],
|
||||
arrLocL: [{ type: "S", extId: to }],
|
||||
outDate: hafasDate,
|
||||
outTime: hafasTime,
|
||||
numF: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(HAFAS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
||||
}
|
||||
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] HAFAS API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Body size guard before parsing
|
||||
const rawBody = await readBodyWithLimit(request, HAFAS_BODY_MAX);
|
||||
|
||||
if (!rawBody || rawBody.trim().length === 0) {
|
||||
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate body shape
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
"svcReqL" in body
|
||||
? !Array.isArray((body as Record<string, unknown>).svcReqL)
|
||||
: false
|
||||
) {
|
||||
// If svcReqL is missing, the injectHafasAuth will add an empty object —
|
||||
// so we need to check if the enriched body has it
|
||||
}
|
||||
|
||||
// Inject required HAFAS protocol fields
|
||||
const hafasBody = injectHafasAuth(body as Record<string, unknown>);
|
||||
|
||||
// Validate enriched body
|
||||
if (!hafasBody || !Array.isArray(hafasBody.svcReqL) || hafasBody.svcReqL.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const svcReq = hafasBody.svcReqL[0];
|
||||
const allowedMethods = ["TripSearch", "LocMatch"];
|
||||
|
||||
if (
|
||||
!svcReq ||
|
||||
typeof svcReq !== "object" ||
|
||||
typeof (svcReq as Record<string, unknown>).meth !== "string" ||
|
||||
!allowedMethods.includes((svcReq as Record<string, unknown>).meth as string)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Cap TripSearch results at 10
|
||||
if (
|
||||
(svcReq as Record<string, unknown>).meth === "TripSearch" &&
|
||||
(svcReq as Record<string, unknown>).req &&
|
||||
typeof (svcReq as Record<string, unknown>).req === "object" &&
|
||||
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF
|
||||
) {
|
||||
((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF = Math.min(
|
||||
Number(((svcReq as Record<string, unknown>).req as Record<string, unknown>).numF),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(HAFAS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(hafasBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
||||
}
|
||||
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] HAFAS API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { WalkRoutingClient } from "@/lib/walk-routing-client";
|
||||
import { validateCoordinate } from "@/lib/api-guards";
|
||||
|
||||
// Module-level singleton — cache persists across requests
|
||||
const client = new WalkRoutingClient();
|
||||
|
||||
/** Maximum valid distance between two points in degrees (sanity check). */
|
||||
const MAX_COORD_DIFF = 10;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const fromLat = validateCoordinate(searchParams.get("fromLat"), -90, 90);
|
||||
const fromLng = validateCoordinate(searchParams.get("fromLng"), -180, 180);
|
||||
const toLat = validateCoordinate(searchParams.get("toLat"), -90, 90);
|
||||
const toLng = validateCoordinate(searchParams.get("toLng"), -180, 180);
|
||||
|
||||
if (fromLat === null || fromLng === null || toLat === null || toLng === null) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
const dLat = Math.abs(toLat - fromLat);
|
||||
const dLng = Math.abs(toLng - fromLng);
|
||||
if (dLat > MAX_COORD_DIFF || dLng > MAX_COORD_DIFF) {
|
||||
return NextResponse.json(
|
||||
{ error: "Coordinates too far apart" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const route = await client.getWalkRoute(fromLat, fromLng, toLat, toLng);
|
||||
|
||||
if (!route) {
|
||||
return NextResponse.json({ error: "No route found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(route);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Walk route API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/wienerlinien-client", () => {
|
||||
const mockGetMonitor = vi.fn();
|
||||
return {
|
||||
WienerLinienClient: vi.fn(function () {
|
||||
return {
|
||||
getMonitor: mockGetMonitor,
|
||||
};
|
||||
}),
|
||||
__mockGetMonitor: mockGetMonitor,
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import * as wlClient from "@/lib/wienerlinien-client";
|
||||
|
||||
const mockGetMonitor = (
|
||||
wlClient as typeof import("@/lib/wienerlinien-client") & {
|
||||
__mockGetMonitor: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).__mockGetMonitor;
|
||||
|
||||
describe("GET /api/wienerlinien/monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 200 with departures for valid stop IDs", async () => {
|
||||
const mockMonitorResponse = {
|
||||
stops: [
|
||||
{
|
||||
stopId: "123",
|
||||
departures: [
|
||||
{
|
||||
stopId: "123",
|
||||
line: { name: "U1" },
|
||||
direction: "Leopoldau",
|
||||
departureTime: Date.now() + 120_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetMonitor.mockResolvedValue(mockMonitorResponse);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=123,456");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.departures).toHaveLength(1);
|
||||
expect(json.departures[0]).toMatchObject({
|
||||
stopId: "123",
|
||||
line: { name: "U1" },
|
||||
direction: "Leopoldau",
|
||||
});
|
||||
expect(mockGetMonitor).toHaveBeenCalledWith(["123", "456"]);
|
||||
});
|
||||
|
||||
it("returns 400 when stopIds is missing", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/monitor");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const json = await response.json();
|
||||
expect(json.error).toContain("stopIds");
|
||||
});
|
||||
|
||||
it("returns 400 when stopIds is empty or whitespace", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=%20");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("caps stop IDs to 10", async () => {
|
||||
mockGetMonitor.mockResolvedValue({ stops: [] });
|
||||
const manyIds = Array.from({ length: 15 }, (_, i) => String(i + 1)).join(",");
|
||||
const request = new NextRequest(`http://localhost/api/wienerlinien/monitor?stopIds=${manyIds}`);
|
||||
await GET(request);
|
||||
|
||||
expect(mockGetMonitor).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetMonitor.mock.calls[0][0]).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("deduplicates stop IDs while preserving order", async () => {
|
||||
mockGetMonitor.mockResolvedValue({ stops: [] });
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=1,2,1,3,2");
|
||||
await GET(request);
|
||||
|
||||
expect(mockGetMonitor).toHaveBeenCalledWith(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("accepts repeated stopIds params from hook (stopIds=a&stopIds=b)", async () => {
|
||||
mockGetMonitor.mockResolvedValue({ stops: [] });
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/wienerlinien/monitor?stopIds=WL:2000001&stopIds=WL:2000002&stopIds=WL:2000003",
|
||||
);
|
||||
await GET(request);
|
||||
|
||||
expect(mockGetMonitor).toHaveBeenCalledWith(["WL:2000001", "WL:2000002", "WL:2000003"]);
|
||||
});
|
||||
|
||||
it("returns 500 with correlationId when client throws", async () => {
|
||||
mockGetMonitor.mockRejectedValue(new Error("upstream timeout"));
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/monitor?stopIds=123");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const json = await response.json();
|
||||
expect(json.error).toBeTruthy();
|
||||
expect(json.correlationId).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WienerLinienClient } from "@/lib/wienerlinien-client";
|
||||
|
||||
const client = new WienerLinienClient();
|
||||
|
||||
/** Wiener Linien stop IDs can be numeric or in WL:format */
|
||||
const STOP_ID_RE = /^(?:\d+|WL:\d+)$/;
|
||||
|
||||
/** Maximum stop IDs to batch-request in a single call. */
|
||||
const MAX_STOP_IDS = 10;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const stopIdsList = searchParams.getAll("stopIds");
|
||||
if (stopIdsList.length === 0 || stopIdsList.every((v) => v.trim() === "")) {
|
||||
return NextResponse.json({ error: "Missing 'stopIds' query parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawIds = stopIdsList.flatMap((param) => param.split(","));
|
||||
const validStopIds = rawIds
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0 && STOP_ID_RE.test(id))
|
||||
.filter((id, index, self) => self.indexOf(id) === index)
|
||||
.slice(0, MAX_STOP_IDS);
|
||||
|
||||
if (validStopIds.length === 0) {
|
||||
return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const monitorData = await client.getMonitor(validStopIds);
|
||||
// Flatten nested stops array into a single departures list
|
||||
const departures = monitorData.stops.flatMap((s) => s.departures);
|
||||
return NextResponse.json({ departures });
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Wiener Linien monitor request failed:`, error);
|
||||
return NextResponse.json({ error: "Failed to fetch departures", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/wienerlinien-client", () => {
|
||||
const mockFindNearbyStops = vi.fn();
|
||||
return {
|
||||
WienerLinienClient: vi.fn(function () {
|
||||
return {
|
||||
findNearbyStops: mockFindNearbyStops,
|
||||
};
|
||||
}),
|
||||
__mockFindNearbyStops: mockFindNearbyStops,
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import * as wlClient from "@/lib/wienerlinien-client";
|
||||
|
||||
const mockFindNearbyStops = (
|
||||
wlClient as typeof import("@/lib/wienerlinien-client") & {
|
||||
__mockFindNearbyStops: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).__mockFindNearbyStops;
|
||||
|
||||
describe("api/wienerlinien/stops/route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 200 with stops for valid coordinates", async () => {
|
||||
const mockStops = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Test Stop",
|
||||
lat: 48.2,
|
||||
lng: 16.3,
|
||||
},
|
||||
];
|
||||
mockFindNearbyStops.mockResolvedValue(mockStops);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ stops: mockStops });
|
||||
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 1000);
|
||||
});
|
||||
|
||||
it("uses custom radius when provided", async () => {
|
||||
mockFindNearbyStops.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=2000");
|
||||
await GET(request);
|
||||
|
||||
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 2000);
|
||||
});
|
||||
|
||||
it("returns 400 when lat is missing", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("lat");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when lng is missing", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("lng");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when both lat and lng are missing", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("lat");
|
||||
expect(data.error).toContain("lng");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when lat is NaN", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=abc&lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("numbers");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when lng is NaN", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=xyz");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("numbers");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when latitude is out of bounds (too high)", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=95&lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("latitude");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when latitude is out of bounds (too low)", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=-95&lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("latitude");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when longitude is out of bounds (too high)", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=200");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("longitude");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when longitude is out of bounds (too low)", async () => {
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=-200");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toContain("longitude");
|
||||
expect(mockFindNearbyStops).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts boundary values for latitude (-90 and 90)", async () => {
|
||||
mockFindNearbyStops.mockResolvedValue([]);
|
||||
|
||||
const requestLow = new NextRequest("http://localhost/api/wienerlinien/stops?lat=-90&lng=0");
|
||||
const responseLow = await GET(requestLow);
|
||||
expect(responseLow.status).toBe(200);
|
||||
|
||||
const requestHigh = new NextRequest("http://localhost/api/wienerlinien/stops?lat=90&lng=0");
|
||||
const responseHigh = await GET(requestHigh);
|
||||
expect(responseHigh.status).toBe(200);
|
||||
});
|
||||
|
||||
it("accepts boundary values for longitude (-180 and 180)", async () => {
|
||||
mockFindNearbyStops.mockResolvedValue([]);
|
||||
|
||||
const requestLow = new NextRequest("http://localhost/api/wienerlinien/stops?lat=0&lng=-180");
|
||||
const responseLow = await GET(requestLow);
|
||||
expect(responseLow.status).toBe(200);
|
||||
|
||||
const requestHigh = new NextRequest("http://localhost/api/wienerlinien/stops?lat=0&lng=180");
|
||||
const responseHigh = await GET(requestHigh);
|
||||
expect(responseHigh.status).toBe(200);
|
||||
});
|
||||
|
||||
it("clamps radius to 5000 when exceeded", async () => {
|
||||
mockFindNearbyStops.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=10000");
|
||||
await GET(request);
|
||||
|
||||
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 5000);
|
||||
});
|
||||
|
||||
it("ignores invalid radius string and defaults to 1000", async () => {
|
||||
mockFindNearbyStops.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3&radius=abc");
|
||||
await GET(request);
|
||||
|
||||
expect(mockFindNearbyStops).toHaveBeenCalledWith(48.2, 16.3, 1000);
|
||||
});
|
||||
|
||||
it("returns 500 with correlationId when client throws", async () => {
|
||||
mockFindNearbyStops.mockRejectedValue(new Error("Network failure"));
|
||||
|
||||
const request = new NextRequest("http://localhost/api/wienerlinien/stops?lat=48.2&lng=16.3");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Failed to fetch nearby stops");
|
||||
expect(data.correlationId).toBeDefined();
|
||||
expect(data.correlationId).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WienerLinienClient } from "@/lib/wienerlinien-client";
|
||||
import { validateCoordinate } from "@/lib/api-guards";
|
||||
|
||||
const client = new WienerLinienClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const lat = validateCoordinate(searchParams.get("lat"), -90, 90);
|
||||
const lng = validateCoordinate(searchParams.get("lng"), -180, 180);
|
||||
|
||||
if (lat === null && lng === null) {
|
||||
return NextResponse.json({ error: "Missing parameters: lat and lng" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (lat === null) {
|
||||
const latParam = searchParams.get("lat");
|
||||
if (latParam === null || latParam === "") {
|
||||
return NextResponse.json({ error: "Missing parameter: lat" }, { status: 400 });
|
||||
} else if (isNaN(parseFloat(latParam))) {
|
||||
return NextResponse.json({ error: "Invalid parameter: lat (must be numbers)" }, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid parameter: lat (latitude out of bounds)" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (lng === null) {
|
||||
const lngParam = searchParams.get("lng");
|
||||
if (lngParam === null || lngParam === "") {
|
||||
return NextResponse.json({ error: "Missing parameter: lng" }, { status: 400 });
|
||||
} else if (isNaN(parseFloat(lngParam))) {
|
||||
return NextResponse.json({ error: "Invalid parameter: lng (must be numbers)" }, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({ error: "Invalid parameter: lng (longitude out of bounds)" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let radius = 1000;
|
||||
const radiusStr = searchParams.get("radius");
|
||||
if (radiusStr !== null) {
|
||||
const parsed = parseFloat(radiusStr);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
radius = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Cap radius at 5000 m
|
||||
if (radius > 5000) {
|
||||
radius = 5000;
|
||||
}
|
||||
|
||||
try {
|
||||
const stops = await client.findNearbyStops(lat, lng, radius);
|
||||
return NextResponse.json({ stops });
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Wiener Linien nearby stops lookup failed:`, error);
|
||||
return NextResponse.json({ error: "Failed to fetch nearby stops", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useCalendar } from "@/hooks/useCalendar";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import UrlTab from "./UrlTab";
|
||||
import FileTab from "./FileTab";
|
||||
|
||||
type CalendarPanelProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
const [activeTab, setActiveTab] = useState<"url" | "file">("url");
|
||||
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
|
||||
const { mergeEvents } = useEventsStore();
|
||||
|
||||
// Merge calendar events into global store whenever they change
|
||||
useEffect(() => {
|
||||
if (calendarEvents.length > 0) {
|
||||
mergeEvents(calendarEvents);
|
||||
}
|
||||
}, [calendarEvents, mergeEvents]);
|
||||
|
||||
const handleLoadCalendar = async (urlOrFile: string | File) => {
|
||||
if (typeof urlOrFile === "string") {
|
||||
await fetchCalendarFromUrl(urlOrFile);
|
||||
} else {
|
||||
await parseCalendarFromFile(urlOrFile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Import Calendar</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
|
||||
<button
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "url" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("url")}
|
||||
disabled={loading}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "file" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("file")}
|
||||
disabled={loading}
|
||||
>
|
||||
File
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{activeTab === "url" ? (
|
||||
<UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
|
||||
) : (
|
||||
<FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarPanel;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
|
||||
import { Event } from "@/types";
|
||||
import { Event } from "@timetoleave/core";
|
||||
|
||||
type CalendarViewProps = {
|
||||
events: Event[];
|
||||
@@ -14,21 +14,35 @@ type CalendarViewProps = {
|
||||
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
|
||||
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
|
||||
|
||||
const eventsByDate = React.useMemo(() => {
|
||||
const map = new Map<string, Event[]>();
|
||||
for (const event of events) {
|
||||
const key = new Date(event.eventTime).toISOString().slice(0, 10);
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.push(event);
|
||||
} else {
|
||||
map.set(key, [event]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
|
||||
>
|
||||
←
|
||||
<
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<h2 className="text-xl font-semibold text-white">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
|
||||
>
|
||||
→
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -40,7 +54,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
headers.push(
|
||||
<div key={i} className="text-center font-medium text-gray-600 dark:text-gray-300 py-2">
|
||||
<div key={i} className="py-2 text-center text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/46">
|
||||
{daysOfWeek[i]}
|
||||
</div>,
|
||||
);
|
||||
@@ -60,14 +74,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
const cells: React.ReactNode[] = [];
|
||||
|
||||
days.forEach((day, index) => {
|
||||
const dayEvents = events.filter((event) => {
|
||||
const eventDate = new Date(event.eventTime);
|
||||
return (
|
||||
eventDate.getDate() === day.getDate() &&
|
||||
eventDate.getMonth() === day.getMonth() &&
|
||||
eventDate.getFullYear() === day.getFullYear()
|
||||
);
|
||||
});
|
||||
const dayKey = day.toISOString().slice(0, 10);
|
||||
const dayEvents = eventsByDate.get(dayKey) ?? [];
|
||||
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const isTodayDate = isToday(day);
|
||||
@@ -79,16 +87,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer
|
||||
${!isCurrentMonth ? "bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500" : "bg-white dark:bg-gray-900"}
|
||||
${isTodayDate ? "ring-2 ring-blue-500" : ""}
|
||||
hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors
|
||||
min-h-24 cursor-pointer rounded-xl border p-2
|
||||
${!isCurrentMonth ? "border-white/[0.06] bg-black/18 text-[#F4F1EA]/28" : "border-white/10 bg-white/[0.045] text-[#F4F1EA]"}
|
||||
${isTodayDate ? "ring-2 ring-[#D946EF]/80" : ""}
|
||||
transition-colors hover:border-[#D946EF]/42 hover:bg-white/[0.075]
|
||||
`}
|
||||
>
|
||||
<div className="text-right">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
|
||||
isTodayDate ? "bg-blue-500 text-white" : ""
|
||||
isTodayDate ? "bg-gradient-to-br from-[#8B5CF6] to-[#FF2D8D] text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{format(day, "d")}
|
||||
@@ -98,13 +106,13 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="text-xs truncate bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"
|
||||
className="truncate rounded bg-[#B23CFF]/22 px-2 py-1 text-xs font-medium text-[#F4F1EA]"
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">+{dayEvents.length - 3} more</div>
|
||||
<div className="text-xs text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
@@ -125,7 +133,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 ${className}`}>
|
||||
<div className={`brand-panel rounded-2xl p-4 ${className}`}>
|
||||
{renderHeader()}
|
||||
{renderDays()}
|
||||
{renderCells()}
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
import React from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Event, TripDataEntry } from "@/types";
|
||||
import { Event, Station } from "@timetoleave/core";
|
||||
import EventCard from "@/app/event/EventCard";
|
||||
|
||||
type DayEventsProps = {
|
||||
events: Event[];
|
||||
date: Date;
|
||||
tripData?: Record<string, TripDataEntry>; // Map of event id to trip data
|
||||
originStation: Station | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, className = "" }) => {
|
||||
const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, className = "" }) => {
|
||||
const filteredEvents = events.filter((event) => {
|
||||
// Direct Date comparison instead of parseISO
|
||||
const eventDate = new Date(event.eventTime);
|
||||
return (
|
||||
eventDate.getDate() === date.getDate() &&
|
||||
@@ -25,7 +24,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, clas
|
||||
|
||||
if (filteredEvents.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 text-gray-500 dark:text-gray-400 ${className}`}>
|
||||
<div className={`brand-panel rounded-2xl px-4 py-8 text-center text-[#F4F1EA]/60 ${className}`}>
|
||||
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -33,14 +32,10 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, clas
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<div className="space-y-3">
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
tripData={tripData[event.id] || { journeys: [], destName: "", demo: false, loading: false }}
|
||||
/>
|
||||
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,9 +62,9 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className={`p-1 ${className}`}>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${dragging ? "border-blue-500 bg-blue-50" : "border-gray-300"}`}
|
||||
className={`mb-4 cursor-pointer rounded-2xl border border-dashed p-8 text-center text-sm transition-colors ${dragging ? "border-[#D946EF] bg-[#B23CFF]/12 text-white" : "border-white/20 bg-white/[0.04] text-[#F4F1EA]/68 hover:border-[#D946EF]/50 hover:bg-white/[0.06]"}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -74,7 +74,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
|
||||
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
|
||||
</div>
|
||||
{error && <div className="mt-4 text-red-600 text-sm">{error}</div>}
|
||||
{error && <div className="mb-4 text-sm text-[#FF2D8D]">{error}</div>}
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
|
||||
{loading ? <>Select File</> : <>Select File</>}
|
||||
</Button>
|
||||
@@ -21,10 +21,10 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className={`p-1 ${className}`}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label htmlFor="calendar-url" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Calendar URL
|
||||
</label>
|
||||
<input
|
||||
@@ -33,12 +33,12 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div role="alert" className="text-red-600 text-sm">
|
||||
<div role="alert" className="text-[#FF2D8D] text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CalendarView from "../CalendarView";
|
||||
|
||||
describe("CalendarView", () => {
|
||||
it("renders month header", () => {
|
||||
render(
|
||||
<CalendarView
|
||||
events={[]}
|
||||
onDateSelect={() => {}}
|
||||
selectedDate={new Date("2025-06-15")}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("June 2025")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders events on the correct day", () => {
|
||||
const event = {
|
||||
id: "cal-1",
|
||||
title: "Sprint Planning",
|
||||
destination: "Vienna",
|
||||
eventTime: new Date("2025-06-15T10:00:00"),
|
||||
source: "calendar" as const,
|
||||
};
|
||||
|
||||
render(
|
||||
<CalendarView
|
||||
events={[event]}
|
||||
onDateSelect={() => {}}
|
||||
selectedDate={new Date("2025-06-15")}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows overflow indicator when more than 3 events on a day", () => {
|
||||
const events = [
|
||||
{ id: "1", title: "Event 1", destination: "A", eventTime: new Date("2025-06-10T09:00:00"), source: "manual" as const },
|
||||
{ id: "2", title: "Event 2", destination: "B", eventTime: new Date("2025-06-10T10:00:00"), source: "manual" as const },
|
||||
{ id: "3", title: "Event 3", destination: "C", eventTime: new Date("2025-06-10T11:00:00"), source: "manual" as const },
|
||||
{ id: "4", title: "Event 4", destination: "D", eventTime: new Date("2025-06-10T12:00:00"), source: "manual" as const },
|
||||
];
|
||||
|
||||
render(
|
||||
<CalendarView
|
||||
events={events}
|
||||
onDateSelect={() => {}}
|
||||
selectedDate={new Date("2025-06-10")}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("+1 more")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import { useOriginStation } from "@/hooks/useOriginStation";
|
||||
import CalendarView from "./CalendarView";
|
||||
import DayEvents from "./DayEvents";
|
||||
import CalendarPanel from "./CalendarPanel";
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { events } = useEventsStore();
|
||||
const { station: originStation } = useOriginStation();
|
||||
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-brand-fuchsia">Calendar sync</p>
|
||||
<h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1>
|
||||
<p className="mt-2 text-brand-light/66">View and manage every appointment from a single departure-focused calendar.</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<CalendarPanel />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
|
||||
</div>
|
||||
<div>
|
||||
<DayEvents events={events} date={selectedDate} originStation={originStation} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BikeRoute } from "@/types";
|
||||
import { BikeRoute } from "@timetoleave/core";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
@@ -11,19 +11,28 @@ type BikeSectionProps = {
|
||||
bikeError: string | null | undefined;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
forceVisible?: boolean;
|
||||
};
|
||||
|
||||
const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeError, onRefresh, className = "" }) => {
|
||||
if (!bikeRoute && !bikeLoading && !bikeError) {
|
||||
const BikeSection: React.FC<BikeSectionProps> = ({
|
||||
bikeRoute,
|
||||
bikeLoading,
|
||||
bikeError,
|
||||
onRefresh,
|
||||
className = "",
|
||||
forceVisible = false,
|
||||
}) => {
|
||||
if (!forceVisible && !bikeRoute && !bikeLoading && !bikeError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden mt-4 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className={`mt-4 overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Bicycle Route</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
|
||||
<p className="text-sm text-brand-light/60">Door-to-door route to the event</p>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -38,36 +47,40 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : bikeError ? (
|
||||
<div className="text-center py-4 text-red-600">Error: {bikeError}</div>
|
||||
<div className="text-center py-4 text-brand-pink">Error: {bikeError}</div>
|
||||
) : bikeRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-300">Distance</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-brand-light/66">Distance</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-300">Duration</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-brand-light/66">Duration</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
||||
</span>
|
||||
</div>
|
||||
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium text-gray-800 dark:text-white mb-2">Steps</h4>
|
||||
<h4 className="mb-2 font-medium text-brand-light">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{bikeRoute.steps.map((step, index) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-blue-600 dark:text-blue-400">{step.name}</span>
|
||||
<span className="ml-2 text-gray-600 dark:text-gray-300">{step.instruction}</span>
|
||||
<span className="font-medium text-brand-fuchsia">{step.name}</span>
|
||||
<span className="ml-2 text-brand-light/70">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-brand-light/58">
|
||||
Add origin and destination coordinates to calculate a bike route.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useJourneys } from "@/hooks/useJourneys";
|
||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||
import { useBikeRoute } from "@/hooks/useBikeRoute";
|
||||
import { useWalkRoute } from "@/hooks/useWalkRoute";
|
||||
import { useGeocode } from "@/hooks/useGeocode";
|
||||
import { useClock } from "@/hooks/useClock";
|
||||
import { useDepartureTime } from "@/hooks/useDepartureTime";
|
||||
import { useReminderSettings } from "@/hooks/useReminderSettings";
|
||||
import { useWienerLinien } from "@/hooks/useWienerLinien";
|
||||
import type { Event, Station } from "@timetoleave/core";
|
||||
import TrainSection from "./TrainSection";
|
||||
import BikeSection from "./BikeSection";
|
||||
import WienerLinienSection from "./WienerLinienSection";
|
||||
import CountdownBadge from "@/app/ui/CountdownBadge";
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
originStation: Station | null;
|
||||
}
|
||||
|
||||
export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
const destStation = useDestinationStation(event.destination);
|
||||
|
||||
const {
|
||||
journeys,
|
||||
loading: journeysLoading,
|
||||
error: journeysError,
|
||||
} = useJourneys(originStation?.extId ?? null, destStation.station?.extId ?? null, event.eventTime, 0);
|
||||
|
||||
const destCoords = useGeocode(event.destination);
|
||||
|
||||
const {
|
||||
bikeRoute,
|
||||
loading: bikeLoading,
|
||||
error: bikeError,
|
||||
} = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const {
|
||||
walkRoute,
|
||||
loading: walkLoading,
|
||||
error: walkError,
|
||||
} = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
|
||||
const {
|
||||
stops,
|
||||
departures,
|
||||
loading: wlLoading,
|
||||
error: wlError,
|
||||
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
|
||||
|
||||
type TransportMode = "train" | "bike";
|
||||
const [requestedMode, setRequestedMode] = useState<TransportMode>("train");
|
||||
const activeMode: TransportMode = !showBikeOption && requestedMode === "bike" ? "train" : requestedMode;
|
||||
|
||||
const { departureTime, arrivalTime, mode: calculatedMode } = useDepartureTime(
|
||||
event.eventTime,
|
||||
journeys,
|
||||
bikeRoute?.duration ?? null,
|
||||
activeMode,
|
||||
);
|
||||
|
||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||
const bikeDisabled = !showBikeOption;
|
||||
const modeOptions: Array<{ id: TransportMode; label: string; meta: string; disabled?: boolean }> = [
|
||||
{ id: "train", label: "Train", meta: showWalkingOption ? "Rail + final walk" : "Rail only" },
|
||||
{
|
||||
id: "bike",
|
||||
label: "Bike",
|
||||
meta: bikeDisabled ? "Disabled in settings" : bikeLoading ? "Calculating route" : "Door to door",
|
||||
disabled: bikeDisabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.24em] text-brand-fuchsia">Next stop</p>
|
||||
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
|
||||
</div>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Destination</p>
|
||||
<p className="mt-1 text-sm font-medium text-brand-light">{event.destination}</p>
|
||||
</div>
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-brand-light/42">Appointment</p>
|
||||
<p className="mt-1 text-sm font-medium text-brand-light">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`rounded-xl border p-3 text-left transition-all ${
|
||||
activeMode === option.id
|
||||
? "border-brand-fuchsia/70 bg-brand-purple/18 shadow-[0_14px_34px_rgba(178,60,255,0.2)]"
|
||||
: "border-white/10 bg-white/[0.045] hover:border-brand-fuchsia/40 hover:bg-white/[0.07]"
|
||||
} ${option.disabled ? "cursor-not-allowed opacity-45 hover:border-white/10 hover:bg-white/[0.045]" : ""}`}
|
||||
onClick={() => {
|
||||
if (!option.disabled) {
|
||||
setRequestedMode(option.id);
|
||||
}
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-white">{option.label}</span>
|
||||
{activeMode === option.id && (
|
||||
<span className="rounded-full bg-brand-pink/20 px-2 py-0.5 text-xs font-semibold text-pink-100">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-brand-light/58">{option.meta}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-3 rounded-xl border border-white/10 bg-black/16 p-3 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Leave by</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{departureTime ? format(departureTime, "HH:mm") : "Pending"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Arrive by</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{arrivalTime ? format(arrivalTime, "HH:mm") : format(new Date(event.eventTime.getTime() - arrivalBufferMinutes * 60_000), "HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-light/42">Buffer</p>
|
||||
<p className="mt-1 font-semibold text-white">
|
||||
{arrivalBufferMinutes} min {calculatedMode ? `via ${calculatedMode}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeMode === "train" && (
|
||||
<TrainSection
|
||||
journeys={journeys}
|
||||
eventTime={event.eventTime}
|
||||
destName={event.destination}
|
||||
loading={journeysLoading}
|
||||
error={journeysError}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
showWalkingOption={showWalkingOption}
|
||||
walkRoute={walkRoute}
|
||||
walkLoading={walkLoading}
|
||||
walkError={walkError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBikeOption && activeMode === "bike" && (
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} forceVisible />
|
||||
)}
|
||||
|
||||
{stops.length > 0 && (
|
||||
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Journey } from "@timetoleave/core";
|
||||
import { formatTime } from "@timetoleave/core";
|
||||
import LeaveByBadge from "./LeaveByBadge";
|
||||
import { calculateCountdown } from "@timetoleave/core";
|
||||
|
||||
type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const JourneyList: React.FC<JourneyListProps> = ({
|
||||
journeys,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
className = "",
|
||||
}) => {
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
|
||||
if (journeys.length === 0) {
|
||||
return <div className={`py-8 text-center text-brand-light/60 ${className}`}>No journeys found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={`space-y-3 p-4 ${className}`}>
|
||||
{journeys.map((journey) => {
|
||||
const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime();
|
||||
const departure = journey.rD ?? journey.sD;
|
||||
const arrival = journey.rA ?? journey.sA;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={journey.id}
|
||||
className={`rounded-xl border p-3 ${
|
||||
arrivesTooLate || journey.cancelled
|
||||
? "border-brand-pink/18 bg-brand-pink/7 opacity-40 line-through"
|
||||
: "border-white/10 bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold text-white">{formatTime(departure)}</span>
|
||||
<span className="ml-2 text-sm text-brand-light/48">{journey.platform}</span>
|
||||
{journey.delay > 0 && (
|
||||
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<span className={`font-medium ${journey.cancelled ? "text-brand-pink" : "text-brand-light"}`}>
|
||||
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<LeaveByBadge countdown={calculateCountdown(departure)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-brand-light/64">
|
||||
<span>{journey.changes > 0 ? `Change(s): ${journey.changes}` : "Direct"}</span>
|
||||
<span>Arrives {formatTime(arrival)}</span>
|
||||
{arrivesTooLate && (
|
||||
<span className="font-medium text-brand-pink">
|
||||
misses {arrivalBufferMinutes} min buffer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default JourneyList;
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CountdownInfo } from "@/types";
|
||||
import { CountdownInfo } from "@timetoleave/core";
|
||||
import Chip from "@/app/ui/Chip";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "text-red-600",
|
||||
orange: "text-orange-600",
|
||||
yellow: "text-yellow-600",
|
||||
green: "text-green-600",
|
||||
blue: "text-blue-600",
|
||||
red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100",
|
||||
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
|
||||
yellow: "border-yellow-300/30 bg-yellow-300/16 text-yellow-100",
|
||||
green: "border-emerald-300/30 bg-emerald-400/16 text-emerald-100",
|
||||
blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
|
||||
};
|
||||
|
||||
type LeaveByBadgeProps = {
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Journey, WalkRoute } from "@timetoleave/core";
|
||||
import { formatDateTime } from "@timetoleave/core";
|
||||
import WalkingOption from "./WalkingOption";
|
||||
import JourneyList from "./JourneyList";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
type TrainSectionProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
destName: string;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
arrivalBufferMinutes?: number;
|
||||
showWalkingOption?: boolean;
|
||||
walkRoute?: WalkRoute | null;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null;
|
||||
};
|
||||
|
||||
const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
journeys,
|
||||
eventTime,
|
||||
destName,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
className = "",
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption,
|
||||
walkRoute,
|
||||
walkLoading,
|
||||
walkError,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Trains</h3>
|
||||
<p className="text-sm text-brand-light/66">
|
||||
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||
</p>
|
||||
{(arrivalBufferMinutes ?? 0) > 0 && (
|
||||
<p className="mt-1 text-xs text-brand-fuchsia">
|
||||
Target arrival: {arrivalBufferMinutes} min early
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-sm text-brand-pink">{error}</div>
|
||||
) : (
|
||||
<>
|
||||
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
||||
{showWalkingOption && (walkRoute || walkLoading || walkError) && (
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrainSection;
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { WalkRoute } from "@timetoleave/core";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
type WalkingOptionProps = {
|
||||
walkRoute: WalkRoute | null | undefined;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null | undefined;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, walkError, onRefresh, className = "" }) => {
|
||||
if (!walkRoute && !walkLoading && !walkError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Final Walk</h3>
|
||||
<p className="text-sm text-brand-light/60">From arrival station to destination</p>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{walkLoading ? (
|
||||
<div className="py-4 text-center">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : walkError ? (
|
||||
<div className="py-4 text-center text-brand-pink">Error: {walkError}</div>
|
||||
) : walkRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-light/66">Distance</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.round(walkRoute.distance / 1000)} km ({Math.round(walkRoute.distance)} m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-light/66">Duration</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.floor(walkRoute.duration / 60)} min {walkRoute.duration % 60} s
|
||||
</span>
|
||||
</div>
|
||||
{walkRoute.steps && walkRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 font-medium text-brand-light">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-brand-fuchsia">{step.name}</span>
|
||||
<span className="ml-2 text-brand-light/70">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalkingOption;
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { WienerLinienStop } from "@timetoleave/core";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Chip from "@/app/ui/Chip";
|
||||
|
||||
interface DepartureRow {
|
||||
stopId: string;
|
||||
lineName: string;
|
||||
direction: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
type WienerLinienSectionProps = {
|
||||
stops: WienerLinienStop[];
|
||||
departures: DepartureRow[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function WienerLinienSection({
|
||||
stops,
|
||||
departures,
|
||||
loading,
|
||||
error,
|
||||
className = "",
|
||||
}: WienerLinienSectionProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<LoadingSpinner />
|
||||
<p className="mt-2 text-sm text-[#F4F1EA]/60">Loading nearby stops...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-[#FF2D8D]">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-[#F4F1EA]/60">No nearby stops found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const departuresByStop = new Map<string, DepartureRow[]>();
|
||||
for (const departure of departures) {
|
||||
const stopId = departure.stopId;
|
||||
const existing = departuresByStop.get(stopId) ?? [];
|
||||
existing.push(departure);
|
||||
departuresByStop.set(stopId, existing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{stops.map((stop) => {
|
||||
const stopDepartures = departuresByStop.get(stop.id) ?? [];
|
||||
|
||||
return (
|
||||
<div key={stop.id} className="mb-4 rounded-xl border border-white/10 bg-black/16 p-4 last:mb-0">
|
||||
<h3 className="text-sm font-semibold text-white">{stop.name}</h3>
|
||||
|
||||
{stopDepartures.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-[#F4F1EA]/40">No departures available</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{stopDepartures.map((departure, index) => (
|
||||
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
|
||||
<Chip>{departure.lineName}</Chip>
|
||||
<span className="text-sm text-[#F4F1EA]/70">{departure.direction}</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{departure.minutes} min
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import EventCard from "../EventCard";
|
||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||
import { useJourneys } from "@/hooks/useJourneys";
|
||||
|
||||
// vi.mock hoists, so these imports are the mocked versions
|
||||
// We can inspect their call arguments directly
|
||||
|
||||
// Mock all hooks that EventCard depends on
|
||||
vi.mock("@/hooks/useGeolocation", () => ({
|
||||
useGeolocation: () => ({
|
||||
location: null,
|
||||
status: "pending",
|
||||
requestLocation: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useDestinationStation", () => ({
|
||||
useDestinationStation: vi.fn(() => ({
|
||||
station: { name: "Wien Hbf", extId: "0WB0F0001500" },
|
||||
loading: false,
|
||||
error: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useGeocode", () => ({
|
||||
useGeocode: () => ({
|
||||
coords: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useJourneys", () => ({
|
||||
useJourneys: vi.fn(() => ({
|
||||
journeys: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useBikeRoute", () => ({
|
||||
useBikeRoute: () => ({
|
||||
bikeRoute: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWalkRoute", () => ({
|
||||
useWalkRoute: () => ({
|
||||
walkRoute: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useDepartureTime", () => ({
|
||||
useDepartureTime: () => ({
|
||||
departureTime: null,
|
||||
arrivalTime: null,
|
||||
mode: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useReminderSettings", () => ({
|
||||
useReminderSettings: () => ({
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
bufferMinutes: 15,
|
||||
enabled: true,
|
||||
setArrivalBufferMinutes: () => {},
|
||||
setShowWalkingOption: () => {},
|
||||
setShowBikeOption: () => {},
|
||||
setBufferMinutes: () => {},
|
||||
setEnabled: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWienerLinien", () => ({
|
||||
useWienerLinien: () => ({
|
||||
stops: [],
|
||||
departures: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useClock", () => ({
|
||||
useClock: () => ({
|
||||
countdown: { label: "No deadline set", color: "text-gray-400", urgent: false },
|
||||
status: "upcoming",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/countdown-utils", () => ({
|
||||
calculateCountdown: () => ({
|
||||
label: "No deadline set",
|
||||
color: "text-gray-400",
|
||||
urgent: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("EventCard", () => {
|
||||
beforeEach(() => {
|
||||
(useDestinationStation as ReturnType<typeof vi.fn>).mockReset();
|
||||
(useDestinationStation as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
station: { name: "Wien Hbf", extId: "0WB0F0001500" },
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
(useJourneys as ReturnType<typeof vi.fn>).mockReset();
|
||||
(useJourneys as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
journeys: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders event title and destination", () => {
|
||||
const mockEvent = {
|
||||
id: "test-1",
|
||||
title: "Team Meeting",
|
||||
destination: "Wien Hbf",
|
||||
eventTime: new Date("2025-12-01T14:00:00"),
|
||||
source: "manual" as const,
|
||||
};
|
||||
|
||||
const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" };
|
||||
|
||||
render(<EventCard event={mockEvent} originStation={mockStation} />);
|
||||
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
|
||||
expect(screen.getByText("Wien Hbf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes destStation.station.extId to useJourneys as the destination argument", () => {
|
||||
const mockEvent = {
|
||||
id: "test-2",
|
||||
title: "Lunch at Hofburg",
|
||||
destination: "Hofburg",
|
||||
eventTime: new Date("2025-12-01T12:00:00"),
|
||||
source: "manual" as const,
|
||||
};
|
||||
|
||||
const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" };
|
||||
|
||||
render(<EventCard event={mockEvent} originStation={mockStation} />);
|
||||
|
||||
// useDestinationStation should be called with the event's destination string
|
||||
expect(useDestinationStation).toHaveBeenCalledWith("Hofburg");
|
||||
|
||||
// useJourneys should be called with origin extId as first arg
|
||||
// and the destination station's extId as second arg (NOT null)
|
||||
expect(useJourneys).toHaveBeenCalledWith("0WB0F0000600", "0WB0F0001500", new Date("2025-12-01T12:00:00"), 0);
|
||||
});
|
||||
|
||||
it("passes null as originStation extId to useJourneys when originStation is null", () => {
|
||||
const mockEvent = {
|
||||
id: "test-3",
|
||||
title: "Meeting without origin",
|
||||
destination: "Parapluie",
|
||||
eventTime: new Date("2025-12-01T10:00:00"),
|
||||
source: "manual" as const,
|
||||
};
|
||||
|
||||
render(<EventCard event={mockEvent} originStation={null} />);
|
||||
|
||||
// When originStation is null, the first arg to useJourneys should be null
|
||||
expect(useJourneys).toHaveBeenCalledWith(null, "0WB0F0001500", new Date("2025-12-01T10:00:00"), 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Test to verify JourneyList uses arrivalBufferMinutes correctly
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import JourneyList from "@/app/event/JourneyList";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
|
||||
describe("JourneyList component", () => {
|
||||
it("should render journey countdowns based on journey departure times", () => {
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"),
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const eventTime = new Date("2025-01-01T16:00:00Z");
|
||||
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show countdown to journey departure (14:00), not to event time (16:00)
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept arrivalBufferMinutes prop", () => {
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"),
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const eventTime = new Date("2025-01-01T16:00:00Z");
|
||||
|
||||
// This should not throw an error
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={10}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should dim late journeys based on arrivalBufferMinutes", () => {
|
||||
const lateJourney: Journey = {
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T16:00:00Z"), // Arrives after event time (too late)
|
||||
rA: new Date("2025-01-01T16:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const onTimeJourney: Journey = {
|
||||
id: "2",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"), // Arrives before event time (on time)
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "2",
|
||||
changes: 0,
|
||||
trains: ["R2"],
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const eventTime = new Date("2025-01-01T15:30:00Z");
|
||||
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={[lateJourney, onTimeJourney]}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={15} // 15 minutes buffer
|
||||
/>
|
||||
);
|
||||
|
||||
// Late journey should be dimmed and have line-through
|
||||
const lateJourneyElement = screen.getByText("R1").closest("li");
|
||||
expect(lateJourneyElement).toHaveClass("opacity-40");
|
||||
expect(lateJourneyElement).toHaveClass("line-through");
|
||||
|
||||
// On-time journey should not be dimmed
|
||||
const onTimeJourneyElement = screen.getByText("R2").closest("li");
|
||||
expect(onTimeJourneyElement).not.toHaveClass("opacity-40");
|
||||
expect(onTimeJourneyElement).not.toHaveClass("line-through");
|
||||
});
|
||||
});
|
||||