Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e6f5c7fd | |||
| c0f34b9e2a | |||
| ce2a900545 | |||
| 7abfddd607 | |||
| 28c25c32ab | |||
| 77b8c6db98 | |||
| c869d4958e | |||
| a7fcbd811e | |||
| 7c345785a7 |
@@ -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
|
||||
Generated
+3
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 10 — Fix 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 | 1–3 | ~45 min |
|
||||
| 2 — Deduplicate Code | 4–7 | ~2 hours |
|
||||
| 3 — Performance & UX | 8–11 | ~1.5 hours |
|
||||
| 4 — Monitoring & Testing | 12–14 | ~1 hour |
|
||||
| **Total** | **14** | **~5 hours** |
|
||||
|
||||
+44
@@ -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"]
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["node-ical"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+65
-153
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useReminder } from "@/hooks/useReminder";
|
||||
|
||||
export default function ReminderEngine() {
|
||||
useReminder();
|
||||
return null;
|
||||
}
|
||||
+24
-45
@@ -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 "Add Event" 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+5
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user