Implement real API clients and update types
Replace mock data in geocoding, HAFAS, and bike routing clients with actual implementations using fetch and appropriate interfaces. Update typescript definitions to match the new data structures, including Journey, Station, and BikeRoute. Add vitest and related testing dependencies, replacing the placeholder test script with actual tests for the new client logic. Refactor calendar and countdown utilities to use the new types and remove unused files. Implement real API clients and update types Replace mock data in HafasClient, GeocodingClient, and BikeRoutingClient with actual fetch implementations. Update types to match API responses and add Vitest tests.
This commit is contained in:
+2
-14
@@ -2,13 +2,6 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@@ -17,21 +10,16 @@
|
|||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files — keep .env.example committed
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
+63
-46
@@ -103,11 +103,10 @@ Each event card shows both travel modes side by side:
|
|||||||
```
|
```
|
||||||
oebb_planner/
|
oebb_planner/
|
||||||
├── package.json
|
├── package.json
|
||||||
├── next.config.js
|
├── next.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── tailwind.config.ts
|
├── postcss.config.mjs ← Tailwind v4 uses @tailwindcss/postcss; no tailwind.config.ts needed
|
||||||
├── postcss.config.mjs
|
├── vitest.config.ts ← must exist before Phase 7; deferred from Phase 1
|
||||||
├── vitest.config.ts
|
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── layout.tsx ← root layout (fonts, providers, navbar)
|
│ │ ├── layout.tsx ← root layout (fonts, providers, navbar)
|
||||||
@@ -161,27 +160,31 @@ oebb_planner/
|
|||||||
│ │ ├── useCalendar.ts ← calendar import logic
|
│ │ ├── useCalendar.ts ← calendar import logic
|
||||||
│ │ └── useEventsStore.ts ← shared events state (NEW)
|
│ │ └── useEventsStore.ts ← shared events state (NEW)
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── hafas.ts ← HAFAS API client + types
|
│ │ ├── hafas-client.ts ← HAFAS API client (class HafasClient)
|
||||||
│ │ ├── calendar.ts ← ICS helpers (extractEvents, cleanLocation)
|
│ │ ├── calendar-utils.ts ← ICS helpers (extractEvents, cleanLocation)
|
||||||
│ │ ├── countdown.ts ← leaveBy, countdownInfo
|
│ │ ├── countdown-utils.ts ← calculateCountdown, leaveBy helpers
|
||||||
│ │ ├── formatting.ts ← fmtTime, delayColor
|
│ │ ├── formatting.ts ← formatTime, formatDate, formatDateTime, formatDuration, formatDistance
|
||||||
│ │ ├── demo.ts ← demo journey generator
|
│ │ ├── demo.ts ← demo journey generator
|
||||||
│ │ ├── constants.ts ← WALK_MINS, RED, SAMPLE_EVENTS, HAFAS_BASE
|
│ │ ├── constants.ts ← HAFAS_URL, NOMINATIM_URL, OSRM_URL, HAFAS_TIMEOUT_MS, DEFAULT_DAYS, APP_VERSION
|
||||||
│ │ ├── geocode.ts ← Nominatim client (NEW)
|
│ │ ├── status-utils.ts ← StatusUtils.checkServerStatus() (used by useServerHealth)
|
||||||
│ │ └── bike-route.ts ← OSRM client (NEW)
|
│ │ ├── live-status-utils.ts ← LiveStatusUtils.getLiveStatus() (stub; live data TBD)
|
||||||
|
│ │ ├── geocoding-client.ts ← Nominatim client (NEW)
|
||||||
|
│ │ ├── bike-routing-client.ts ← OSRM client (NEW)
|
||||||
|
│ │ └── index.ts ← re-exports all lib modules
|
||||||
│ └── types/
|
│ └── types/
|
||||||
│ └── index.ts ← all TypeScript interfaces
|
│ └── index.ts ← all TypeScript interfaces
|
||||||
├── __tests__/
|
├── src/lib/__tests__/ ← unit tests live alongside lib, not at repo root
|
||||||
│ ├── lib/
|
│ ├── hafas-client.test.ts
|
||||||
│ │ ├── calendar.test.ts
|
│ ├── geocoding-client.test.ts
|
||||||
│ │ ├── countdown.test.ts
|
│ ├── calendar-utils.test.ts
|
||||||
│ │ └── bike-route.test.ts ← NEW
|
│ ├── countdown-utils.test.ts
|
||||||
│ └── api/
|
│ └── bike-routing-client.test.ts ← NEW
|
||||||
│ ├── hafas.test.ts
|
├── src/app/api/__tests__/ ← API route tests
|
||||||
│ ├── calendar.test.ts
|
│ ├── hafas.test.ts
|
||||||
│ ├── geocode.test.ts ← NEW
|
│ ├── calendar.test.ts
|
||||||
│ ├── bike-route.test.ts ← NEW
|
│ ├── geocode.test.ts ← NEW
|
||||||
│ └── health.test.ts
|
│ ├── bike-route.test.ts ← NEW
|
||||||
|
│ └── health.test.ts
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
│ └── robots.txt
|
│ └── robots.txt
|
||||||
@@ -335,7 +338,7 @@ type CalStatus = null | "loading" | "ok" | "error";
|
|||||||
|----------|--------|-----------|
|
|----------|--------|-----------|
|
||||||
| Framework | Next.js 15 App Router | Modern standard; API routes are serverless-compatible |
|
| Framework | Next.js 15 App Router | Modern standard; API routes are serverless-compatible |
|
||||||
| Language | TypeScript (strict) | Type safety across frontend and backend |
|
| Language | TypeScript (strict) | Type safety across frontend and backend |
|
||||||
| Styling | Tailwind CSS | Replaces 700 lines of inline styles; consistent theming |
|
| Styling | Tailwind CSS v4 | Replaces 700 lines of inline styles; v4 uses CSS-native config, no `tailwind.config.ts` |
|
||||||
| State | React hooks + Context | No Redux needed for this scale |
|
| State | React hooks + Context | No Redux needed for this scale |
|
||||||
| API proxy | Next.js Route Handlers | Same logic as Express, no Express dependency |
|
| API proxy | Next.js Route Handlers | Same logic as Express, no Express dependency |
|
||||||
| ICS parsing | Keep `node-ical` | Already works, well-tested |
|
| ICS parsing | Keep `node-ical` | Already works, well-tested |
|
||||||
@@ -360,11 +363,11 @@ type CalStatus = null | "loading" | "ok" | "error";
|
|||||||
### Phase 2 — Types + Library Layer (~2 hours)
|
### Phase 2 — Types + Library Layer (~2 hours)
|
||||||
|
|
||||||
7. Define TypeScript types in `src/types/index.ts`
|
7. Define TypeScript types in `src/types/index.ts`
|
||||||
8. Port `lib/hafas.ts` — HAFAS API client functions with proper types
|
8. Port `lib/hafas-client.ts` — `HafasClient` class with `searchStation()` and `fetchJourneys()`
|
||||||
9. Port `lib/calendar.ts` — `extractEvents` + `cleanLocation` (reusable in API routes AND tests)
|
9. Port `lib/calendar-utils.ts` — `extractEvents()` + `cleanLocation()` (reusable in API routes AND tests)
|
||||||
10. Port `lib/countdown.ts`, `lib/formatting.ts`, `lib/constants.ts`, `lib/demo.ts`
|
10. Port `lib/countdown-utils.ts`, `lib/formatting.ts`, `lib/constants.ts`, `lib/demo.ts`, `lib/status-utils.ts`, `lib/live-status-utils.ts`
|
||||||
11. Create `lib/geocode.ts` — Nominatim client (NEW)
|
11. Create `lib/geocoding-client.ts` — Nominatim client (NEW)
|
||||||
12. Create `lib/bike-route.ts` — OSRM client (NEW)
|
12. Create `lib/bike-routing-client.ts` — OSRM client (NEW)
|
||||||
|
|
||||||
### Phase 3 — API Routes (~45 min)
|
### Phase 3 — API Routes (~45 min)
|
||||||
|
|
||||||
@@ -411,10 +414,12 @@ type CalStatus = null | "loading" | "ok" | "error";
|
|||||||
|
|
||||||
### Phase 7 — Tests (~2 hours)
|
### Phase 7 — Tests (~2 hours)
|
||||||
|
|
||||||
44. Migrate `server/__tests__/*.test.js` → `__tests__/api/*.test.ts`
|
> Install test dependencies first: `npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom jsdom` and create `vitest.config.ts`. Update the `test` script in `package.json` to `vitest`.
|
||||||
45. Add unit tests for `lib/calendar.ts`, `lib/countdown.ts`
|
|
||||||
46. Add API tests for `geocode` and `bike-route` (NEW)
|
44. Migrate `server/__tests__/*.test.js` → `src/app/api/__tests__/*.test.ts`
|
||||||
47. Add component smoke tests with `@testing-library/react`
|
45. Add unit tests for `src/lib/calendar-utils.ts`, `src/lib/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)_
|
||||||
|
|
||||||
### Phase 8 — Cleanup (~30 min)
|
### Phase 8 — Cleanup (~30 min)
|
||||||
|
|
||||||
@@ -428,33 +433,43 @@ type CalStatus = null | "loading" | "ok" | "error";
|
|||||||
|
|
||||||
## 8. Dependencies
|
## 8. Dependencies
|
||||||
|
|
||||||
### Runtime
|
### Runtime (installed)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.0.0",
|
"next": "16.2.6",
|
||||||
"react": "^19.0.0",
|
"react": "19.2.4",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "19.2.4",
|
||||||
"node-ical": "^0.18.0"
|
"node-ical": "^0.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development (installed)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### To be installed before Phase 7 (tests)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.0",
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@types/react": "^19.0.0",
|
|
||||||
"@types/react-dom": "^19.0.0",
|
|
||||||
"@types/node-ical": "^0.18.0",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"postcss": "^8.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
|
||||||
"vitest": "^2.0.0",
|
"vitest": "^2.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
"jsdom": "^25.0.0"
|
"jsdom": "^25.0.0"
|
||||||
@@ -462,6 +477,8 @@ type CalStatus = null | "loading" | "ok" | "error";
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Note: Tailwind v4 no longer requires `autoprefixer` or a separate `tailwind.config.ts` — configuration is done via CSS and `@tailwindcss/postcss`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Environment Variables
|
## 9. Environment Variables
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Allow _-prefixed parameters and variables to signal intentionally unused.
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
Generated
+1786
-15
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -7,21 +7,29 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "echo 'No tests yet'"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"node-ical": "^0.18.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from "next/server";
|
||||||
|
import { APP_VERSION } from "@/lib/constants";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({ status: 'OK', timestamp: new Date().toISOString() });
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
version: APP_VERSION,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,100 @@
|
|||||||
import { GeocodingClient } from '../geocoding-client';
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { GeocodingClient } from "../geocoding-client";
|
||||||
|
|
||||||
|
const nominatimResult = (lat: string, lon: string, display: string) => [
|
||||||
|
{ lat, lon, display_name: display },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GeocodingClient.geocode", () => {
|
||||||
|
it("parses Nominatim response into GeocodeResult", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(nominatimResult("48.2082", "16.3738", "Wien, Österreich")),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('GeocodingClient', () => {
|
|
||||||
it('should be instantiated correctly', () => {
|
|
||||||
const client = new GeocodingClient();
|
const client = new GeocodingClient();
|
||||||
expect(client).toBeInstanceOf(GeocodingClient);
|
const results = await client.geocode("Wien");
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].lat).toBeCloseTo(48.2082);
|
||||||
|
expect(results[0].lng).toBeCloseTo(16.3738);
|
||||||
|
expect(results[0].display_name).toBe("Wien, Österreich");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should geocode', async () => {
|
it("returns cached result on repeated calls", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(nominatimResult("47.0707", "15.4395", "Graz, Österreich")),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
const client = new GeocodingClient();
|
const client = new GeocodingClient();
|
||||||
const results = await client.geocode('Vienna');
|
await client.geocode("Graz");
|
||||||
expect(results).toHaveLength(1);
|
await client.geocode("Graz");
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes countrycodes param when provided", async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(nominatimResult("47.8095", "13.0550", "Salzburg, Österreich")),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
|
||||||
|
const client = new GeocodingClient();
|
||||||
|
await client.geocode("Salzburg", "at");
|
||||||
|
|
||||||
|
const url = mockFetch.mock.calls[0][0] as string;
|
||||||
|
expect(url).toContain("countrycodes=at");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-OK response", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 429 }));
|
||||||
|
const client = new GeocodingClient();
|
||||||
|
await expect(client.geocode("Wien")).rejects.toThrow("429");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when Nominatim returns no results", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
|
||||||
|
);
|
||||||
|
const client = new GeocodingClient();
|
||||||
|
const results = await client.geocode("xyznotaplace");
|
||||||
|
expect(results).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GeocodingClient.reverseGeocode", () => {
|
||||||
|
it("parses reverse geocode response", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ lat: "48.2082", lon: "16.3738", display_name: "Wien, Österreich" }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new GeocodingClient();
|
||||||
|
const result = await client.reverseGeocode(48.2082, 16.3738);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.lat).toBeCloseTo(48.2082);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on non-OK response", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
||||||
|
const client = new GeocodingClient();
|
||||||
|
const result = await client.reverseGeocode(0, 0);
|
||||||
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,122 @@
|
|||||||
import { HafasClient } from '../hafas-client';
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { HafasClient } from "../hafas-client";
|
||||||
|
|
||||||
|
const WIEN = { name: "Wien Hbf", extId: "0WB0F0001500" };
|
||||||
|
const GRAZ = { name: "Graz Hbf", extId: "0WB0F0000600" };
|
||||||
|
|
||||||
|
const makeLocationResponse = (locations: object[]) =>
|
||||||
|
JSON.stringify({ svcResL: [{ res: { match: { locL: locations } } }] });
|
||||||
|
|
||||||
|
const makeTripResponse = (journeys: object[]) =>
|
||||||
|
JSON.stringify({ svcResL: [{ res: { outConL: journeys } }] });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HafasClient.searchStation", () => {
|
||||||
|
it("returns mapped stations from HAFAS response", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.parse(
|
||||||
|
makeLocationResponse([
|
||||||
|
{ type: "S", name: "Wien Hbf", extId: "0WB0F0001500" },
|
||||||
|
{ type: "A", name: "Wien Airport", extId: "0WB0F0099999" },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
describe('HafasClient', () => {
|
|
||||||
it('should be instantiated correctly', () => {
|
|
||||||
const client = new HafasClient();
|
const client = new HafasClient();
|
||||||
expect(client).toBeInstanceOf(HafasClient);
|
const results = await client.searchStation("Wien");
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0]).toEqual({ name: "Wien Hbf", extId: "0WB0F0001500" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch journeys', async () => {
|
it("returns empty array when no stations found", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(JSON.parse(makeLocationResponse([]))),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const client = new HafasClient();
|
const client = new HafasClient();
|
||||||
const journeys = await client.fetchJourneys('Vienna', 'Salzburg', new Date());
|
const results = await client.searchStation("nowhere");
|
||||||
expect(journeys).toHaveLength(1);
|
expect(results).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-OK response", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
||||||
|
const client = new HafasClient();
|
||||||
|
await expect(client.searchStation("Wien")).rejects.toThrow("503");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HafasClient.fetchJourneys", () => {
|
||||||
|
it("maps HAFAS outConL to Journey objects", async () => {
|
||||||
|
const now = new Date("2024-11-14T13:00:00");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.parse(
|
||||||
|
makeTripResponse([
|
||||||
|
{
|
||||||
|
ctxRecon: "ctx-abc",
|
||||||
|
secL: [
|
||||||
|
{
|
||||||
|
dep: { dTimeS: "130000", dTimeR: "130500", dPlatfS: "3" },
|
||||||
|
arr: { aTimeS: "143500", aTimeR: "144000" },
|
||||||
|
jny: { stopL: [{ name: "RJX 5234" }], dlySum: 5, isCncl: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new HafasClient();
|
||||||
|
const journeys = await client.fetchJourneys(WIEN, GRAZ, now);
|
||||||
|
|
||||||
|
expect(journeys).toHaveLength(1);
|
||||||
|
const j = journeys[0];
|
||||||
|
expect(j.id).toBe("ctx-abc");
|
||||||
|
expect(j.platform).toBe("3");
|
||||||
|
expect(j.trains).toEqual(["RJX 5234"]);
|
||||||
|
expect(j.cancelled).toBe(false);
|
||||||
|
expect(j.changes).toBe(0);
|
||||||
|
expect(j.delay).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no journeys found", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(JSON.parse(makeTripResponse([]))),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new HafasClient();
|
||||||
|
const journeys = await client.fetchJourneys(WIEN, GRAZ, new Date());
|
||||||
|
expect(journeys).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on non-OK response", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
||||||
|
const client = new HafasClient();
|
||||||
|
await expect(client.fetchJourneys(WIEN, GRAZ, new Date())).rejects.toThrow("500");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,74 +1,59 @@
|
|||||||
// Bike routing client for ÖBB Planner
|
import type { BikeRoute, BikeStep } from "@/types";
|
||||||
import { BikeRoute, BikeStep } from "@/types";
|
import { OSRM_URL } from "./constants";
|
||||||
|
|
||||||
|
interface OsrmStep {
|
||||||
|
name: string;
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
maneuver: { instruction?: string; type: string; modifier?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OsrmRoute {
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
legs: Array<{ steps: OsrmStep[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OsrmResponse {
|
||||||
|
code: string;
|
||||||
|
routes: OsrmRoute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepInstruction(step: OsrmStep): string {
|
||||||
|
if (step.maneuver.instruction) return step.maneuver.instruction;
|
||||||
|
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
|
||||||
|
return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
export class BikeRoutingClient {
|
export class BikeRoutingClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|
||||||
constructor(baseUrl: string = "https://router.project-osrm.org") {
|
constructor(baseUrl: string = OSRM_URL) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBikeRoute(
|
async getBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
|
||||||
fromLat: number,
|
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
|
||||||
fromLng: number,
|
const url = `${this.baseUrl}/route/v1/bicycle/${coords}?overview=false&steps=true`;
|
||||||
toLat: number,
|
|
||||||
toLng: number
|
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||||
): Promise<BikeRoute> {
|
if (!res.ok) throw new Error(`OSRM request failed: ${res.status}`);
|
||||||
// This would make an actual API call to OSRM or similar
|
|
||||||
// For now, we'll return mock data
|
const data: OsrmResponse = await res.json();
|
||||||
|
if (data.code !== "Ok" || !data.routes.length) return null;
|
||||||
|
|
||||||
|
const route = data.routes[0];
|
||||||
|
const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
distance: s.distance,
|
||||||
|
duration: s.duration,
|
||||||
|
instruction: stepInstruction(s),
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
distance: 5000,
|
distance: route.distance,
|
||||||
duration: 1200,
|
duration: route.duration,
|
||||||
steps: [
|
steps,
|
||||||
{
|
|
||||||
distance: 2000,
|
|
||||||
duration: 400,
|
|
||||||
instruction: "Head north on Sample Street for 2 km",
|
|
||||||
bearing: 0,
|
|
||||||
geometry: {
|
|
||||||
coordinates: [
|
|
||||||
[fromLng, fromLat],
|
|
||||||
[fromLng, fromLat + 0.01],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
distance: 3000,
|
|
||||||
duration: 800,
|
|
||||||
instruction: "Turn right on Main Avenue for 3 km",
|
|
||||||
bearing: 90,
|
|
||||||
geometry: {
|
|
||||||
coordinates: [
|
|
||||||
[fromLng, fromLat + 0.01],
|
|
||||||
[fromLng + 0.01, fromLat + 0.01],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBikeSteps(
|
|
||||||
fromLat: number,
|
|
||||||
fromLng: number,
|
|
||||||
toLat: number,
|
|
||||||
toLng: number
|
|
||||||
): Promise<BikeStep[]> {
|
|
||||||
// This would return just the steps without distance/duration
|
|
||||||
// For now, we'll return mock data
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
distance: 2000,
|
|
||||||
duration: 400,
|
|
||||||
instruction: "Head north on Sample Street for 2 km",
|
|
||||||
bearing: 0,
|
|
||||||
geometry: {
|
|
||||||
coordinates: [
|
|
||||||
[fromLng, fromLat],
|
|
||||||
[fromLng, fromLat + 0.01],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-23
@@ -1,27 +1,52 @@
|
|||||||
// Calendar utilities for ÖBB Planner
|
import * as ical from "node-ical";
|
||||||
import { CalendarEvent } from "@/types";
|
import type { CalendarEvent } from "@/types";
|
||||||
|
|
||||||
export class CalendarUtils {
|
export function extractEvents(content: string, days: number = 14): CalendarEvent[] {
|
||||||
static parseICalContent(content: string): CalendarEvent[] {
|
const parsed = ical.sync.parseICS(content);
|
||||||
// This would parse iCal content
|
const now = new Date();
|
||||||
// For now, we'll return mock data
|
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
return [
|
const events: CalendarEvent[] = [];
|
||||||
{
|
|
||||||
id: "event-1",
|
for (const key of Object.keys(parsed)) {
|
||||||
title: "Sample Event",
|
const comp = parsed[key];
|
||||||
start: new Date(),
|
if (comp.type !== "VEVENT") continue;
|
||||||
end: new Date(Date.now() + 3600000),
|
|
||||||
location: "Sample Location",
|
const start = comp.start as Date | undefined;
|
||||||
description: "Sample description",
|
if (!start || !(start instanceof Date) || isNaN(start.getTime())) continue;
|
||||||
},
|
if (start < now || start > cutoff) continue;
|
||||||
];
|
|
||||||
|
const location = typeof comp.location === "string" ? comp.location : "";
|
||||||
|
if (!location) continue;
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
id: comp.uid ?? key,
|
||||||
|
title: typeof comp.summary === "string" ? comp.summary : "Untitled",
|
||||||
|
destination: cleanLocation(location),
|
||||||
|
eventTime: start.toISOString(),
|
||||||
|
source: "calendar",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static getEventsForDays(events: CalendarEvent[], days: number): CalendarEvent[] {
|
events.sort((a, b) => a.eventTime.localeCompare(b.eventTime));
|
||||||
// Filter events for the specified number of days
|
return events;
|
||||||
const now = new Date();
|
}
|
||||||
const future = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
|
||||||
|
export function cleanLocation(location: string): string {
|
||||||
return events.filter(event => event.start >= now && event.start <= future);
|
const knownStations: Record<string, string> = {
|
||||||
}
|
"Graz Hauptbahnhof": "Graz Hbf",
|
||||||
|
"Wien Hauptbahnhof": "Wien Hbf",
|
||||||
|
"Salzburg Hauptbahnhof": "Salzburg Hbf",
|
||||||
|
"Innsbruck Hauptbahnhof": "Innsbruck Hbf",
|
||||||
|
"Linz Hauptbahnhof": "Linz Hbf",
|
||||||
|
"Villach Hauptbahnhof": "Villach Hbf",
|
||||||
|
"Klagenfurt Hauptbahnhof": "Klagenfurt Hbf",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [full, short] of Object.entries(knownStations)) {
|
||||||
|
if (location.toLowerCase().includes(full.toLowerCase())) {
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return location.split(",")[0].trim();
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-36
@@ -1,46 +1,48 @@
|
|||||||
// Countdown utilities for ÖBB Planner
|
// Countdown utilities for ÖBB Planner
|
||||||
import { CountdownInfo } from "@/types";
|
import { CountdownInfo } from "@/types";
|
||||||
|
|
||||||
export class CountdownUtils {
|
export function calculateCountdown(targetDate: Date): CountdownInfo {
|
||||||
static calculateCountdown(targetDate: Date): CountdownInfo {
|
const now = new Date();
|
||||||
const now = new Date();
|
const diffMs = targetDate.getTime() - now.getTime();
|
||||||
const diff = targetDate.getTime() - now.getTime();
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
if (diff <= 0) {
|
|
||||||
return {
|
|
||||||
minutes: 0,
|
|
||||||
seconds: 0,
|
|
||||||
status: 'running'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(diff / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
|
if (diffMin <= 0) {
|
||||||
return {
|
return {
|
||||||
minutes,
|
label: "Now",
|
||||||
seconds,
|
color: "red",
|
||||||
status: 'running'
|
urgent: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static formatCountdown(countdown: CountdownInfo): string {
|
if (diffMin <= 10) {
|
||||||
if (countdown.status === 'cancelled') {
|
return {
|
||||||
return 'Cancelled';
|
label: `${diffMin}min`,
|
||||||
}
|
color: "orange",
|
||||||
|
urgent: true,
|
||||||
if (countdown.status === 'delayed') {
|
};
|
||||||
return 'Delayed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countdown.minutes === 0 && countdown.seconds === 0) {
|
|
||||||
return 'Now';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countdown.minutes > 0) {
|
|
||||||
return `${countdown.minutes}m ${countdown.seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${countdown.seconds}s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (diffMin <= 30) {
|
||||||
|
return {
|
||||||
|
label: `${diffMin}min`,
|
||||||
|
color: "yellow",
|
||||||
|
urgent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMin < 60) {
|
||||||
|
return {
|
||||||
|
label: `${diffMin}min`,
|
||||||
|
color: "green",
|
||||||
|
urgent: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(diffMin / 60);
|
||||||
|
const remainingMin = diffMin % 60;
|
||||||
|
return {
|
||||||
|
label: `${hours}h ${remainingMin}min`,
|
||||||
|
color: "blue",
|
||||||
|
urgent: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-28
@@ -1,42 +1,73 @@
|
|||||||
// Geocoding client for ÖBB Planner
|
import type { GeocodeResult } from "@/types";
|
||||||
import { GeocodeResult } from "@/types";
|
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "./constants";
|
||||||
|
|
||||||
|
interface NominatimResult {
|
||||||
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class GeocodingClient {
|
export class GeocodingClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private userAgent: string;
|
||||||
|
private cache = new Map<string, GeocodeResult[]>();
|
||||||
|
|
||||||
constructor(baseUrl: string = "https://nominatim.openstreetmap.org") {
|
constructor(baseUrl: string = NOMINATIM_URL, userAgent: string = NOMINATIM_USER_AGENT) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.userAgent = userAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async geocode(query: string, countrycodes?: string[]): Promise<GeocodeResult[]> {
|
async geocode(query: string, countrycodes?: string): Promise<GeocodeResult[]> {
|
||||||
// This would make an actual API call to Nominatim
|
const cacheKey = `${query}|${countrycodes ?? ""}`;
|
||||||
// For now, we'll return mock data
|
const cached = this.cache.get(cacheKey);
|
||||||
return [
|
if (cached) return cached;
|
||||||
{
|
|
||||||
lat: 48.2082,
|
const params = new URLSearchParams({ q: query, format: "json", limit: "5" });
|
||||||
lon: 16.3738,
|
if (countrycodes) params.set("countrycodes", countrycodes);
|
||||||
display_name: query,
|
|
||||||
address: {
|
const res = await fetch(`${this.baseUrl}/search?${params}`, {
|
||||||
city: "Vienna",
|
headers: {
|
||||||
state: "Vienna",
|
"User-Agent": this.userAgent,
|
||||||
country: "Austria",
|
Accept: "application/json",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Nominatim geocode failed: ${res.status}`);
|
||||||
|
|
||||||
|
const data: NominatimResult[] = await res.json();
|
||||||
|
const results = data.map((r) => ({
|
||||||
|
lat: parseFloat(r.lat),
|
||||||
|
lng: parseFloat(r.lon),
|
||||||
|
display_name: r.display_name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.cache.set(cacheKey, results);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeocode(lat: number, lon: number): Promise<GeocodeResult> {
|
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
|
||||||
// This would make an actual API call to Nominatim
|
const params = new URLSearchParams({
|
||||||
// For now, we'll return mock data
|
lat: String(lat),
|
||||||
|
lon: String(lng),
|
||||||
|
format: "json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}/reverse?${params}`, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": this.userAgent,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const data: NominatimResult = await res.json();
|
||||||
return {
|
return {
|
||||||
lat,
|
lat: parseFloat(data.lat),
|
||||||
lon,
|
lng: parseFloat(data.lon),
|
||||||
display_name: "Sample Location",
|
display_name: data.display_name,
|
||||||
address: {
|
|
||||||
city: "Sample City",
|
|
||||||
state: "Sample State",
|
|
||||||
country: "Sample Country",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-61
@@ -1,74 +1,144 @@
|
|||||||
// HAFAS client for ÖBB Planner
|
import type { Journey, Station } from "@/types";
|
||||||
import { Journey, Station, Event } from "@/types";
|
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
|
||||||
|
|
||||||
export interface HafasResponse {
|
interface HafasLocation {
|
||||||
journeys: Journey[];
|
type: "S" | "A" | "P";
|
||||||
stations: Station[];
|
name: string;
|
||||||
events: Event[];
|
extId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HafasJourney {
|
||||||
|
ctxRecon?: string;
|
||||||
|
secL?: Array<{
|
||||||
|
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
|
||||||
|
arr?: { aTimeS?: string; aTimeR?: string };
|
||||||
|
jny?: {
|
||||||
|
prodX?: number;
|
||||||
|
stopL?: Array<{ name: string }>;
|
||||||
|
dlySum?: number;
|
||||||
|
isCncl?: boolean;
|
||||||
|
};
|
||||||
|
chgDurR?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHafasTime(date: string, time: string): Date {
|
||||||
|
const y = parseInt(date.slice(0, 4));
|
||||||
|
const mo = parseInt(date.slice(4, 6)) - 1;
|
||||||
|
const d = parseInt(date.slice(6, 8));
|
||||||
|
const h = parseInt(time.slice(0, 2));
|
||||||
|
const m = parseInt(time.slice(2, 4));
|
||||||
|
const s = parseInt(time.slice(4, 6));
|
||||||
|
return new Date(y, mo, d, h, m, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hafasDateTime(date: Date): { date: string; time: string } {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const d = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
|
||||||
|
const t = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}000`;
|
||||||
|
return { date: d, time: t };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HafasClient {
|
export class HafasClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
private timeoutMs: number;
|
||||||
|
|
||||||
constructor(baseUrl: string = "https://reiseauskunft.bahn.de") {
|
constructor(baseUrl: string = HAFAS_URL, timeoutMs: number = HAFAS_TIMEOUT_MS) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchJourneys(
|
async searchStation(query: string): Promise<Station[]> {
|
||||||
from: string,
|
const body = {
|
||||||
to: string,
|
svcReqL: [
|
||||||
date: Date,
|
{
|
||||||
options?: {
|
meth: "LocMatch",
|
||||||
products?: string[];
|
req: { input: { loc: { name: query, type: "S" }, maxLoc: 5 } },
|
||||||
changes?: number;
|
|
||||||
}
|
|
||||||
): Promise<Journey[]> {
|
|
||||||
// This would make an actual API call to ÖBB HAFAS
|
|
||||||
// For now, we'll return mock data
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "journey-1",
|
|
||||||
from: {
|
|
||||||
id: "station-1",
|
|
||||||
name: from,
|
|
||||||
latitude: 48.2082,
|
|
||||||
longitude: 16.3738,
|
|
||||||
},
|
},
|
||||||
to: {
|
],
|
||||||
id: "station-2",
|
|
||||||
name: to,
|
|
||||||
latitude: 48.8566,
|
|
||||||
longitude: 2.3522,
|
|
||||||
},
|
|
||||||
date,
|
|
||||||
duration: 120,
|
|
||||||
changes: 1,
|
|
||||||
products: ["ICE", "IC"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchStations(query: string): Promise<Station[]> {
|
|
||||||
// This would make an actual API call to Nominatim or similar
|
|
||||||
// For now, we'll return mock data
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "station-1",
|
|
||||||
name: query,
|
|
||||||
latitude: 48.2082,
|
|
||||||
longitude: 16.3738,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStationInfo(stationId: string): Promise<Station> {
|
|
||||||
// This would fetch detailed station information
|
|
||||||
// For now, we'll return mock data
|
|
||||||
return {
|
|
||||||
id: stationId,
|
|
||||||
name: "Sample Station",
|
|
||||||
latitude: 48.2082,
|
|
||||||
longitude: 16.3738,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const res = await fetch(this.baseUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HAFAS station search failed: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const match = json?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||||
|
return (match as HafasLocation[])
|
||||||
|
.filter((l) => l.type === "S")
|
||||||
|
.map((l) => ({ name: l.name, extId: l.extId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchJourneys(from: Station, to: Station, date: Date): Promise<Journey[]> {
|
||||||
|
const { date: hafasDate, time: hafasTime } = hafasDateTime(date);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
svcReqL: [
|
||||||
|
{
|
||||||
|
meth: "TripSearch",
|
||||||
|
req: {
|
||||||
|
depLocL: [{ type: "S", extId: from.extId }],
|
||||||
|
arrLocL: [{ type: "S", extId: to.extId }],
|
||||||
|
outDate: hafasDate,
|
||||||
|
outTime: hafasTime,
|
||||||
|
numF: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(this.baseUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(this.timeoutMs),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HAFAS trip search failed: ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const outConL: HafasJourney[] = json?.svcResL?.[0]?.res?.outConL ?? [];
|
||||||
|
|
||||||
|
return outConL.map((con, i): Journey => {
|
||||||
|
const first = con.secL?.[0];
|
||||||
|
const last = con.secL?.[con.secL.length - 1];
|
||||||
|
const dep = first?.dep;
|
||||||
|
const arr = last?.arr;
|
||||||
|
|
||||||
|
const sD = dep?.dTimeS ? parseHafasTime(hafasDate, dep.dTimeS) : date;
|
||||||
|
const rD = dep?.dTimeR ? parseHafasTime(hafasDate, dep.dTimeR) : sD;
|
||||||
|
const sA = arr?.aTimeS ? parseHafasTime(hafasDate, arr.aTimeS) : sD;
|
||||||
|
const rA = arr?.aTimeR ? parseHafasTime(hafasDate, arr.aTimeR) : sA;
|
||||||
|
|
||||||
|
const delayMs = rD.getTime() - sD.getTime();
|
||||||
|
const delay = Math.max(0, Math.round(delayMs / 60000));
|
||||||
|
|
||||||
|
const trains = (con.secL ?? [])
|
||||||
|
.filter((s) => s.jny)
|
||||||
|
.map((s) => s.jny?.stopL?.[0]?.name ?? "")
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const cancelled = (con.secL ?? []).some((s) => s.jny?.isCncl === true);
|
||||||
|
const changes = Math.max(0, (con.secL ?? []).filter((s) => s.jny).length - 1);
|
||||||
|
const platform = first?.dep?.dPlatfS ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: con.ctxRecon ?? `journey-${i}`,
|
||||||
|
sD,
|
||||||
|
rD,
|
||||||
|
sA,
|
||||||
|
rA,
|
||||||
|
delay,
|
||||||
|
platform,
|
||||||
|
changes,
|
||||||
|
trains,
|
||||||
|
cancelled,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -1,8 +1,11 @@
|
|||||||
// Main library exports for ÖBB Planner
|
// Main library exports for ÖBB Planner
|
||||||
export * from './hafas-client';
|
export * from "./hafas-client";
|
||||||
export * from './geocoding-client';
|
export * from "./geocoding-client";
|
||||||
export * from './bike-routing-client';
|
export * from "./bike-routing-client";
|
||||||
export * from './calendar-utils';
|
export * from "./calendar-utils";
|
||||||
export * from './status-utils';
|
export * from "./status-utils";
|
||||||
export * from './live-status-utils';
|
export * from "./live-status-utils";
|
||||||
export * from './countdown-utils';
|
export * from "./countdown-utils";
|
||||||
|
export * from "./formatting";
|
||||||
|
export * from "./constants";
|
||||||
|
export * from "./demo";
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
// Live status utilities for ÖBB Planner
|
// Live status utilities for ÖBB Planner
|
||||||
import { LiveStatus } from "@/types";
|
import type { LiveStatus } from "@/types";
|
||||||
|
|
||||||
export class LiveStatusUtils {
|
export class LiveStatusUtils {
|
||||||
static async getLiveStatus(journeyId: string): Promise<LiveStatus> {
|
static async getLiveStatus(_journeyId: string): Promise<LiveStatus> {
|
||||||
// This would make an actual API call to get live status
|
// This would make an actual API call to get live status
|
||||||
// For now, we'll return mock data
|
// For now, we'll return mock data
|
||||||
return "on_time";
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
static async getMultipleLiveStatus(journeyIds: string[]): Promise<Record<string, LiveStatus>> {
|
|
||||||
// Get live status for multiple journeys
|
|
||||||
const results: Record<string, LiveStatus> = {};
|
|
||||||
|
|
||||||
for (const id of journeyIds) {
|
|
||||||
results[id] = await this.getLiveStatus(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getStatusMessage(status: LiveStatus): string {
|
static getStatusMessage(status: LiveStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "on_time":
|
case true:
|
||||||
return "On time";
|
return "On time";
|
||||||
case "delayed":
|
case false:
|
||||||
return "Delayed";
|
return "Delayed or cancelled";
|
||||||
case "cancelled":
|
case null:
|
||||||
return "Cancelled";
|
return "No live data";
|
||||||
case "disrupted":
|
|
||||||
return "Service disrupted";
|
|
||||||
default:
|
default:
|
||||||
return "Unknown status";
|
return "Unknown status";
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-14
@@ -1,21 +1,16 @@
|
|||||||
// Status utilities for ÖBB Planner
|
// Status utilities for ÖBB Planner
|
||||||
import { ServerStatus } from "@/types";
|
import type { ServerStatus } from "@/types";
|
||||||
|
|
||||||
export class StatusUtils {
|
export class StatusUtils {
|
||||||
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
static async checkServerStatus(url: string): Promise<ServerStatus> {
|
||||||
// This would make an actual HTTP request to check server status
|
try {
|
||||||
// For now, we'll return mock data
|
const res = await fetch(url, {
|
||||||
return "operational";
|
method: "HEAD",
|
||||||
}
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
static async checkMultipleServices(services: string[]): Promise<Record<string, ServerStatus>> {
|
return res.ok;
|
||||||
// Check status of multiple services
|
} catch {
|
||||||
const results: Record<string, ServerStatus> = {};
|
return false;
|
||||||
|
|
||||||
for (const service of services) {
|
|
||||||
results[service] = await this.checkServerStatus(service);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
// Test file to verify setup
|
|
||||||
export const testFunction = () => {
|
|
||||||
return "test";
|
|
||||||
};
|
|
||||||
+50
-40
@@ -1,80 +1,90 @@
|
|||||||
// Types for ÖBB Planner - Next.js Rewrite
|
// Types for ÖBB Planner - Next.js Rewrite
|
||||||
|
|
||||||
|
// ── HAFAS / Train ──────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Journey {
|
export interface Journey {
|
||||||
id: string;
|
id: string;
|
||||||
from: Station;
|
sD: Date; // scheduled departure
|
||||||
to: Station;
|
rD: Date; // real departure
|
||||||
date: Date;
|
sA: Date; // scheduled arrival
|
||||||
duration: number;
|
rA: Date; // real arrival
|
||||||
|
delay: number; // minutes
|
||||||
|
platform: string;
|
||||||
changes: number;
|
changes: number;
|
||||||
products: string[];
|
trains: string[];
|
||||||
price?: number;
|
cancelled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Station {
|
export interface Station {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
latitude: number;
|
extId: string;
|
||||||
longitude: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Events ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'departure' | 'arrival';
|
title: string;
|
||||||
station: Station;
|
destination: string;
|
||||||
time: Date;
|
eventTime: Date;
|
||||||
platform?: string;
|
source: "manual" | "calendar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Trip Data (per event) ──────────────────────────────────────
|
||||||
|
|
||||||
export interface TripDataEntry {
|
export interface TripDataEntry {
|
||||||
id: string;
|
journeys: Journey[];
|
||||||
journey: Journey;
|
destName: string;
|
||||||
events: Event[];
|
demo: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
bikeRoute?: BikeRoute | null; // NEW
|
||||||
|
bikeLoading?: boolean; // NEW
|
||||||
|
bikeError?: string | null; // NEW
|
||||||
|
destCoords?: { lat: number; lng: number }; // NEW (cached geocode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bicycle Routing (NEW) ─────────────────────────────────────
|
||||||
|
|
||||||
export interface BikeRoute {
|
export interface BikeRoute {
|
||||||
distance: number;
|
distance: number; // meters
|
||||||
duration: number;
|
duration: number; // seconds
|
||||||
steps: BikeStep[];
|
steps?: BikeStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BikeStep {
|
export interface BikeStep {
|
||||||
|
name: string;
|
||||||
distance: number;
|
distance: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
instruction: string;
|
instruction: string;
|
||||||
bearing: number;
|
|
||||||
geometry: {
|
|
||||||
coordinates: [number, number][];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Geocoding (NEW) ────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GeocodeResult {
|
export interface GeocodeResult {
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lng: number;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
address: {
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
country?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Calendar Import ────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
destination: string;
|
||||||
end: Date;
|
eventTime: string; // ISO string from API
|
||||||
location?: string;
|
source: "calendar";
|
||||||
description?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UI Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface CountdownInfo {
|
export interface CountdownInfo {
|
||||||
minutes: number;
|
label: string;
|
||||||
seconds: number;
|
color: string;
|
||||||
status: 'running' | 'delayed' | 'cancelled';
|
urgent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerStatus = 'operational' | 'degraded' | 'down';
|
export type ServerStatus = null | true | false;
|
||||||
export type LiveStatus = 'on_time' | 'delayed' | 'cancelled' | 'disrupted';
|
export type LiveStatus = null | true | false;
|
||||||
export type LocState = 'idle' | 'loading' | 'error';
|
export type LocState = "pending" | "granted" | "denied";
|
||||||
export type CalStatus = 'idle' | 'loading' | 'success' | 'error';
|
export type CalStatus = null | "loading" | "ok" | "error";
|
||||||
|
|||||||
Reference in New Issue
Block a user