rewrite phase 3
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user