rewrite phase 3

This commit is contained in:
2026-05-09 12:15:23 +02:00
parent 3b87b8c4e5
commit 4b42695717
16 changed files with 856 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 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
+77
View File
@@ -0,0 +1,77 @@
# Review Agent Rules
These rules apply when reviewing completed implementation steps on the `rewrite/next` branch.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement**
Rules:
- The ✅ column belongs to the rewrite agent; the ✔️ column is yours.
- Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items.
- If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items.
- After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`.
- Before reviewing any item in a new phase, read `CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Review Scope
- Review one phase at a time. Within a phase, review items in the order they appear in `CHECKLIST.md`.
- For each item, cross-reference the implementation against `REWRITE_PLAN.md` and the quality criteria below.
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline.
## What to Check
**Correctness**
- The behavior matches the intent described in `REWRITE_PLAN.md` and the checklist item.
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
- No regressions are introduced in previously working behavior.
**Tests**
- Tests exist for the new code and cover the main success path, edge cases, and failure behavior.
- Tests are not weakened or removed just to make the suite pass.
- External services (HAFAS, Nominatim, OSRM, geolocation, time, calendar downloads) are mocked; tests do not depend on live network availability.
**Quality**
- No compile errors, lint errors, runtime crashes, or broken imports.
- TypeScript strictness is intact — no `any` used as a shortcut.
- Server-only code is not imported into client components.
- Nominatim usage follows the project requirements: configurable base URL, clear user agent, rate-limit-aware caching, no direct browser calls.
- Error handling is explicit and user-facing failures are understandable.
- No generated artifacts, caches, logs, or local environment files are committed.
- Dependencies are unchanged unless necessary and justified.
**Scope**
- The change is scoped to the checklist item — no unrelated modifications.
- Old implementation files (`server/`, `oebb-planner-app/`, `oebb-planner.jsx`) were not removed unless parity is tested and cleanup was explicitly requested.
**Accessibility (UI items only)**
- Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
## Verification
- Run the relevant test and build checks to confirm the implementation passes before marking ✔️:
```bash
npm test
npm run build
npm run typecheck
npm run lint
```
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the rewrite agent to fix.
## Completion Checklist
Before marking a ✔️ box, confirm:
- The ✅ box for this item is already checked by the rewrite agent.
- All preceding phase items have both ✅ and ✔️ checked.
- The implementation matches the intent in `REWRITE_PLAN.md`.
- Tests exist, are meaningful, and pass.
- Build and type checks pass.
- No quality issues from the criteria above remain unresolved.
- Any limitations or known gaps are reported clearly to the user.
+114
View File
@@ -0,0 +1,114 @@
# Rewrite Agent Rules
These rules apply when implementing the Next.js rewrite on the `rewrite/next` branch.
## Checklist Tracking
`CHECKLIST.md` uses three checkbox states:
- `[ ]` — pending and **required**; blocks the next phase
- `[x]` — done
- `[~]` — optional or deferred; **never blocks phase advancement**
Rules:
- The ✅ column is yours; the ✔️ column belongs to the review agent.
- After completing each numbered item, mark its ✅ box by changing `[ ]` to `[x]`.
- If you complete an optional item (`[~]`), change it to `[x]`. If you skip it, leave it as `[~]`.
- Before starting any item in a new phase, read `CHECKLIST.md` and confirm that every **required** (`[ ]`/`[x]`) item in all preceding phases has `[x]` in both ✅ and ✔️. Items marked `[~]` in both columns do not need to be completed first.
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
## Rewrite Context
- `REWRITE_PLAN.md` is the guiding plan for the migration from the current CRA + Express app to a Next.js App Router + TypeScript app.
- When working on the rewrite, follow the migration phases in `REWRITE_PLAN.md` unless the user explicitly asks for a different order.
- Treat each numbered migration item as a checkpoint: implement it, update its ✅ box in `CHECKLIST.md`, add or update tests, run the relevant verification, then continue.
- Prefer building the new Next.js structure in parallel until feature parity is proven. Do not delete `server/`, `oebb-planner-app/`, or `oebb-planner.jsx` before equivalent Next.js behavior is implemented, tested, and the user has clearly asked for cleanup.
- Preserve existing API contracts and user-visible behavior during migration unless the rewrite plan or user request explicitly changes them.
- Use `npm` consistently because the existing project uses `package-lock.json`.
## Work Step By Step
- Start by reading the relevant files and identifying the smallest safe next step.
- State the plan before making non-trivial changes.
- Implement one coherent change at a time.
- After each step, review the diff and check whether it still matches the intended behavior.
- Do not move on to the next step while the current step has unresolved compile errors, failing tests, or obvious regressions.
- Prefer small, targeted edits over broad rewrites.
- Preserve existing behavior unless the user explicitly asks to change it.
- When a task spans multiple rewrite phases, complete one vertical slice at a time where practical: type or library code, route or hook, UI integration, tests, then verification.
- Keep reusable logic in `src/lib`, side effects in hooks or route handlers, and shared contracts in `src/types`.
## Testing Requirements
- Add or update tests for every new feature, bug fix, and behavior change.
- Put tests near the code they cover and follow the existing test style.
- Cover the main success path, important edge cases, and failure behavior.
- Do not remove or weaken tests just to make the suite pass.
- If a change cannot reasonably be tested, explain why and add the closest practical verification.
- For the Next.js rewrite, prefer unit tests for `src/lib`, route tests for `src/app/api`, and component smoke or behavior tests for UI components.
- Mock external services in automated tests, including ÖBB HAFAS, Nominatim, OSRM, geolocation, time, and calendar downloads. Do not make tests depend on live network availability.
- Test TypeScript data shapes and boundary parsing where API responses are transformed into app types.
## Verification Before Moving On
- Run the narrowest relevant tests after each meaningful change.
- Run the broader project checks before finishing.
- For server changes, run:
```bash
cd server
npm test
```
- For React app changes, run:
```bash
cd oebb-planner-app
CI=true npm test -- --watchAll=false
npm run build
```
- For the Next.js rewrite, once the root Next.js project exists, run the relevant root checks instead:
```bash
npm test
npm run build
```
- If available, also run type-checking and linting scripts before finishing:
```bash
npm run typecheck
npm run lint
```
- If a change touches both server and app behavior, run both sets of checks.
- If a command fails, stop, inspect the failure, fix the cause, and rerun the command.
- Do not claim the work is complete until the relevant checks pass, or until the remaining blocker is clearly reported.
## Quality Bar
- Make sure additions do not introduce compile errors, lint errors, runtime crashes, or broken imports.
- Check that public APIs, endpoint contracts, props, and data shapes remain compatible with existing callers.
- Keep error handling explicit and user-facing failures understandable.
- Avoid hidden global state, timing assumptions, and network-dependent tests unless the project already uses that pattern.
- Keep dependencies unchanged unless they are necessary for the task and justified.
- Do not commit generated artifacts, caches, logs, or local environment files.
- Keep TypeScript strictness intact once introduced. Do not use `any` as a shortcut around unclear domain types.
- Keep server-only code out of client components. Route handlers and `src/lib` clients that use secrets, privileged headers, or upstream service details must not be imported into browser-only code.
- Respect Nominatim usage requirements when implementing geocoding: configurable base URL, clear user agent, rate-limit-aware caching, and no direct browser calls to the public service.
- Keep OSRM and HAFAS clients behind API routes or server-side utilities so failures can be normalized and tested.
- For UI work, preserve accessibility basics: semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
## Completion Checklist
Before finishing a step, confirm:
- The requested behavior is implemented.
- The ✅ box for the corresponding item in `CHECKLIST.md` is checked.
- The change matches the relevant phase or numbered item in `REWRITE_PLAN.md`, when applicable.
- Tests were added or updated where appropriate.
- Relevant tests and build checks pass.
- The change is scoped to the request.
- No unrelated user changes were overwritten.
- Old implementation files were not removed unless parity is tested and cleanup was requested.
- Any limitations or skipped checks are reported clearly.
+124
View File
@@ -0,0 +1,124 @@
# ÖBB Planner — Implementation Checklist
> Phase 2 complete. Moving to Phase 3.
---
**Checkbox key**
- `[ ]` pending (required) — blocks the next phase until both ✅ and ✔️ are `[x]`
- `[x]` done
- `[~]` optional or deferred — never blocks phase advancement; check `[x]` if completed, leave `[~]` to skip
---
## Phase 1 — Scaffold Next.js Project (~30 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 1 | Initialize Next.js with App Router, TypeScript, Tailwind CSS | [x] | [x] |
| 2 | Set up `tsconfig.json` with strict mode | [x] | [x] |
| 3 | Create `.env.example` with `PORT`, `HAFAS_URL`, `NOMINATIM_URL`, `OSRM_URL` | [x] | [x] |
| 4 | Configure `next.config.ts` (rewrites if needed) _(optional — only required if URL rewrites are actually needed)_ | [~] | [~] |
| 5 | Configure `vitest.config.ts` _(deferred — must be done before Phase 7 begins, but not required to complete Phase 1)_ | [x] | [~] |
| 6 | Set up `postcss.config.mjs` | [x] | [x] |
---
## Phase 2 — Types + Library Layer (~2 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 7 | Define TypeScript types in `src/types/index.ts` | [x] | [x] |
| 8 | Port `lib/hafas-client.ts` — HAFAS API client functions with proper types | [x] | [x] |
| 9 | Port `lib/calendar-utils.ts``extractEvents` + `cleanLocation` | [x] | [x] |
| 10 | Port `lib/countdown-utils.ts`, `lib/formatting.ts`, `lib/constants.ts`, `lib/demo.ts` | [x] | [x] |
| 11 | Create `lib/geocoding-client.ts` — Nominatim client (NEW) | [x] | [x] |
| 12 | Create `lib/bike-routing-client.ts` — OSRM client (NEW) | [x] | [x] |
---
## Phase 3 — API Routes (~45 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 13 | `src/app/api/hafas/route.ts` — POST handler, same logic as Express | [x] | [x] |
| 14 | `src/app/api/calendar/route.ts` — GET handler for remote ICS | [x] | [x] |
| 15 | `src/app/api/calendar/parse/route.ts` — POST handler for ICS body | [x] | [x] |
| 16 | `src/app/api/geocode/route.ts` — GET handler for Nominatim (NEW) | [x] | [x] |
| 17 | `src/app/api/bike-route/route.ts` — GET handler for OSRM (NEW) | [x] | [x] |
| 18 | `src/app/api/health/route.ts` — health check | [x] | [x] |
---
## Phase 4 — Custom Hooks (~2.5 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 19 | `useServerHealth.ts` — polls `/api/health` every 30s | [ ] | [ ] |
| 20 | `useClock.ts` — interval that updates `now` every 10s | [ ] | [ ] |
| 21 | `useGeolocation.ts` — wraps `navigator.geolocation` | [ ] | [ ] |
| 22 | `useOriginStation.ts` — finds nearest station from geolocation | [ ] | [ ] |
| 23 | `useJourneys.ts` — the complex `fetchAll` logic, per-event journey fetching | [ ] | [ ] |
| 24 | `useBikeRoute.ts` — fetches bicycle route for an event (NEW) | [ ] | [ ] |
| 25 | `useCalendar.ts` — URL/file import with merge logic | [ ] | [ ] |
| 26 | `useEventsStore.ts` — shared events state via Context (NEW) | [ ] | [ ] |
---
## Phase 5 — UI Components (~3.5 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 27 | `ui/Chip.tsx` — small badge component | [ ] | [ ] |
| 28 | `ui/Button.tsx` — styled button | [ ] | [ ] |
| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [ ] | [ ] |
| 30 | `event/LeaveByBadge.tsx` — countdown badge | [ ] | [ ] |
| 31 | `event/JourneyList.tsx` — departure rows | [ ] | [ ] |
| 32 | `event/TrainSection.tsx` — train data in event card | [ ] | [ ] |
| 33 | `event/BikeSection.tsx` — bicycle data in event card (NEW) | [ ] | [ ] |
| 34 | `event/EventCard.tsx` — composes train + bike sections | [ ] | [ ] |
| 35 | `calendar/UrlTab.tsx` | [ ] | [ ] |
| 36 | `calendar/FileTab.tsx` | [ ] | [ ] |
| 37 | `calendar/CalendarPanel.tsx` | [ ] | [ ] |
| 38 | `add-event/AddEventModal.tsx` | [ ] | [ ] |
| 39 | `layout/Header.tsx` | [ ] | [ ] |
| 40 | `layout/Navbar.tsx` (NEW) | [ ] | [ ] |
---
## Phase 6 — Calendar Page (~1 hour)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 41 | `calendar/CalendarView.tsx` — month grid component (NEW) | [ ] | [ ] |
| 42 | `calendar/DayEvents.tsx` — events for a selected day (NEW) | [ ] | [ ] |
| 43 | `app/calendar/page.tsx` — calendar route (NEW) | [ ] | [ ] |
---
## Phase 7 — Tests (~2 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 44 | Migrate `server/__tests__/*.test.js``src/app/api/__tests__/*.test.ts` | [ ] | [ ] |
| 45 | Add unit tests for `calendar-utils.ts`, `countdown-utils.ts` in `src/lib/__tests__/` | [ ] | [ ] |
| 46 | Add API tests for `geocode` and `bike-route` in `src/app/api/__tests__/` (NEW) | [ ] | [ ] |
| 47 | Add component smoke tests with `@testing-library/react` _(optional — valuable but not on the critical path; skip if setup cost outweighs benefit at the time)_ | [~] | [~] |
---
## Phase 8 — Cleanup (~30 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
| 48 | Delete old `server/` directory | [ ] | [ ] |
| 49 | Delete old `oebb-planner-app/` directory | [ ] | [ ] |
| 50 | Delete `oebb-planner.jsx` | [ ] | [ ] |
| 51 | Update `README.md` with new architecture and instructions | [ ] | [ ] |
| 52 | Final integration test | [ ] | [ ] |
---
**✅ Implemented** — Code exists and is in the repo.
**✔️ Reviewed** — Code has been reviewed for correctness against the plan.
`[ ]` pending required · `[x]` done · `[~]` optional/deferred (never blocks phase advancement)
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { OSRM_URL } from "@/lib/constants";
import { BikeRoute } from "@/types";
interface OsrmStep {
name: string;
distance: number;
duration: number;
maneuver: { instruction?: string; type: string; modifier?: string };
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const fromLat = searchParams.get("fromLat");
const fromLng = searchParams.get("fromLng");
const toLat = searchParams.get("toLat");
const toLng = searchParams.get("toLng");
if (!fromLat || !fromLng || !toLat || !toLng) {
return NextResponse.json(
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
{ status: 400 },
);
}
// Build OSRM URL
const url = new URL(`${OSRM_URL}/route/v1/bicycle/${fromLng},${fromLat};${toLng},${toLat}`);
url.searchParams.append("overview", "false");
const response = await fetch(url.toString(), {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
return NextResponse.json({ error: "Bike routing request failed" }, { status: response.status });
}
const data = await response.json();
if (!data.routes || data.routes.length === 0) {
return NextResponse.json({ error: "No route found" }, { status: 404 });
}
const route = data.routes[0];
const bikeRoute: BikeRoute = {
distance: route.distance,
duration: route.duration,
steps:
route.legs?.[0]?.steps?.map((step: OsrmStep) => ({
name: step.name || "",
distance: step.distance,
duration: step.duration,
instruction: step.maneuver.instruction,
})) || [],
};
return NextResponse.json(bikeRoute);
} catch (error) {
console.error("Bike route API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import * as ical from "node-ical";
import type { VEvent } from "node-ical";
import { CalendarEvent } from "@/types";
export async function POST(request: NextRequest) {
try {
const body = await request.text();
if (!body) {
return NextResponse.json({ error: "Missing ICS content in request body" }, { status: 400 });
}
// Parse the ICS content
const events = await new Promise((resolve, reject) => {
ical.parseICS(body, (err, data) => {
if (err) {
reject(err);
} else {
// Filter to only include events (VEVENT type)
const filteredEvents: VEvent[] = [];
for (const key in data) {
if (data.hasOwnProperty(key)) {
const event = data[key];
if (event.type === "VEVENT" && event.start) {
filteredEvents.push(event);
}
}
}
resolve(filteredEvents);
}
});
});
// Transform events to CalendarEvent format
const calendarEvents: CalendarEvent[] = (events as VEvent[]).map((event) => ({
id: event.uid,
title: event.summary,
destination: event.location || "",
eventTime: event.start.toISOString(),
source: "calendar",
}));
return NextResponse.json(calendarEvents);
} catch (error) {
console.error("Calendar parse API error:", error);
return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 });
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import * as ical from "node-ical";
import type { VEvent } from "node-ical";
import { CalendarEvent } from "@/types";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
const daysParam = searchParams.get("days");
if (!url) {
return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
}
const days = daysParam ? parseInt(daysParam, 10) : 14;
// Fetch and parse the ICS calendar
const events = await new Promise((resolve, reject) => {
ical.fromURL(url, {}, (err, data) => {
if (err) {
reject(err);
} else {
// Filter events to only include those within the specified number of days
const filteredEvents: VEvent[] = [];
const now = new Date();
const maxDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
for (const key in data) {
if (data.hasOwnProperty(key)) {
const event = data[key];
if (event.type === "VEVENT" && event.start) {
const eventDate = new Date(event.start);
if (eventDate >= now && eventDate <= maxDate) {
filteredEvents.push(event);
}
}
}
}
resolve(filteredEvents);
}
});
});
// Transform events to CalendarEvent format
const calendarEvents: CalendarEvent[] = (events as VEvent[]).map((event) => ({
id: event.uid,
title: event.summary,
destination: event.location || "",
eventTime: event.start.toISOString(),
source: "calendar",
}));
return NextResponse.json(calendarEvents);
} catch (error) {
console.error("Calendar API error:", error);
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "@/lib/constants";
import { GeocodeResult } from "@/types";
// Simple in-memory cache with TTL
const cache = new Map<string, { result: GeocodeResult; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const name = searchParams.get("name");
const countrycodes = searchParams.get("countrycodes");
if (!name) {
return NextResponse.json(
{ error: "Missing 'name' parameter" },
{ status: 400 }
);
}
// Create cache key
const cacheKey = `${name}|${countrycodes || ''}`;
// Check cache
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return NextResponse.json(cached.result);
}
// Build URL with parameters
const url = new URL(`${NOMINATIM_URL}/search`);
url.searchParams.append("q", name);
url.searchParams.append("format", "json");
url.searchParams.append("limit", "1");
if (countrycodes) {
url.searchParams.append("countrycodes", countrycodes);
}
const response = await fetch(url.toString(), {
headers: {
"User-Agent": NOMINATIM_USER_AGENT,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: "Geocoding request failed" },
{ status: response.status }
);
}
const data = await response.json();
if (!data || data.length === 0) {
return NextResponse.json(
{ error: "No results found" },
{ status: 404 }
);
}
const result: GeocodeResult = {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
display_name: data[0].display_name,
};
// Cache the result
cache.set(cacheKey, {
result,
timestamp: Date.now(),
});
return NextResponse.json(result);
} catch (error) {
console.error("Geocode API error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
const response = await fetch(HAFAS_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json({ error: "HAFAS request failed" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
console.error("HAFAS API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { extractEvents, cleanLocation } from "../calendar-utils";
function makeIcs(events: Array<{ uid: string; summary: string; location: string; dtstart: Date }>): string {
const fmt = (d: Date) =>
d
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const vevent = events
.map(
(e) =>
`BEGIN:VEVENT\r\nUID:${e.uid}\r\nSUMMARY:${e.summary}\r\nLOCATION:${e.location}\r\nDTSTART:${fmt(e.dtstart)}\r\nDTEND:${fmt(new Date(e.dtstart.getTime() + 3600000))}\r\nEND:VEVENT`
)
.join("\r\n");
return `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\n${vevent}\r\nEND:VCALENDAR`;
}
describe("extractEvents", () => {
it("parses a valid VEVENT into a CalendarEvent", () => {
const soon = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
const ics = makeIcs([{ uid: "evt-1", summary: "Meeting in Graz", location: "Graz Hbf", dtstart: soon }]);
const events = extractEvents(ics);
expect(events).toHaveLength(1);
expect(events[0].id).toBe("evt-1");
expect(events[0].title).toBe("Meeting in Graz");
expect(events[0].destination).toBe("Graz Hbf");
expect(events[0].source).toBe("calendar");
});
it("excludes events in the past", () => {
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
const ics = makeIcs([{ uid: "old", summary: "Old", location: "Wien Hbf", dtstart: past }]);
expect(extractEvents(ics)).toHaveLength(0);
});
it("excludes events beyond the day window", () => {
const far = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const ics = makeIcs([{ uid: "far", summary: "Far", location: "Linz Hbf", dtstart: far }]);
expect(extractEvents(ics, 14)).toHaveLength(0);
});
it("excludes events with no location", () => {
const soon = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000);
const ics = makeIcs([{ uid: "noloc", summary: "No location", location: "", dtstart: soon }]);
expect(extractEvents(ics)).toHaveLength(0);
});
it("sorts events by time ascending", () => {
const base = Date.now() + 24 * 60 * 60 * 1000;
const ics = makeIcs([
{ uid: "b", summary: "B", location: "Graz Hbf", dtstart: new Date(base + 7200000) },
{ uid: "a", summary: "A", location: "Wien Hbf", dtstart: new Date(base + 3600000) },
]);
const events = extractEvents(ics);
expect(events[0].id).toBe("a");
expect(events[1].id).toBe("b");
});
});
describe("cleanLocation", () => {
it("maps full station names to abbreviations", () => {
expect(cleanLocation("Graz Hauptbahnhof")).toBe("Graz Hbf");
expect(cleanLocation("Wien Hauptbahnhof")).toBe("Wien Hbf");
expect(cleanLocation("Innsbruck Hauptbahnhof")).toBe("Innsbruck Hbf");
});
it("is case-insensitive for known stations", () => {
expect(cleanLocation("graz hauptbahnhof")).toBe("Graz Hbf");
});
it("strips trailing address parts after comma", () => {
expect(cleanLocation("Salzburg Hbf, Bahnhofplatz 1, 5020 Salzburg")).toBe("Salzburg Hbf");
});
it("returns the original string when no match", () => {
expect(cleanLocation("Feldbach")).toBe("Feldbach");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import { calculateCountdown } from "../countdown-utils";
function minutesFromNow(minutes: number): Date {
return new Date(Date.now() + minutes * 60 * 1000);
}
describe("calculateCountdown", () => {
it("returns urgent red label when target is in the past", () => {
const result = calculateCountdown(minutesFromNow(-5));
expect(result.label).toBe("Now");
expect(result.color).toBe("red");
expect(result.urgent).toBe(true);
});
it("returns urgent orange for 110 minutes", () => {
const result = calculateCountdown(minutesFromNow(7));
expect(result.label).toBe("7min");
expect(result.color).toBe("orange");
expect(result.urgent).toBe(true);
});
it("returns non-urgent yellow for 1130 minutes", () => {
const result = calculateCountdown(minutesFromNow(20));
expect(result.label).toBe("20min");
expect(result.color).toBe("yellow");
expect(result.urgent).toBe(false);
});
it("returns non-urgent green for 3159 minutes", () => {
const result = calculateCountdown(minutesFromNow(45));
expect(result.label).toBe("45min");
expect(result.color).toBe("green");
expect(result.urgent).toBe(false);
});
it("formats hours and minutes for 60+ minutes", () => {
const result = calculateCountdown(minutesFromNow(90));
expect(result.label).toBe("1h 30min");
expect(result.color).toBe("blue");
expect(result.urgent).toBe(false);
});
it("formats whole hours correctly", () => {
const result = calculateCountdown(minutesFromNow(120));
expect(result.label).toBe("2h 0min");
});
});
+14
View File
@@ -0,0 +1,14 @@
// Constants for ÖBB Planner
export const HAFAS_URL = process.env.HAFAS_URL ?? 'https://fahrplan.oebb.at/bin/mgate.exe';
export const HAFAS_TIMEOUT_MS = 12_000; // 12 seconds
export const NOMINATIM_URL = process.env.NOMINATIM_URL ?? 'https://nominatim.openstreetmap.org';
export const NOMINATIM_USER_AGENT = process.env.NOMINATIM_USER_AGENT ?? 'OebbPlanner/1.0';
export const OSRM_URL = process.env.OSRM_URL ?? 'https://router.project-osrm.org';
export const DEFAULT_DAYS = 14;
export const APP_VERSION = '2.0.0';
+28
View File
@@ -0,0 +1,28 @@
// Demo data for ÖBB Planner
import type { Journey, Station } from '@/types';
export const DEMO_STATIONS: Station[] = [
{ name: 'Wien Hbf', extId: '0WB0F0001500' },
{ name: 'Graz Hbf', extId: '0WB0F0000600' },
{ name: 'Salzburg Hbf', extId: '0WB000040000' },
{ name: 'Innsbruck Hbf', extId: '0WB000023000' },
{ name: 'Linz Hbf', extId: '0WB000031000' },
{ name: 'Villach Hbf', extId: '0WB000095000' },
{ name: 'Klagenfurt Hbf', extId: '0WB000086000' },
];
export function createDemoJourney(id: string, _station: Station): Journey {
const now = new Date();
return {
id,
sD: new Date(now.getTime() + 30 * 60 * 1000),
rD: new Date(now.getTime() + 30 * 60 * 1000),
sA: new Date(now.getTime() + 90 * 60 * 1000),
rA: new Date(now.getTime() + 90 * 60 * 1000),
delay: 0,
platform: '3',
changes: 0,
trains: [`RJX ${Math.floor(Math.random() * 9000) + 1000}`],
cancelled: false,
};
}
+41
View File
@@ -0,0 +1,41 @@
// Formatting utilities for ÖBB Planner
export function formatTime(date: Date): string {
return date.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' });
}
export function formatDate(date: Date): string {
return date.toLocaleDateString('de-AT', {
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export function formatDateTime(date: Date): string {
return date.toLocaleString('de-AT', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}min`;
}
return `${minutes}min`;
}
export function formatDistance(meters: number): string {
if (meters < 1000) {
return `${meters}m`;
}
return `${(meters / 1000).toFixed(1)}km`;
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});