Files
time_to_leave/REWRITE_PLAN.md
T
2026-05-10 21:19:39 +02:00

1133 lines
34 KiB
Markdown

# TimeToLeave — Monorepo + Mobile App Plan
> **Status:** Not Started
> **Updated:** Monorepo refactor to support Expo React Native mobile app
> **Scope:** Convert to npm workspaces, extract shared packages, scaffold Expo mobile MVP
---
## 1. Target Architecture
```
TimeToLeave/
├── package.json # workspace root
├── apps/
│ ├── web/ # Next.js 16 backend + web frontend (moved from root)
│ │ ├── src/
│ │ ├── public/
│ │ ├── package.json
│ │ ├── next.config.ts
│ │ ├── tsconfig.json
│ │ ├── eslint.config.mjs
│ │ ├── postcss.config.mjs
│ │ ├── vitest.config.ts
│ │ ├── Dockerfile
│ │ └── docker-compose.yml
│ └── mobile/ # Expo React Native app (new)
│ ├── app/
│ ├── package.json
│ ├── app.json
│ └── tsconfig.json
└── packages/
├── core/ # Platform-neutral types + utilities (new)
│ ├── src/
│ │ ├── index.ts
│ │ ├── types.ts
│ │ ├── countdown-utils.ts
│ │ ├── formatting.ts
│ │ ├── status-utils.ts
│ │ └── hafas-time.ts
│ ├── package.json
│ └── tsconfig.json
└── api-client/ # Typed API wrapper for web + mobile (new)
├── src/
│ ├── index.ts
│ └── client.ts
├── package.json
└── tsconfig.json
```
---
## 2. Current State
| Layer | Technology | Status |
|-------|-----------|--------|
| Framework | Next.js 16 + App Router | ✅ Working |
| Language | TypeScript (strict) | ✅ Compiles clean |
| State | React Context + localStorage | ✅ Working |
| Styling | Tailwind CSS v4 | ✅ Working |
| Tests | Vitest + jsdom + RTL | ✅ Working |
| Build | Docker multi-stage standalone | ✅ Working |
| HAFAS Integration | ApiClient + HafasClient + route | ✅ Working |
| Geocoding | ApiClient + GeocodingClient | ✅ Working |
| Bike Routing | ApiClient + BikeRoutingClient | ✅ Working |
---
## 3. Phases Overview
| Phase | Scope | Steps | Estimated Effort |
|-------|-------|-------|-----------------|
| 1 | Workspace + shared packages | 1-9 | 3-4 hours |
| 2 | Expo mobile MVP | 10-18 | 6-8 hours |
| 3 | Notifications + deployment | 19-24 | 4-5 hours |
---
## 4. Implementation Steps
### Phase 1 — Workspace + Shared Packages
Convert the repository into an npm workspace and extract platform-neutral code.
---
#### Step 1: Create Safety Baseline (~10 min)
**Goal:** Verify the current web app is in a clean state before refactoring.
**Commands to run at project root:**
```bash
npm run typecheck
npm run lint
npm test
npm run build
```
**Acceptance criteria:**
- [ ] `npm run typecheck` reports zero errors
- [ ] `npm run lint` reports zero errors
- [ ] `npm test` passes all tests
- [ ] `npm run build` completes successfully
**If any command fails:** Fix the issue before proceeding. Do not carry bugs into the refactor.
---
#### Step 2: Add Workspace Support (~10 min)
**File:** Root `package.json`
**Goal:** Convert the repository root into an npm workspace that manages `apps/*` and `packages/*`.
**Change root `package.json`:**
```json
{
"name": "time-to-leave",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "npm run dev -w apps/web",
"build": "npm run build -w apps/web",
"start": "npm run start -w apps/web",
"lint": "npm run lint -w apps/web",
"typecheck": "npm run typecheck -w apps/web",
"test": "npm run test -w apps/web"
}
}
```
**Actions:**
1. Set `"workspaces"` to include `"apps/*"` and `"packages/*"`
2. Move all npm scripts to delegate to `apps/web` using `-w apps/web`
3. Remove all dependencies from the root `package.json` (they'll move to workspace members)
4. Run `npm install` at the root to re-install with workspace support
**Acceptance criteria:**
- [ ] `npm install` at root succeeds
- [ ] `npm ls --workspaces` shows workspace structure
---
#### Step 3: Move Current Web App Into `apps/web` (~20 min)
**Goal:** Move the existing Next.js app files into `apps/web/` so the workspace structure is in place.
**Directories/files to move:**
| Source | Destination |
|--------|-------------|
| `src/` | `apps/web/src/` |
| `public/` | `apps/web/public/` |
| `next.config.ts` | `apps/web/next.config.ts` |
| `tsconfig.json` | `apps/web/tsconfig.json` |
| `eslint.config.mjs` | `apps/web/eslint.config.mjs` |
| `postcss.config.mjs` | `apps/web/postcss.config.mjs` |
| `vitest.config.ts` | `apps/web/vitest.config.ts` |
| `Dockerfile` | `apps/web/Dockerfile` |
| `docker-compose.yml` | `apps/web/docker-compose.yml` |
| `.dockerignore` | `apps/web/.dockerignore` |
**Create `apps/web/package.json`:**
```json
{
"name": "@timetoleave/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@timetoleave/core": "*",
"@timetoleave/api-client": "*",
"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/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"
}
}
```
**Actions:**
1. Create `apps/web/` directory
2. Move each file/directory from project root into `apps/web/`
3. Create the `package.json` above with all current dependencies
4. Run `npm install` at root
5. Verify the web app still works:
```bash
npm run typecheck -w apps/web
npm run lint -w apps/web
npm test -w apps/web
npm run build -w apps/web
```
**Acceptance criteria:**
- [ ] All four verification commands pass after the move
- [ ] `npm run dev` at root starts the Next.js dev server
---
#### Step 4: Create `packages/core` (~30 min)
**Goal:** Extract platform-neutral code into a shared package that both web and mobile can import.
**Source files to extract:**
| Current Path | New Path in `packages/core/src/` |
|--------------|--------------------------------|
| `src/types/index.ts` | `types.ts` |
| `src/lib/countdown-utils.ts` | `countdown-utils.ts` |
| `src/lib/formatting.ts` | `formatting.ts` |
| `src/lib/status-utils.ts` | `status-utils.ts` |
| `src/lib/hafas-time.ts` | `hafas-time.ts` |
**Create `packages/core/src/index.ts`:**
```typescript
// Re-export everything for clean imports
export * from './types';
export * from './countdown-utils';
export * from './formatting';
export * from './status-utils';
export * from './hafas-time';
```
**Create `packages/core/package.json`:**
```json
{
"name": "@timetoleave/core",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"date-fns": "^4.1.0"
},
"devDependencies": {
"typescript": "^5"
}
}
```
**Create `packages/core/tsconfig.json`:**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
```
**Actions:**
1. Create directory structure
2. Copy (not move yet) the source files from `src/lib/` and `src/types/`
3. Remove any `@/` alias imports inside the copied files — use relative imports
4. Create the barrel `index.ts`
5. Run `npm run typecheck -w @timetoleave/core`
**Acceptance criteria:**
- [ ] `packages/core/src/index.ts` exports all types and utilities
- [ ] `npm run typecheck -w @timetoleave/core` passes
---
#### Step 5: Update Web Imports to Use `@timetoleave/core` (~30 min)
**Goal:** Replace local imports of extracted files with imports from the core package.
**Import changes in `apps/web/src/`:**
| Before | After |
|--------|-------|
| `import type { Event } from "@/types"` | `import type { Event } from "@timetoleave/core"` |
| `import { calculateCountdown } from "@/lib/countdown-utils"` | `import { calculateCountdown } from "@timetoleave/core"` |
| `import { formatDuration } from "@/lib/formatting"` | `import { formatDuration } from "@timetoleave/core"` |
| `import { getLeaveStatus } from "@/lib/status-utils"` | `import { getLeaveStatus } from "@timetoleave/core"` |
| `import { hafasToLocalTime } from "@/lib/hafas-time"` | `import { hafasToLocalTime } from "@timetoleave/core"` |
**Actions:**
1. Search `apps/web/src/` for all imports from `@/types`, `@/lib/countdown-utils`, `@/lib/formatting`, `@/lib/status-utils`, `@/lib/hafas-time`
2. Replace each with the corresponding `@timetoleave/core` import
3. Delete the original files from `apps/web/src/lib/` and `apps/web/src/types/`
4. Run verification:
```bash
npm run typecheck -w apps/web
npm run lint -w apps/web
npm test -w apps/web
npm run build -w apps/web
```
**Acceptance criteria:**
- [ ] All four verification commands pass
- [ ] No remaining imports of `@/types`, `@/lib/countdown-utils`, `@/lib/formatting`, `@/lib/status-utils`, `@/lib/hafas-time`
---
#### Step 6: Create `packages/api-client` (~45 min)
**Goal:** Add typed wrappers around the existing web API routes that both web hooks and mobile can use.
**Create `packages/api-client/src/client.ts`:**
```typescript
const DEFAULT_BASE_URL = '';
export class ApiClient {
private readonly baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
}
async getHealth(): Promise<{ status: 'ok'; uptime: number }> {
const res = await fetch(`${this.baseUrl}/api/health`);
if (!res.ok) throw new Error(`Health check failed: ${res.status}`);
return res.json();
}
async geocode(name: string, countrycodes?: string): Promise<GeocodeResult[]> {
const url = new URL(`${this.baseUrl}/api/geocode`);
url.searchParams.set('name', name);
if (countrycodes) url.searchParams.set('countrycodes', countrycodes);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Geocode failed: ${res.status}`);
return res.json();
}
async getBikeRoute(
fromLat: number,
fromLng: number,
toLat: number,
toLng: number,
): Promise<BikeRoute> {
const url = new URL(`${this.baseUrl}/api/bike-route`);
url.searchParams.set('fromLat', String(fromLat));
url.searchParams.set('fromLng', String(fromLng));
url.searchParams.set('toLat', String(toLat));
url.searchParams.set('toLng', String(toLng));
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Bike route failed: ${res.status}`);
return res.json();
}
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
const api = new URL(`${this.baseUrl}/api/calendar`);
api.searchParams.set('url', url);
if (days) api.searchParams.set('days', String(days));
const res = await fetch(api.toString());
if (!res.ok) throw new Error(`Calendar fetch failed: ${res.status}`);
return res.json();
}
async parseCalendarIcs(content: string): Promise<CalendarEvent[]> {
const res = await fetch(`${this.baseUrl}/api/calendar/parse`, {
method: 'POST',
headers: { 'Content-Type': 'text/calendar' },
body: content,
});
if (!res.ok) throw new Error(`Calendar parse failed: ${res.status}`);
return res.json();
}
async searchJourneys(
fromStationExtId: string,
toStationExtId: string,
date: Date,
): Promise<Journey[]> {
const url = new URL(`${this.baseUrl}/api/hafas`);
url.searchParams.set('from', fromStationExtId);
url.searchParams.set('to', toStationExtId);
url.searchParams.set('date', date.toISOString());
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Journey search failed: ${res.status}`);
return res.json();
}
async searchStation(query: string): Promise<Station[]> {
const url = new URL(`${this.baseUrl}/api/station`);
url.searchParams.set('q', query);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Station search failed: ${res.status}`);
return res.json();
}
}
```
The types (`GeocodeResult`, `BikeRoute`, `CalendarEvent`, `Journey`, `Station`) should be imported from `@timetoleave/core`.
**Create `packages/api-client/src/index.ts`:**
```typescript
export { ApiClient } from './client';
```
**Create `packages/api-client/package.json`:**
```json
{
"name": "@timetoleave/api-client",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"@timetoleave/core": "*"
},
"devDependencies": {
"typescript": "^5"
}
}
```
**Create `packages/api-client/tsconfig.json`:**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
```
**Acceptance criteria:**
- [ ] `npm run typecheck -w @timetoleave/api-client` passes
- [ ] `ApiClient` class accepts `baseUrl` in constructor
- [ ] All seven methods are implemented: `getHealth`, `geocode`, `getBikeRoute`, `fetchCalendar`, `parseCalendarIcs`, `searchJourneys`, `searchStation`
---
#### Step 7: Refactor Web Hooks to Use `api-client` (~45 min)
**Goal:** Gradually update the web hooks to use `ApiClient` internally instead of raw `fetch` calls. The hooks should manage React state; the API package should manage request shape and parsing.
**Hooks to update:**
| Hook File | Method to Use |
|-----------|--------------|
| `useJourneys.ts` | `client.searchJourneys()` |
| `useBikeRoute.ts` | `client.getBikeRoute()` |
| `useGeocode.ts` | `client.geocode()` |
| `useDestinationStation.ts` | `client.searchStation()` |
| `useCalendar.ts` | `client.fetchCalendar()` / `client.parseCalendarIcs()` |
| `useServerHealth.ts` | `client.getHealth()` |
**Pattern for each hook:**
```typescript
// BEFORE — raw fetch in hook:
const response = await fetch(`/api/geocode?name=${query}`);
const data = await response.json();
// AFTER — api-client in hook:
import { ApiClient } from '@timetoleave/api-client';
const client = new ApiClient(); // baseUrl = '' for web (same origin)
const results = await client.geocode(query);
```
**Key principles:**
- Hooks continue to manage `loading`, `data`, `error`, and `refresh` state
- `ApiClient` handles URL construction, fetch, error status checking, and JSON parsing
- For web, `baseUrl` is empty string (same-origin)
- Error handling in hooks should catch `ApiClient` exceptions and set hook `error` state
**Acceptance criteria:**
- [ ] Each hook uses `ApiClient` method instead of raw `fetch`
- [ ] Hook state shape (`loading`, `data`, `error`, `refresh`) is preserved
- [ ] `npm run typecheck -w apps/web` passes
---
#### Step 8: Run Web Verification Again (~10 min)
**Goal:** Confirm the web app is still fully functional after all extraction and refactoring.
**Commands:**
```bash
npm run typecheck -w apps/web
npm run lint -w apps/web
npm test -w apps/web
npm run build -w apps/web
```
**Acceptance criteria:**
- [ ] All four commands pass with zero errors
- [ ] `npm run dev` at root starts the dev server and the app loads correctly
- [ ] Core utilities work from `@timetoleave/core` in the web app
- [ ] Hooks work via `@timetoleave/api-client`
---
#### Step 9: Phase 1 Summary Verification (~5 min)
**Goal:** Final verification that the workspace structure is correct and all packages compile.
**Commands:**
```bash
npm run typecheck -w @timetoleave/core
npm run typecheck -w @timetoleave/api-client
npm run typecheck -w apps/web
npm run build -w apps/web
```
**Acceptance criteria:**
- [ ] All three packages compile without errors
- [ ] Web build produces a valid Next.js output
- [ ] `packages/core` contains only platform-neutral code
- [ ] `packages/api-client` depends only on `@timetoleave/core`
---
### Phase 2 — Expo Mobile MVP
Scaffold and implement the React Native mobile app that consumes the web API.
---
#### Step 10: Scaffold Expo Mobile App (~15 min)
**Goal:** Create the Expo app with TypeScript template.
**Command:**
```bash
npx create-expo-app apps/mobile --template
```
Use the TypeScript template.
**Install core dependencies:**
```bash
npm install -w apps/mobile @react-navigation/native @react-navigation/native-stack
npm install -w apps/mobile react-native-screens react-native-safe-area-context
npm install -w apps/mobile expo-location expo-notifications
npm install -w apps/mobile @react-native-async-storage/async-storage
```
**Acceptance criteria:**
- [ ] `apps/mobile/` directory exists with Expo boilerplate
- [ ] `npx expo start` in `apps/mobile/` launches the dev server
- [ ] All dependencies are installed
---
#### Step 11: Configure Mobile API Base URL (~5 min)
**Goal:** Set up environment variable so mobile knows where to call the API.
**Create `apps/mobile/.env`:**
```
EXPO_PUBLIC_API_BASE_URL=https://your-deployed-web-app.example.com
```
**Important:** The mobile app should never call ÖBB, Nominatim, or OSRM directly. It should always call the Next.js API routes.
**In the mobile app, create a configured ApiClient singleton:**
```typescript
// apps/mobile/src/services/api.ts
import { ApiClient } from '@timetoleave/api-client';
const baseUrl = process.env.EXPO_PUBLIC_API_BASE_URL ?? '';
export const api = new ApiClient(baseUrl);
```
**Acceptance criteria:**
- [ ] `.env` file exists with `EXPO_PUBLIC_API_BASE_URL`
- [ ] Mobile uses the singleton `api` instance for all network calls
- [ ] `.env` is listed in `.gitignore`
---
#### Step 12: Add Mobile App Shell (~30 min)
**Goal:** Create basic navigation structure with stack navigation and placeholder screens.
**Screens to create:**
| Screen | Purpose |
|--------|---------|
| `EventListScreen` | List all events with countdown and status |
| `EventDetailScreen` | Show train journeys, bike route, delays |
| `AddEventScreen` | Form to add a new event |
| `SettingsScreen` | Origin station, notification prefs |
| `CalendarImportScreen` | ICS URL import flow |
**Directory structure:**
```
apps/mobile/
├── src/
│ ├── navigation/
│ │ └── AppNavigator.tsx
│ ├── screens/
│ │ ├── EventListScreen.tsx
│ │ ├── EventDetailScreen.tsx
│ │ ├── AddEventScreen.tsx
│ │ ├── SettingsScreen.tsx
│ │ └── CalendarImportScreen.tsx
│ ├── services/
│ │ └── api.ts
│ └── store/
│ └── eventStore.ts
├── app.json
├── .env
└── package.json
```
**Create `AppNavigator.tsx` with a `NavigationContainer` and `Stack.Navigator` linking to all screens.**
**Acceptance criteria:**
- [ ] All five screens render (even as placeholders)
- [ ] Navigation between screens works
- [ ] Safe area context wraps the navigation container
---
#### Step 13: Add Mobile Event Store (~30 min)
**Goal:** Create local storage for events using AsyncStorage.
**Store shape:**
```typescript
interface MobileStore {
events: Event[]; // from @timetoleave/core
originStation: Station | null;
notificationSettings: {
enabled: boolean;
remindersMinutesBefore: number[]; // defaults: [30, 10, 0]
};
}
```
**Create `apps/mobile/src/store/eventStore.ts`:**
```typescript
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Event, Station } from '@timetoleave/core';
const EVENTS_KEY = '@timetoleave_events';
const ORIGIN_KEY = '@timetoleave_origin';
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
export async function loadEvents(): Promise<Event[]> { /* ... */ }
export async function saveEvents(events: Event[]): Promise<void> { /* ... */ }
export async function addEvent(event: Event): Promise<void> { /* ... */ }
export async function removeEvent(id: string): Promise<void> { /* ... */ }
export async function loadOriginStation(): Promise<Station | null> { /* ... */ }
export async function saveOriginStation(station: Station): Promise<void> { /* ... */ }
export async function loadNotificationSettings(): Promise<NotificationSettings> { /* ... */ }
export async function saveNotificationSettings(settings: NotificationSettings): Promise<void> { /* ... */ }
```
**Key design decisions:**
- Start with AsyncStorage (simple, works for MVP)
- Later migrate to `expo-sqlite` if event history grows large
- All dates stored as ISO strings, converted to `Date` on load
- Add merge logic: when events are loaded, merge with any calendar-sourced events (deduplicate by title + date)
**Acceptance criteria:**
- [ ] Events can be saved and loaded from AsyncStorage
- [ ] Origin station persists across app restarts
- [ ] Notification settings persist with sensible defaults
---
#### Step 14: Build Event List Screen (~45 min)
**Goal:** Display all events with countdown, status, and quick actions.
**Reuse shared utilities from `@timetoleave/core`:**
- `calculateCountdown` for time remaining
- `getLeaveStatus` for status indicator
- `formatDuration` for readable times
**Each event card shows:**
| Field | Source |
|-------|--------|
| Event title | `event.title` |
| Destination | `event.destination` |
| Event time | `event.eventTime` |
| Countdown | `calculateCountdown(event.eventTime)` |
| Leave-by status | `getLeaveStatus(event, journeys)` |
| Train summary | Latest journey (from API if available) |
| Bike summary | Duration + distance (from API if available) |
| Refresh button | Re-fetch journeys/bike for this event |
**Interactions:**
- Press event card → navigate to `EventDetailScreen`
- Swipe left → delete event
- Pull to refresh → re-fetch all journey data
**Acceptance criteria:**
- [ ] Events are displayed with countdown and status
- [ ] Navigation to detail screen works
- [ ] Pull-to-refresh reloads data
- [ ] Empty state shown when no events exist
---
#### Step 15: Build Add Event Screen (~30 min)
**Goal:** Native form to create a new event.
**Form fields:**
| Field | Type | Validation |
|-------|------|------------|
| Title | `TextInput` | Required, min 1 char |
| Destination | `TextInput` | Required, min 1 char |
| Date | `DatePicker` (native) | Must be future date |
| Time | `TimePicker` (native) | Combined with date must be future |
| Save button | `TouchableOpacity` | Disabled if validation fails |
**Validation rules:**
- Title is required
- Destination is required
- Date + Time combined must be in the future
- Show inline validation errors
**On save:**
1. Create `Event` object with generated `id`
2. Add to local store via `addEvent()`
3. Navigate back to event list
4. Schedule notifications (see Step 19)
**Acceptance criteria:**
- [ ] Form validates all fields correctly
- [ ] Saved events appear in event list
- [ ] Invalid submissions show error messages
---
#### Step 16: Build Origin Setup (~30 min)
**Goal:** Let the user configure their origin station in Settings.
**Features in Settings screen:**
| Feature | Implementation |
|---------|---------------|
| Default origin station | Station search via `api.searchStation()` |
| Use current location toggle | `expo-location` for GPS coordinates |
| Location permission state | Show permission status |
**Station search flow:**
1. User types station name in TextInput
2. Debounce 400ms, call `api.searchStation(query)`
3. Display results as a list
4. On select, save to store as `originStation`
5. Recompute all event journeys with new origin
**Location-based station detection:**
1. Request location permission via `expo-location`
2. Get current coordinates
3. Call `api.geocode()` to find nearest station
4. Present nearest station to confirm
**Important:** Use `expo-location` only for getting coordinates. Station lookup still goes through the backend API.
**Acceptance criteria:**
- [ ] Station search returns results from backend
- [ ] Selected origin persists in AsyncStorage
- [ ] Location permission is requested correctly
- [ ] Changing origin triggers event data refresh
---
#### Step 17: Build Event Detail Screen (~45 min)
**Goal:** Show comprehensive journey information for a single event.
**Data to fetch and display:**
| Section | Data | Source |
|---------|------|--------|
| Train journeys | Departure, arrival, delay, platform, transfers | `api.searchJourneys()` |
| Current delay | Real-time delay info | Journey data |
| Platform info | Platform number | Journey data |
| Bike route | Duration, distance | `api.getBikeRoute()` |
| Refresh button | Re-fetch all data | Manual trigger |
| Error states | Display errors from API calls | Error handling |
**UI layout (suggested):**
1. Event header: title, destination, time, countdown
2. Train section: list of journeys with delay/platform info
3. Bike section: duration, distance, map placeholder
4. Error banner if any API call failed
5. Refresh button at bottom
**Reuse from `@timetoleave/core`:**
- `hafasToLocalTime()` for converting HAFAS timestamps
- `formatDuration()` for bike duration display
- Journey types for type-safe rendering
**Acceptance criteria:**
- [ ] Train journeys are displayed with delay/platform info
- [ ] Bike route shows duration and distance
- [ ] Refresh button reloads all data
- [ ] Error states are displayed gracefully
- [ ] Loading states shown while fetching
---
#### Step 18: Phase 2 Summary Verification (~10 min)
**Goal:** Confirm the mobile app shell and core screens work.
**Verification:**
- [ ] `npx expo start` launches without errors
- [ ] All five screens render correctly
- [ ] Navigation between screens works
- [ ] Events can be added and persist in AsyncStorage
- [ ] Origin station can be set and persists
- [ ] Event list shows countdown and status from `@timetoleave/core`
- [ ] Event detail fetches real data via `@timetoleave/api-client`
---
### Phase 3 — Notifications, Calendar, Deployment
Polish the MVP with reminders, ICS import, and deployment preparation.
---
#### Step 19: Add Local Notifications (~45 min)
**Goal:** Schedule push notifications for leave reminders using `expo-notifications`.
**For each event, calculate leave-by time and schedule:**
| Reminder | Timing |
|----------|--------|
| Prepare to leave | 30 minutes before leave time |
| Final reminder | 10 minutes before leave time |
| Leave now | At leave-by time |
**Implementation outline:**
```typescript
import * as Notifications from 'expo-notifications';
import type { Event } from '@timetoleave/core';
export async function scheduleNotificationsForEvent(
event: Event,
leaveByTime: Date,
settings: NotificationSettings
): Promise<void> {
// Cancel existing notifications for this event
const existing = await Notifications.getAllScheduledNotificationsAsync();
const toCancel = existing.filter(n => n.content.data.eventId === event.id);
await Notifications.cancelScheduledNotificationsAsync(
toCancel.map(n => n.identifier)
);
// Schedule new notifications
for (const minutesBefore of settings.remindersMinutesBefore) {
const triggerTime = new Date(leaveByTime.getTime() - minutesBefore * 60 * 1000);
if (triggerTime <= new Date()) continue; // skip past times
await Notifications.scheduleNotificationAsync({
content: {
title: `🚆 ${event.title}`,
body: minutesBefore === 0
? 'Time to leave!'
: `${minutesBefore} minutes until you should leave`,
data: { eventId: event.id },
},
trigger: triggerTime,
});
}
}
```
**Recompute notifications whenever:**
- Events are added, modified, or removed
- Origin station is changed (affects journey times)
- Notification settings are updated
- Journey data is refreshed (delays may change leave time)
**Acceptance criteria:**
- [ ] Permission is requested on first notification trigger
- [ ] Notifications are scheduled for each event
- [ ] Notifications fire at correct times
- [ ] Old notifications are cancelled when events change
- [ ] Past notification triggers are skipped
---
#### Step 20: Add Native Calendar Import (Post-MVP)
**Goal:** After MVP works, add native calendar read access.
**Why this is separate from ICS URL import:**
- Requires platform-specific permissions
- Needs a privacy explanation for the app store
- iOS and Android have different calendar APIs
**Future approach:**
- Use a library like `react-native-calendar-events` or `unimodules`
- Request read permission with a clear explanation
- Let user select which calendars to import from
- Merge imported events into local store with source tracking
**Acceptance criteria (for later):**
- [ ] Native calendar events can be read
- [ ] Imported events show calendar source indicator
- [ ] Duplicate detection works across import methods
---
#### Step 21: Add Mobile Tests (~45 min)
**Goal:** Start with non-UI tests, then add React Native Testing Library for key screens.
**Non-UI tests (priority):**
| Test Area | What to Test |
|-----------|-------------|
| Core utilities | `calculateCountdown`, `getLeaveStatus`, `formatDuration` |
| API client | Request URL construction, error handling |
| Event store | Save/load/merge behavior with AsyncStorage |
| Notification scheduling | Leave-by time calculations, trigger times |
**UI tests (secondary):**
| Screen | What to Test |
|--------|-------------|
| EventListScreen | Renders events, shows empty state |
| AddEventScreen | Validation, save navigation |
| EventDetailScreen | Shows journey data, handles errors |
**Setup:**
```bash
npm install -w apps/mobile --save-dev jest jest-expo @testing-library/react-native
```
**Acceptance criteria:**
- [ ] Core utility tests pass
- [ ] API client tests verify URL construction
- [ ] Event store tests verify persistence
- [ ] At least EventListScreen and AddEventScreen have basic tests
---
#### Step 22: Prepare Deployment (~30 min)
**Goal:** Prepare both web backend and mobile app for production.
**Web backend deployment:**
1. Deploy the Next.js app to your hosting provider
2. Configure environment variables (HAFAS credentials, OSRM, Nominatim)
3. Verify API routes are reachable from external networks
4. Ensure CORS is configured to allow mobile app origin
**Mobile deployment:**
1. Configure EAS (Expo Application Services):
```bash
npx eas-cli build --platform android
npx eas-cli build --platform ios
```
2. Set up app icon and splash screen
3. Configure bundle identifiers:
- Android: `com.timetoleave.app`
- iOS: `com.timetoleave.app`
4. Prepare privacy policy explaining:
- Location data usage (only for station detection)
- Calendar data usage (events stored locally)
- No analytics or tracking
5. Add permission text for:
- Location permission
- Notification permission
6. Submit to TestFlight (iOS) and internal Android release
**Acceptance criteria:**
- [ ] Web API is deployed and reachable
- [ ] EAS is configured with project settings
- [ ] App icon and splash screen are set
- [ ] Privacy policy is written
- [ ] TestFlight / internal Android build is created
---
#### Step 23: Release MVP (~15 min)
**Goal:** Verify MVP acceptance criteria are met.
**MVP acceptance criteria checklist:**
- [ ] User can add events via the AddEventScreen
- [ ] User can import events via ICS URL (CalendarImportScreen)
- [ ] User can set their origin station (SettingsScreen)
- [ ] App shows train journeys and bike options for each event
- [ ] App persists events locally via AsyncStorage
- [ ] App sends leave reminders via local notifications
- [ ] App works on both iOS and Android
**Post-release monitoring:**
- Track crash reports via Sentry or similar
- Monitor API route errors
- Gather user feedback on UX
---
#### Step 24: Post-MVP Improvements
**Goal:** Plan future enhancements after MVP is stable.
**Next best additions (prioritized):**
| # | Feature | Description |
|---|---------|-------------|
| 1 | Wiener Linien support | U-Bahn/tram connections within Vienna |
| 2 | Native calendar read access | Direct access to device calendar events |
| 3 | Home screen widget | Quick glance at next event + countdown |
| 4 | Offline cached journeys | Cache journey results for offline access |
| 5 | Favorite destinations | Quick-select frequently used destinations |
| 6 | Smarter station matching | Fuzzy matching for station names |
| 7 | Background refresh | Auto-refresh journey data in background |
**Implementation strategy:** One feature per PR, starting with Wiener Linien support since it's a high-value addition for Vienna users.
---
## 5. Implementation Strategy
**Execute this plan in three PR-sized chunks:**
### PR 1: Workspace + Shared Packages (Steps 1-9)
- Convert to npm workspaces
- Move web app into `apps/web/`
- Create `packages/core/` with shared types and utilities
- Create `packages/api-client/` with typed API wrapper
- Refactor web hooks to use api-client
- **Verify:** Web app builds, tests pass, typecheck is clean
### PR 2: Expo Mobile MVP (Steps 10-18)
- Scaffold Expo app
- Set up navigation, event store, all screens
- Wire screens to api-client for data
- **Verify:** All screens work, events persist, navigation flows
### PR 3: Notifications + Calendar + Deploy (Steps 19-24)
- Add local notification scheduling
- Polish ICS import flow
- Add tests
- Prepare deployment configuration
- **Verify:** MVP acceptance criteria are met
---
## 6. Dependencies
| Package | Used By | Purpose |
|---------|---------|---------|
| `@timetoleave/core` | web, mobile | Shared types, countdown/status/formatting utilities |
| `@timetoleave/api-client` | web, mobile | Typed wrapper around Next.js API routes |
| `expo-location` | mobile | GPS coordinates for station detection |
| `expo-notifications` | mobile | Local push notifications for reminders |
| `@react-native-async-storage/async-storage` | mobile | Local persistence for events |
| `@react-navigation/native` | mobile | Navigation framework |
| `@react-navigation/native-stack` | mobile | Stack navigator for screen transitions |
---