From 4b4269571706fb9f82982485cfdeb88ac1a9abe6 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 12:15:23 +0200 Subject: [PATCH] rewrite phase 3 --- .env.example | 14 +++ .zed/rules/review.md | 77 ++++++++++++++ .zed/rules/rewrite.md | 114 ++++++++++++++++++++ CHECKLIST.md | 124 ++++++++++++++++++++++ src/app/api/bike-route/route.ts | 64 +++++++++++ src/app/api/calendar/parse/route.ts | 51 +++++++++ src/app/api/calendar/route.ts | 60 +++++++++++ src/app/api/geocode/route.ts | 83 +++++++++++++++ src/app/api/hafas/route.ts | 36 +++++++ src/lib/__tests__/calendar-utils.test.ts | 84 +++++++++++++++ src/lib/__tests__/countdown-utils.test.ts | 48 +++++++++ src/lib/constants.ts | 14 +++ src/lib/demo.ts | 28 +++++ src/lib/formatting.ts | 41 +++++++ src/test/setup.ts | 1 + vitest.config.ts | 17 +++ 16 files changed, 856 insertions(+) create mode 100644 .env.example create mode 100644 .zed/rules/review.md create mode 100644 .zed/rules/rewrite.md create mode 100644 CHECKLIST.md create mode 100644 src/app/api/bike-route/route.ts create mode 100644 src/app/api/calendar/parse/route.ts create mode 100644 src/app/api/calendar/route.ts create mode 100644 src/app/api/geocode/route.ts create mode 100644 src/app/api/hafas/route.ts create mode 100644 src/lib/__tests__/calendar-utils.test.ts create mode 100644 src/lib/__tests__/countdown-utils.test.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/demo.ts create mode 100644 src/lib/formatting.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5bc88a8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.zed/rules/review.md b/.zed/rules/review.md new file mode 100644 index 0000000..25608b2 --- /dev/null +++ b/.zed/rules/review.md @@ -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. diff --git a/.zed/rules/rewrite.md b/.zed/rules/rewrite.md new file mode 100644 index 0000000..10e8db7 --- /dev/null +++ b/.zed/rules/rewrite.md @@ -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. diff --git a/CHECKLIST.md b/CHECKLIST.md new file mode 100644 index 0000000..9d6f016 --- /dev/null +++ b/CHECKLIST.md @@ -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) diff --git a/src/app/api/bike-route/route.ts b/src/app/api/bike-route/route.ts new file mode 100644 index 0000000..ddae417 --- /dev/null +++ b/src/app/api/bike-route/route.ts @@ -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 }); + } +} diff --git a/src/app/api/calendar/parse/route.ts b/src/app/api/calendar/parse/route.ts new file mode 100644 index 0000000..3092dac --- /dev/null +++ b/src/app/api/calendar/parse/route.ts @@ -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 }); + } +} diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts new file mode 100644 index 0000000..67fd836 --- /dev/null +++ b/src/app/api/calendar/route.ts @@ -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 }); + } +} diff --git a/src/app/api/geocode/route.ts b/src/app/api/geocode/route.ts new file mode 100644 index 0000000..cf15d49 --- /dev/null +++ b/src/app/api/geocode/route.ts @@ -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(); +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 } + ); + } +} diff --git a/src/app/api/hafas/route.ts b/src/app/api/hafas/route.ts new file mode 100644 index 0000000..d59ce90 --- /dev/null +++ b/src/app/api/hafas/route.ts @@ -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 }); + } +} diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts new file mode 100644 index 0000000..3e494ad --- /dev/null +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -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"); + }); +}); diff --git a/src/lib/__tests__/countdown-utils.test.ts b/src/lib/__tests__/countdown-utils.test.ts new file mode 100644 index 0000000..ff4c505 --- /dev/null +++ b/src/lib/__tests__/countdown-utils.test.ts @@ -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 1–10 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 11–30 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 31–59 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"); + }); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..37abf40 --- /dev/null +++ b/src/lib/constants.ts @@ -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'; diff --git a/src/lib/demo.ts b/src/lib/demo.ts new file mode 100644 index 0000000..1a3761e --- /dev/null +++ b/src/lib/demo.ts @@ -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, + }; +} diff --git a/src/lib/formatting.ts b/src/lib/formatting.ts new file mode 100644 index 0000000..d68dbf6 --- /dev/null +++ b/src/lib/formatting.ts @@ -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`; +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9d3a608 --- /dev/null +++ b/vitest.config.ts @@ -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"), + }, + }, +});