Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef36d227e9 | |||
| 6fb7941d56 | |||
| 55c77c7572 | |||
| fff5700132 | |||
| 5bcfafcbaf | |||
| 20159262c1 | |||
| d08afc1dcb | |||
| 442a00dbbe | |||
| 330cbb6b37 | |||
| de9c16430b | |||
| b2608a4a60 | |||
| 7901368971 |
@@ -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
|
||||
|
||||
@@ -27,3 +27,14 @@ npm-debug.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.aider*
|
||||
|
||||
# agent loop generated output
|
||||
agent_loop/logs/
|
||||
agent_loop/runs/
|
||||
agent_loop/__pycache__/
|
||||
logs/
|
||||
runs/
|
||||
|
||||
# next.js build output (apps)
|
||||
apps/web/.next/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"label": "Run Wiener Linien agent loop",
|
||||
"command": "python",
|
||||
"args": [
|
||||
"ttl_agent_gemma4.py",
|
||||
"--workspace", "/home/fegger/Code/TimeToLeave",
|
||||
"--run-until-done"
|
||||
],
|
||||
"cwd": "$ZED_WORKTREE_ROOT/agent_loop",
|
||||
"use_new_terminal": true,
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "always"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Aider Coding Rules
|
||||
|
||||
You are editing a real repository. Correctness is more important than speed.
|
||||
|
||||
## Operating Rules
|
||||
|
||||
1. Before editing, identify the exact requested task and restate the concrete files or behaviors that must
|
||||
change.
|
||||
2. Read the relevant existing files before making changes. Do not infer APIs, imports, types, or component
|
||||
contracts from memory.
|
||||
3. Implement every requested step. Do not skip checklist items, tests, wiring, exports, or documentation
|
||||
updates that are part of the task.
|
||||
4. Keep changes minimal and scoped. Do not refactor unrelated code, rename unrelated symbols, or change
|
||||
behavior outside the task.
|
||||
5. Prefer existing project patterns over new abstractions.
|
||||
6. If a requirement is ambiguous, choose the smallest implementation that satisfies the written request and
|
||||
state the assumption.
|
||||
|
||||
## Code Quality Rules
|
||||
|
||||
1. Do not introduce type errors, broken imports, missing exports, unused variables, or dead code.
|
||||
2. Do not use placeholder code, TODOs, stubs, fake implementations, or comments claiming work is done when
|
||||
it is not.
|
||||
3. Preserve existing public APIs unless the task explicitly changes them.
|
||||
4. Handle null, undefined, empty arrays, failed network calls, and invalid user input where relevant.
|
||||
5. Keep async behavior explicit. Await promises that must complete before continuing.
|
||||
6. Do not weaken or delete tests to make checks pass.
|
||||
|
||||
## Step Completion Rules
|
||||
|
||||
Before finishing, verify this checklist mentally and fix any failures:
|
||||
|
||||
- The requested behavior is fully implemented.
|
||||
- Every required file is created or updated.
|
||||
- All changed imports resolve.
|
||||
- All changed types are valid.
|
||||
- Existing behavior not mentioned in the task is preserved.
|
||||
- Tests were added or updated when behavior changed.
|
||||
- No generated files, build artifacts, cache files, or secrets were edited.
|
||||
- The final response lists what changed and any checks that still need to be run.
|
||||
|
||||
## If You Are Unsure
|
||||
|
||||
Do not guess. Inspect the repository first. If still uncertain, make the smallest safe change and
|
||||
explicitly mention the assumption in the final response.
|
||||
@@ -1,62 +1,47 @@
|
||||
# TimeToLeave — Implementation Checklist
|
||||
|
||||
> **Source:** [REWRITE_PLAN.md](./REWRITE_PLAN.md)
|
||||
> **Total:** 4 phases, 14 steps, ~5 hours estimated effort
|
||||
## Phase 1 — Workspace + Shared Packages (Steps 1-9)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 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 2 — Expo Mobile MVP (Steps 10-18)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|-|
|
||||
| 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] |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Unblock Runtime (~45 min)
|
||||
|
||||
Fix bugs that crash the app or lose user data.
|
||||
|
||||
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||
|------|:--------------:|:-----------:|-------|
|
||||
| **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
|
||||
| **Step 2: Wire Calendar Events into `EventsStore`** (~15 min) | [x] | [x] | `CalendarPanel.tsx` merges via `useEffect` when calendar events arrive. `mergeEvents()` converts `CalendarEvent` (string `eventTime`) → `Event` (Date `eventTime`). Bonus: localStorage persistence with rehydration. |
|
||||
| **Step 3: Add Input Validation to `/api/hafas`** (~20 min) | [x] | [x] | Validates `svcReqL` array shape, method allowlist (TripSearch/LocMatch), caps `numF` at 10. Extra type guard on `svcReq.meth` (`typeof svcReq.meth !== 'string'`) exceeds spec. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Deduplicate Code (~2 hours)
|
||||
|
||||
Eliminate duplicated logic so each integration has one source of truth.
|
||||
|
||||
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||
|------|:------:|:------:|-------|
|
||||
| **Step 4: Consolidate HAFAS Journey Parsing** (~40 min) | [x] | [x] | parseHafasJourneys moved to hafas-client.ts, exported, imported by useJourneys.ts. Option A followed. |
|
||||
| **Step 5: Wire API Routes to Use Library Clients** (~30 min) | [x] | [x] | Both routes use module-level singleton clients. Param validation, error handling, and try/catch intact. |
|
||||
| **Step 6: Remove Dead Code** (~5 min) | [x] | [x] | live-status-utils.ts deleted, export removed from index.ts. No remaining references. |
|
||||
| **Step 7: Create Missing Test Setup File** (~10 min) | [x] | [x] | src/test/setup.ts created with jest-dom vitest import. Referenced correctly in vitest.config.ts. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Performance & UX (~1.5 hours)
|
||||
|
||||
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||
|------|:------:|:------:|-------|
|
||||
| **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | [x] | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. |
|
||||
| **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | [x] | `useMemo` builds `Map<string, Event[]>` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. |
|
||||
| **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | [x] | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. |
|
||||
| **Step 11: Fix Bike Route Steps** (~5 min) | [x] | [x] | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Monitoring & Testing (~1 hour)
|
||||
|
||||
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||
|------|:------:|:------:|-------|
|
||||
| **Step 12: Add Correlation IDs to API Errors** (~15 min) | [x] | [x] | `randomUUID().slice(0, 8)` in catch blocks of `bike-route`, `geocode`, `hafas`, `calendar`, `calendar/parse`. Logged server-side, returned in JSON. Existing API tests updated to assert `correlationId`. |
|
||||
| **Step 13: Add Hook Tests** (~30 min) | [x] | [x] | `useJourneys.test.ts` (4 tests: no-op when missing IDs, success, HTTP error, fetch throw). `useBikeRoute.test.ts` (4 tests: no-op when missing coords, success, HTTP error, fetch throw). |
|
||||
| **Step 14: Add Component Tests** (~15 min) | [x] | [x] | `EventCard.test.tsx` (renders title + destination, hooks mocked). `CalendarView.test.tsx` (3 tests: month header, event on correct day, overflow indicator). |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Steps | Est. Time |
|
||||
|-------|-------|-----------|
|
||||
| 1 — Unblock Runtime | 1–3 | ~45 min |
|
||||
| 2 — Deduplicate Code | 4–7 | ~2 hours |
|
||||
| 3 — Performance & UX | 8–11 | ~1.5 hours |
|
||||
| 4 — Monitoring & Testing | 12–14 | ~1 hour |
|
||||
| **Total** | **14** | **~5 hours** |
|
||||
**Legend:**
|
||||
- ✅ = Done (code written)
|
||||
- ✔️ = Verified (tests/builds pass)
|
||||
- `[~]` = Optional or deferred (never blocks phase advancement)
|
||||
|
||||
@@ -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].
|
||||
@@ -0,0 +1,137 @@
|
||||
# ⏱️ TimeToLeave
|
||||
|
||||
> **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), calculates your bike route to the station, and provides a live "Leave Status" based on real-time delays.
|
||||
|
||||
   
|
||||
|
||||
## 🚀 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:** Zustand (for events and station selection)
|
||||
* **Routing:** Next.js built-in routing for `/add-event`, `/calendar`, and `/event` 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 by name
|
||||
const stations = await api.searchStation("Wien Mitte");
|
||||
|
||||
// 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
|
||||
stations[0].lat, stations[0].lng // To lat/lng
|
||||
);
|
||||
```
|
||||
|
||||
## 🛡️ Testing & Quality Assurance
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
*Built for developers who bike to the train and hate missing their connections.*
|
||||
@@ -0,0 +1,179 @@
|
||||
from agent_base import main
|
||||
|
||||
WRITER_PROMPT = """You are a disciplined TypeScript software engineering agent implementing the Wiener Linien feature on the TimeToLeave project.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and required; blocks the next step
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; never blocks advancement
|
||||
|
||||
Rules:
|
||||
- The ✅ column is yours; the ✔️ column belongs to the review agent.
|
||||
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
|
||||
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
|
||||
- Implement only that one step. Do not implement any later steps.
|
||||
- After completing the step, mark its ✅ box by changing `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
|
||||
- Do not mark the ✔️ column — that belongs to the review agent.
|
||||
|
||||
## Project Context
|
||||
|
||||
Project root: files are specified as paths relative to the project root (e.g. `src/types/index.ts`).
|
||||
Stack: Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest.
|
||||
Feature: Wiener Linien real-time departures via the free OGD Echtzeitdaten REST API (`https://www.wienerlinien.at/ogd_realtime`).
|
||||
Architecture pattern: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → UI components in `src/app`.
|
||||
|
||||
## Work Step By Step
|
||||
|
||||
- Start by reading `CHECKLIST.md` to identify the current step, then read the relevant existing files.
|
||||
- State what the current step requires before making changes.
|
||||
- Implement one coherent change at a time. Prefer small targeted edits over rewrites.
|
||||
- Review the diff mentally before submitting — ensure it matches the intended behavior.
|
||||
- Do not move to the next step. The review agent must mark ✔️ before the next step begins.
|
||||
|
||||
## Quality Bar
|
||||
|
||||
- Patch the existing project; do not restart from scratch unless the file does not yet exist.
|
||||
- Use relative file paths only.
|
||||
- Return full file contents — no partial diffs or placeholders.
|
||||
- Keep TypeScript strictness intact. Never use `any`; use `unknown` at JSON/API boundaries with explicit type guards.
|
||||
- Prefer `const` over `let`; never use `var`.
|
||||
- Use async/await throughout; never mix Promise chains and callbacks.
|
||||
- Use `import type` for type-only imports.
|
||||
- Use named exports; avoid default exports in library code.
|
||||
- Keep server-only code out of client components. API calls to Wiener Linien must go through proxy routes, not directly from the browser.
|
||||
- Preserve existing working behavior unless the current step explicitly changes it.
|
||||
- For UI work: semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
- If you cannot produce a valid response matching the schema, emit: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
|
||||
- Return only JSON matching the writer schema.
|
||||
"""
|
||||
|
||||
REVIEWER_PROMPT = """<|think|>
|
||||
You are a strict senior TypeScript reviewer embedded in a code-generation loop for the TimeToLeave project.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and required; blocks the next step
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; never blocks advancement
|
||||
|
||||
Rules:
|
||||
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
|
||||
- Only review steps whose ✅ box is already `[x]`. Do not attempt to review unimplemented steps.
|
||||
- Before reviewing, confirm that all preceding steps have both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking.
|
||||
- If the step passes the quality bar below, set verdict to "approve". The orchestrator will mark ✔️ automatically.
|
||||
- If issues remain, set verdict to "needs_changes". Report the failures with file paths and line numbers.
|
||||
|
||||
## Review Scope
|
||||
|
||||
- Review one step at a time in the order steps appear in `CHECKLIST.md`.
|
||||
- Cross-reference the implementation against the step description in `CHECKLIST.md`.
|
||||
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
|
||||
|
||||
## What to Check
|
||||
|
||||
**Correctness**
|
||||
- Behavior matches the intent described in the CHECKLIST step.
|
||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
||||
- No regressions in previously working behavior.
|
||||
|
||||
**Tests**
|
||||
- Tests exist for new code covering the main success path, edge cases, and failure behavior.
|
||||
- Tests are not weakened or removed just to make the suite pass.
|
||||
- External services (Wiener Linien API, geolocation, time) are mocked; tests do not depend on live network availability.
|
||||
|
||||
**Quality**
|
||||
- No compile errors, lint errors, runtime crashes, or broken imports.
|
||||
- TypeScript strictness is intact — no `any` used as a shortcut.
|
||||
- Server-only code is not imported into client components.
|
||||
- Wiener Linien API calls go through proxy routes, not directly from the browser.
|
||||
- Error handling is explicit; user-facing failures are understandable.
|
||||
- No generated artifacts, caches, logs, or local environment files are committed.
|
||||
- Dependencies unchanged unless necessary and justified.
|
||||
|
||||
**Scope**
|
||||
- The change is scoped to the current CHECKLIST step — no unrelated modifications.
|
||||
|
||||
**Accessibility (UI steps only)**
|
||||
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
|
||||
## Verification
|
||||
|
||||
Confirm that these checks would pass before setting verdict to "approve":
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
If any check would fail, set verdict to "needs_changes", report the failure with exact details, and leave the step for the writing agent to fix.
|
||||
|
||||
## Review priorities
|
||||
|
||||
1. build-breaking defects
|
||||
2. test-breaking defects
|
||||
3. runtime-breaking defects
|
||||
4. mismatch with the CHECKLIST step intent
|
||||
5. missing files, exports, wiring, or integration
|
||||
6. unsafe behavior
|
||||
7. incorrect types, null handling, async handling, state management
|
||||
8. missing edge-case handling
|
||||
9. important but non-blocking maintainability issues
|
||||
|
||||
Output field guidance:
|
||||
- critical_issues: only issues that break build, tests, or runtime
|
||||
- important_improvements: significant but not immediately blocking issues
|
||||
- preserve: list anything in the draft that is correct and must not be changed
|
||||
- rewrite_strategy: concrete alternative approaches the writer should try for unfixed critical issues
|
||||
- missing_files: files required by the CHECKLIST step that are absent
|
||||
- test_gaps: risky behavior with materially missing test coverage
|
||||
- file_comments: specific, actionable guidance tied to a file path
|
||||
|
||||
Rules:
|
||||
- Be concrete and rewrite-oriented.
|
||||
- Prefer issues the writer can directly fix in the next pass.
|
||||
- Do not ask questions.
|
||||
- Do not praise unless identifying something that must be preserved.
|
||||
- Assume the writer should patch the current code, not restart from scratch.
|
||||
- If the draft appears unchanged from a previous attempt for a given issue, escalate that issue to critical and suggest an alternative implementation approach.
|
||||
- If you have already flagged an issue and it was not fixed, say specifically what is still wrong and why the previous attempt failed.
|
||||
- Return only JSON matching the review schema.
|
||||
"""
|
||||
|
||||
DESIGN_PROMPT = """<|think|>
|
||||
You are a disciplined TypeScript architect working inside a coding loop on the TimeToLeave project.
|
||||
|
||||
Your job is to produce a concise implementation design for the current CHECKLIST step before coding begins.
|
||||
|
||||
Project context:
|
||||
- Next.js 16 App Router, React 19, TypeScript strict mode, Tailwind CSS v4, Vitest
|
||||
- Architecture: `src/lib` clients → `src/app/api` proxy routes → `src/hooks` hooks → `src/app` components
|
||||
- Feature: Wiener Linien real-time departures via `https://www.wienerlinien.at/ogd_realtime`
|
||||
- Read `CHECKLIST.md` to determine which step is being designed
|
||||
|
||||
Design priorities:
|
||||
1. file layout — which files to create or modify, and why
|
||||
2. responsibilities — what each file owns
|
||||
3. interfaces — types and function signatures
|
||||
4. integration points — how this step connects to existing code
|
||||
5. dependencies — imports from existing modules
|
||||
6. testing plan — what to test and how to mock
|
||||
7. risks — anything that could break existing behavior
|
||||
8. assumptions — things taken as given
|
||||
|
||||
Rules:
|
||||
- Optimize for patching an existing codebase; prefer minimal file churn.
|
||||
- Identify any conflicts with existing code (naming, module structure, API contracts).
|
||||
- Flag required structural changes separately from new additions.
|
||||
- If the step can be completed by modifying a single existing file, say so explicitly rather than proposing new files.
|
||||
- Do not redesign the whole project unless the step requires it.
|
||||
- Keep the design concrete and implementation-ready.
|
||||
- Return only JSON matching the design schema.
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main("TS", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
|
||||
@@ -0,0 +1,298 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default workspace to the project root (parent of this script's agent_loop/ dir)
|
||||
# so the script works without --workspace when run from anywhere.
|
||||
os.environ.setdefault("AGENT_WORKSPACE", str(Path(__file__).resolve().parent.parent))
|
||||
os.environ.setdefault("AGENT_TASK", "Implement the next pending step in CHECKLIST.md")
|
||||
os.environ.setdefault("OLLAMA_API_BASE", "http://100.103.83.12:11435")
|
||||
os.environ.setdefault("WRITER_MODEL", "qwen3.6:27b-64k")
|
||||
os.environ.setdefault("REVIEWER_MODEL", "qwen3.6:27b-64k")
|
||||
os.environ.setdefault("DESIGN_MODEL", "qwen3.6:27b-64k")
|
||||
os.environ.setdefault("AGENT_MAX_REVIEW_LOOPS", "12")
|
||||
|
||||
from agent_base_gemma4 import main
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TimeToLeave — project-specific agent
|
||||
#
|
||||
# Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 · Vitest
|
||||
# Run: python ttl_agent_gemma4.py --task "..." --workspace <project-root> --write-to-workspace
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WRITER_PROMPT = """You are a software engineering agent implementing features on the TimeToLeave project.
|
||||
|
||||
## FILE EXTENSION RULE — CHECK EVERY FILE BEFORE SUBMITTING
|
||||
|
||||
- `.tsx` — any file that contains JSX (`<Tag />`, `<div>`, `return (...)` with markup)
|
||||
- `.ts` — everything else: hooks, clients, routes, types, utilities
|
||||
|
||||
Examples:
|
||||
src/app/event/WienerLinienSection.tsx ← renders JSX → .tsx
|
||||
src/hooks/useWienerLinien.ts ← no JSX → .ts
|
||||
src/lib/wienerlinien-client.ts ← no JSX → .ts
|
||||
src/app/api/wienerlinien/stops/route.ts← no JSX → .ts
|
||||
|
||||
Wrong extension = broken build. Verify each path ends in the correct suffix.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and required; blocks the next step
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; never blocks advancement
|
||||
|
||||
Rules:
|
||||
- The ✅ column is yours; the ✔️ column belongs to the review agent.
|
||||
- Before starting, read `CHECKLIST.md` and find the first step where ✅ is `[ ]`.
|
||||
- Confirm that every preceding step has both ✅ and ✔️ as `[x]`. If not, stop and report which steps are blocking — do not proceed.
|
||||
- Implement only that one step. Do not implement any later steps.
|
||||
- After completing the step, change its ✅ from `[ ]` to `[x]` in `CHECKLIST.md` and include the updated file in your `files` array.
|
||||
- Do not mark the ✔️ column — that belongs to the review agent.
|
||||
|
||||
## Stack
|
||||
|
||||
Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
||||
|
||||
**CRITICAL:** This Next.js version has breaking changes. Before using any Next.js API (routing,
|
||||
metadata, image, font, caching), read the relevant guide in `node_modules/next/dist/docs/`.
|
||||
Heed all deprecation notices. APIs and file conventions may differ from your training data.
|
||||
|
||||
## Architecture — Four Layers
|
||||
|
||||
Always follow this pattern:
|
||||
|
||||
src/lib/<name>-client.ts ← singleton, calls external API, server-only
|
||||
src/app/api/<name>/route.ts ← proxy: validates input, calls client, returns NextResponse
|
||||
src/hooks/use<Name>.ts ← hook: calls proxy route, manages loading/error/data
|
||||
src/app/**/<Name>Section.tsx ← component: receives props or calls hook, renders UI
|
||||
|
||||
Reference implementations (read before writing):
|
||||
- Client: src/lib/bike-routing-client.ts
|
||||
- Route: src/app/api/bike-route/route.ts
|
||||
- Hook: src/hooks/useBikeRoute.ts
|
||||
- Component: src/app/event/BikeSection.tsx
|
||||
- ApiClient: src/lib/api-service.ts — use ApiClient for caching + retries in new clients
|
||||
- Constants: src/lib/constants.ts — add env vars here as `process.env.X ?? "default"`
|
||||
- Types: src/types/index.ts — add all new types here
|
||||
|
||||
## Next.js Rules
|
||||
|
||||
- Route handlers: `export async function GET(request: NextRequest)` or `POST`. Named exports only.
|
||||
- Imports: `NextRequest`, `NextResponse` from `"next/server"`.
|
||||
- `"use client"` goes at the top of a file only when it uses `useState`, `useEffect`, event
|
||||
handlers, `window`, or `navigator`. Server components and route handlers must never have it.
|
||||
- Never import `src/lib` clients, `crypto`, or `process.env` secrets into client components.
|
||||
- Path alias: use `@/` for `src/` (e.g. `import { Foo } from "@/types"`).
|
||||
|
||||
## Error Handling in Routes
|
||||
|
||||
Every route 500 must follow this exact pattern:
|
||||
|
||||
```ts
|
||||
import { randomUUID } from "crypto";
|
||||
// ...
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] <description>:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: "Human-readable message", correlationId: corrId },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Input validation errors return `{ error: "..." }` with status 400 — no correlationId needed.
|
||||
|
||||
## TypeScript Rules
|
||||
|
||||
- Never use `any`. Use `unknown` at JSON/API boundaries with explicit type guards.
|
||||
- All new types go in `src/types/index.ts`.
|
||||
- Use `import type` for type-only imports.
|
||||
- Named exports everywhere. No default exports in `src/lib` or `src/hooks`.
|
||||
- `const` over `let`. Never `var`. Async/await throughout — no mixed Promise chains.
|
||||
|
||||
## Tailwind CSS v4
|
||||
|
||||
- Utility classes directly in JSX only. Never `@apply` in CSS files.
|
||||
- Dark mode uses the `dark:` variant (toggled via a class on `<html>`).
|
||||
- Do not modify `tailwind.config.ts` unless strictly necessary.
|
||||
|
||||
## Vitest Rules
|
||||
|
||||
- Test files: `src/lib/__tests__/`, `src/app/api/__tests__/`, `src/hooks/__tests__/`, `src/app/**/__tests__/`.
|
||||
- Use `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*` APIs.
|
||||
- Mock `global.fetch` or use `vi.mock` to intercept HTTP — no live network in tests.
|
||||
- Mock all external services: any third-party API, geolocation, timers (`vi.useFakeTimers()`).
|
||||
- Cover: main success path, input validation, error/failure behavior.
|
||||
- Import the module under test, not internal helpers directly.
|
||||
|
||||
## General Rules
|
||||
|
||||
- Patch the existing project. Only create new files when the layer does not exist yet.
|
||||
- Return full file contents — no partial diffs, ellipsis, or placeholders.
|
||||
- Use relative file paths only.
|
||||
- Use `npm` (project uses `package-lock.json`).
|
||||
- Do not add features, abstractions, or cleanup beyond what the current CHECKLIST step requires.
|
||||
- Preserve existing working behavior unless the step explicitly changes it.
|
||||
- Do not commit generated files, caches, logs, or `.env` secrets.
|
||||
- Before finalising the files array, verify every path: does it contain JSX? → `.tsx`. No JSX? → `.ts`.
|
||||
- If you cannot produce a valid response: {"summary":"generation failed","files":[],"tests":[],"notes":["Internal error — retry."]}
|
||||
- Return only JSON matching the writer schema.
|
||||
"""
|
||||
|
||||
REVIEWER_PROMPT = """You are a strict senior code reviewer embedded in a code-generation loop for the TimeToLeave project.
|
||||
|
||||
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and required; blocks the next step
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; never blocks advancement
|
||||
|
||||
Rules:
|
||||
- The ✔️ column is yours; the ✅ column belongs to the writing agent.
|
||||
- Only review steps whose ✅ box is already `[x]`. Do not review unimplemented steps.
|
||||
- Confirm all preceding steps have both ✅ and ✔️ as `[x]` before reviewing. If not, report what is blocking.
|
||||
- If the step passes all checks below, set verdict to "approve". The orchestrator marks ✔️ automatically.
|
||||
- If issues remain, set verdict to "needs_changes" and report failures with file paths and line numbers.
|
||||
|
||||
## Review Scope
|
||||
|
||||
One CHECKLIST step at a time. Cross-reference against the step description. Report concrete issues
|
||||
with file paths and line numbers. Do not flag style nitpicks not covered by a project guideline.
|
||||
|
||||
## What to Check
|
||||
|
||||
**Correctness**
|
||||
- Behavior matches the current CHECKLIST step's intent.
|
||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
||||
- No regressions in previously working behavior.
|
||||
|
||||
**File extensions**
|
||||
- `.tsx` for files containing JSX; `.ts` for everything else (routes, hooks, lib, types).
|
||||
- Flag any component returning JSX saved as `.ts`, or any non-JSX file saved as `.tsx`.
|
||||
|
||||
**Next.js conventions**
|
||||
- Route handlers export named `GET`/`POST` functions with `(request: NextRequest)` signature.
|
||||
- `"use client"` is present when a component uses `useState`, `useEffect`, event handlers, `window`,
|
||||
or `navigator`; absent on all other files.
|
||||
- No server-only imports (`src/lib` clients, `crypto`, `process.env` secrets) in client components.
|
||||
- Next.js APIs match what is documented in `node_modules/next/dist/docs/` — flag anything that looks
|
||||
like a training-data artifact from an older Next.js version.
|
||||
- Path alias `@/` used for `src/` imports.
|
||||
|
||||
**Error handling**
|
||||
- All route 500 errors return `{ error: string, correlationId: string }` using `randomUUID().slice(0, 8)`.
|
||||
- Input validation errors return `{ error: string }` with status 400.
|
||||
|
||||
**Tests**
|
||||
- Tests exist for the new code covering the main success path, input validation, and failure behavior.
|
||||
- Only Vitest APIs: `vi.fn()`, `vi.mock()`, `vi.spyOn()`. Never `jest.*`.
|
||||
- All external services and network calls are mocked — no live network in tests.
|
||||
- Tests are not weakened or removed just to make the suite pass.
|
||||
|
||||
**TypeScript**
|
||||
- No `any`. `unknown` at API/JSON boundaries with explicit type guards.
|
||||
- No missing null/undefined checks on values from API responses or array indexing.
|
||||
- No missing `await`, unhandled rejections, or mixed async styles.
|
||||
- No missing exports for symbols referenced by other files.
|
||||
- `import type` used for type-only imports.
|
||||
|
||||
**Scope and quality**
|
||||
- Change is scoped to the current CHECKLIST step only.
|
||||
- No generated artifacts, caches, logs, or `.env` secrets committed.
|
||||
- Dependencies unchanged unless necessary and justified.
|
||||
|
||||
**Accessibility (UI steps only)**
|
||||
- Semantic elements, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
|
||||
## Verification
|
||||
|
||||
These must pass before setting verdict to "approve":
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
If any check would fail, set verdict to "needs_changes" and report the exact failure details.
|
||||
|
||||
## Review Priorities
|
||||
|
||||
1. Build-breaking defects
|
||||
2. Test-breaking defects
|
||||
3. Runtime-breaking defects
|
||||
4. Mismatch with CHECKLIST step intent
|
||||
5. Missing files, exports, wiring, or integration
|
||||
6. Incorrect Next.js APIs or conventions
|
||||
7. Unsafe behavior, missing null checks, async errors
|
||||
8. Missing edge-case handling
|
||||
9. Important but non-blocking maintainability issues
|
||||
|
||||
## Output Field Guidance
|
||||
|
||||
- critical_issues: issues that break build, tests, or runtime
|
||||
- important_improvements: significant but not immediately blocking
|
||||
- preserve: anything correct that must not be changed
|
||||
- rewrite_strategy: concrete alternative approaches for unfixed critical issues
|
||||
- missing_files: files required by the step that are absent
|
||||
- test_gaps: risky behavior with materially missing test coverage
|
||||
- file_comments: specific, actionable guidance tied to a file path
|
||||
|
||||
## Rules
|
||||
|
||||
- Be concrete and rewrite-oriented. Prefer issues the writer can fix in the next pass.
|
||||
- Do not ask questions. Do not praise unless identifying something that must be preserved.
|
||||
- Assume the writer should patch the current code, not restart from scratch.
|
||||
- If the draft is unchanged from a previous attempt on a flagged issue, escalate to critical and
|
||||
suggest a concrete alternative implementation approach.
|
||||
- If you have already flagged an issue that was not fixed, say specifically what is still wrong
|
||||
and why the previous attempt failed.
|
||||
- Return only JSON matching the review schema.
|
||||
"""
|
||||
|
||||
DESIGN_PROMPT = """You are a disciplined architect working inside a coding loop on the TimeToLeave project.
|
||||
|
||||
Stack: Next.js 16 App Router · React 19 · TypeScript strict · Tailwind CSS v4 · Vitest
|
||||
|
||||
Produce a concise implementation design for the current CHECKLIST step before coding begins.
|
||||
|
||||
Before designing:
|
||||
1. Read `CHECKLIST.md` to identify the current step.
|
||||
2. Read `node_modules/next/dist/docs/` for any Next.js API the step will use — this version differs from training data.
|
||||
3. Check whether `ApiClient` in `src/lib/api-service.ts` covers the new external service's caching and retry needs before proposing a new client.
|
||||
4. Check `src/lib/constants.ts` for the env-var pattern before adding new configuration.
|
||||
|
||||
Architecture layers (follow the existing pattern):
|
||||
- `src/lib/<name>-client.ts` — singleton, server-only, wraps external API via ApiClient
|
||||
- `src/app/api/<name>/route.ts` — proxy route, validates input, calls client, NextResponse
|
||||
- `src/hooks/use<Name>.ts` — hook, fetches from proxy, manages loading/error/data
|
||||
- `src/app/**/<Name>Section.tsx` — component, renders UI, `"use client"` where needed
|
||||
|
||||
Design priorities:
|
||||
1. File layout — which files to create or modify (prefer modifying over creating new files)
|
||||
2. Responsibilities — what each file owns
|
||||
3. Interfaces — exported types and function signatures
|
||||
4. Integration points — how this connects to existing code
|
||||
5. Dependencies — exact imports from existing modules
|
||||
6. Testing plan — what to test, which services to mock, which Vitest APIs to use
|
||||
7. Risks — anything that could break existing behavior or violate Next.js conventions
|
||||
8. Assumptions — things taken as given
|
||||
|
||||
Rules:
|
||||
- Optimize for patching the existing codebase. Prefer minimal file churn.
|
||||
- Identify conflicts with existing code (naming, module structure, API contracts).
|
||||
- Flag required structural changes separately from new additions.
|
||||
- If the step can be completed by modifying a single existing file, say so explicitly.
|
||||
- Do not redesign unrelated parts of the project.
|
||||
- Keep the design concrete and immediately usable by the writer.
|
||||
- Return only JSON matching the design schema.
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main("TTL", WRITER_PROMPT, REVIEWER_PROMPT, DESIGN_PROMPT))
|
||||
@@ -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,42 @@
|
||||
# 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
|
||||
@@ -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 |
|
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,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,41 @@
|
||||
{
|
||||
"name": "@timetoleave/mobile",
|
||||
"version": "1.0.0",
|
||||
"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": "echo 'no lint yet'",
|
||||
"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": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.0",
|
||||
"react-test-renderer": "19.2.4",
|
||||
"ts-jest": "^29.4.9",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"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,231 @@
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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,173 @@
|
||||
// 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 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);
|
||||
|
||||
// Leave-by time should be 30 minutes before event time
|
||||
const expectedTime = new Date('2025-01-01T09:30: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);
|
||||
|
||||
// Should use earliest non-cancelled journey (journey-2 at 07:00) minus buffer
|
||||
const expectedTime = new Date('2025-01-01T06:30: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);
|
||||
|
||||
// Should use journey-2 since journey-1 is cancelled
|
||||
const expectedTime = new Date('2025-01-01T06:30: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);
|
||||
|
||||
// All journeys cancelled, fall back to event time minus buffer
|
||||
const expectedTime = new Date('2025-01-01T09:30: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, [], 0);
|
||||
|
||||
expect(leaveByTime.getTime()).toBe(event.eventTime.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,143 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
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,211 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
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,238 @@
|
||||
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 }: 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,
|
||||
});
|
||||
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;
|
||||
|
||||
// Search for stations near the user's actual GPS coordinates
|
||||
// Use Nominatim reverse geocode via the API to find a nearby station
|
||||
const geoResults = await api.geocode('Wien', 'at');
|
||||
// Find the station closest to the user's actual coordinates
|
||||
if (geoResults.length > 0) {
|
||||
const closest = geoResults.reduce((best: { lat: number; lng: number; display_name: string } | null, candidate) => {
|
||||
if (!best) return 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;
|
||||
}, null);
|
||||
|
||||
if (closest) {
|
||||
const station: Station = {
|
||||
name: closest.display_name,
|
||||
extId: String(closest.lat) + ',' + String(closest.lng),
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</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 },
|
||||
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,146 @@
|
||||
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.
|
||||
*/
|
||||
export function calculateLeaveByTime(
|
||||
event: Event,
|
||||
journeys: Journey[],
|
||||
bufferMinutes: number
|
||||
): Date {
|
||||
// If we have journeys, use the earliest non-cancelled real departure
|
||||
if (journeys.length > 0) {
|
||||
const best = journeys
|
||||
.filter((j) => !j.cancelled)
|
||||
.sort((a, b) => a.rD.getTime() - b.rD.getTime())[0];
|
||||
|
||||
if (best) {
|
||||
return new Date(best.rD.getTime() - bufferMinutes * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: event time minus buffer (no journey data)
|
||||
return new Date(event.eventTime.getTime() - 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.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,199 @@
|
||||
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,
|
||||
};
|
||||
|
||||
// ── 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, bufferMinutes: number): Promise<Date> {
|
||||
// Fallback: event time minus buffer (no journey data)
|
||||
return new Date(event.eventTime.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.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);
|
||||
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.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,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,22 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
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') {
|
||||
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 @@
|
||||
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":3981.5076039999994,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":86.31673999999975,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":31.295493999999962,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":13.02209999999991,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2098.29757,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":58.96350099999995,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":91.7685570000001,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":18.296495999999934,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":71.45395499999995,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":21.57249200000001,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":53.28839500000004,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.105226000000016,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":222.98069600000008,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":70.98355300000003,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":23.987784000000147,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":16.915164000000004,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":3.030644999999936,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.18994099999986,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":91.62786400000005,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":6.45920799999999,"failed":false}]]}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@timetoleave/web",
|
||||
"version": "0.1.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 |
|
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,94 @@
|
||||
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: () => void;
|
||||
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();
|
||||
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 requests for API routes', () => {
|
||||
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('*');
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, PUT, DELETE, 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 handle regular API requests with cross-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')).toBe('*');
|
||||
expect(response.headers.get('Vary')).toBe('Origin');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WienerLinienClient } from "@/lib/wienerlinien-client";
|
||||
|
||||
const client = new WienerLinienClient();
|
||||
|
||||
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)
|
||||
.filter((id, index, self) => self.indexOf(id) === index);
|
||||
|
||||
if (validStopIds.length === 0) {
|
||||
return NextResponse.json({ error: "No valid stop IDs provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cappedIds = validStopIds.slice(0, 10);
|
||||
|
||||
try {
|
||||
const monitorData = await client.getMonitor(cappedIds);
|
||||
// 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,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WienerLinienClient } from "@/lib/wienerlinien-client";
|
||||
|
||||
const client = new WienerLinienClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const latStr = searchParams.get("lat");
|
||||
const lngStr = searchParams.get("lng");
|
||||
|
||||
if (!latStr || !lngStr) {
|
||||
return NextResponse.json({ error: "Missing required parameters: lat and lng" }, { status: 400 });
|
||||
}
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lng = parseFloat(lngStr);
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
return NextResponse.json({ error: "Invalid coordinates: lat and lng must be numbers" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (lat < -90 || lat > 90) {
|
||||
return NextResponse.json({ error: "Invalid latitude: must be between -90 and 90" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (lng < -180 || lng > 180) {
|
||||
return NextResponse.json({ error: "Invalid longitude: must be between -180 and 180" }, { status: 400 });
|
||||
}
|
||||
|
||||
let radius = 1000;
|
||||
const radiusStr = searchParams.get("radius");
|
||||
if (radiusStr !== null) {
|
||||
const parsed = parseFloat(radiusStr);
|
||||
if (!isNaN(parsed)) {
|
||||
radius = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Event, Station } from "@/types";
|
||||
import { Event, Station } from "@timetoleave/core";
|
||||
import EventCard from "@/app/event/EventCard";
|
||||
|
||||
type DayEventsProps = {
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { useJourneys } from "@/hooks/useJourneys";
|
||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||
import { useBikeRoute } from "@/hooks/useBikeRoute";
|
||||
import { useGeocode } from "@/hooks/useGeocode";
|
||||
import { useClock } from "@/hooks/useClock";
|
||||
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 {
|
||||
stops,
|
||||
departures,
|
||||
loading: wlLoading,
|
||||
error: wlError,
|
||||
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const { countdown, status } = useClock(event.eventTime);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{event.title}</h3>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Destination:</span> {event.destination}
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TrainSection
|
||||
journeys={journeys}
|
||||
eventTime={event.eventTime}
|
||||
destName={event.destination}
|
||||
loading={journeysLoading}
|
||||
error={journeysError}
|
||||
/>
|
||||
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
|
||||
|
||||
{stops.length > 0 && (
|
||||
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Journey } from "@/types";
|
||||
import { formatTime } from "@/lib/formatting";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
import { formatTime } from "@timetoleave/core";
|
||||
import LeaveByBadge from "./LeaveByBadge";
|
||||
import { calculateCountdown } from "@/lib/countdown-utils";
|
||||
import { calculateCountdown } from "@timetoleave/core";
|
||||
|
||||
type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
@@ -1,7 +1,7 @@
|
||||
"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> = {
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Journey } from "@/types";
|
||||
import { formatDateTime } from "@/lib/formatting";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
import { formatDateTime } from "@timetoleave/core";
|
||||
import JourneyList from "./JourneyList";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
@@ -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-gray-500 dark:text-gray-400">Loading nearby stops...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">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 last:mb-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{stop.name}</h3>
|
||||
|
||||
{stopDepartures.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">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-gray-600 dark:text-gray-300">{departure.direction}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{departure.minutes} min
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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/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,61 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import WienerLinienSection from "../WienerLinienSection";
|
||||
import type { WienerLinienStop } from "@timetoleave/core";
|
||||
|
||||
vi.mock("@/app/ui/LoadingSpinner", () => ({
|
||||
default: function MockLoadingSpinner() {
|
||||
return <div data-testid="loading-spinner">Loading...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/ui/Chip", () => ({
|
||||
default: function MockChip({ children }: { children: React.ReactNode }) {
|
||||
return <span data-testid={`chip-${String(children)}`}>{children}</span>;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockStops: WienerLinienStop[] = [
|
||||
{ id: "stop-1", name: "Stephansplatz", lat: 48.208, lng: 16.373 },
|
||||
{ id: "stop-2", name: "Karlsplatz", lat: 48.201, lng: 16.372 },
|
||||
];
|
||||
|
||||
const mockDepartures = [
|
||||
{ stopId: "stop-1", lineName: "U1", direction: "Leopoldau", minutes: 2 },
|
||||
{ stopId: "stop-1", lineName: "U3", direction: "Simmering", minutes: 5 },
|
||||
{ stopId: "stop-2", lineName: "U2", direction: "Seitengasse", minutes: 1 },
|
||||
];
|
||||
|
||||
describe("WienerLinienSection", () => {
|
||||
it("renders skeleton when loading=true", () => {
|
||||
render(<WienerLinienSection stops={[]} departures={[]} loading error={null} />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.getByText("Loading nearby stops...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error message when error string is provided", () => {
|
||||
render(<WienerLinienSection stops={[]} departures={[]} loading={false} error="Failed to fetch stops" />);
|
||||
expect(screen.getByText("Failed to fetch stops")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders stop names and departure line badges correctly", () => {
|
||||
render(<WienerLinienSection stops={mockStops} departures={mockDepartures} loading={false} error={null} />);
|
||||
expect(screen.getByText("Stephansplatz")).toBeInTheDocument();
|
||||
expect(screen.getByText("Karlsplatz")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chip-U1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chip-U3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chip-U2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders minute countdown text for each departure", () => {
|
||||
render(<WienerLinienSection stops={mockStops} departures={mockDepartures} loading={false} error={null} />);
|
||||
expect(screen.getByText("2 min")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 min")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 min")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles empty stops/departures arrays without crashing", () => {
|
||||
render(<WienerLinienSection stops={[]} departures={[]} loading={false} error={null} />);
|
||||
expect(screen.getByText("No nearby stops found.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Chip from "./Chip";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "text-red-600 bg-red-50 dark:bg-red-900/30",
|
||||
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
|
||||
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
|
||||
green: "text-green-600 bg-green-50 dark:bg-green-900/30",
|
||||
blue: "text-blue-600 bg-blue-50 dark:bg-blue-900/30",
|
||||
};
|
||||
|
||||
type CountdownBadgeProps = {
|
||||
countdown: {
|
||||
label: string;
|
||||
color: string;
|
||||
urgent: boolean;
|
||||
};
|
||||
status: "upcoming" | "now" | "past";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CountdownBadge: React.FC<CountdownBadgeProps> = ({ countdown, status, className = "" }) => {
|
||||
const bgColor = colorMap[countdown.color] || colorMap.blue;
|
||||
const isUrgent = countdown.urgent || status === "now";
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={`${className} ${bgColor} ${isUrgent ? "animate-pulse" : ""}`}
|
||||
>
|
||||
{countdown.label}
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CountdownBadge;
|
||||
@@ -65,6 +65,7 @@ describe("useBikeRoute", () => {
|
||||
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBe("Network error"));
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.bikeRoute).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { renderHook, act } from "@testing-library/react";
|
||||
import { useReminder } from "../useReminder";
|
||||
import { EventsProvider } from "../useEventsStore";
|
||||
import { ReminderSettingsProvider } from "../useReminderSettings";
|
||||
import type { Event } from "@/types";
|
||||
import type { Event } from "@timetoleave/core";
|
||||
import React from "react";
|
||||
|
||||
// ── Notification mock ──
|
||||
@@ -0,0 +1,362 @@
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useWienerLinien } from "../useWienerLinien";
|
||||
|
||||
const mockStops = [
|
||||
{ id: "stop-1", name: "Test Station 1", lat: 48.2, lng: 16.37 },
|
||||
{ id: "stop-2", name: "Test Station 2", lat: 48.21, lng: 16.38 },
|
||||
];
|
||||
|
||||
const mockDepartures = [
|
||||
{
|
||||
stopId: "stop-1",
|
||||
line: { name: "U1" },
|
||||
direction: "Leopoldau",
|
||||
departureTime: Date.now() + 120_000,
|
||||
delay: 0,
|
||||
},
|
||||
{
|
||||
stopId: "stop-2",
|
||||
line: { name: "U2" },
|
||||
direction: "Seitengasse",
|
||||
departureTime: Date.now() + 300_000,
|
||||
delay: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe("useWienerLinien", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty arrays when coordinates are undefined", () => {
|
||||
const { result } = renderHook(() => useWienerLinien(undefined, undefined));
|
||||
|
||||
expect(result.current.stops).toEqual([]);
|
||||
expect(result.current.departures).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("debounces the initial fetch by 400 ms", async () => {
|
||||
let fetchCalled = false;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(() => {
|
||||
fetchCalled = true;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
renderHook(() => useWienerLinien(48.2, 16.37));
|
||||
|
||||
expect(fetchCalled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(fetchCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches stops then monitor sequentially", async () => {
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
fetchCalls.push(url);
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
|
||||
|
||||
// Advance timer to fire debounce, then flush all promise chains
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(fetchCalls).toHaveLength(2);
|
||||
expect(fetchCalls[0]).toContain("/api/wienerlinien/stops");
|
||||
expect(fetchCalls[1]).toContain("/api/wienerlinien/monitor");
|
||||
expect(result.current.stops).toEqual(mockStops);
|
||||
expect(result.current.departures).toHaveLength(2);
|
||||
expect(result.current.departures[0]).toMatchObject({
|
||||
stopId: "stop-1",
|
||||
lineName: "U1",
|
||||
direction: "Leopoldau",
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("includes radius parameter in stops request when provided", async () => {
|
||||
let capturedStopsUrl = "";
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
capturedStopsUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
renderHook(() => useWienerLinien(48.2, 16.37, 500));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(capturedStopsUrl).toContain("radius=500");
|
||||
});
|
||||
|
||||
it("refreshes departures every 60 seconds", async () => {
|
||||
let monitorCallCount = 0;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
monitorCallCount++;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
|
||||
|
||||
// Initial fetch
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
|
||||
expect(monitorCallCount).toBe(1);
|
||||
|
||||
// Advance by 60 s to trigger refresh
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
});
|
||||
|
||||
expect(monitorCallCount).toBe(2);
|
||||
expect(result.current.departures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cleans up interval on unmount", async () => {
|
||||
let monitorCallCount = 0;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
monitorCallCount++;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { unmount } = renderHook(() => useWienerLinien(48.2, 16.37));
|
||||
|
||||
// Initial fetch
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(monitorCallCount).toBe(1);
|
||||
|
||||
// Unmount before the next interval tick
|
||||
unmount();
|
||||
|
||||
// Advance by 60 s — interval should NOT fire
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(monitorCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it("sets error state on non-200 stops response", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: "Internal server error" }),
|
||||
} as Response);
|
||||
|
||||
const { result } = renderHook(() => useWienerLinien(48.2, 16.37));
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Internal server error");
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("refetches when coordinates change", async () => {
|
||||
let fetchCount = 0;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
fetchCount++;
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(({ lat, lng }) => useWienerLinien(lat, lng), {
|
||||
initialProps: { lat: 48.2, lng: 16.37 },
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
|
||||
expect(fetchCount).toBe(2); // stops + monitor
|
||||
|
||||
// Change coordinates — triggers cleanup + new debounced fetch
|
||||
rerender({ lat: 48.3, lng: 16.4 });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
|
||||
expect(fetchCount).toBe(4); // 2 more: stops + monitor
|
||||
});
|
||||
|
||||
it("resets state when coordinates become undefined", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.includes("/api/wienerlinien/stops")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ stops: mockStops }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
if (url.includes("/api/wienerlinien/monitor")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ departures: mockDepartures }),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: "Unknown route" }),
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(({ lat, lng }) => useWienerLinien(lat, lng), {
|
||||
initialProps: { lat: 48.2, lng: 16.37 } as { lat: number | undefined; lng: number | undefined },
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(result.current.stops).toHaveLength(2);
|
||||
expect(result.current.departures).toHaveLength(2);
|
||||
|
||||
// Coordinates become undefined
|
||||
rerender({ lat: undefined, lng: undefined });
|
||||
|
||||
expect(result.current.stops).toEqual([]);
|
||||
expect(result.current.departures).toEqual([]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||