1133 lines
34 KiB
Markdown
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 |
|
|
|
|
--- |