20 KiB
ÖBB Planner — Next.js Rewrite Plan
Status: Planned Created: 2024 Scope: Full rewrite from CRA + Express to Next.js App Router with new features
1. Current State
| Layer | Technology | Files |
|---|---|---|
| Backend | Express.js + CORS + node-ical | server/index.js (~140 lines) |
| Frontend | Create React App (React 19) | oebb-planner.jsx (~700 lines monolithic component) |
| Tests | Jest + Supertest | server/__tests__/*.test.js |
| Build | Separate npm start for server + react-scripts start for frontend |
Two independent processes |
Problems with current architecture
- Two separate processes to manage
- Monolithic component (~700 lines) — no component breakdown
- CRA is deprecated;
react-scriptsis unmaintained - No TypeScript
- Inline styles make theming/maintenance painful
- No proper state management pattern
- Duplication between
oebb-planner.jsxandoebb-planner-app/src/App.js
2. New Features (Beyond Rewrite)
2.1 Calendar View (/calendar)
A browsable month view that lets you navigate between months, see events as badges on dates, and click into a day to see event + train details.
How it works:
- User imports calendar via the existing ICS flow
- Events are stored in React state / localStorage
- A month grid shows event dots on dates that have events
- Clicking a day shows that day's events with their train info
- Clicking an event scrolls to / expands the full event card
UI wireframe:
┌──────────────────────┬──────────────────────┐
│ ← November 2024 → │ [Day] [Week] [Month] │
├──────────────────────┼──────────────────────┤
│ Su Mo Tu We Th Fr Sa │ Thursday, 14 Nov │
│ 1 2 3 4 5 6 7 │ │
│ 8 9 10 11 12 13 14 │ ● 14:00 Graz Hbf │
│ ● ● │ 🚂 Leave 13:10 │
│ 15 16 17 18 19 20 21 │ 🚲 Leave 11:07 │
│ ● ● │ │
│ ... │ ● 17:00 Linz Hbf │
│ │ 🚂 Leave 15:30 │
│ │ 🚲 Leave 15:45 │
└──────────────────────┴──────────────────────┘
2.2 Bicycle Routing
API choice: OSRM Public Demo Server (router.project-osrm.org)
- Free, no API key required, open source
- Supports
bicycleprofile - Returns distance, duration, turn-by-turn steps
- For production: self-host or switch to OpenRouteService
Geocoding: Nominatim (OpenStreetMap) to convert station/event location names to coordinates for routing.
Data flow:
Origin station (name) → already have coords from geolocation
Destination station (name) → geocode via Nominatim → get coordinates
→ OSRM bicycle route → distance + duration
→ cache in TripData alongside train journeys
2.3 Train vs Bicycle Comparison
Each event card shows both travel modes side by side:
┌───────────────────────────────────────────────────┐
│ 🏔 Meeting in Graz │
│ 📍 Graz Hbf · 14:00 · Thu, 14 Nov │
├───────────────────────────────────────────────────┤
│ 🚂 NEXT BEST TRAIN 🚲 BYCYCLE │
│ RJX 5234 on time 42 km │
│ DEP 13:22 ARR 14:35 2h 15min │
│ PLAT 3 0 emissions │
│ Leave by 13:10 Leave by 11:07 │
└───────────────────────────────────────────────────┘
3. Target Architecture
TimeToLeave/
├── package.json
├── next.config.ts
├── tsconfig.json
├── postcss.config.mjs ← Tailwind v4 uses @tailwindcss/postcss; no tailwind.config.ts needed
├── vitest.config.ts ← must exist before Phase 7; deferred from Phase 1
├── src/
│ ├── app/
│ │ ├── layout.tsx ← root layout (fonts, providers, navbar)
│ │ ├── page.tsx ← main planner page (event cards)
│ │ ├── globals.css
│ │ ├── calendar/
│ │ │ └── page.tsx ← browsable calendar view (NEW)
│ │ ├── api/
│ │ │ ├── hafas/
│ │ │ │ └── route.ts ← POST → proxy to ÖBB HAFAS
│ │ │ ├── calendar/
│ │ │ │ ├── route.ts ← GET → fetch remote ICS calendar
│ │ │ │ └── parse/
│ │ │ │ └── route.ts ← POST → parse raw ICS body
│ │ │ ├── geocode/
│ │ │ │ └── route.ts ← GET → Nominatim geocoding (NEW)
│ │ │ ├── bike-route/
│ │ │ │ └── route.ts ← GET → OSRM bicycle routing (NEW)
│ │ │ └── health/
│ │ │ └── route.ts ← GET → liveness check
│ │ └── not-found.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── Header.tsx ← logo, server status, clock
│ │ │ └── Navbar.tsx ← navigation between pages (NEW)
│ │ ├── calendar/
│ │ │ ├── CalendarPanel.tsx ← collapsible import panel
│ │ │ ├── CalendarView.tsx ← month grid component (NEW)
│ │ │ ├── DayEvents.tsx ← events for a selected day (NEW)
│ │ │ ├── UrlTab.tsx
│ │ │ └── FileTab.tsx
│ │ ├── event/
│ │ │ ├── EventCard.tsx ← single event card (with train+bike)
│ │ │ ├── TrainSection.tsx ← train data in event card
│ │ │ ├── BikeSection.tsx ← bicycle data in event card (NEW)
│ │ │ ├── JourneyList.tsx ← all departures table
│ │ │ └── LeaveByBadge.tsx
│ │ ├── add-event/
│ │ │ └── AddEventModal.tsx
│ │ └── ui/
│ │ ├── Chip.tsx
│ │ ├── Button.tsx
│ │ └── LoadingSpinner.tsx
│ ├── hooks/
│ │ ├── useServerHealth.ts ← polls /api/health every 30s
│ │ ├── useClock.ts ← ticking clock (every 10s)
│ │ ├── useGeolocation.ts ← GPS positioning
│ │ ├── useOriginStation.ts ← finds nearest ÖBB station
│ │ ├── useJourneys.ts ← fetches + caches train data
│ │ ├── useBikeRoute.ts ← fetches bicycle route (NEW)
│ │ ├── useCalendar.ts ← calendar import logic
│ │ └── useEventsStore.ts ← shared events state (NEW)
│ ├── lib/
│ │ ├── hafas-client.ts ← HAFAS API client (class HafasClient)
│ │ ├── calendar-utils.ts ← ICS helpers (extractEvents, cleanLocation)
│ │ ├── countdown-utils.ts ← calculateCountdown, leaveBy helpers
│ │ ├── formatting.ts ← formatTime, formatDate, formatDateTime, formatDuration, formatDistance
│ │ ├── demo.ts ← demo journey generator
│ │ ├── constants.ts ← HAFAS_URL, NOMINATIM_URL, OSRM_URL, HAFAS_TIMEOUT_MS, DEFAULT_DAYS, APP_VERSION
│ │ ├── status-utils.ts ← StatusUtils.checkServerStatus() (used by useServerHealth)
│ │ ├── live-status-utils.ts ← LiveStatusUtils.getLiveStatus() (stub; live data TBD)
│ │ ├── geocoding-client.ts ← Nominatim client (NEW)
│ │ ├── bike-routing-client.ts ← OSRM client (NEW)
│ │ └── index.ts ← re-exports all lib modules
│ └── types/
│ └── index.ts ← all TypeScript interfaces
├── src/lib/__tests__/ ← unit tests live alongside lib, not at repo root
│ ├── hafas-client.test.ts
│ ├── geocoding-client.test.ts
│ ├── calendar-utils.test.ts
│ ├── countdown-utils.test.ts
│ └── bike-routing-client.test.ts ← NEW
├── src/app/api/__tests__/ ← API route tests
│ ├── hafas.test.ts
│ ├── calendar.test.ts
│ ├── geocode.test.ts ← NEW
│ ├── bike-route.test.ts ← NEW
│ └── health.test.ts
├── public/
│ ├── favicon.ico
│ └── robots.txt
├── .env.example
└── README.md
4. TypeScript Types
src/types/index.ts
// ── HAFAS / Train ───────────────────────────────────────────
interface Journey {
id: string;
sD: Date; // scheduled departure
rD: Date; // real departure
sA: Date; // scheduled arrival
rA: Date; // real arrival
delay: number; // minutes
platform: string;
changes: number;
trains: string[];
cancelled: boolean;
}
interface Station {
name: string;
extId: string;
}
// ── Events ───────────────────────────────────────────────────
interface Event {
id: string;
title: string;
destination: string;
eventTime: Date;
source: "manual" | "calendar";
}
// ── Trip Data (per event) ────────────────────────────────────
interface TripDataEntry {
journeys: Journey[];
destName: string;
demo: boolean;
loading: boolean;
bikeRoute?: BikeRoute | null; // NEW
bikeLoading?: boolean; // NEW
bikeError?: string | null; // NEW
destCoords?: { lat: number; lng: number }; // NEW (cached geocode)
}
// ── Bicycle Routing (NEW) ────────────────────────────────────
interface BikeRoute {
distance: number; // meters
duration: number; // seconds
steps?: BikeStep[];
}
interface BikeStep {
name: string;
distance: number;
duration: number;
instruction: string;
}
// ── Geocoding (NEW) ──────────────────────────────────────────
interface GeocodeResult {
lat: number;
lng: number;
display_name: string;
}
// ── Calendar Import ──────────────────────────────────────────
interface CalendarEvent {
id: string;
title: string;
destination: string;
eventTime: string; // ISO string from API
source: "calendar";
}
// ── UI Helpers ───────────────────────────────────────────────
interface CountdownInfo {
label: string;
color: string;
urgent: boolean;
}
type ServerStatus = null | true | false;
type LiveStatus = null | true | false;
type LocState = "pending" | "granted" | "denied";
type CalStatus = null | "loading" | "ok" | "error";
5. New API Routes
POST /api/hafas
- Purpose: Proxy to
fahrplan.oebb.at/bin/mgate.exe - Body:
{ svcReqL: [...] }— HAFAS service requests - Returns: HAFAS JSON response
- Timeout: 12s
GET /api/calendar?url=<ics_url>&days=14
- Purpose: Fetch and parse a remote ICS calendar
- Returns:
CalendarEvent[] - Supports:
https://,webcal://
POST /api/calendar/parse
- Purpose: Parse raw ICS content sent in request body
- Body: Raw ICS text
- Returns:
CalendarEvent[]
GET /api/geocode?name=Graz+Hbf&countrycodes=at (NEW)
- Purpose: Proxy to Nominatim for address→coordinates
- Upstream:
nominatim.openstreetmap.org/search?q={name}&format=json&limit=1&countrycodes=at - Returns:
{ lat, lng, display_name } - Caching: In-memory cache with TTL to avoid rate limits
GET /api/bike-route?fromLat=...&fromLng=...&toLat=...&toLng=... (NEW)
- Purpose: Proxy to OSRM for bicycle routing
- Upstream:
router.project-osrm.org/route/v1/bicycle/{lon1},{lat1};{lon2},{lat2}?overview=false - Returns:
{ distance, duration, steps: [...] }
GET /api/health
- Purpose: Liveness check
- Returns:
{ ok: true, ts: ISOString, version: "2.0.0" }
6. Key Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Framework | Next.js 15 App Router | Modern standard; API routes are serverless-compatible |
| Language | TypeScript (strict) | Type safety across frontend and backend |
| Styling | Tailwind CSS v4 | Replaces 700 lines of inline styles; v4 uses CSS-native config, no tailwind.config.ts |
| State | React hooks + Context | No Redux needed for this scale |
| API proxy | Next.js Route Handlers | Same logic as Express, no Express dependency |
| ICS parsing | Keep node-ical |
Already works, well-tested |
| Bike routing | OSRM public demo | Free, no API key, good enough for dev |
| Geocoding | Nominatim | Free, open source, good Austria coverage |
| Testing | Vitest + Testing Library | Faster than Jest for TS, better DX |
| Deployment | Vercel (recommended) or any Node host | Single artifact |
7. Migration Phases
Phase 1 — Scaffold Next.js Project (~30 min)
- Initialize Next.js 15 with App Router, TypeScript, Tailwind CSS
- Set up
tsconfig.jsonwith strict mode - Create
.env.examplewithPORT,HAFAS_URL,NOMINATIM_URL,OSRM_URL - Configure
next.config.js(rewrites if needed) - Configure
vitest.config.ts - Set up
postcss.config.mjs
Phase 2 — Types + Library Layer (~2 hours)
- Define TypeScript types in
src/types/index.ts - Port
lib/hafas-client.ts—HafasClientclass withsearchStation()andfetchJourneys() - Port
lib/calendar-utils.ts—extractEvents()+cleanLocation()(reusable in API routes AND tests) - Port
lib/countdown-utils.ts,lib/formatting.ts,lib/constants.ts,lib/demo.ts,lib/status-utils.ts,lib/live-status-utils.ts - Create
lib/geocoding-client.ts— Nominatim client (NEW) - Create
lib/bike-routing-client.ts— OSRM client (NEW)
Phase 3 — API Routes (~45 min)
src/app/api/hafas/route.ts— POST handler, same logic as Expresssrc/app/api/calendar/route.ts— GET handler for remote ICSsrc/app/api/calendar/parse/route.ts— POST handler for ICS bodysrc/app/api/geocode/route.ts— GET handler for Nominatim (NEW)src/app/api/bike-route/route.ts— GET handler for OSRM (NEW)src/app/api/health/route.ts— health check
Phase 4 — Custom Hooks (~2.5 hours)
useServerHealth.ts— polls/api/healthevery 30suseClock.ts— interval that updatesnowevery 10suseGeolocation.ts— wrapsnavigator.geolocationuseOriginStation.ts— finds nearest station from geolocationuseJourneys.ts— the complexfetchAlllogic, per-event journey fetchinguseBikeRoute.ts— fetches bicycle route for an event (NEW)useCalendar.ts— URL/file import with merge logicuseEventsStore.ts— shared events state via Context (NEW)
Phase 5 — UI Components (~3.5 hours)
ui/Chip.tsx— small badge componentui/Button.tsx— styled buttonui/LoadingSpinner.tsx— loading indicatorevent/LeaveByBadge.tsx— countdown badgeevent/JourneyList.tsx— departure rowsevent/TrainSection.tsx— train data in event cardevent/BikeSection.tsx— bicycle data in event card (NEW)event/EventCard.tsx— composes train + bike sectionscalendar/UrlTab.tsxcalendar/FileTab.tsxcalendar/CalendarPanel.tsxadd-event/AddEventModal.tsxlayout/Header.tsxlayout/Navbar.tsx(NEW)
Phase 6 — Calendar Page (~1 hour)
calendar/CalendarView.tsx— month grid component (NEW)calendar/DayEvents.tsx— events for a selected day (NEW)app/calendar/page.tsx— calendar route (NEW)
Phase 7 — Tests (~2 hours)
Install test dependencies first:
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom jsdomand createvitest.config.ts. Update thetestscript inpackage.jsontovitest.
- Migrate
server/__tests__/*.test.js→src/app/api/__tests__/*.test.ts - Add unit tests for
src/lib/calendar-utils.ts,src/lib/countdown-utils.tsinsrc/lib/__tests__/ - Add API tests for
geocodeandbike-routeinsrc/app/api/__tests__/(NEW) - Add component smoke tests with
@testing-library/react(optional)
Phase 8 — Cleanup (~30 min)
- Delete old
server/directory - Delete old
oebb-planner-app/directory - Delete
oebb-planner.jsx - Update
README.mdwith new architecture and instructions - Final integration test
8. Dependencies
Runtime (installed)
{
"dependencies": {
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"node-ical": "^0.18.0"
}
}
Development (installed)
{
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.2.6"
}
}
To be installed before Phase 7 (tests)
{
"devDependencies": {
"vitest": "^2.0.0",
"@vitejs/plugin-react": "^4.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.0.0",
"jsdom": "^25.0.0"
}
}
Note: Tailwind v4 no longer requires
autoprefixeror a separatetailwind.config.ts— configuration is done via CSS and@tailwindcss/postcss.
9. Environment Variables
# .env.example
# Server port
PORT=3001
# ÖBB HAFAS API
HAFAS_URL=https://fahrplan.oebb.at/bin/mgate.exe
# Nominatim geocoding (OpenStreetMap)
NOMINATIM_URL=https://nominatim.openstreetmap.org
# OSRM bicycle routing
OSRM_URL=https://router.project-osrm.org
# Nominatim user-agent / referer (required by their ToS)
NOMINATIM_USER_AGENT=OebbPlanner/1.0
10. Benefits of the Rewrite
- Single process —
npm run devstarts everything - Type safety — TypeScript catches bugs at compile time
- Composability — 700-line component becomes ~15 focused components
- Reusability —
lib/functions testable independently - Deployable — one artifact, works on Vercel / any Node host
- Maintainable — hooks encapsulate side effects, components are pure UI
- Modern tooling — no more CRA, no more
react-scripts ejectanxiety - Bicycle routing — complete picture of train vs bike for each event
- Calendar view — browse events by date, not just a flat list
- Train+bike comparison — side-by-side travel times in each event card
11. Estimated Effort
| Phase | Description | Est. Time |
|---|---|---|
| 1 | Scaffold Next.js + Tailwind + TS | 30 min |
| 2 | Types + lib layer (incl. geocode, bike-route) | 2 hours |
| 3 | API routes (incl. geocode, bike-route) | 45 min |
| 4 | Custom hooks (incl. bike-route, events-store) | 2.5 hours |
| 5 | UI Components (incl. bike section, navbar) | 3.5 hours |
| 6 | Calendar page (incl. month view, day events) | 1 hour |
| 7 | Tests (API + lib + component) | 2 hours |
| 8 | Cleanup (delete old dirs, update README) | 30 min |
| Total | ~13-14 hours |