mirror of
http://100.103.83.12:3003/fegger/time_to_leave.git
synced 2026-09-17 09:42:44 +00:00
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">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<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>
|
</modules>
|
||||||
</component>
|
</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 | ✅ | ✔️ |
|
Fix bugs that crash the app or lose user data.
|
||||||
|---|---|----|-|
|
|
||||||
| 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] |
|
| Step | ✅ Implemented | ✔️ Reviewed | Notes |
|
||||||
| 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] |
|
| **Step 1: Fix SSR Crash in `useBikeRoute`** (~10 min) | [x] | [x] | Relative URL fetch replaces `window.location.href` — SSR-safe. `isMounted` guard intact. |
|
||||||
| 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] |
|
| **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. |
|
||||||
| 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] |
|
| **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. |
|
||||||
| 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] |
|
---
|
||||||
|
|
||||||
|
## 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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
|
serverExternalPackages: ["node-ical"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+65
-153
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "oebb_planner",
|
"name": "time-to-leave",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oebb_planner",
|
"name": "time-to-leave",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"next": "16.2.6",
|
"next": "^16.2.6",
|
||||||
"node-ical": "^0.18.0",
|
"node-ical": "^0.26.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
@@ -1329,6 +1329,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -3336,12 +3348,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -3368,17 +3374,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -3499,6 +3494,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -3608,18 +3604,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -3830,15 +3814,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
@@ -3885,6 +3860,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -4009,6 +3985,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4018,6 +3995,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4062,6 +4040,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -4074,6 +4053,7 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -4690,26 +4670,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -4726,22 +4686,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -4761,6 +4705,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -4821,6 +4766,7 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -4845,6 +4791,7 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -4932,6 +4879,7 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5003,6 +4951,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5015,6 +4964,7 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -5030,6 +4980,7 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -5614,6 +5565,12 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/jsdom": {
|
||||||
"version": "29.1.1",
|
"version": "29.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
@@ -6116,6 +6073,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6152,27 +6110,6 @@
|
|||||||
"node": ">=8.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": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -6206,27 +6143,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -6376,15 +6292,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-ical": {
|
"node_modules/node-ical": {
|
||||||
"version": "0.18.0",
|
"version": "0.26.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz",
|
||||||
"integrity": "sha512-FrOUPztjw9OUgSB9o/ffhl86BiVClQTut97C2NqCwKIgOAcKPEw5UQMuSuNJO/Y4hqTyJdKZh2TCqNHQnE9YFg==",
|
"integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "1.6.7",
|
"rrule-temporal": "^1.5.3",
|
||||||
"moment-timezone": "^0.5.44",
|
"temporal-polyfill": "^0.3.2"
|
||||||
"rrule": "2.8.1",
|
},
|
||||||
"uuid": "^9.0.0"
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
@@ -6774,12 +6691,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -7003,13 +6914,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rrule": {
|
"node_modules/rrule-temporal": {
|
||||||
"version": "2.8.1",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.3.tgz",
|
||||||
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
"integrity": "sha512-qALnXyu4MKNUeykkkO0r6Xxl5or3rM8Cf6ibKIe/29sgmq3tGm1oNq4G1Ddp8Ku3mnKmvC3+3yFAJ3OgOu6OJw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"@js-temporal/polyfill": "^0.5.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
@@ -7603,6 +7514,21 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -8023,20 +7949,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.11",
|
"version": "8.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"next": "16.2.6",
|
"next": "^16.2.6",
|
||||||
"node-ical": "^0.18.0",
|
"node-ical": "^0.26.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { GET } from "../bike-route/route";
|
|
||||||
|
|
||||||
// Mock the fetch function to avoid making actual HTTP requests
|
const mockGetBikeRoute = vi.fn();
|
||||||
global.fetch = 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", () => {
|
describe("api/bike-route/route", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
mockGetBikeRoute.mockReset();
|
||||||
global.fetch = vi.fn();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return error when no required parameters are provided", async () => {
|
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 () => {
|
it("should handle valid bike route request", async () => {
|
||||||
// Mock successful fetch response
|
mockGetBikeRoute.mockResolvedValue({
|
||||||
const mockResponse = {
|
distance: 1500,
|
||||||
ok: true,
|
duration: 300,
|
||||||
json: vi.fn().mockResolvedValue({
|
steps: [
|
||||||
distance: 1500,
|
{ name: "Start", distance: 100, duration: 10, instruction: "Go straight" },
|
||||||
duration: 300,
|
{ name: "Turn left", distance: 200, duration: 20, instruction: "Turn left at the corner" },
|
||||||
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);
|
|
||||||
|
|
||||||
const request = new NextRequest(
|
const request = new NextRequest(
|
||||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
"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");
|
expect(data).toHaveProperty("steps");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle fetch error", async () => {
|
it("should handle client error", async () => {
|
||||||
vi.mocked(fetch).mockRejectedValue(new Error("Network error"));
|
mockGetBikeRoute.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
const request = new NextRequest(
|
const request = new NextRequest(
|
||||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
"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);
|
expect(response.status).toBe(500);
|
||||||
const data = await response.json();
|
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 () => {
|
it("should handle no route found", async () => {
|
||||||
// Mock fetch response with empty routes
|
mockGetBikeRoute.mockResolvedValue(null);
|
||||||
const mockResponse = {
|
|
||||||
ok: true,
|
|
||||||
json: vi.fn().mockResolvedValue({
|
|
||||||
routes: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
|
|
||||||
|
|
||||||
const request = new NextRequest(
|
const request = new NextRequest(
|
||||||
"http://localhost/api/bike-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
"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);
|
expect(response.status).toBe(500);
|
||||||
const data = await response.json();
|
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 () => {
|
it("should handle no results found", async () => {
|
||||||
|
|||||||
@@ -1,64 +1,35 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { OSRM_URL } from "@/lib/constants";
|
import { BikeRoutingClient } from "@/lib/bike-routing-client";
|
||||||
import { BikeRoute } from "@/types";
|
|
||||||
|
|
||||||
interface OsrmStep {
|
// Module-level singleton — cache persists across requests
|
||||||
name: string;
|
const client = new BikeRoutingClient();
|
||||||
distance: number;
|
|
||||||
duration: number;
|
|
||||||
maneuver: { instruction?: string; type: string; modifier?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const fromLat = searchParams.get("fromLat");
|
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
|
||||||
const fromLng = searchParams.get("fromLng");
|
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
|
||||||
const toLat = searchParams.get("toLat");
|
const toLat = parseFloat(searchParams.get("toLat") ?? "");
|
||||||
const toLng = searchParams.get("toLng");
|
const toLng = parseFloat(searchParams.get("toLng") ?? "");
|
||||||
|
|
||||||
if (!fromLat || !fromLng || !toLat || !toLng) {
|
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
|
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build OSRM URL
|
const route = await client.getBikeRoute(fromLat, fromLng, toLat, toLng);
|
||||||
const url = new URL(`${OSRM_URL}/route/v1/bicycle/${fromLng},${fromLat};${toLng},${toLat}`);
|
|
||||||
url.searchParams.append("overview", "false");
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
if (!route) {
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return NextResponse.json({ error: "Bike routing request failed" }, { status: response.status });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.routes || data.routes.length === 0) {
|
|
||||||
return NextResponse.json({ error: "No route found" }, { status: 404 });
|
return NextResponse.json({ error: "No route found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = data.routes[0];
|
return NextResponse.json(route);
|
||||||
|
|
||||||
const bikeRoute: BikeRoute = {
|
|
||||||
distance: route.distance,
|
|
||||||
duration: route.duration,
|
|
||||||
steps:
|
|
||||||
route.legs?.[0]?.steps?.map((step: OsrmStep) => ({
|
|
||||||
name: step.name || "",
|
|
||||||
distance: step.distance,
|
|
||||||
duration: step.duration,
|
|
||||||
instruction: step.maneuver.instruction,
|
|
||||||
})) || [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(bikeRoute);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Bike route API error:", error);
|
const corrId = randomUUID().slice(0, 8);
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { extractEvents } from "@/lib/calendar-utils";
|
import { extractEvents } from "@/lib/calendar-utils";
|
||||||
import { DEFAULT_DAYS } from "@/lib/constants";
|
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||||
@@ -15,7 +16,8 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(events);
|
return NextResponse.json(events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Calendar parse API error:", error);
|
const corrId = randomUUID().slice(0, 8);
|
||||||
return NextResponse.json({ error: "Failed to parse calendar" }, { status: 500 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { extractEvents } from "@/lib/calendar-utils";
|
import { extractEvents } from "@/lib/calendar-utils";
|
||||||
import { DEFAULT_DAYS } from "@/lib/constants";
|
import { DEFAULT_DAYS } from "@/lib/constants";
|
||||||
@@ -30,7 +31,8 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(events);
|
return NextResponse.json(events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Calendar API error:", error);
|
const corrId = randomUUID().slice(0, 8);
|
||||||
return NextResponse.json({ error: "Failed to fetch calendar" }, { status: 500 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "@/lib/constants";
|
import { GeocodingClient } from "@/lib/geocoding-client";
|
||||||
import { GeocodeResult } from "@/types";
|
|
||||||
|
|
||||||
// Simple in-memory cache with TTL
|
// Module-level singleton — cache persists across requests
|
||||||
const cache = new Map<string, { result: GeocodeResult; timestamp: number }>();
|
const client = new GeocodingClient();
|
||||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -16,64 +15,16 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
|
return NextResponse.json({ error: "Missing 'name' parameter" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache key
|
const results = await client.geocode(name, countrycodes || undefined);
|
||||||
const cacheKey = `${name}|${countrycodes || ""}`;
|
|
||||||
|
|
||||||
// Check cache
|
if (results.length === 0) {
|
||||||
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.
|
|
||||||
return NextResponse.json({ error: "No results found" }, { status: 404 });
|
return NextResponse.json({ error: "No results found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: GeocodeResult = {
|
return NextResponse.json(results[0]);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Geocode API error:", error);
|
const corrId = randomUUID().slice(0, 8);
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "@/lib/constants";
|
||||||
|
|
||||||
@@ -5,6 +6,31 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), HAFAS_TIMEOUT_MS);
|
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 });
|
return NextResponse.json({ error: "HAFAS request timeout" }, { status: 408 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("HAFAS API error:", error);
|
const corrId = randomUUID().slice(0, 8);
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
console.error(`[${corrId}] HAFAS API error:`, error);
|
||||||
|
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
"use client";
|
"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 UrlTab from "./UrlTab";
|
||||||
import FileTab from "./FileTab";
|
import FileTab from "./FileTab";
|
||||||
|
|
||||||
type CalendarPanelProps = {
|
type CalendarPanelProps = {
|
||||||
onLoadCalendar: (url: string | File) => Promise<void>;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
className?: string;
|
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 [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 (
|
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}`}>
|
||||||
@@ -41,9 +57,9 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ onLoadCalendar, loading,
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === "url" ? (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ type CalendarViewProps = {
|
|||||||
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
|
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
|
||||||
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
|
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 = () => {
|
const renderHeader = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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[] = [];
|
const cells: React.ReactNode[] = [];
|
||||||
|
|
||||||
days.forEach((day, index) => {
|
days.forEach((day, index) => {
|
||||||
const dayEvents = events.filter((event) => {
|
const dayKey = day.toISOString().slice(0, 10);
|
||||||
const eventDate = new Date(event.eventTime);
|
const dayEvents = eventsByDate.get(dayKey) ?? [];
|
||||||
return (
|
|
||||||
eventDate.getDate() === day.getDate() &&
|
|
||||||
eventDate.getMonth() === day.getMonth() &&
|
|
||||||
eventDate.getFullYear() === day.getFullYear()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||||
const isTodayDate = isToday(day);
|
const isTodayDate = isToday(day);
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Event, TripDataEntry } from "@/types";
|
import { Event, Station } from "@/types";
|
||||||
import EventCard from "@/app/event/EventCard";
|
import EventCard from "@/app/event/EventCard";
|
||||||
|
|
||||||
type DayEventsProps = {
|
type DayEventsProps = {
|
||||||
events: Event[];
|
events: Event[];
|
||||||
date: Date;
|
date: Date;
|
||||||
tripData?: Record<string, TripDataEntry>; // Map of event id to trip data
|
originStation: Station | null;
|
||||||
className?: string;
|
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) => {
|
const filteredEvents = events.filter((event) => {
|
||||||
// Direct Date comparison instead of parseISO
|
|
||||||
const eventDate = new Date(event.eventTime);
|
const eventDate = new Date(event.eventTime);
|
||||||
return (
|
return (
|
||||||
eventDate.getDate() === date.getDate() &&
|
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>
|
<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">
|
<div className="space-y-3">
|
||||||
{filteredEvents.map((event) => (
|
{filteredEvents.map((event) => (
|
||||||
<EventCard
|
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
tripData={tripData[event.id] || { journeys: [], destName: "", demo: false, loading: false }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Event } from "@/types";
|
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||||
|
import { useOriginStation } from "@/hooks/useOriginStation";
|
||||||
import CalendarView from "./CalendarView";
|
import CalendarView from "./CalendarView";
|
||||||
import DayEvents from "./DayEvents";
|
import DayEvents from "./DayEvents";
|
||||||
|
import CalendarPanel from "./CalendarPanel";
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
// Mock events for demonstration
|
const { events } = useEventsStore();
|
||||||
const events: Event[] = [
|
const { station: originStation } = useOriginStation();
|
||||||
{
|
|
||||||
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 [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
|
||||||
setSelectedDate(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-4">
|
<div className="max-w-6xl mx-auto p-4">
|
||||||
<div className="mb-6">
|
<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>
|
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<CalendarPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<CalendarView events={events} onDateSelect={handleDateSelect} selectedDate={selectedDate} />
|
<CalendarView events={events} onDateSelect={setSelectedDate} selectedDate={selectedDate} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<DayEvents events={events} date={selectedDate} />
|
<DayEvents events={events} date={selectedDate} originStation={originStation} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-17
@@ -1,27 +1,47 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Event, TripDataEntry } from '@/types';
|
import { Event, Station } from '@/types';
|
||||||
import TrainSection from './TrainSection';
|
import TrainSection from './TrainSection';
|
||||||
import BikeSection from './BikeSection';
|
import BikeSection from './BikeSection';
|
||||||
import LeaveByBadge from './LeaveByBadge';
|
import LeaveByBadge from './LeaveByBadge';
|
||||||
import { calculateCountdown } from '@/lib/countdown-utils';
|
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 = {
|
type EventCardProps = {
|
||||||
event: Event;
|
event: Event;
|
||||||
tripData: TripDataEntry;
|
originStation: Station | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EventCard: React.FC<EventCardProps> = ({
|
const EventCard: React.FC<EventCardProps> = ({ event, originStation, className = '' }) => {
|
||||||
event,
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
tripData,
|
const handleRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||||
className = '',
|
|
||||||
}) => {
|
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 (
|
return (
|
||||||
<div className={
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden ${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="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -35,15 +55,17 @@ const EventCard: React.FC<EventCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<TrainSection
|
<TrainSection
|
||||||
journeys={tripData.journeys}
|
journeys={journeys}
|
||||||
eventTime={event.eventTime}
|
eventTime={event.eventTime}
|
||||||
destName={event.destination}
|
destName={destStation?.name ?? event.destination}
|
||||||
loading={tripData.loading}
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
<BikeSection
|
<BikeSection
|
||||||
bikeRoute={tripData.bikeRoute}
|
bikeRoute={bikeRoute}
|
||||||
bikeLoading={tripData.bikeLoading || false}
|
bikeLoading={bikeLoading}
|
||||||
bikeError={tripData.bikeError}
|
bikeError={bikeError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type TrainSectionProps = {
|
|||||||
eventTime: Date;
|
eventTime: Date;
|
||||||
destName: string;
|
destName: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error?: string | null;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
@@ -21,6 +22,7 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
eventTime,
|
eventTime,
|
||||||
destName,
|
destName,
|
||||||
loading,
|
loading,
|
||||||
|
error,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
className = "",
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
@@ -46,6 +48,8 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
|||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<LoadingSpinner size="lg" />
|
<LoadingSpinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-8 text-center text-red-600 dark:text-red-400 text-sm">{error}</div>
|
||||||
) : (
|
) : (
|
||||||
<JourneyList journeys={journeys} eventTime={eventTime} />
|
<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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: Arial, Helvetica, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
+16
-13
@@ -1,16 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { EventsProvider } from "@/hooks/useEventsStore";
|
||||||
const geistSans = Geist({
|
import { ReminderSettingsProvider } from "@/hooks/useReminderSettings";
|
||||||
variable: "--font-geist-sans",
|
import Header from "@/app/layout/Header";
|
||||||
subsets: ["latin"],
|
import Navbar from "@/app/layout/Navbar";
|
||||||
});
|
import ReminderEngine from "@/app/layout/ReminderEngine";
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "TimeToLeave",
|
title: "TimeToLeave",
|
||||||
@@ -23,8 +17,17 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
<html lang="en" className="h-full antialiased">
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<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>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Button from "@/app/ui/Button";
|
import Button from "@/app/ui/Button";
|
||||||
import AddEventModal from "@/app/add-event/AddEventModal";
|
import AddEventModal from "@/app/add-event/AddEventModal";
|
||||||
|
import ReminderSettingsPanel from "@/app/ui/ReminderSettingsPanel";
|
||||||
import { useServerHealth } from "@/hooks/useServerHealth";
|
import { useServerHealth } from "@/hooks/useServerHealth";
|
||||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||||
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -12,8 +14,10 @@ type HeaderProps = {
|
|||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||||
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
const [showAddEventModal, setShowAddEventModal] = useState(false);
|
||||||
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||||
const { status } = useServerHealth();
|
const { status } = useServerHealth();
|
||||||
const { events } = useEventsStore();
|
const { events } = useEventsStore();
|
||||||
|
const { dark, toggle } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`bg-white dark:bg-gray-800 shadow-sm ${className}`}>
|
<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)}>
|
<Button variant="secondary" size="sm" onClick={() => setShowAddEventModal(true)}>
|
||||||
Add Event
|
Add Event
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="max-w-3xl mx-auto w-full px-4 py-6">
|
||||||
<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">
|
{upcoming.length === 0 ? (
|
||||||
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
|
<div className="text-center py-16 text-gray-500 dark:text-gray-400">
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<p className="text-lg font-medium">No upcoming events</p>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
) : (
|
||||||
<a
|
<div className="space-y-4">
|
||||||
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"
|
{upcoming.map((event) => (
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||||
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>
|
</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);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL("/api/bike-route", window.location.href);
|
const response = await fetch(
|
||||||
url.searchParams.set("fromLat", String(fromLat));
|
`/api/bike-route?fromLat=${fromLat}&fromLng=${fromLng}&toLat=${toLat}&toLng=${toLng}`,
|
||||||
url.searchParams.set("fromLng", String(fromLng));
|
);
|
||||||
url.searchParams.set("toLat", String(toLat));
|
|
||||||
url.searchParams.set("toLng", String(toLng));
|
|
||||||
|
|
||||||
const response = await fetch(url.toString());
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errBody = await response.json().catch(() => ({}));
|
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";
|
"use client";
|
||||||
import type { Event } from "@/types";
|
|
||||||
|
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 {
|
interface EventsContextType {
|
||||||
events: Event[];
|
events: Event[];
|
||||||
@@ -8,46 +24,60 @@ interface EventsContextType {
|
|||||||
removeEvent: (id: string) => void;
|
removeEvent: (id: string) => void;
|
||||||
clearEvents: () => void;
|
clearEvents: () => void;
|
||||||
setEvents: (events: Event[]) => void;
|
setEvents: (events: Event[]) => void;
|
||||||
|
mergeEvents: (events: CalendarEvent[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
const EventsContext = createContext<EventsContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function EventsProvider({ children }: { children: ReactNode }) {
|
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) => {
|
const addEvent = useCallback((event: Event) => {
|
||||||
setEvents((prev) => {
|
setEventsState((prev) => {
|
||||||
const exists = prev.some((e) => e.id === event.id);
|
if (prev.some((e) => e.id === event.id)) return prev;
|
||||||
if (exists) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [...prev, event];
|
return [...prev, event];
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateEvent = useCallback((id: string, updates: Partial<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) => {
|
const removeEvent = useCallback((id: string) => {
|
||||||
setEvents((prev) => prev.filter((event) => event.id !== id));
|
setEventsState((prev) => prev.filter((event) => event.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearEvents = useCallback(() => {
|
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 (
|
return (
|
||||||
<EventsContext.Provider
|
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
|
||||||
value={{
|
|
||||||
events,
|
|
||||||
addEvent,
|
|
||||||
updateEvent,
|
|
||||||
removeEvent,
|
|
||||||
clearEvents,
|
|
||||||
setEvents,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</EventsContext.Provider>
|
</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 { useState, useEffect } from "react";
|
||||||
import type { Journey } from "@/types";
|
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 {
|
export function useJourneys(
|
||||||
ctxRecon?: string;
|
fromStationExtId: string | null,
|
||||||
secL?: Array<{
|
toStationExtId: string | null,
|
||||||
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
|
date: Date,
|
||||||
arr?: { aTimeS?: string; aTimeR?: string };
|
refreshKey = 0,
|
||||||
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) {
|
|
||||||
const [journeys, setJourneys] = useState<Journey[]>([]);
|
const [journeys, setJourneys] = useState<Journey[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -127,7 +74,7 @@ export function useJourneys(fromStationExtId: string | null, toStationExtId: str
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [fromStationExtId, toStationExtId, date]);
|
}, [fromStationExtId, toStationExtId, date, refreshKey]);
|
||||||
|
|
||||||
return { journeys, loading, error };
|
return { journeys, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import type { Station } from "@/types";
|
import type { Station } from "@/types";
|
||||||
|
import { DEFAULT_STATION_NAME, DEFAULT_STATION_EXT_ID } from "@/lib/constants";
|
||||||
import { useGeolocation } from "./useGeolocation";
|
import { useGeolocation } from "./useGeolocation";
|
||||||
|
|
||||||
interface HafasLocation {
|
interface HafasLocation {
|
||||||
@@ -20,7 +21,7 @@ export function useOriginStation() {
|
|||||||
const fetchNearestStation = async () => {
|
const fetchNearestStation = async () => {
|
||||||
if (!location || state !== "granted") {
|
if (!location || state !== "granted") {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
|
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,14 +72,14 @@ export function useOriginStation() {
|
|||||||
if (stations.length > 0) {
|
if (stations.length > 0) {
|
||||||
setStation(stations[0]);
|
setStation(stations[0]);
|
||||||
} else {
|
} else {
|
||||||
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
|
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to find nearest station";
|
const message = err instanceof Error ? err.message : "Failed to find nearest station";
|
||||||
setError(message);
|
setError(message);
|
||||||
setStation({ name: "Graz Hbf", extId: "0WB0F0000600" });
|
setStation({ name: DEFAULT_STATION_NAME, extId: DEFAULT_STATION_EXT_ID });
|
||||||
setLoading(false);
|
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 type { BikeRoute, BikeStep } from "@/types";
|
||||||
import { OSRM_URL } from "./constants";
|
import { OSRM_URL } from "./constants";
|
||||||
|
import { ApiClient } from "./api-service";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OSRM response types (internal, not exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface OsrmStep {
|
interface OsrmStep {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,30 +24,58 @@ interface OsrmResponse {
|
|||||||
routes: OsrmRoute[];
|
routes: OsrmRoute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: Build human-readable instruction from OSRM step data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function stepInstruction(step: OsrmStep): string {
|
function stepInstruction(step: OsrmStep): string {
|
||||||
if (step.maneuver.instruction) return step.maneuver.instruction;
|
if (step.maneuver.instruction) return step.maneuver.instruction;
|
||||||
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
|
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
|
||||||
return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
|
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) {
|
export class BikeRoutingClient {
|
||||||
this.baseUrl = baseUrl;
|
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> {
|
async getBikeRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
|
||||||
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
|
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) });
|
const cacheKey = `osrm:bike:${coords}`;
|
||||||
if (!res.ok) throw new Error(`OSRM request failed: ${res.status}`);
|
|
||||||
|
|
||||||
const data: OsrmResponse = await res.json();
|
const res = await this.client.get<OsrmResponse>(
|
||||||
if (data.code !== "Ok" || !data.routes.length) return null;
|
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) => ({
|
const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
distance: s.distance,
|
distance: s.distance,
|
||||||
@@ -56,4 +89,18 @@ export class BikeRoutingClient {
|
|||||||
steps,
|
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 DEFAULT_DAYS = 14;
|
||||||
|
|
||||||
export const APP_VERSION = "2.0.0";
|
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 type { GeocodeResult } from "@/types";
|
||||||
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "./constants";
|
import { NOMINATIM_URL, NOMINATIM_USER_AGENT } from "./constants";
|
||||||
|
import { ApiClient } from "./api-service";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Nominatim response types (internal, not exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface NominatimResult {
|
interface NominatimResult {
|
||||||
lat: string;
|
lat: string;
|
||||||
@@ -7,67 +12,98 @@ interface NominatimResult {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GeocodingClient {
|
type NominatimSearchResponse = NominatimResult[];
|
||||||
private baseUrl: string;
|
type NominatimReverseResponse = NominatimResult;
|
||||||
private userAgent: string;
|
|
||||||
private cache = new Map<string, GeocodeResult[]>();
|
|
||||||
|
|
||||||
constructor(baseUrl: string = NOMINATIM_URL, userAgent: string = NOMINATIM_USER_AGENT) {
|
// ---------------------------------------------------------------------------
|
||||||
this.baseUrl = baseUrl;
|
// GeocodingClient — Wraps the Nominatim geocoding API
|
||||||
this.userAgent = userAgent;
|
//
|
||||||
|
// 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[]> {
|
async geocode(query: string, countrycodes?: string): Promise<GeocodeResult[]> {
|
||||||
const cacheKey = `${query}|${countrycodes ?? ""}`;
|
const params: Record<string, string> = {
|
||||||
const cached = this.cache.get(cacheKey);
|
q: query,
|
||||||
if (cached) return cached;
|
format: "json",
|
||||||
|
limit: "5",
|
||||||
|
};
|
||||||
|
|
||||||
const params = new URLSearchParams({ q: query, format: "json", limit: "5" });
|
if (countrycodes) {
|
||||||
if (countrycodes) params.set("countrycodes", countrycodes);
|
params.countrycodes = countrycodes;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${this.baseUrl}/search?${params}`, {
|
// Build a stable cache key that includes all query parameters
|
||||||
headers: {
|
const cacheKey = `nominatim:search:${query}|${countrycodes ?? ""}`;
|
||||||
"User-Agent": this.userAgent,
|
|
||||||
Accept: "application/json",
|
const res = await this.client.get<NominatimSearchResponse>("/search", params, {
|
||||||
},
|
cacheKey,
|
||||||
signal: AbortSignal.timeout(5000),
|
ttl: this.defaultTtlMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Nominatim geocode failed: ${res.status}`);
|
return res.map((r) => ({
|
||||||
|
|
||||||
const data: NominatimResult[] = await res.json();
|
|
||||||
const results = data.map((r) => ({
|
|
||||||
lat: parseFloat(r.lat),
|
lat: parseFloat(r.lat),
|
||||||
lng: parseFloat(r.lon),
|
lng: parseFloat(r.lon),
|
||||||
display_name: r.display_name,
|
display_name: r.display_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.cache.set(cacheKey, results);
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
|
async reverseGeocode(lat: number, lng: number): Promise<GeocodeResult | null> {
|
||||||
const params = new URLSearchParams({
|
const params: Record<string, string> = {
|
||||||
lat: String(lat),
|
lat: String(lat),
|
||||||
lon: String(lng),
|
lon: String(lng),
|
||||||
format: "json",
|
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 type { Journey, Station } from "@/types";
|
||||||
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
|
import { HAFAS_URL, HAFAS_TIMEOUT_MS } from "./constants";
|
||||||
import { parseHafasTime, hafasDateTime } from "./hafas-time";
|
import { parseHafasTime, hafasDateTime } from "./hafas-time";
|
||||||
|
import { ApiClient } from "./api-service";
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// HAFAS response types (internal, not exported)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
|
||||||
interface HafasLocation {
|
interface HafasLocation {
|
||||||
type: "S" | "A" | "P";
|
type: "S" | "A" | "P";
|
||||||
@@ -8,7 +13,17 @@ interface HafasLocation {
|
|||||||
extId: string;
|
extId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HafasJourney {
|
interface HafasLocationResponse {
|
||||||
|
svcResL?: Array<{
|
||||||
|
res?: {
|
||||||
|
match?: {
|
||||||
|
locL?: HafasLocation[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HafasJourney {
|
||||||
ctxRecon?: string;
|
ctxRecon?: string;
|
||||||
secL?: Array<{
|
secL?: Array<{
|
||||||
dep?: { dTimeS?: string; dTimeR?: string; dPlatfS?: string };
|
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 {
|
export class HafasClient {
|
||||||
private baseUrl: string;
|
private client: ApiClient;
|
||||||
private timeoutMs: number;
|
|
||||||
|
|
||||||
constructor(baseUrl: string = HAFAS_URL, timeoutMs: number = HAFAS_TIMEOUT_MS) {
|
constructor(baseUrl: string = HAFAS_URL, timeoutMs: number = HAFAS_TIMEOUT_MS) {
|
||||||
this.baseUrl = baseUrl;
|
this.client = new ApiClient({
|
||||||
this.timeoutMs = timeoutMs;
|
baseUrl,
|
||||||
|
defaultTimeoutMs: timeoutMs,
|
||||||
|
defaultTtlMs: 0, // No caching for HAFAS — journeys are live data
|
||||||
|
maxRetries: 3,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchStation(query: string): Promise<Station[]> {
|
async searchStation(query: string): Promise<Station[]> {
|
||||||
@@ -42,18 +123,15 @@ export class HafasClient {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(this.baseUrl, {
|
const res = await this.client.post<HafasLocationResponse>("", body);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(this.timeoutMs),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HAFAS station search failed: ${res.status}`);
|
const match = res?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||||
|
return (match as HafasLocation[])
|
||||||
const json = await res.json();
|
.filter((l) => l.type === "S")
|
||||||
const match = json?.svcResL?.[0]?.res?.match?.locL ?? [];
|
.map((l) => ({
|
||||||
return (match as HafasLocation[]).filter((l) => l.type === "S").map((l) => ({ name: l.name, extId: l.extId }));
|
name: l.name,
|
||||||
|
extId: l.extId,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchJourneys(from: Station, to: Station, date: Date): Promise<Journey[]> {
|
async fetchJourneys(from: Station, to: Station, date: Date): Promise<Journey[]> {
|
||||||
@@ -74,53 +152,15 @@ export class HafasClient {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(this.baseUrl, {
|
const res = await this.client.post<HafasTripResponse>("", body);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: AbortSignal.timeout(this.timeoutMs),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HAFAS trip search failed: ${res.status}`);
|
return parseHafasJourneys(res, hafasDate, date);
|
||||||
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
/**
|
||||||
const outConL: HafasJourney[] = json?.svcResL?.[0]?.res?.outConL ?? [];
|
* Expose cache statistics (always empty for HAFAS, but useful for debugging).
|
||||||
|
*/
|
||||||
return outConL.map((con, i): Journey => {
|
public cacheStats() {
|
||||||
const first = con.secL?.[0];
|
return this.client.cacheStats();
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,10 +1,10 @@
|
|||||||
// Main library exports for TimeToLeave
|
// Main library exports for TimeToLeave
|
||||||
|
export * from "./api-service";
|
||||||
export * from "./hafas-client";
|
export * from "./hafas-client";
|
||||||
export * from "./geocoding-client";
|
export * from "./geocoding-client";
|
||||||
export * from "./bike-routing-client";
|
export * from "./bike-routing-client";
|
||||||
export * from "./calendar-utils";
|
export * from "./calendar-utils";
|
||||||
export * from "./status-utils";
|
export * from "./status-utils";
|
||||||
export * from "./live-status-utils";
|
|
||||||
export * from "./countdown-utils";
|
export * from "./countdown-utils";
|
||||||
export * from "./formatting";
|
export * from "./formatting";
|
||||||
export * from "./constants";
|
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 LiveStatus = null | true | false;
|
||||||
export type LocState = "pending" | "granted" | "denied";
|
export type LocState = "pending" | "granted" | "denied";
|
||||||
export type CalStatus = null | "loading" | "ok" | "error";
|
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 react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
const config = {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
@@ -14,4 +14,6 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user