13 Commits

Author SHA1 Message Date
fegger 014fe789f8 chore: bump to v1.0.0 and add CHANGELOG 2026-05-11 20:28:59 +02:00
fegger ef36d227e9 chore: remove accidentally tracked Next.js build artifacts from .next/ 2026-05-11 19:52:36 +02:00
fegger 6fb7941d56 Update README.md 2026-05-11 19:09:24 +02:00
fegger 55c77c7572 Update README.md 2026-05-11 19:00:02 +02:00
fegger fff5700132 Add README for TimeToLeave project
Adds comprehensive setup and structure documentation to the root README file. This outlines the monorepo structure,
prerequisites, and development workflow for new contributors.
2026-05-11 18:55:01 +02:00
fegger 5bcfafcbaf Add mobile services, web proxy, and Gemma4 agent loop
- Mobile: add push notification and calendar sync services with full test suite (calendar, eventStore, notifications,
  screens)
- Mobile: add EAS build config and Jest setup
- Web: add API proxy, update useDestinationStation/useJourneys hooks, add middleware tests
- Web: update next.config and rebuild
- Agent: add Gemma4-based agent loop (agent_base, ttl_agent, ts_agent)
- Docs: add privacy policy, post-MVP plan, and aider rules
2026-05-11 18:32:54 +02:00
fegger 20159262c1 Add getLeaveStatus utility and update project status
- Introduce `getLeaveStatus` function in `packages/core/src/status-utils.ts` to determine leave-by status based on
  journey data
- Mark Phase 1 mobile app tasks as complete in `CHECKLIST.md`
- Add mobile workspace configurations and npm scripts
2026-05-10 22:14:41 +02:00
fegger d08afc1dcb mobile app phase 1 2026-05-10 21:19:39 +02:00
fegger 442a00dbbe Add Wiener Linien integration with API routes and client 2026-05-10 19:20:19 +02:00
fegger 330cbb6b37 Refactor EventCard tests to verify hook arguments 2026-05-10 19:11:04 +02:00
fegger de9c16430b Support repeated stopIds and update WienerLinien API URL 2026-05-10 18:06:41 +02:00
fegger b2608a4a60 Fix ApiClient mock implementation and update constructor tests 2026-05-10 17:53:19 +02:00
fegger 7901368971 Add Wiener Linien API integration and departures monitoring 2026-05-10 17:19:24 +02:00
158 changed files with 19722 additions and 1291 deletions
+9
View File
@@ -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
+11
View File
@@ -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/
+15
View File
@@ -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"
}
]
+45
View File
@@ -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.
+57
View File
@@ -0,0 +1,57 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
---
## [1.0.0] — 2025-01-27
### 🎉 Initial MVP Release
First stable release of TimeToLeave: a smart departure planner that integrates
your calendar with real-time public transport data to tell you exactly when to leave.
### Web Application
- Next.js 16 web dashboard with Tailwind CSS 4
- Calendar sync via `.ics` file import or URL
- Station search and journey planning for upcoming events
- Real-time departures with dynamic leave-status countdown
- Browser-based leave reminders with push notifications
- Dark/light theme toggle
- Debounced search and optimized calendar loading
- Docker support with multi-stage build and standalone output
### Mobile Application
- React Native 0.81 / Expo 54 mobile client
- Five-screen navigation: Event List, Add Event, Event Detail, Origin Setup, Settings
- Native calendar import via `expo-calendar`
- Geolocation-based origin detection via `expo-location`
- Local push notifications via `expo-notifications`
- Persistent settings and state via AsyncStorage
### Public Transport Integration
- HAFAS protocol support for Austrian railway (ÖBB) real-time departures
- WienerLinien integration for Vienna U-Bahn, tram, and bus departures
- Accurate timezone-aware HAFAS time parsing with DST transition handling for `Europe/Vienna`
- Support for repeated stop IDs and multiple connections
### Routing
- Bike route calculation from origin to departure station via OSRM
- Geocoding API integration for station lookups
- Fallback and caching logic for API failures
### Shared Infrastructure
- Monorepo structure with npm workspaces
- `@timetoleave/core` — shared types, countdown utilities, and leave-status logic
- `@timetoleave/api-client` — typed client for HAFAS, calendar, geocoding, and bike routing
- Correlation IDs for request tracing and monitoring
- Comprehensive test coverage across web (Vitest) and mobile (Jest)
- Strict TypeScript type-checking across all workspaces
- ESLint linting across the codebase
+42 -57
View File
@@ -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 | 13 | ~45 min |
| 2 — Deduplicate Code | 47 | ~2 hours |
| 3 — Performance & UX | 811 | ~1.5 hours |
| 4 — Monitoring & Testing | 1214 | ~1 hour |
| **Total** | **14** | **~5 hours** |
**Legend:**
- ✅ = Done (code written)
- ✔️ = Verified (tests/builds pass)
- `[~]` = Optional or deferred (never blocks phase advancement)
+79
View File
@@ -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)
+27
View File
@@ -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].
+137
View File
@@ -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.
![Platform](https://img.shields.io/badge/platform-Web_%26_Mobile-blue) ![Next.js](https://img.shields.io/badge/Next.js-16.2-green) ![React Native](https://img.shields.io/badge/React%20Native-0.81-blue) ![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)
## 🚀 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.*
+1033 -471
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+179
View File
@@ -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))
+298
View File
@@ -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))
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
root: true,
extends: ['expo'],
rules: {
'react-native/no-inline-styles': 'off',
},
ignorePatterns: ['node_modules/', '.expo/', 'dist/'],
};
+42
View File
@@ -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
+23
View File
@@ -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 />;
}
+46
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+34
View File
@@ -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"
}
}
}
}
+8
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
preset: 'jest-expo',
testMatch: ['**/__tests__/**/*.test.[jt]s?(x)'],
};
+41
View File
@@ -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
}
+138
View File
@@ -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());
});
});
});
+204
View File
@@ -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>
);
}
+143
View File
@@ -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' },
});
+190
View File
@@ -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 },
});
+238
View File
@@ -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 },
});
+4
View File
@@ -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);
+41
View File
@@ -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}`,
}));
}
+146
View File
@@ -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,
}),
});
}
+199
View File
@@ -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 },
});
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/__tests__"]
}
+8
View File
@@ -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/'],
};
View File
+22
View File
@@ -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}]]}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@timetoleave/web",
"version": "1.0.0"
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@timetoleave/api-client": "*",
"@timetoleave/core": "*",
"date-fns": "^4.1.0",
"next": "^16.2.6",
"node-ical": "^0.26.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.5"
}
}

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

+94
View File
@@ -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";
+78
View File
@@ -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

+37
View File
@@ -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 ──

Some files were not shown because too many files have changed in this diff Show More