9 Commits

Author SHA1 Message Date
fegger 48e6f5c7fd Add leave reminder feature with browser notifications 2026-05-10 10:32:55 +02:00
fegger c0f34b9e2a Update CHECKLIST.md 2026-05-10 09:49:22 +02:00
fegger ce2a900545 Add correlation IDs and tests for monitoring
Generate short UUIDs in API route catch blocks to aid debugging.
Update existing API tests to assert the correlation ID. Add unit
tests for `useJourneys` and `useBikeRoute` hooks, and component
tests for `EventCard` and `CalendarView`.
2026-05-10 08:59:17 +02:00
fegger 7abfddd607 Add debounce, theme toggle, and calendar optimization
Implement 400ms debounce with AbortController in useGeocode and
useDestinationStation to prevent excessive API calls. Add dark mode
toggle via new useTheme hook, persisting preference to localStorage
and applying to document root.

Pre-group calendar events by date using a Map in CalendarView to
replace O(n) filter operations with O(1) lookups.
2026-05-10 03:39:30 +02:00
fegger 28c25c32ab Refactor API routes and hooks to use library clients
- Wire `api/geocode` and `api/bike-route` to use `GeocodingClient`
  and `BikeRoutingClient` instead of raw fetch calls
- Move `parseHafasJourneys` from `useJourneys` to `hafas-client`
- Remove dead code `src/lib/live-status-utils.ts`
- Update test setup to import `@testing-library/jest-dom/vitest`
2026-05-10 03:23:07 +02:00
fegger 77b8c6db98 Rewrite implementation checklist and fix runtime bugs
Update CHECKLIST.md and REWRITE_PLAN.md to reflect the current
post-rewrite status and remaining tasks.

- Add input validation to /api/hafas route to enforce request shape
  and cap results
- Fix SSR crash in useBikeRoute by using relative fetch URLs
- Wire CalendarPanel to fetch calendar events and merge them into the
  global events store
2026-05-10 03:09:38 +02:00
fegger c869d4958e Upgrade Next.js to v16 and refactor API client
Bump Next.js from v9 to v16.2.6 and add node-ical to
serverExternalPackages. Remove Geist font dependencies in favor
of system fonts. Fix state updates in geocode hooks to prevent
stale closures. Expose ApiClient methods as public and improve
error handling in fetchWithRetry.
2026-05-10 02:07:47 +02:00
fegger a7fcbd811e Refactor API clients and update project structure for TimeToLeave 2026-05-10 01:47:22 +02:00
fegger 7c345785a7 Add Docker support with multi-stage build and standalone output 2026-05-10 00:42:39 +02:00
56 changed files with 2985 additions and 1185 deletions
+47
View File
@@ -0,0 +1,47 @@
# dependencies
node_modules
npm-debug.log*
# next.js
.next/
out/
# misc
.DS_Store
*.pem
coverage/
# env files
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE / editors
.idea/
.vscode/
.zed/
.claude/
# git
.git/
.gitignore
# docker
Dockerfile
docker-compose.yml
.dockerignore
# docs
AGENTS.md
CLAUDE.md
CHECKLIST.md
REWRITE_PLAN.md
# tests
vitest.config.ts
+3 -3
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/oebb_planner.iml" filepath="$PROJECT_DIR$/.idea/oebb_planner.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/TimeToLeave.iml" filepath="$PROJECT_DIR$/.idea/TimeToLeave.iml" />
</modules>
</component>
</project>
</project>
+5
View File
@@ -0,0 +1,5 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{}
+58 -19
View File
@@ -1,23 +1,62 @@
## Phase 9 — Fix Broken Tests (~1 hour)
# TimeToLeave — Implementation Checklist
The API route tests were written against an earlier interface and will fail as-is.
> **Source:** [REWRITE_PLAN.md](./REWRITE_PLAN.md)
> **Total:** 4 phases, 14 steps, ~5 hours estimated effort
| # | Item | ✅ | ✔️ |
|---|---|----|-|
| 53 | `geocode.test.ts`: change `?q=``?name=` to match the actual route parameter | [x] | [x] |
| 54 | `geocode.test.ts`: fix expected error strings (`"Failed to geocode location"``"Internal server error"` / `"No results found"`) | [x] | [x] |
| 55 | `bike-route.test.ts`: change `?start=` / `?end=``?fromLat=&fromLng=&toLat=&toLng=` to match actual route | [x] | [x] |
| 56 | `bike-route.test.ts`: fix expected error strings (`"Missing 'start' or 'end' parameter"``"Missing required parameters ..."` and `"Failed to fetch bike route"``"Internal server error"`) | [x] | [x] |
| 57 | `calendar-utils.test.ts` (`extractEvents`): replace hardcoded past dates (2020-01-01, 2023-01-01) with `vi.setSystemTime` + dates relative to the frozen clock so filters behave as expected | [x] | [x] |
---
## Phase 10Fix Architecture & Critical Bugs (~2 hours)
## Phase 1 — Unblock Runtime (~45 min)
| # | Item | ✅ | ✔️ |
|---|---|----|-|
| 58 | `useJourneys.ts`: remove direct `HafasClient` instantiation; route all HAFAS calls through `/api/hafas` to prevent direct browser→HAFAS requests (CORS + IP leakage) | [x] | [x] |
| 59 | `useBikeRoute.ts`: remove direct `BikeRoutingClient` instantiation; call `/api/bike-route` instead so OSRM is never contacted directly from the browser | [x] | [x] |
| 60 | `useOriginStation.ts`: use `location.coords.latitude` / `longitude` in the station search instead of the hardcoded `"Bahnhof"` query; use a HAFAS nearby-station lookup or geocode → nearest-station fallback | [x] | [x] |
| 61 | `hafas-client.ts` `parseHafasTime`: replace `new Date(y, mo, d, h, m, s)` (local TZ) with Vienna-timezone-aware construction — use `Intl` or a fixed UTC offset — so departure/arrival times are correct when the server is not in CET/CEST | [x] | [x] |
| 62 | `api/calendar/route.ts` and `api/calendar/parse/route.ts`: replace the inlined parsing logic with calls to `extractEvents()` from `calendar-utils.ts` so `cleanLocation()` and location-presence filtering are applied consistently | [x] | [x] |
| 63 | `useBikeRoute.ts:18`: replace `if (!fromLat || !fromLng || !toLat || !toLng)` with `!= null` checks so coordinates at `0` (valid) are not skipped | [x] | [x] |
| 64 | Move `HafasClient` / `GeocodingClient` / `BikeRoutingClient` instances to module scope (or a shared context) so the in-instance caches in `GeocodingClient` survive across renders | [x] | [x] |
Fix bugs that crash the app or lose user data.
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:--------------:|:-----------:|-------|
| **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
| **Step 2: Wire Calendar Events into `EventsStore`** (~15 min) | [x] | [x] | `CalendarPanel.tsx` merges via `useEffect` when calendar events arrive. `mergeEvents()` converts `CalendarEvent` (string `eventTime`) → `Event` (Date `eventTime`). Bonus: localStorage persistence with rehydration. |
| **Step 3: Add Input Validation to `/api/hafas`** (~20 min) | [x] | [x] | Validates `svcReqL` array shape, method allowlist (TripSearch/LocMatch), caps `numF` at 10. Extra type guard on `svcReq.meth` (`typeof svcReq.meth !== 'string'`) exceeds spec. |
---
## Phase 2 — Deduplicate Code (~2 hours)
Eliminate duplicated logic so each integration has one source of truth.
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 4: Consolidate HAFAS Journey Parsing** (~40 min) | [x] | [x] | parseHafasJourneys moved to hafas-client.ts, exported, imported by useJourneys.ts. Option A followed. |
| **Step 5: Wire API Routes to Use Library Clients** (~30 min) | [x] | [x] | Both routes use module-level singleton clients. Param validation, error handling, and try/catch intact. |
| **Step 6: Remove Dead Code** (~5 min) | [x] | [x] | live-status-utils.ts deleted, export removed from index.ts. No remaining references. |
| **Step 7: Create Missing Test Setup File** (~10 min) | [x] | [x] | src/test/setup.ts created with jest-dom vitest import. Referenced correctly in vitest.config.ts. |
---
## Phase 3 — Performance & UX (~1.5 hours)
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 8: Add Debounce to Lookup Hooks** (~25 min) | [x] | [x] | 400ms setTimeout + AbortController in `useGeocode.ts` and `useDestinationStation.ts`. AbortError silently ignored. |
| **Step 9: Pre-Group Calendar Events by Date** (~20 min) | [x] | [x] | `useMemo` builds `Map<string, Event[]>` keyed by `YYYY-MM-DD`. Per-cell `filter()` replaced with O(1) map lookup. |
| **Step 10: Add Dark Mode Toggle** (~20 min) | [x] | [x] | `useTheme.ts` created (localStorage + prefers-color-scheme). Sun/moon toggle button added to `Header.tsx`. |
| **Step 11: Fix Bike Route Steps** (~5 min) | [x] | [x] | `steps: "true"` already present in `BikeRoutingClient.getBikeRoute()` query params. |
---
## Phase 4 — Monitoring & Testing (~1 hour)
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|------|:------:|:------:|-------|
| **Step 12: Add Correlation IDs to API Errors** (~15 min) | [x] | [x] | `randomUUID().slice(0, 8)` in catch blocks of `bike-route`, `geocode`, `hafas`, `calendar`, `calendar/parse`. Logged server-side, returned in JSON. Existing API tests updated to assert `correlationId`. |
| **Step 13: Add Hook Tests** (~30 min) | [x] | [x] | `useJourneys.test.ts` (4 tests: no-op when missing IDs, success, HTTP error, fetch throw). `useBikeRoute.test.ts` (4 tests: no-op when missing coords, success, HTTP error, fetch throw). |
| **Step 14: Add Component Tests** (~15 min) | [x] | [x] | `EventCard.test.tsx` (renders title + destination, hooks mocked). `CalendarView.test.tsx` (3 tests: month header, event on correct day, overflow indicator). |
---
## Summary
| Phase | Steps | Est. Time |
|-------|-------|-----------|
| 1 — Unblock Runtime | 13 | ~45 min |
| 2 — Deduplicate Code | 47 | ~2 hours |
| 3 — Performance & UX | 811 | ~1.5 hours |
| 4 — Monitoring & Testing | 1214 | ~1 hour |
| **Total** | **14** | **~5 hours** |
+44
View File
@@ -0,0 +1,44 @@
# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- Stage 2: Build the app ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Stage 3: Production image ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy built artifacts
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
# Use standalone output if available, otherwise copy .next
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Ensure correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
-21
View File
@@ -1,21 +0,0 @@
# TimeToLeave - Next.js Rewrite
This is a rewrite of the TimeToLeave application using Next.js App Router with TypeScript.
## Development
```bash
npm run dev
```
## Building
```bash
npm run build
```
## Testing
```bash
npm test
```
+493 -456
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: time-to-leave
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
- HAFAS_URL=${HAFAS_URL:-https://fahrplan.oebb.at/bin/mgate.exe}
- NOMINATIM_URL=${NOMINATIM_URL:-https://nominatim.openstreetmap.org}
- NOMINATIM_USER_AGENT=${NOMINATIM_USER_AGENT:-TimeToLeave/2.0}
- OSRM_URL=${OSRM_URL:-https://router.project-osrm.org}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
serverExternalPackages: ["node-ical"],
};
export default nextConfig;
+65 -153
View File
@@ -1,16 +1,16 @@
{
"name": "oebb_planner",
"name": "time-to-leave",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oebb_planner",
"name": "time-to-leave",
"version": "0.1.0",
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.6",
"node-ical": "^0.18.0",
"next": "^16.2.6",
"node-ical": "^0.26.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
@@ -1329,6 +1329,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@js-temporal/polyfill": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
"integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
"license": "ISC",
"dependencies": {
"jsbi": "^4.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -3336,12 +3348,6 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -3368,17 +3374,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.4",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3499,6 +3494,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3608,18 +3604,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -3830,15 +3814,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -3885,6 +3860,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -4009,6 +3985,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4018,6 +3995,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4062,6 +4040,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -4074,6 +4053,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4690,26 +4670,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4726,22 +4686,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4761,6 +4705,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4821,6 +4766,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4845,6 +4791,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4932,6 +4879,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5003,6 +4951,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5015,6 +4964,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -5030,6 +4980,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5614,6 +5565,12 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsbi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz",
"integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==",
"license": "Apache-2.0"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
@@ -6116,6 +6073,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6152,27 +6110,6 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -6206,27 +6143,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6376,15 +6292,16 @@
}
},
"node_modules/node-ical": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.18.0.tgz",
"integrity": "sha512-FrOUPztjw9OUgSB9o/ffhl86BiVClQTut97C2NqCwKIgOAcKPEw5UQMuSuNJO/Y4hqTyJdKZh2TCqNHQnE9YFg==",
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz",
"integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==",
"license": "Apache-2.0",
"dependencies": {
"axios": "1.6.7",
"moment-timezone": "^0.5.44",
"rrule": "2.8.1",
"uuid": "^9.0.0"
"rrule-temporal": "^1.5.3",
"temporal-polyfill": "^0.3.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/node-releases": {
@@ -6774,12 +6691,6 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7003,13 +6914,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/rrule": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
"license": "BSD-3-Clause",
"node_modules/rrule-temporal": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.3.tgz",
"integrity": "sha512-qALnXyu4MKNUeykkkO0r6Xxl5or3rM8Cf6ibKIe/29sgmq3tGm1oNq4G1Ddp8Ku3mnKmvC3+3yFAJ3OgOu6OJw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
"@js-temporal/polyfill": "^0.5.1"
}
},
"node_modules/run-parallel": {
@@ -7603,6 +7514,21 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/temporal-polyfill": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz",
"integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==",
"license": "MIT",
"dependencies": {
"temporal-spec": "0.3.1"
}
},
"node_modules/temporal-spec": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz",
"integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==",
"license": "ISC"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -8023,20 +7949,6 @@
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": {
"version": "8.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+2 -2
View File
@@ -13,8 +13,8 @@
},
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.6",
"node-ical": "^0.18.0",
"next": "^16.2.6",
"node-ical": "^0.26.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
+23 -51
View File
@@ -1,14 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "../bike-route/route";
// Mock the fetch function to avoid making actual HTTP requests
global.fetch = vi.fn();
const mockGetBikeRoute = vi.fn();
vi.mock("@/lib/bike-routing-client", () => ({
BikeRoutingClient: class MockBikeRoutingClient {
getBikeRoute = mockGetBikeRoute;
},
}));
const { GET } = await import("../bike-route/route");
describe("api/bike-route/route", () => {
beforeEach(() => {
vi.resetAllMocks();
global.fetch = vi.fn();
mockGetBikeRoute.mockReset();
});
it("should return error when no required parameters are provided", async () => {
@@ -21,40 +26,14 @@ describe("api/bike-route/route", () => {
});
it("should handle valid bike route request", async () => {
// Mock successful fetch response
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
distance: 1500,
duration: 300,
routes: [
{
distance: 1500,
duration: 300,
legs: [
{
steps: [
{
name: "Start",
distance: 100,
duration: 10,
maneuver: { instruction: "Go straight", type: "straight" },
},
{
name: "Turn left",
distance: 200,
duration: 20,
maneuver: { instruction: "Turn left at the corner", type: "turn", modifier: "left" },
},
],
},
],
},
],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
mockGetBikeRoute.mockResolvedValue({
distance: 1500,
duration: 300,
steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
],
});
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -68,8 +47,8 @@ describe("api/bike-route/route", () => {
expect(data).toHaveProperty("steps");
});
it("should handle fetch error", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
it("should handle client error", async () => {
mockGetBikeRoute.mockRejectedValue(new Error("Network error"));
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
@@ -78,19 +57,12 @@ describe("api/bike-route/route", () => {
expect(response.status).toBe(500);
const data = await response.json();
expect(data).toEqual({ error: "Internal server error" });
expect(data.error).toBe("Internal server error");
expect(data.correlationId).toHaveLength(8);
});
it("should handle no route found", async () => {
// Mock fetch response with empty routes
const mockResponse = {
ok: true,
json: vi.fn().mockResolvedValue({
routes: [],
}),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
mockGetBikeRoute.mockResolvedValue(null);
const request = new NextRequest(
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
+2 -1
View File
@@ -55,7 +55,8 @@ describe("api/geocode/route", () => {
expect(response.status).toBe(500);
const data = await response.json();
expect(data).toEqual({ error: "Internal server error" });
expect(data.error).toBe("Internal server error");
expect(data.correlationId).toHaveLength(8);
});
it("should handle no results found", async () => {
+15 -44
View File
@@ -1,64 +1,35 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { OSRM_URL } from "@/lib/constants";
import { BikeRoute } from "@/types";
import { BikeRoutingClient } from "@/lib/bike-routing-client";
interface OsrmStep {
name: string;
distance: number;
duration: number;
maneuver: { instruction?: string; type: string; modifier?: string };
}
// Module-level singleton — cache persists across requests
const client = new BikeRoutingClient();
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");
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
const toLat = parseFloat(searchParams.get("toLat") ?? "");
const toLng = parseFloat(searchParams.get("toLng") ?? "");
if (!fromLat || !fromLng || !toLat || !toLng) {
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(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 route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
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) {
if (!route) {
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);
return NextResponse.json(route);
} catch (error) {
console.error("Bike route API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Bike route API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
@@ -15,7 +16,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json(events);
} catch (error) {
console.error("Calendar parse API error:", error);
return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 });
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Calendar parse API error:`, error);
return NextResponse.json({ error: "Failed to parse calendar", correlationId: corrId }, { status: 500 });
}
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { extractEvents } from "@/lib/calendar-utils";
import { DEFAULT_DAYS } from "@/lib/constants";
@@ -30,7 +31,8 @@ export async function GET(request: NextRequest) {
return NextResponse.json(events);
} catch (error) {
console.error("Calendar API error:", error);
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 });
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Calendar API error:`, error);
return NextResponse.json({ error: "Failed to fetch calendar", correlationId: corrId }, { status: 500 });
}
}
+10 -59
View File
@@ -1,10 +1,9 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "@/lib/constants";
import { GeocodeResult } from "@/types";
import { GeocodingClient } from "@/lib/geocoding-client";
// Simple in-memory cache with TTL
const cache = new Map<string, { result: GeocodeResult; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Module-level singleton — cache persists across requests
const client = new GeocodingClient();
export async function GET(request: NextRequest) {
try {
@@ -16,64 +15,16 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
}
// Create cache key
const cacheKey = `${name}|${countrycodes || ""}`;
const results = await client.geocode(name, countrycodes || undefined);
// 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);
}
let response: Response;
try {
response = await fetch(url.toString(), {
headers: {
"User-Agent": NOMINATIM_USER_AGENT,
},
});
} catch (error) {
console.error("Geocoding fetch failed:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
if (!response.ok) {
// If response is not ok, return error status without caching.
return NextResponse.json({ error: "Geocoding request failed" }, { status: response.status });
}
const data = await response.json();
if (!data || data.length === 0) {
// If no results, return error status without caching.
if (results.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 ONLY on success path
cache.set(cacheKey, {
result,
timestamp: Date.now(),
});
return NextResponse.json(result);
return NextResponse.json(results[0]);
} catch (error) {
console.error("Geocode API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] Geocode API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
+29 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
@@ -5,6 +6,31 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate body shape
if (!body || !Array.isArray(body.svcReqL) || body.svcReqL.length === 0) {
return NextResponse.json({ error: "Invalid HAFAS request body" }, { status: 400 });
}
const svcReq = body.svcReqL[0];
const allowedMethods = ["TripSearch", "LocMatch"];
if (
!svcReq ||
typeof svcReq !== "object" ||
typeof svcReq.meth !== "string" ||
!allowedMethods.includes(svcReq.meth)
) {
return NextResponse.json(
{ error: `Invalid HAFAS method. Allowed: ${allowedMethods.join(", ")}` },
{ status: 400 },
);
}
// Cap TripSearch results at 10
if (svcReq.meth === "TripSearch" && svcReq.req?.numF > 10) {
svcReq.req.numF = 10;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
@@ -30,7 +56,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
}
console.error("HAFAS API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
const corrId = randomUUID().slice(0, 8);
console.error(`[${corrId}] HAFAS API error:`, error);
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
}
}
+23 -7
View File
@@ -1,18 +1,34 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { useCalendar } from "@/hooks/useCalendar";
import { useEventsStore } from "@/hooks/useEventsStore";
import UrlTab from "./UrlTab";
import FileTab from "./FileTab";
type CalendarPanelProps = {
onLoadCalendar: (url: string | File) => Promise<void>;
loading: boolean;
error: string | null;
className?: string;
};
const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading, error, className = "" }) => {
const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
const [activeTab, setActiveTab] = useState<"url" | "file">("url");
const { events: calendarEvents, loading, error, fetchCalendarFromUrl, parseCalendarFromFile } = useCalendar();
const { mergeEvents } = useEventsStore();
// Merge calendar events into global store whenever they change
useEffect(() => {
if (calendarEvents.length > 0) {
mergeEvents(calendarEvents);
}
}, [calendarEvents, mergeEvents]);
const handleLoadCalendar = async (urlOrFile: string | File) => {
if (typeof urlOrFile === "string") {
await fetchCalendarFromUrl(urlOrFile);
} else {
await parseCalendarFromFile(urlOrFile);
}
};
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
@@ -41,9 +57,9 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading,
</nav>
</div>
{activeTab === "url" ? (
<UrlTab onLoadCalendar={(url) => onLoadCalendar(url)} loading={loading} error={error} />
<UrlTab onLoadCalendar={(url) => handleLoadCalendar(url)} loading={loading} error={error} />
) : (
<FileTab onLoadCalendar={(file) => onLoadCalendar(file)} loading={loading} error={error} />
<FileTab onLoadCalendar={(file) => handleLoadCalendar(file)} loading={loading} error={error} />
)}
</div>
</div>
+16 -8
View File
@@ -14,6 +14,20 @@ type CalendarViewProps = {
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
const eventsByDate = React.useMemo(() => {
const map = new Map<string, Event[]>();
for (const event of events) {
const key = new Date(event.eventTime).toISOString().slice(0, 10);
const existing = map.get(key);
if (existing) {
existing.push(event);
} else {
map.set(key, [event]);
}
}
return map;
}, [events]);
const renderHeader = () => {
return (
<div className="flex items-center justify-between mb-4">
@@ -60,14 +74,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
const cells: React.ReactNode[] = [];
days.forEach((day, index) => {
const dayEvents = events.filter((event) => {
const eventDate = new Date(event.eventTime);
return (
eventDate.getDate() === day.getDate() &&
eventDate.getMonth() === day.getMonth() &&
eventDate.getFullYear() === day.getFullYear()
);
});
const dayKey = day.toISOString().slice(0, 10);
const dayEvents = eventsByDate.get(dayKey) ?? [];
const isCurrentMonth = isSameMonth(day, monthStart);
const isTodayDate = isToday(day);
+4 -9
View File
@@ -2,19 +2,18 @@
import React from "react";
import { format } from "date-fns";
import { Event, TripDataEntry } from "@/types";
import { Event, Station } from "@/types";
import EventCard from "@/app/event/EventCard";
type DayEventsProps = {
events: Event[];
date: Date;
tripData?: Record<string, TripDataEntry>; // Map of event id to trip data
originStation: Station | null;
className?: string;
};
const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, className = "" }) => {
const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, className = "" }) => {
const filteredEvents = events.filter((event) => {
// Direct Date comparison instead of parseISO
const eventDate = new Date(event.eventTime);
return (
eventDate.getDate() === date.getDate() &&
@@ -36,11 +35,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, clas
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
<div className="space-y-3">
{filteredEvents.map((event) => (
<EventCard
key={event.id}
event={event}
tripData={tripData[event.id] || { journeys: [], destName: "", demo: false, loading: false }}
/>
<EventCard key={event.id} event={event} originStation={originStation} />
))}
</div>
</div>
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import CalendarView from "../CalendarView";
describe("CalendarView", () => {
it("renders month header", () => {
render(
<CalendarView
events={[]}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-15")}
/>,
);
expect(screen.getByText("June 2025")).toBeInTheDocument();
});
it("renders events on the correct day", () => {
const event = {
id: "cal-1",
title: "Sprint Planning",
destination: "Vienna",
eventTime: new Date("2025-06-15T10:00:00"),
source: "calendar" as const,
};
render(
<CalendarView
events={[event]}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-15")}
/>,
);
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
});
it("shows overflow indicator when more than 3 events on a day", () => {
const events = [
{ id: "1", title: "Event 1", destination: "A", eventTime: new Date("2025-06-10T09:00:00"), source: "manual" as const },
{ id: "2", title: "Event 2", destination: "B", eventTime: new Date("2025-06-10T10:00:00"), source: "manual" as const },
{ id: "3", title: "Event 3", destination: "C", eventTime: new Date("2025-06-10T11:00:00"), source: "manual" as const },
{ id: "4", title: "Event 4", destination: "D", eventTime: new Date("2025-06-10T12:00:00"), source: "manual" as const },
];
render(
<CalendarView
events={events}
onDateSelect={() => {}}
selectedDate={new Date("2025-06-10")}
/>,
);
expect(screen.getByText("+1 more")).toBeInTheDocument();
});
});
+11 -32
View File
@@ -1,42 +1,17 @@
"use client";
import React from "react";
import { Event } from "@/types";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents";
import CalendarPanel from "./CalendarPanel";
export default function CalendarPage() {
// Mock events for demonstration
const events: Event[] = [
{
id: "1",
title: "Meeting with team",
destination: "Berlin",
eventTime: new Date(new Date().setDate(new Date().getDate() + 1)), // Tomorrow
source: "manual",
},
{
id: "2",
title: "Train trip to Munich",
destination: "Munich",
eventTime: new Date(new Date().setDate(new Date().getDate() + 2)), // Day after tomorrow
source: "manual",
},
{
id: "3",
title: "Conference in Vienna",
destination: "Vienna",
eventTime: new Date(new Date().setDate(new Date().getDate() + 3)), // In 3 days
source: "calendar",
},
];
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
const handleDateSelect = (date: Date) => {
setSelectedDate(date);
};
return (
<div className="max-w-6xl mx-auto p-4">
<div className="mb-6">
@@ -44,12 +19,16 @@ export default function CalendarPage() {
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
</div>
<div className="mb-6">
<CalendarPanel />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<CalendarView events={events} onDateSelect={handleDateSelect} selectedDate={selectedDate} />
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
</div>
<div>
<DayEvents events={events} date={selectedDate} />
<DayEvents events={events} date={selectedDate} originStation={originStation} />
</div>
</div>
</div>
+39 -17
View File
@@ -1,27 +1,47 @@
'use client';
import React from 'react';
import { Event, TripDataEntry } from '@/types';
import React, { useState, useCallback } from 'react';
import { Event, Station } from '@/types';
import TrainSection from './TrainSection';
import BikeSection from './BikeSection';
import LeaveByBadge from './LeaveByBadge';
import { calculateCountdown } from '@/lib/countdown-utils';
import { useDestinationStation } from '@/hooks/useDestinationStation';
import { useGeocode } from '@/hooks/useGeocode';
import { useJourneys } from '@/hooks/useJourneys';
import { useBikeRoute } from '@/hooks/useBikeRoute';
import { useGeolocation } from '@/hooks/useGeolocation';
type EventCardProps = {
event: Event;
tripData: TripDataEntry;
originStation: Station | null;
className?: string;
};
const EventCard: React.FC<EventCardProps> = ({
event,
tripData,
className = '',
}) => {
const EventCard: React.FC<EventCardProps> = ({ event, originStation, className = '' }) => {
const [refreshKey, setRefreshKey] = useState(0);
const handleRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const { location } = useGeolocation();
const { station: destStation } = useDestinationStation(event.destination);
const { coords: destCoords } = useGeocode(event.destination);
const { journeys, loading, error } = useJourneys(
originStation?.extId ?? null,
destStation?.extId ?? null,
event.eventTime,
refreshKey,
);
const { bikeRoute, loading: bikeLoading, error: bikeError } = useBikeRoute(
location?.coords.latitude,
location?.coords.longitude,
destCoords?.lat,
destCoords?.lng,
);
return (
<div className={
`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`
}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${className}`}>
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between">
<div className="flex-1">
@@ -35,15 +55,17 @@ const EventCard: React.FC<EventCardProps> = ({
</div>
<div className="p-4">
<TrainSection
journeys={tripData.journeys}
journeys={journeys}
eventTime={event.eventTime}
destName={event.destination}
loading={tripData.loading}
destName={destStation?.name ?? event.destination}
loading={loading}
error={error}
onRefresh={handleRefresh}
/>
<BikeSection
bikeRoute={tripData.bikeRoute}
bikeLoading={tripData.bikeLoading || false}
bikeError={tripData.bikeError}
bikeRoute={bikeRoute}
bikeLoading={bikeLoading}
bikeError={bikeError}
/>
</div>
</div>
+4
View File
@@ -12,6 +12,7 @@ type TrainSectionProps = {
eventTime: Date;
destName: string;
loading: boolean;
error?: string | null;
onRefresh?: () => void;
className?: string;
};
@@ -21,6 +22,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
eventTime,
destName,
loading,
error,
onRefresh,
className = "",
}) => {
@@ -46,6 +48,8 @@ const TrainSection: React.FC<TrainSectionProps> = ({
<div className="p-8 text-center">
<LoadingSpinner size="lg" />
</div>
) : error ? (
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div>
) : (
<JourneyList journeys={journeys} eventTime={eventTime} />
)}
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import EventCard from "../EventCard";
// Mock all hooks that EventCard depends on
vi.mock("@/hooks/useGeolocation", () => ({
useGeolocation: () => ({
location: null,
status: "pending",
requestLocation: () => {},
}),
}));
vi.mock("@/hooks/useDestinationStation", () => ({
useDestinationStation: () => ({
station: { name: "Wien Hbf", extId: "0WB0F0001500" },
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useGeocode", () => ({
useGeocode: () => ({
coords: null,
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useJourneys", () => ({
useJourneys: () => ({
journeys: [],
loading: false,
error: null,
}),
}));
vi.mock("@/hooks/useBikeRoute", () => ({
useBikeRoute: () => ({
bikeRoute: null,
loading: false,
error: null,
}),
}));
vi.mock("@/lib/countdown-utils", () => ({
calculateCountdown: () => ({
label: "No deadline set",
color: "text-gray-400",
urgent: false,
}),
}));
describe("EventCard", () => {
it("renders event title and destination", () => {
const mockEvent = {
id: "test-1",
title: "Team Meeting",
destination: "Wien Hbf",
eventTime: new Date("2025-12-01T14:00:00"),
source: "manual" as const,
};
const mockStation = { name: "Graz Hbf", extId: "0WB0F0000600" };
render(<EventCard event={mockEvent} originStation={mockStation} />);
expect(screen.getByText("Team Meeting")).toBeInTheDocument();
expect(screen.getByText("Wien Hbf")).toBeInTheDocument();
});
});
+2 -2
View File
@@ -8,8 +8,8 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: Arial, Helvetica, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
@media (prefers-color-scheme: dark) {
+16 -13
View File
@@ -1,16 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { EventsProvider } from "@/hooks/useEventsStore";
import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
import Header from "@/app/layout/Header";
import Navbar from "@/app/layout/Navbar";
import ReminderEngine from "@/app/layout/ReminderEngine";
export const metadata: Metadata = {
title: "TimeToLeave",
@@ -23,8 +17,17 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<ReminderSettingsProvider>
<EventsProvider>
<ReminderEngine />
<Header />
<div className="flex-1">{children}</div>
<Navbar />
</EventsProvider>
</ReminderSettingsProvider>
</body>
</html>
);
}
+33
View File
@@ -3,8 +3,10 @@
import React, { useState } from "react";
import Button from "@/app/ui/Button";
import AddEventModal from "@/app/add-event/AddEventModal";
import ReminderSettingsPanel from "@/app/ui/ReminderSettingsPanel";
import { useServerHealth } from "@/hooks/useServerHealth";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useTheme } from "@/hooks/useTheme";
type HeaderProps = {
className?: string;
@@ -12,8 +14,10 @@ type HeaderProps = {
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
const [showAddEventModal, setShowAddEventModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const { status } = useServerHealth();
const { events } = useEventsStore();
const { dark, toggle } = useTheme();
return (
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
@@ -39,10 +43,39 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
Add Event
</Button>
<button
onClick={toggle}
aria-label="Toggle dark mode"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
{dark ? "☀️" : "🌙"}
</button>
<button
onClick={() => setShowSettingsModal(true)}
aria-label="Settings"
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
</button>
</div>
</div>
</div>
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
{showSettingsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-full max-w-sm relative">
<button
onClick={() => setShowSettingsModal(false)}
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg"
aria-label="Close settings"
>
</button>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Settings</h2>
<ReminderSettingsPanel />
</div>
</div>
)}
</header>
);
};
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useReminder } from "@/hooks/useReminder";
export default function ReminderEngine() {
useReminder();
return null;
}
+24 -45
View File
@@ -1,52 +1,31 @@
import Image from "next/image";
"use client";
import { useEventsStore } from "@/hooks/useEventsStore";
import { useOriginStation } from "@/hooks/useOriginStation";
import EventCard from "@/app/event/EventCard";
export default function Home() {
const { events } = useEventsStore();
const { station: originStation } = useOriginStation();
const upcoming = events
.filter((e) => e.eventTime >= new Date())
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<main className="max-w-3xl mx-auto w-full px-4 py-6">
{upcoming.length === 0 ? (
<div className="text-center py-16 text-gray-500 dark:text-gray-400">
<p className="text-lg font-medium">No upcoming events</p>
<p className="text-sm mt-1">Use &quot;Add Event&quot; to add one, or import from the Calendar.</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-39.5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/8 px-5 transition-colors hover:border-transparent hover:bg-black/4 dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-39.5"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
) : (
<div className="space-y-4">
{upcoming.map((event) => (
<EventCard key={event.id} event={event} originStation={originStation} />
))}
</div>
</main>
</div>
)}
</main>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import React from "react";
import { useReminderSettings } from "@/hooks/useReminderSettings";
type ReminderSettingsPanelProps = {
className?: string;
};
export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings();
const permission =
typeof window !== "undefined" && "Notification" in window
? Notification.permission
: "unavailable";
const statusLabel =
permission === "granted"
? "Notifications enabled"
: permission === "denied"
? "Notifications blocked — check browser settings"
: "Notifications not yet requested";
return (
<div className={`space-y-4 ${className}`}>
{/* Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Leave reminders
</span>
<button
role="switch"
aria-checked={enabled}
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Buffer minutes */}
{enabled && (
<div className="space-y-2">
<label
htmlFor="buffer-minutes"
className="text-sm font-medium text-gray-700 dark:text-gray-200"
>
Remind me{" "}
<span className="text-gray-500 dark:text-gray-400">
(minutes before event)
</span>
</label>
<div className="flex items-center gap-3">
<input
id="buffer-minutes"
type="range"
min={1}
max={120}
step={1}
value={bufferMinutes}
onChange={(e) => setBufferMinutes(Number(e.target.value))}
className="flex-1 accent-blue-600"
/>
<output
htmlFor="buffer-minutes"
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-gray-200"
>
{bufferMinutes}
</output>
</div>
</div>
)}
{/* Permission status */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
{permission !== "granted" && permission !== "denied" && (
<button
onClick={() => Notification.requestPermission()}
className="mt-2 text-xs text-blue-600 hover:underline"
>
Request permission now
</button>
)}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useBikeRoute } from "../useBikeRoute";
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useBikeRoute", () => {
it("does nothing when coordinates are missing", async () => {
const { result } = renderHook(() => useBikeRoute(undefined, undefined, 48.21, 16.38));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.bikeRoute).toBeNull();
expect(result.current.error).toBeNull();
});
it("returns bike route when API succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
distance: 1500,
duration: 300,
steps: [
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
],
}),
} as Response);
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.bikeRoute).not.toBeNull();
expect(result.current.bikeRoute?.distance).toBe(1500);
expect(result.current.bikeRoute?.duration).toBe(300);
expect(result.current.error).toBeNull();
});
it("sets error when API fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.bikeRoute).toBeNull();
});
it("sets error when fetch throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() =>
useBikeRoute(48.2082, 16.3738, 48.21, 16.38),
);
await waitFor(() => expect(result.current.error).toBe("Network error"));
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useJourneys } from "../useJourneys";
// HAFAS response shape that parseHafasJourneys can process
function makeHafasSuccessData() {
return {
svcResL: [
{
res: {
outConL: [
{
ctxRecon: "journey-1",
secL: [
{
dep: { dTimeS: "20250101120000", dTimeR: "20250101120000" },
prod: { name: "Railjet 123" },
},
{
arr: { aTimeS: "20250101130000", aTimeR: "20250101130000" },
prod: { name: "Railjet 123" },
},
],
},
],
},
},
],
};
}
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useJourneys", () => {
it("does nothing when station IDs are missing", async () => {
const { result } = renderHook(() => useJourneys(null, "0WB0F0001500", new Date("2025-01-01T12:00:00")));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.journeys).toHaveLength(0);
expect(result.current.error).toBeNull();
});
it("returns journeys when API succeeds", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => makeHafasSuccessData(),
} as Response);
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
// Briefly enters loading
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.journeys).toHaveLength(1);
expect(result.current.journeys[0].id).toBe("journey-1");
expect(result.current.error).toBeNull();
});
it("sets error when API fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 500,
} as Response);
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(result.current.journeys).toHaveLength(0);
});
it("sets error when fetch throws", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
const { result } = renderHook(() =>
useJourneys("0WB0F0000600", "0WB0F0001500", new Date("2025-01-01T12:00:00")),
);
await waitFor(() => expect(result.current.error).toBe("Network error"));
});
});
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useReminder } from "../useReminder";
import { EventsProvider } from "../useEventsStore";
import { ReminderSettingsProvider } from "../useReminderSettings";
import type { Event } from "@/types";
import React from "react";
// ── Notification mock ──
const mockInstances: unknown[] = [];
class MockNotification {
static permission: "granted" | "denied" | "default" = "granted";
static requestPermission = vi.fn(() => Promise.resolve(MockNotification.permission));
static instances = mockInstances;
constructor(
public title: string,
public options?: NotificationOptions,
) {
mockInstances.push(this);
}
}
// ── Helpers ──
function seedEvents(events: Event[]) {
localStorage.setItem("ttl_events", JSON.stringify(events));
}
function eventInMinutes(offsetMinutes: number, id = "evt-1"): Event {
return {
id,
title: "Team Standup",
destination: "Graz Hbf",
eventTime: new Date(Date.now() + offsetMinutes * 60_000),
source: "manual",
};
}
function wrapper({ children }: { children: React.ReactNode }) {
return (
<ReminderSettingsProvider>
<EventsProvider>{children}</EventsProvider>
</ReminderSettingsProvider>
);
}
// ── Tests ──
describe("useReminder", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useFakeTimers();
MockNotification.permission = "granted";
mockInstances.length = 0;
localStorage.clear();
Object.defineProperty(window, "Notification", {
value: MockNotification,
writable: true,
});
});
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
});
it("fires a notification when event is within reminder window", () => {
// Event in 10 min, buffer 15 min → reminder already passed
seedEvents([eventInMinutes(10)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(1);
const notif = mockInstances[0] as MockNotification;
expect(notif.title).toBe("Time to leave!");
expect(notif.options?.body).toContain("Team Standup");
});
it("does not fire when event is too far away", () => {
// Event in 120 min, buffer 15 min → not yet time
seedEvents([eventInMinutes(120)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(0);
});
it("does not fire for past events", () => {
seedEvents([
{
id: "evt-past",
title: "Old Meeting",
destination: "Graz Hbf",
eventTime: new Date(Date.now() - 60_000),
source: "manual",
},
]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(0);
});
it("does not fire when reminder is disabled", () => {
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 15, enabled: false }));
seedEvents([eventInMinutes(10)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(0);
});
it("fires at most once per event across multiple polls", () => {
seedEvents([eventInMinutes(10)]);
renderHook(() => useReminder(), { wrapper });
// Already fired once on mount + initial check
const firstCount = mockInstances.length;
// Advance multiple intervals
act(() => vi.advanceTimersByTime(30_000));
act(() => vi.advanceTimersByTime(30_000));
act(() => vi.advanceTimersByTime(30_000));
expect(mockInstances.length).toBe(firstCount);
});
it("requests permission if not yet granted and event is due", () => {
MockNotification.permission = "default";
seedEvents([eventInMinutes(10)]);
renderHook(() => useReminder(), { wrapper });
expect(MockNotification.requestPermission).toHaveBeenCalled();
});
it("does nothing when Notification API is unavailable", () => {
Object.defineProperty(window, "Notification", {
value: undefined,
writable: true,
});
seedEvents([eventInMinutes(10)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(0);
});
it("respects custom buffer minutes", () => {
// Buffer = 30 min, event in 20 min → reminder passed
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 30, enabled: true }));
seedEvents([eventInMinutes(20)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(1);
});
it("does not fire when buffer is larger than time to event", () => {
// Buffer = 60 min, event in 120 min → reminder at now+60, not yet
localStorage.setItem("ttl_reminder_settings", JSON.stringify({ bufferMinutes: 60, enabled: true }));
seedEvents([eventInMinutes(120)]);
renderHook(() => useReminder(), { wrapper });
expect(mockInstances.length).toBe(0);
});
});
+3 -7
View File
@@ -23,13 +23,9 @@ export function useBikeRoute(
setError(null);
try {
const url = new URL("/api/bike-route", window.location.href);
url.searchParams.set("fromLat", String(fromLat));
url.searchParams.set("fromLng", String(fromLng));
url.searchParams.set("toLat", String(toLat));
url.searchParams.set("toLng", String(toLng));
const response = await fetch(url.toString());
const response = await fetch(
`/api/bike-route?fromLat=${fromLat}&fromLng=${fromLng}&toLat=${toLat}&toLng=${toLng}`,
);
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
+72
View File
@@ -0,0 +1,72 @@
import { useState, useEffect } from "react";
import type { Station } from "@/types";
interface HafasLocation {
type: string;
name: string;
extId: string;
}
export function useDestinationStation(destination: string) {
const [station, setStation] = useState<Station | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
let abortController: AbortController | null = null;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
abortController = new AbortController();
try {
const body = {
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { name: destination, type: "S" }, maxLoc: 1 } },
},
],
};
const response = await fetch("/api/hafas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: abortController.signal,
});
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
throw new Error(errBody.error ?? `Station lookup failed with status ${response.status}`);
}
const data = await response.json();
const locL: HafasLocation[] = data?.svcResL?.[0]?.res?.match?.locL ?? [];
const stations = locL.filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
if (!isMounted) return;
setStation(stations[0] ?? null);
setLoading(false);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (isMounted) {
setError(err instanceof Error ? err.message : "Station lookup failed");
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
abortController?.abort();
};
}, [destination]);
return { station, loading, error };
}
+51 -21
View File
@@ -1,5 +1,21 @@
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import type { Event } from "@/types";
"use client";
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import type { Event, CalendarEvent } from "@/types";
const STORAGE_KEY = "ttl_events";
function loadFromStorage(): Event[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime as string) })) as Event[];
} catch {
return [];
}
}
interface EventsContextType {
events: Event[];
@@ -8,46 +24,60 @@ interface EventsContextType {
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: Event[]) => void;
mergeEvents: (events: CalendarEvent[]) => void;
}
const EventsContext = createContext<EventsContextType | undefined>(undefined);
export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEvents] = useState<Event[]>([]);
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
}, [events]);
const addEvent = useCallback((event: Event) => {
setEvents((prev) => {
const exists = prev.some((e) => e.id === event.id);
if (exists) {
return prev;
}
setEventsState((prev) => {
if (prev.some((e) => e.id === event.id)) return prev;
return [...prev, event];
});
}, []);
const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
setEvents((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
setEventsState((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
}, []);
const removeEvent = useCallback((id: string) => {
setEvents((prev) => prev.filter((event) => event.id !== id));
setEventsState((prev) => prev.filter((event) => event.id !== id));
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
setEventsState([]);
}, []);
const setEvents = useCallback((evts: Event[]) => {
setEventsState(evts);
}, []);
const mergeEvents = useCallback((calendarEvents: CalendarEvent[]) => {
const converted: Event[] = calendarEvents.map((e) => ({
id: e.id,
title: e.title,
destination: e.destination,
eventTime: new Date(e.eventTime),
source: e.source,
}));
setEventsState((prev) => {
const merged = new Map<string, Event>();
prev.forEach((event) => merged.set(event.id, event));
converted.forEach((event) => merged.set(event.id, event));
return Array.from(merged.values());
});
}, []);
return (
<EventsContext.Provider
value={{
events,
addEvent,
updateEvent,
removeEvent,
clearEvents,
setEvents,
}}
>
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
{children}
</EventsContext.Provider>
);
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from "react";
export function useGeocode(destination: string) {
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!destination.trim()) return;
let isMounted = true;
let abortController: AbortController | null = null;
const timeoutId = setTimeout(async () => {
setLoading(true);
setError(null);
abortController = new AbortController();
try {
const res = await fetch(`/api/geocode?name=${encodeURIComponent(destination)}`, {
signal: abortController.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Geocoding failed with status ${res.status}`);
}
const data = await res.json();
if (isMounted) {
setCoords({ lat: data.lat, lng: data.lng });
setLoading(false);
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
if (isMounted) {
setError(err instanceof Error ? err.message : "Geocoding failed");
setLoading(false);
}
}
}, 400);
return () => {
isMounted = false;
clearTimeout(timeoutId);
abortController?.abort();
};
}, [destination]);
return { coords, loading, error };
}
+9 -62
View File
@@ -1,67 +1,14 @@
import { useState, useEffect } from "react";
import type { Journey } from "@/types";
import { parseHafasTime, hafasDateTime } from "@/lib/hafas-time";
import { parseHafasJourneys } from "@/lib/hafas-client";
import { hafasDateTime } from "@/lib/hafas-time";
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 parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// HAFAS response shape is too complex for a clean TypeScript type; use any for parsing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.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) : queryDate;
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,
};
});
}
export function useJourneys(fromStationExtId: string | null, toStationExtId: string | null, date: Date) {
export function useJourneys(
fromStationExtId: string | null,
toStationExtId: string | null,
date: Date,
refreshKey = 0,
) {
const [journeys, setJourneys] = useState<Journey[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
@@ -127,7 +74,7 @@ export function useJourneys(fromStationExtId: string | null, toStationExtId: str
return () => {
isMounted = false;
};
}, [fromStationExtId, toStationExtId, date]);
}, [fromStationExtId, toStationExtId, date, refreshKey]);
return { journeys, loading, error };
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import type { Station } from "@/types";
import { DEFAULT_STATION_NAME, DEFAULT_STATION_EXT_ID } from "@/lib/constants";
import { useGeolocation } from "./useGeolocation";
interface HafasLocation {
@@ -20,7 +21,7 @@ export function useOriginStation() {
const fetchNearestStation = async () => {
if (!location || state !== "granted") {
if (isMounted) {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
}
return;
}
@@ -71,14 +72,14 @@ export function useOriginStation() {
if (stations.length > 0) {
setStation(stations[0]);
} else {
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
}
setLoading(false);
} catch (err: unknown) {
if (isMounted) {
const message = err instanceof Error ? err.message : "Failed to find nearest station";
setError(message);
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
setLoading(false);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useRef, useCallback } from "react";
import { useEventsStore } from "./useEventsStore";
import { useReminderSettings } from "./useReminderSettings";
const POLLS_MS = 30_000; // 30s — matches useClock cadence
export function useReminder() {
const { events } = useEventsStore();
const { bufferMinutes, enabled } = useReminderSettings();
const firedRef = useRef(new Set<string>());
const check = useCallback(() => {
if (!enabled) return;
if (typeof window === "undefined") return;
if (typeof Notification === "undefined") return;
const now = new Date();
const upcoming = events
.filter((e) => e.eventTime > now)
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
for (const event of upcoming) {
const reminderTime = new Date(event.eventTime.getTime() - bufferMinutes * 60_000);
if (now >= reminderTime && !firedRef.current.has(event.id)) {
firedRef.current.add(event.id);
if (Notification.permission === "granted") {
new Notification("Time to leave!", {
body: `${event.title} starts in ${bufferMinutes} minutes`,
tag: `reminder-${event.id}`,
});
} else if (Notification.permission !== "denied") {
Notification.requestPermission().then((perm) => {
if (perm === "granted") {
new Notification("Time to leave!", {
body: `${event.title} starts in ${bufferMinutes} minutes`,
tag: `reminder-${event.id}`,
});
}
});
}
}
}
// Clear entries for events that no longer exist
const ids = new Set(upcoming.map((e) => e.id));
for (const id of firedRef.current) {
if (!ids.has(id)) firedRef.current.delete(id);
}
}, [events, bufferMinutes, enabled]);
useEffect(() => {
check();
const id = setInterval(check, POLLS_MS);
return () => clearInterval(id);
}, [check]);
}
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import { ReminderSettings } from "@/types";
const STORAGE_KEY = "ttl_reminder_settings";
const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true };
function loadFromStorage(): ReminderSettings {
if (typeof window === "undefined") return DEFAULTS;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
return { ...DEFAULTS, ...JSON.parse(raw) };
} catch {
return DEFAULTS;
}
}
interface ReminderContextType extends ReminderSettings {
setBufferMinutes: (minutes: number) => void;
setEnabled: (enabled: boolean) => void;
}
const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
export function ReminderSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<ReminderSettings>(loadFromStorage);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}, [settings]);
const setBufferMinutes = useCallback((minutes: number) => {
setSettings((prev) => ({ ...prev, bufferMinutes: Math.max(1, Math.min(120, minutes)) }));
}, []);
const setEnabled = useCallback((enabled: boolean) => {
setSettings((prev) => ({ ...prev, enabled }));
}, []);
return (
<ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}>
{children}
</ReminderContext.Provider>
);
}
export function useReminderSettings() {
const context = useContext(ReminderContext);
if (context === undefined) {
throw new Error("useReminderSettings must be used within a ReminderSettingsProvider");
}
return context;
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useState, useCallback, useEffect } from "react";
const THEME_KEY = "ttl_theme";
function getServerTheme(): "dark" | "light" {
return "light";
}
function getClientTheme(): "dark" | "light" {
if (typeof window === "undefined") return getServerTheme();
const stored = localStorage.getItem(THEME_KEY);
if (stored) return stored as "dark" | "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function useTheme() {
const [dark, setDark] = useState(() => getClientTheme() === "dark");
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
const toggle = useCallback(() => {
setDark((prev) => {
const next = !prev;
localStorage.setItem(THEME_KEY, next ? "dark" : "light");
return next;
});
}, []);
return { dark, toggle };
}
+405
View File
@@ -0,0 +1,405 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Mock } from "vitest";
import { MemoryCache, ApiClient, ApiError, fetchWithRetry, cachedFetch, calculateBackoff, sleep } from "../api-service";
type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<unknown>>;
function mockedFetch(): FetchMock {
return vi.mocked(fetch) as FetchMock;
}
// ---------------------------------------------------------------------------
// MemoryCache Tests
// ---------------------------------------------------------------------------
describe("MemoryCache", () => {
let cache: MemoryCache<string>;
beforeEach(() => {
vi.useFakeTimers();
cache = new MemoryCache({ defaultTtlMs: 5000 });
});
afterEach(() => {
vi.useRealTimers();
});
it("stores and retrieves values", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
});
it("returns null for missing keys", () => {
expect(cache.get("missing")).toBeNull();
});
it("expires entries after TTL", () => {
cache.set("key", "value");
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBeNull();
});
it("supports per-entry TTL override", () => {
cache.set("key", "value", 10000);
vi.advanceTimersByTime(6000);
expect(cache.get("key")).toBe("value");
vi.advanceTimersByTime(5000);
expect(cache.get("key")).toBeNull();
});
it("invalidates specific keys", () => {
cache.set("key", "value");
expect(cache.invalidate("key")).toBe(true);
expect(cache.get("key")).toBeNull();
expect(cache.invalidate("key")).toBe(false);
});
it("clears all entries", () => {
cache.set("a", "1");
cache.set("b", "2");
cache.clear();
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBeNull();
});
it("evicts oldest entry when max size exceeded", () => {
const smallCache = new MemoryCache<string>({ defaultTtlMs: 60_000, maxSize: 2 });
smallCache.set("first", "1");
vi.advanceTimersByTime(100);
smallCache.set("second", "2");
vi.advanceTimersByTime(100);
smallCache.set("third", "3");
// Oldest entry should be evicted
expect(smallCache.get("first")).toBeNull();
expect(smallCache.get("second")).toBe("2");
expect(smallCache.get("third")).toBe("3");
});
it("tracks hit/miss statistics", () => {
cache.set("key", "value");
cache.get("key"); // hit
cache.get("missing"); // miss
cache.get("key"); // hit
const stats = cache.stats();
expect(stats.hits).toBe(2);
expect(stats.misses).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.6667, 2);
});
});
// ---------------------------------------------------------------------------
// calculateBackoff Tests
// ---------------------------------------------------------------------------
describe("calculateBackoff", () => {
it("doubles delay exponentially", () => {
// Attempt 0: baseDelay * 2^0 = 100
const d0 = calculateBackoff(0, 100, 10000, 0);
expect(d0).toBeGreaterThanOrEqual(100);
expect(d0).toBeLessThanOrEqual(100);
// Attempt 1: baseDelay * 2^1 = 200
const d1 = calculateBackoff(1, 100, 10000, 0);
expect(d1).toBe(200);
// Attempt 2: baseDelay * 2^2 = 400
const d2 = calculateBackoff(2, 100, 10000, 0);
expect(d2).toBe(400);
});
it("caps delay at maxDelayMs", () => {
const d = calculateBackoff(10, 100, 500, 0);
expect(d).toBe(500);
});
it("adds jitter within expected range", () => {
// With jitter=0.3 and base delay 1000, max delay 10000
// Attempt 0: 1000 + [0, 300]
const d = calculateBackoff(0, 1000, 10000, 0.3);
expect(d).toBeGreaterThanOrEqual(1000);
expect(d).toBeLessThanOrEqual(1300);
});
});
// ---------------------------------------------------------------------------
// fetchWithRetry Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("fetchWithRetry", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns response on first success", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, { maxRetries: 3 });
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("retries on HTTP 500 with backoff", async () => {
const mockFetch = mockedFetch();
mockFetch
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Server Error" })
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("retries on HTTP 429", async () => {
const mockFetch = mockedFetch();
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 429,
headers: new Headers({ "Retry-After": "0" }),
})
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("throws ApiError after exhausting retries", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 503 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 1,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
});
it("retries on network errors (TypeError)", async () => {
const mockFetch = mockedFetch();
mockFetch
.mockRejectedValueOnce(new TypeError("network failure"))
.mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.resolve({ ok: true }) });
const res = await fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
});
expect(res.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not retry on non-retryable status (404)", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 3,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("respects maxRetries limit", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({ ok: false, status: 500 });
await expect(
fetchWithRetry("https://example.com", undefined, {
maxRetries: 2,
baseDelayMs: 2,
jitter: 0,
}),
).rejects.toThrow(ApiError);
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
});
// ---------------------------------------------------------------------------
// cachedFetch Tests — use real timers with tiny delays
// ---------------------------------------------------------------------------
describe("cachedFetch", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("caches successful responses", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
const result1 = await cachedFetch(cache, "https://example.com", undefined, {});
const result2 = await cachedFetch(cache, "https://example.com", undefined, {});
expect(result1).toEqual({ data: "hello" });
expect(result2).toEqual({ data: "hello" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("bypasses cache when skipCache is true", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
await cachedFetch(cache, "https://example.com", undefined, { skipCache: true });
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses custom cache key when provided", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "hello" }),
});
const cache = new MemoryCache({ defaultTtlMs: 10_000 });
// Same cache key, different URL
await cachedFetch(cache, "https://example.com/a", undefined, { cacheKey: "shared" });
await cachedFetch(cache, "https://example.com/b", undefined, { cacheKey: "shared" });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws on non-OK response after retries", async () => {
const mockFetch = mockedFetch();
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
});
const cache = new MemoryCache();
await expect(cachedFetch(cache, "https://example.com", undefined, { maxRetries: 0 })).rejects.toThrow(ApiError);
});
});
// ---------------------------------------------------------------------------
// ApiClient Tests
// ---------------------------------------------------------------------------
describe("ApiClient", () => {
let client: ApiClient;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
client = new ApiClient({
baseUrl: "https://api.example.com",
defaultTimeoutMs: 5000,
defaultTtlMs: 30_000,
maxRetries: 2,
userAgent: "TestClient/1.0",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("builds correct headers with User-Agent", () => {
expect(client.headers).toHaveProperty("User-Agent", "TestClient/1.0");
});
it("provides cache statistics", () => {
const stats = client.cacheStats();
expect(stats.size).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it("clears cache", () => {
client.clearCache();
const stats = client.cacheStats();
expect(stats.size).toBe(0);
});
});
// ---------------------------------------------------------------------------
// ApiError Tests
// ---------------------------------------------------------------------------
describe("ApiError", () => {
it("can be constructed with status and flags", () => {
const err = new ApiError("Rate limited");
err.status = 429;
(err as ApiError).isRateLimit = true;
expect(err.message).toBe("Rate limited");
expect(err.status).toBe(429);
expect((err as ApiError).isRateLimit).toBe(true);
});
it("is an instance of Error", () => {
const err = new ApiError("Something broke");
expect(err).toBeInstanceOf(Error);
});
it("has name ApiError", () => {
const err = new ApiError("oops");
expect(err.name).toBe("ApiError");
});
});
// ---------------------------------------------------------------------------
// sleep Tests
// ---------------------------------------------------------------------------
describe("sleep", () => {
it("resolves after specified delay", async () => {
vi.useFakeTimers();
const promise = sleep(100);
expect(promise).toBeDefined();
vi.advanceTimersByTime(100);
await promise;
vi.useRealTimers();
});
});
+441
View File
@@ -0,0 +1,441 @@
// ============================================================================
// API Service Wrapper — Rate Limiting, Exponential Backoff, and Caching
// ============================================================================
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// NOTE: ApiError is a class (not interface) because it is instantiated with `new`.
export class ApiError extends Error {
status?: number;
body?: unknown;
isRateLimit?: boolean;
isRetryable?: boolean;
constructor(message: string) {
super(message);
this.name = "ApiError";
}
}
export interface RetryOptions {
/** Maximum number of retry attempts. Default: 3 */
maxRetries?: number;
/** Base delay in ms before the first retry. Default: 250 */
baseDelayMs?: number;
/** Maximum delay cap in ms. Default: 30_000 */
maxDelayMs?: number;
/** Jitter factor (0-1). Adds randomness to prevent thundering herd. Default: 0.3 */
jitter?: number;
/** HTTP status codes that should trigger a retry. Default: [429, 500, 502, 503, 504] */
retryableStatuses?: number[];
}
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
export interface CacheStats {
size: number;
hits: number;
misses: number;
hitRate: number;
}
// ---------------------------------------------------------------------------
// In-Memory Cache with TTL support and LRU eviction
// ---------------------------------------------------------------------------
export class MemoryCache<T = unknown> {
private store = new Map<string, CacheEntry<T>>();
private _defaultTtl: number;
private _maxSize: number;
private _hits = 0;
private _misses = 0;
constructor(options: { defaultTtlMs?: number; maxSize?: number } = {}) {
this._defaultTtl = options.defaultTtlMs ?? 15 * 60 * 1000; // 15 minutes
this._maxSize = options.maxSize ?? 500;
}
get(key: string): T | null {
const entry = this.store.get(key);
if (!entry) {
this._misses++;
return null;
}
// Check TTL expiration
if (Date.now() - entry.timestamp > entry.ttl) {
this.store.delete(key);
this._misses++;
return null;
}
this._hits++;
return entry.data;
}
set(key: string, data: T, ttl?: number): void {
// Evict oldest entries if over capacity
if (this.store.size >= this._maxSize && !this.store.has(key)) {
const oldestKey = this.store.keys().next().value;
if (oldestKey) this.store.delete(oldestKey);
}
this.store.set(key, {
data,
timestamp: Date.now(),
ttl: ttl ?? this._defaultTtl,
});
}
invalidate(key: string): boolean {
return this.store.delete(key);
}
clear(): void {
this.store.clear();
}
stats(): CacheStats {
const total = this._hits + this._misses;
return {
size: this.store.size,
hits: this._hits,
misses: this._misses,
hitRate: total > 0 ? this._hits / total : 0,
};
}
}
// ---------------------------------------------------------------------------
// Retry Logic with Exponential Backoff + Jitter
// ---------------------------------------------------------------------------
function calculateBackoff(attempt: number, baseDelayMs: number, maxDelayMs: number, jitter: number): number {
// Exponential backoff: baseDelay * 2^attempt
const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
// Add jitter to prevent thundering herd
const jitterRange = cappedDelay * jitter;
const jitterValue = Math.random() * jitterRange;
return cappedDelay + jitterValue;
}
function isRetryableError(error: unknown, retryableStatuses: number[]): boolean {
if (error instanceof ApiError) {
const status = error.status;
if (status !== undefined && retryableStatuses.includes(status)) {
return true;
}
// Network errors (no status code) are retryable
if (status === undefined) {
return true;
}
}
// Generic network/timeout errors
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
if (error instanceof TypeError) {
// Network failures often throw TypeError in browsers/Node
return true;
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchWithRetry(
url: string | URL | Request,
init?: RequestInit,
options: RetryOptions = {},
): Promise<Response> {
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 250;
const maxDelayMs = options.maxDelayMs ?? 30_000;
const jitter = options.jitter ?? 0.3;
const retryableStatuses = options.retryableStatuses ?? [429, 500, 502, 503, 504];
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, init);
// Check if the status is retryable
if (response.status === 429) {
// HTTP 429: Too Many Requests — respect Retry-After header
const retryAfterHeader = response.headers?.get("Retry-After");
let retryAfterMs = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
if (retryAfterHeader) {
const parsed = parseInt(retryAfterHeader, 10);
if (!isNaN(parsed)) {
retryAfterMs = Math.max(retryAfterMs, parsed * 1000);
}
}
if (attempt < maxRetries) {
lastError = new ApiError(`Rate limited (HTTP 429). Retrying in ${Math.round(retryAfterMs)}ms…`);
(lastError as ApiError).status = 429;
(lastError as ApiError).isRateLimit = true;
(lastError as ApiError).isRetryable = true;
await sleep(retryAfterMs);
continue;
}
// Exhausted retries — throw
const err = new ApiError("Rate limit exceeded after all retries (HTTP 429)");
err.status = 429;
(err as ApiError).isRateLimit = true;
(err as ApiError).isRetryable = false;
throw err;
}
if (!response.ok && retryableStatuses.includes(response.status)) {
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
if (attempt < maxRetries) {
lastError = new ApiError(`Server error (HTTP ${response.status}). Retrying in ${Math.round(delay)}ms…`);
(lastError as ApiError).status = response.status;
(lastError as ApiError).isRetryable = true;
await sleep(delay);
continue;
}
// Exhausted retries — throw
const err = new ApiError(`Server error after all retries: HTTP ${response.status}`);
err.status = response.status;
(err as ApiError).isRetryable = false;
throw err;
}
if (!response.ok) {
const err = new ApiError(`HTTP ${response.status}: ${response.statusText}`);
err.status = response.status;
err.isRetryable = false;
throw err;
}
return response;
} catch (error) {
lastError = error;
// If it's already a non-retryable ApiError, throw immediately
if (error instanceof ApiError && !(error as ApiError).isRetryable) {
throw error;
}
if (attempt < maxRetries && isRetryableError(error, retryableStatuses)) {
const delay = calculateBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
await sleep(delay);
continue;
}
throw error;
}
}
// Should not reach here, but TypeScript needs it
throw lastError ?? new ApiError("Unexpected fetch failure");
}
// ---------------------------------------------------------------------------
// Cached Fetch Wrapper
// ---------------------------------------------------------------------------
interface CachedFetchOptions extends RetryOptions {
cacheKey?: string;
ttl?: number;
skipCache?: boolean;
}
async function cachedFetch<T>(
cache: MemoryCache<unknown>,
url: string | URL | Request,
init?: RequestInit,
options: CachedFetchOptions = {},
): Promise<T> {
const key = options.cacheKey ?? String(url);
// Check cache (unless skipped)
if (!options.skipCache) {
const cached = cache.get(key);
if (cached !== null) {
return cached as T;
}
}
const response = await fetchWithRetry(url, init, options);
if (!response.ok) {
const err = new ApiError(`HTTP ${response.status}: ${response.statusText}`);
err.status = response.status;
throw err;
}
const data = (await response.json()) as T;
cache.set(key, data, options.ttl);
return data;
}
// ---------------------------------------------------------------------------
// APIClient — Composable Base Class
// ---------------------------------------------------------------------------
export interface ApiClientOptions {
baseUrl: string;
defaultTimeoutMs?: number;
defaultTtlMs?: number;
maxRetries?: number;
userAgent?: string;
headers?: Record<string, string>;
}
export class ApiClient {
public readonly baseUrl: string;
public readonly cache: MemoryCache<unknown>;
public readonly defaultTimeoutMs: number;
public readonly defaultTtlMs: number;
public readonly maxRetries: number;
public readonly userAgent?: string;
public readonly headers: Record<string, string>;
constructor(options: ApiClientOptions) {
this.baseUrl = options.baseUrl;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 12_000;
this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000;
this.maxRetries = options.maxRetries ?? 3;
this.userAgent = options.userAgent;
this.headers = { ...options.headers };
if (this.userAgent) {
this.headers["User-Agent"] = this.userAgent;
}
this.cache = new MemoryCache({ defaultTtlMs: this.defaultTtlMs });
}
/**
* Build a common headers object, merging defaults with overrides.
*/
protected buildHeaders(extra?: Record<string, string>): Record<string, string> {
return {
...this.headers,
...extra,
};
}
/**
* Build an AbortSignal that fires after the configured timeout.
*/
protected buildTimeoutSignal(timeoutMs?: number): AbortSignal {
const ms = timeoutMs ?? this.defaultTimeoutMs;
return AbortSignal.timeout(ms);
}
/**
* Perform a GET request with retry + caching.
*/
public async get<T>(
path: string,
search?: Record<string, string>,
options: {
ttl?: number;
cacheKey?: string;
skipCache?: boolean;
maxRetries?: number;
timeoutMs?: number;
headers?: Record<string, string>;
} = {},
): Promise<T> {
const url = new URL(path, this.baseUrl);
if (search) {
for (const [k, v] of Object.entries(search)) {
url.searchParams.set(k, v);
}
}
return cachedFetch<T>(
this.cache,
url.toString(),
{
headers: this.buildHeaders(options.headers),
signal: this.buildTimeoutSignal(options.timeoutMs),
},
{
cacheKey: options.cacheKey ?? url.toString(),
ttl: options.ttl,
skipCache: options.skipCache,
maxRetries: options.maxRetries ?? this.maxRetries,
},
);
}
/**
* Perform a POST request with retry logic (never cached).
*/
public async post<T>(
path: string,
body: unknown,
options: {
maxRetries?: number;
timeoutMs?: number;
headers?: Record<string, string>;
} = {},
): Promise<T> {
const url = new URL(path, this.baseUrl);
return cachedFetch<T>(
this.cache,
url.toString(),
{
method: "POST",
headers: {
...this.buildHeaders(options.headers),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: this.buildTimeoutSignal(options.timeoutMs),
},
{
skipCache: true,
maxRetries: options.maxRetries ?? this.maxRetries,
},
);
}
/**
* Get cache statistics.
*/
public cacheStats(): CacheStats {
return this.cache.stats();
}
/**
* Clear the cache.
*/
public clearCache(): void {
this.cache.clear();
}
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { fetchWithRetry, cachedFetch, calculateBackoff, sleep };
+57 -10
View File
@@ -1,5 +1,10 @@
import type { BikeRoute, BikeStep } from "@/types";
import { OSRM_URL } from "./constants";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// OSRM response types (internal, not exported)
// ---------------------------------------------------------------------------
interface OsrmStep {
name: string;
@@ -19,30 +24,58 @@ interface OsrmResponse {
routes: OsrmRoute[];
}
// ---------------------------------------------------------------------------
// Helper: Build human-readable instruction from OSRM step data
// ---------------------------------------------------------------------------
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 {
private baseUrl: string;
// ---------------------------------------------------------------------------
// BikeRoutingClient — Wraps the OSRM bike routing API
//
// OSRM routes are cached for 5 minutes. Bike routes between the same
// coordinates rarely change, and caching significantly reduces API load.
// ---------------------------------------------------------------------------
constructor(baseUrl: string = OSRM_URL) {
this.baseUrl = baseUrl;
export class BikeRoutingClient {
private client: ApiClient;
private readonly defaultTtlMs: number;
constructor(
baseUrl: string = OSRM_URL,
ttlMs: number = 5 * 60 * 1000, // 5 minutes — routes are very stable
) {
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: 10_000,
defaultTtlMs: ttlMs,
maxRetries: 3,
});
this.defaultTtlMs = ttlMs;
}
async getBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
const url = `${this.baseUrl}/route/v1/bicycle/${coords}?overview=false&steps=true`;
const path = `/route/v1/bicycle/${coords}`;
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!res.ok) throw new Error(`OSRM request failed: ${res.status}`);
const cacheKey = `osrm:bike:${coords}`;
const data: OsrmResponse = await res.json();
if (data.code !== "Ok" || !data.routes.length) return null;
const res = await this.client.get<OsrmResponse>(
path,
{ overview: "false", steps: "true" },
{
cacheKey,
ttl: this.defaultTtlMs,
},
);
const route = data.routes[0];
if (res.code !== "Ok" || !res.routes.length) return null;
const route = res.routes[0];
const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
name: s.name,
distance: s.distance,
@@ -56,4 +89,18 @@ export class BikeRoutingClient {
steps,
};
}
/**
* Expose cache statistics for debugging/monitoring.
*/
public cacheStats() {
return this.client.cacheStats();
}
/**
* Clear the cache (useful for testing or forced refresh).
*/
public clearCache() {
this.client.clearCache();
}
}
+3
View File
@@ -12,3 +12,6 @@ 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";
export const DEFAULT_STATION_NAME = process.env.NEXT_PUBLIC_DEFAULT_STATION_NAME ?? "Graz Hbf";
export const DEFAULT_STATION_EXT_ID = process.env.NEXT_PUBLIC_DEFAULT_STATION_EXT_ID ?? "0WB0F0000600";
+79 -43
View File
@@ -1,5 +1,10 @@
import type { GeocodeResult } from "@/types";
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "./constants";
import { ApiClient } from "./api-service";
// ---------------------------------------------------------------------------
// Nominatim response types (internal, not exported)
// ---------------------------------------------------------------------------
interface NominatimResult {
lat: string;
@@ -7,67 +12,98 @@ interface NominatimResult {
display_name: string;
}
export class GeocodingClient {
private baseUrl: string;
private userAgent: string;
private cache = new Map<string, GeocodeResult[]>();
type NominatimSearchResponse = NominatimResult[];
type NominatimReverseResponse = NominatimResult;
constructor(baseUrl: string = NOMINATIM_URL, userAgent: string = NOMINATIM_USER_AGENT) {
this.baseUrl = baseUrl;
this.userAgent = userAgent;
// ---------------------------------------------------------------------------
// GeocodingClient — Wraps the Nominatim geocoding API
//
// Nominatim has strict usage limits (1 request/sec, require User-Agent).
// Results are cached for 15 minutes by default since geographic data changes
// very slowly.
// ---------------------------------------------------------------------------
export class GeocodingClient {
private client: ApiClient;
private readonly defaultTtlMs: number;
constructor(
baseUrl: string = NOMINATIM_URL,
userAgent: string = NOMINATIM_USER_AGENT,
ttlMs: number = 15 * 60 * 1000, // 15 minutes — geographic data is very stable
) {
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: 10_000,
defaultTtlMs: ttlMs,
maxRetries: 3,
userAgent,
});
this.defaultTtlMs = ttlMs;
}
async geocode(query: string, countrycodes?: string): Promise<GeocodeResult[]> {
const cacheKey = `${query}|${countrycodes ?? ""}`;
const cached = this.cache.get(cacheKey);
if (cached) return cached;
const params: Record<string, string> = {
q: query,
format: "json",
limit: "5",
};
const params = new URLSearchParams({ q: query, format: "json", limit: "5" });
if (countrycodes) params.set("countrycodes", countrycodes);
if (countrycodes) {
params.countrycodes = countrycodes;
}
const res = await fetch(`${this.baseUrl}/search?${params}`, {
headers: {
"User-Agent": this.userAgent,
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
// Build a stable cache key that includes all query parameters
const cacheKey = `nominatim:search:${query}|${countrycodes ?? ""}`;
const res = await this.client.get<NominatimSearchResponse>("/search", params, {
cacheKey,
ttl: this.defaultTtlMs,
});
if (!res.ok) throw new Error(`Nominatim geocode failed: ${res.status}`);
const data: NominatimResult[] = await res.json();
const results = data.map((r) => ({
return res.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, lng: number): Promise<GeocodeResult | null> {
const params = new URLSearchParams({
const params: Record<string, string> = {
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 {
lat: parseFloat(data.lat),
lng: parseFloat(data.lon),
display_name: data.display_name,
};
const cacheKey = `nominatim:reverse:${lat}:${lng}`;
try {
const res = await this.client.get<NominatimReverseResponse>("/reverse", params, {
cacheKey,
ttl: this.defaultTtlMs,
});
return {
lat: parseFloat(res.lat),
lng: parseFloat(res.lon),
display_name: res.display_name,
};
} catch {
return null;
}
}
/**
* Expose cache statistics for debugging/monitoring.
*/
public cacheStats() {
return this.client.cacheStats();
}
/**
* Clear the cache (useful for testing or forced refresh).
*/
public clearCache() {
this.client.clearCache();
}
}
+102 -62
View File
@@ -1,6 +1,11 @@
import type { Journey, Station } from "@/types";
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
import { parseHafasTime, hafasDateTime } from "./hafas-time";
import { ApiClient } from "./api-service";
// -------------------------------------------------------------
// HAFAS response types (internal, not exported)
// -------------------------------------------------------------
interface HafasLocation {
type: "S" | "A" | "P";
@@ -8,7 +13,17 @@ interface HafasLocation {
extId: string;
}
interface HafasJourney {
interface HafasLocationResponse {
svcResL?: Array<{
res?: {
match?: {
locL?: HafasLocation[];
};
};
}>;
}
export interface HafasJourney {
ctxRecon?: string;
secL?: Array<{
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
@@ -23,13 +38,79 @@ interface HafasJourney {
}>;
}
interface HafasTripResponse {
svcResL?: Array<{
res?: {
outConL?: HafasJourney[];
};
}>;
}
// -------------------------------------------------------------
// Shared HAFAS journey parser
// -------------------------------------------------------------
/**
* Parse a raw HAFAS response into Journey[].
* Works with both typed and untyped (raw JSON) responses.
*/
export function parseHafasJourneys(json: unknown, hafasDate: string, queryDate: Date): Journey[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- HAFAS response shape is undocumented and deeply nested
const data = json as any;
const outConL: HafasJourney[] = data?.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) : queryDate;
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,
};
});
}
// -------------------------------------------------------------
// HafasClient — Wraps the ÖBB HAFAS journey-planning API
// -------------------------------------------------------------
export class HafasClient {
private baseUrl: string;
private timeoutMs: number;
private client: ApiClient;
constructor(baseUrl: string = HAFAS_URL, timeoutMs: number = HAFAS_TIMEOUT_MS) {
this.baseUrl = baseUrl;
this.timeoutMs = timeoutMs;
this.client = new ApiClient({
baseUrl,
defaultTimeoutMs: timeoutMs,
defaultTtlMs: 0, // No caching for HAFAS — journeys are live data
maxRetries: 3,
});
}
async searchStation(query: string): Promise<Station[]> {
@@ -42,18 +123,15 @@ export class HafasClient {
],
};
const res = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs),
});
const res = await this.client.post<HafasLocationResponse>("", body);
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 }));
const match = res?.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[]> {
@@ -74,53 +152,15 @@ export class HafasClient {
],
};
const res = await fetch(this.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs),
});
const res = await this.client.post<HafasTripResponse>("", body);
if (!res.ok) throw new Error(`HAFAS trip search failed: ${res.status}`);
return parseHafasJourneys(res, hafasDate, date);
}
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,
};
});
/**
* Expose cache statistics (always empty for HAFAS, but useful for debugging).
*/
public cacheStats() {
return this.client.cacheStats();
}
}
+1 -1
View File
@@ -1,10 +1,10 @@
// Main library exports for TimeToLeave
export * from "./api-service";
export * from "./hafas-client";
export * from "./geocoding-client";
export * from "./bike-routing-client";
export * from "./calendar-utils";
export * from "./status-utils";
export * from "./live-status-utils";
export * from "./countdown-utils";
export * from "./formatting";
export * from "./constants";
-23
View File
@@ -1,23 +0,0 @@
// Live status utilities for TimeToLeave
import type { LiveStatus } from "@/types";
export class LiveStatusUtils {
static async getLiveStatus(_journeyId: string): Promise<LiveStatus> {
// This would make an actual API call to get live status
// For now, we'll return mock data
return null;
}
static getStatusMessage(status: LiveStatus): string {
switch (status) {
case true:
return "On time";
case false:
return "Delayed or cancelled";
case null:
return "No live data";
default:
return "Unknown status";
}
}
}
+1 -1
View File
@@ -1 +1 @@
import "@testing-library/jest-dom";
import "@testing-library/jest-dom/vitest";
+7
View File
@@ -88,3 +88,10 @@ export type ServerStatus = null | true | false;
export type LiveStatus = null | true | false;
export type LocState = "pending" | "granted" | "denied";
export type CalStatus = null | "loading" | "ok" | "error";
// ── Reminder Settings ───────────────────────────────────────
export interface ReminderSettings {
bufferMinutes: number;
enabled: boolean;
}
View File
+5 -3
View File
@@ -1,8 +1,8 @@
import { defineConfig } from "vitest/config";
import type {} from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
const config = {
plugins: [react()],
test: {
environment: "jsdom",
@@ -14,4 +14,6 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
});
};
export default config;