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

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 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:

{
  "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:

{
  "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:
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:

// 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:

  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:
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-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:

// 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:

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:

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:

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 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:

// 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:

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-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:

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:

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):
    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