34 KiB
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:
npm run typecheck
npm run lint
npm test
npm run build
Acceptance criteria:
npm run typecheckreports zero errorsnpm run lintreports zero errorsnpm testpasses all testsnpm run buildcompletes 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:
{
"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:
- Set
"workspaces"to include"apps/*"and"packages/*" - Move all npm scripts to delegate to
apps/webusing-w apps/web - Remove all dependencies from the root
package.json(they'll move to workspace members) - Run
npm installat the root to re-install with workspace support
Acceptance criteria:
npm installat root succeedsnpm ls --workspacesshows 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:
{
"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:
- Create
apps/web/directory - Move each file/directory from project root into
apps/web/ - Create the
package.jsonabove with all current dependencies - Run
npm installat root - Verify the web app still works:
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 devat 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:
// 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:
{
"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:
{
"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:
- Create directory structure
- Copy (not move yet) the source files from
src/lib/andsrc/types/ - Remove any
@/alias imports inside the copied files — use relative imports - Create the barrel
index.ts - Run
npm run typecheck -w @timetoleave/core
Acceptance criteria:
packages/core/src/index.tsexports all types and utilitiesnpm run typecheck -w @timetoleave/corepasses
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:
- Search
apps/web/src/for all imports from@/types,@/lib/countdown-utils,@/lib/formatting,@/lib/status-utils,@/lib/hafas-time - Replace each with the corresponding
@timetoleave/coreimport - Delete the original files from
apps/web/src/lib/andapps/web/src/types/ - Run verification:
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:
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:
export { ApiClient } from './client';
Create packages/api-client/package.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:
{
"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-clientpassesApiClientclass acceptsbaseUrlin 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:
// 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, andrefreshstate ApiClienthandles URL construction, fetch, error status checking, and JSON parsing- For web,
baseUrlis empty string (same-origin) - Error handling in hooks should catch
ApiClientexceptions and set hookerrorstate
Acceptance criteria:
- Each hook uses
ApiClientmethod instead of rawfetch - Hook state shape (
loading,data,error,refresh) is preserved npm run typecheck -w apps/webpasses
Step 8: Run Web Verification Again (~10 min)
Goal: Confirm the web app is still fully functional after all extraction and refactoring.
Commands:
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 devat root starts the dev server and the app loads correctly- Core utilities work from
@timetoleave/corein 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:
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/corecontains only platform-neutral codepackages/api-clientdepends 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:
npx create-expo-app apps/mobile --template
Use the TypeScript template.
Install core dependencies:
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 boilerplatenpx expo startinapps/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:
// 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:
.envfile exists withEXPO_PUBLIC_API_BASE_URL- Mobile uses the singleton
apiinstance for all network calls .envis 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:
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:
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-sqliteif event history grows large - All dates stored as ISO strings, converted to
Dateon 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:
calculateCountdownfor time remaininggetLeaveStatusfor status indicatorformatDurationfor 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:
- Create
Eventobject with generatedid - Add to local store via
addEvent() - Navigate back to event list
- 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:
- User types station name in TextInput
- Debounce 400ms, call
api.searchStation(query) - Display results as a list
- On select, save to store as
originStation - Recompute all event journeys with new origin
Location-based station detection:
- Request location permission via
expo-location - Get current coordinates
- Call
api.geocode()to find nearest station - 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):
- Event header: title, destination, time, countdown
- Train section: list of journeys with delay/platform info
- Bike section: duration, distance, map placeholder
- Error banner if any API call failed
- Refresh button at bottom
Reuse from @timetoleave/core:
hafasToLocalTime()for converting HAFAS timestampsformatDuration()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 startlaunches 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:
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-eventsorunimodules - 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:
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:
- Deploy the Next.js app to your hosting provider
- Configure environment variables (HAFAS credentials, OSRM, Nominatim)
- Verify API routes are reachable from external networks
- Ensure CORS is configured to allow mobile app origin
Mobile deployment:
- Configure EAS (Expo Application Services):
npx eas-cli build --platform android npx eas-cli build --platform ios - Set up app icon and splash screen
- Configure bundle identifiers:
- Android:
com.timetoleave.app - iOS:
com.timetoleave.app
- Android:
- Prepare privacy policy explaining:
- Location data usage (only for station detection)
- Calendar data usage (events stored locally)
- No analytics or tracking
- Add permission text for:
- Location permission
- Notification permission
- 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 |