mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
11 Commits
main
...
7c4bf3a5df
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4bf3a5df | |||
| 3a837f8313 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/dist
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -102,16 +102,15 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update package.json files and Helm chart
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
|
||||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
|
|||||||
@@ -8,10 +8,33 @@ on:
|
|||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
name: Server Tests
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,12 +44,24 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd server && npm ci
|
run: npm ci --workspace shared && npm ci --workspace server
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck (informational)
|
||||||
|
# Pre-existing type errors in the NestJS rewrite; surfaces them without
|
||||||
|
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
|
||||||
|
continue-on-error: true
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd server && npm run test:coverage
|
run: cd server && npm run test:coverage
|
||||||
@@ -48,12 +83,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd client && npm ci
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
run: cd client && npm run test:coverage
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
server/public/*
|
server/public/*
|
||||||
!server/public/.gitkeep
|
!server/public/.gitkeep
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,524 @@
|
|||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||||
|
|
||||||
|
# TREK 3.0.0
|
||||||
|
|
||||||
|
<video src="https://github.com/mauriceboe/trek-media/raw/main/.github/assets/TREK1.mp4" controls width="100%"></video>
|
||||||
|
|
||||||
|
> **The biggest TREK release to date.** A new Journey addon turns your trips into rich travel journals. Mapbox GL joins Leaflet as a first-class renderer. MCP gets a full OAuth 2.1 authorization server. Offline-first PWA, self-service password reset, and a dashboard redesigned from the ground up. Fifteen languages, top to bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
### Photos moved from Trip Planner to Journey
|
||||||
|
|
||||||
|
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||||
|
|
||||||
|
**What this means for you:**
|
||||||
|
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||||
|
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||||
|
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||||
|
|
||||||
|
### New Immich API Key Permissions Required
|
||||||
|
|
||||||
|
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||||
|
|
||||||
|
**Previous versions required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `user.read` | Connection test |
|
||||||
|
| `asset.read` | Browse photos by date, search |
|
||||||
|
| `asset.view` | Stream thumbnails |
|
||||||
|
| `asset.download` | Stream originals |
|
||||||
|
| `album.read` | List and browse albums |
|
||||||
|
| `timeline.read` | Browse timeline buckets |
|
||||||
|
|
||||||
|
**New in 3.0.0 — additionally required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||||
|
|
||||||
|
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||||
|
|
||||||
|
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||||
|
|
||||||
|
### OIDC_ONLY deprecated
|
||||||
|
|
||||||
|
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||||
|
|
||||||
|
## Journey Addon — Travel Journal
|
||||||
|
|
||||||
|
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||||
|
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||||
|
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||||
|
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||||
|
- **Entry reorder** — move-up / move-down arrows on each entry (desktop), skipped on skeleton suggestions
|
||||||
|
- **Hide skeletons toggle** — per-contributor setting to focus on the written entries only
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||||
|
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||||
|
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||||
|
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||||
|
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||||
|
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||||
|
- **Safari gallery picker fix** — repaired grid layout collapse on Safari (#717)
|
||||||
|
|
||||||
|
### Sharing & Export
|
||||||
|
- **Public share links** — token-based access with language picker, no login required
|
||||||
|
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||||
|
- **Thumbnail size in public gallery** — grid loads thumbnails instead of originals, lightbox keeps originals (cuts bandwidth on shared links significantly)
|
||||||
|
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **Contributors** — invite users as editors or viewers
|
||||||
|
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||||
|
- **Cover image** — upload or pick from journey photos
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||||
|
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||||
|
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapbox GL as a First-Class Renderer
|
||||||
|
|
||||||
|
Leaflet gets a sibling. Users can now switch the trip planner map to **Mapbox GL JS** for a proper 3D globe, terrain, and 3D buildings.
|
||||||
|
|
||||||
|
- **Settings toggle** — choose between Leaflet and Mapbox GL in Settings > Map
|
||||||
|
- **Globe projection** — smooth rotating globe when zoomed out, mercator when zoomed in
|
||||||
|
- **3D terrain and buildings** — enabled on Standard and Satellite styles, with custom 3D buildings in dark/light mode
|
||||||
|
- **Trip route, GPX geometries, place markers** — full feature parity with the Leaflet renderer
|
||||||
|
- **Transport reservations overlay** — great-circle arcs for flights/cruises, straight lines for trains/cars, clickable endpoint badges with IATA codes, rotating mid-arc stats label for flights. Honours the per-booking "show route" toggle in DayPlanSidebar
|
||||||
|
- **Auto-fit on load** — planner map zooms to the trip's places on initial render
|
||||||
|
- **Booking route label toggle** — separate setting to hide IATA labels on endpoint markers
|
||||||
|
- **Infrastructure** — WebAssembly allowed in CSP for Mapbox GL's 3D engine, PWA precache limit raised so the mapbox-gl bundle builds, Mapbox endpoints allowed in `connect-src` / `img-src`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP: OAuth 2.1 & Granular Scopes
|
||||||
|
|
||||||
|
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||||
|
|
||||||
|
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||||
|
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||||
|
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at `POST /oauth/register`, with strict redirect_uri validation (HTTPS / loopback / reverse-DNS private-use schemes only; rejects `javascript:` / `data:` / `file:` / etc.)
|
||||||
|
- **RFC 9728 Protected Resource Metadata** — `/.well-known/oauth-protected-resource` exposes the MCP endpoint's auth requirements for client auto-discovery
|
||||||
|
- **RFC 8707 audience binding** — tokens are audience-bound to `<app_url>/mcp` by default and validated on every MCP request
|
||||||
|
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||||
|
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||||
|
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||||
|
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||||
|
- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
|
||||||
|
- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
|
||||||
|
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||||
|
- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Service Password Reset
|
||||||
|
|
||||||
|
Users can now reset their own password without admin intervention.
|
||||||
|
|
||||||
|
- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
|
||||||
|
- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
|
||||||
|
- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
|
||||||
|
- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
|
||||||
|
- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Redesign
|
||||||
|
|
||||||
|
The dashboard has been rebuilt with a mobile-first design language.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||||
|
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||||
|
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||||
|
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
|
||||||
|
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||||
|
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||||
|
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||||
|
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||||
|
|
||||||
|
### Both
|
||||||
|
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||||
|
- **Dark mode** — full dark mode support across all new components
|
||||||
|
- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Offline Mode
|
||||||
|
|
||||||
|
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||||
|
|
||||||
|
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||||
|
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||||
|
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||||
|
- **Offline trip planner** — full planner functionality with cached data
|
||||||
|
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||||
|
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||||
|
- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
|
||||||
|
- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transport Reservations: Multi-Day + Map Visualization
|
||||||
|
|
||||||
|
- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
|
||||||
|
- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
|
||||||
|
- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
|
||||||
|
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
|
||||||
|
- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservations Redesign
|
||||||
|
|
||||||
|
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||||
|
|
||||||
|
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||||
|
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||||
|
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||||
|
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||||
|
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Apple Wallet pkpass Support
|
||||||
|
|
||||||
|
- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
|
||||||
|
- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Todo Due-Date Reminders
|
||||||
|
|
||||||
|
- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
|
||||||
|
- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
|
||||||
|
- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collab Sub-Feature Toggles
|
||||||
|
|
||||||
|
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||||
|
|
||||||
|
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||||
|
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||||
|
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||||
|
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Place Import: KMZ/KML + Naver Maps + Selective GPX
|
||||||
|
|
||||||
|
Three ways to import places into your trips.
|
||||||
|
|
||||||
|
### KMZ/KML Import
|
||||||
|
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||||
|
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||||
|
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||||
|
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||||
|
|
||||||
|
### Naver Maps List Import
|
||||||
|
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||||
|
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||||
|
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||||
|
|
||||||
|
### Selective GPX/KML Element Import
|
||||||
|
- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
|
||||||
|
- **Performance** — larger files (thousands of points) parse and render without freezing the UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Autocomplete
|
||||||
|
|
||||||
|
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||||
|
- **Google Places API** — primary autocomplete provider with location bias
|
||||||
|
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||||
|
- **Bounding box bias** — search results biased to the current map viewport
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy Notification Channel
|
||||||
|
|
||||||
|
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||||
|
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||||
|
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||||
|
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login & Language
|
||||||
|
|
||||||
|
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||||
|
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||||
|
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Granular Auth Toggles
|
||||||
|
|
||||||
|
- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
|
||||||
|
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synology Photos: OTP, SSL Skip & Session Management
|
||||||
|
|
||||||
|
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||||
|
- **Skip SSL verification** — toggle for self-signed certificates
|
||||||
|
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||||
|
- **Session-cleared notification** — routed through unified notification system
|
||||||
|
- **Provider URL hint** — contextual help text for Synology URL format
|
||||||
|
- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
|
||||||
|
- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Atlas Improvements
|
||||||
|
|
||||||
|
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||||
|
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||||
|
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||||
|
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n: Full 15-Language Coverage
|
||||||
|
|
||||||
|
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||||
|
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||||
|
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||||
|
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||||
|
- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
|
||||||
|
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vacay Improvements
|
||||||
|
|
||||||
|
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||||
|
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||||
|
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||||
|
- **Today marker** — visual indicator for the current day in the calendar
|
||||||
|
- **Unified toolbar** — same header style as planner/dashboard/journey
|
||||||
|
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iCal Export Improvements
|
||||||
|
|
||||||
|
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget Improvements
|
||||||
|
|
||||||
|
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||||
|
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||||
|
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||||
|
- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Packing List Improvements
|
||||||
|
|
||||||
|
- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
|
||||||
|
- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
|
||||||
|
- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner & UX Improvements
|
||||||
|
|
||||||
|
- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
|
||||||
|
- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
|
||||||
|
- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
|
||||||
|
- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
|
||||||
|
- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
|
||||||
|
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||||
|
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||||
|
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||||
|
- **File download button** — all file views now include a download button
|
||||||
|
- **Note modal** — no longer closes on outside click (#480)
|
||||||
|
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||||
|
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||||
|
- **Trip date change** — preserving day content when date range changes
|
||||||
|
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Panel Improvements
|
||||||
|
|
||||||
|
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||||
|
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||||
|
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||||
|
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||||
|
- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Improvements
|
||||||
|
|
||||||
|
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||||
|
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
|
||||||
|
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||||
|
- **Bottom nav dark mode** — consistent dark mode styling
|
||||||
|
- **Safe area support** — proper insets for iOS PWA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation & Wiki
|
||||||
|
|
||||||
|
- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
|
||||||
|
- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
|
||||||
|
- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
|
||||||
|
- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
|
||||||
|
|
||||||
|
- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
|
||||||
|
- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
|
||||||
|
- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
|
||||||
|
- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
|
||||||
|
- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
|
||||||
|
- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
|
||||||
|
- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||||
|
- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
|
||||||
|
- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
|
||||||
|
- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
|
||||||
|
- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
|
||||||
|
- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
|
||||||
|
- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
|
||||||
|
- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
|
||||||
|
- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
|
||||||
|
- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
|
||||||
|
- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
|
||||||
|
|
||||||
|
Upstream CVEs patched:
|
||||||
|
|
||||||
|
- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
|
||||||
|
- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
|
||||||
|
- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fixed OIDC-only mode login/logout loop (#491)
|
||||||
|
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||||
|
- Fixed booking date handling and file auth bugs
|
||||||
|
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||||
|
- Fixed streaming response end on client disconnect during asset pipe
|
||||||
|
- Fixed per-day transport positions for multi-day reservations
|
||||||
|
- Fixed stale budget category reset when category no longer exists
|
||||||
|
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||||
|
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||||
|
- Fixed HEIC photo rendering on non-Safari browsers
|
||||||
|
- Fixed CSP path matching for paths ending in /
|
||||||
|
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||||
|
- Fixed budget member avatars lost after updating item fields
|
||||||
|
- Fixed budget table column alignment broken by `display: flex` on `<td>` (#759)
|
||||||
|
- Fixed collab notes line break preservation (#608)
|
||||||
|
- Fixed weather archive date handling for future trips (#599)
|
||||||
|
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||||
|
- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
|
||||||
|
- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
|
||||||
|
- Fixed journey map OSM tile warning (#627)
|
||||||
|
- Fixed journey gallery picker grid collapse on Safari (#717)
|
||||||
|
- Fixed content divider placement in journal entries (#624)
|
||||||
|
- Fixed local photos wrong provider label (#625)
|
||||||
|
- Fixed Synology pagination and album scroll leak (#644)
|
||||||
|
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||||
|
- Fixed Nominatim User-Agent and error diagnostics
|
||||||
|
- Fixed map tooltips, journey creation, and contributor avatars
|
||||||
|
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||||
|
- Fixed stale accommodation_id on reservation update (#522)
|
||||||
|
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||||
|
- Fixed MCP safeBroadcast recursive call bug
|
||||||
|
- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
|
||||||
|
- Fixed Vite module preload polyfill CSP inline script violation
|
||||||
|
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||||
|
- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
|
||||||
|
- Fixed journey bugs reported by @roel-de-vries (#722–#736)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||||
|
- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
|
||||||
|
- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
|
||||||
|
- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||||
|
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||||
|
- **Journey** — 89.5% new code coverage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who contributed to this release:
|
||||||
|
|
||||||
|
- @mauriceboe
|
||||||
|
- @jubnl
|
||||||
|
- @gravitysc
|
||||||
|
- @luojiyin1987
|
||||||
|
- @marco783
|
||||||
|
- @isaiastavares
|
||||||
|
- @tiquis0290
|
||||||
|
- @xenocent
|
||||||
|
- @gfrcsd
|
||||||
|
- @roel-de-vries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Commits | 500+ |
|
||||||
|
| Merged PRs | 130+ |
|
||||||
|
| Files changed | 700+ |
|
||||||
|
| Lines added | 120,000+ |
|
||||||
|
| Contributors | 12+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull mauriceboe/trek:3.0.0
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on startup. No manual steps required.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||||
|
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||||
|
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||||
|
4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||||
|
|
||||||
|
# TREK 3.0.0
|
||||||
|
|
||||||
|
> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
### Photos moved from Trip Planner to Journey
|
||||||
|
|
||||||
|
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||||
|
|
||||||
|
**What this means for you:**
|
||||||
|
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||||
|
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||||
|
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||||
|
|
||||||
|
### New Immich API Key Permissions Required
|
||||||
|
|
||||||
|
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||||
|
|
||||||
|
**Previous versions required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `user.read` | Connection test |
|
||||||
|
| `asset.read` | Browse photos by date, search |
|
||||||
|
| `asset.view` | Stream thumbnails |
|
||||||
|
| `asset.download` | Stream originals |
|
||||||
|
| `album.read` | List and browse albums |
|
||||||
|
| `timeline.read` | Browse timeline buckets |
|
||||||
|
|
||||||
|
**New in 3.0.0 — additionally required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||||
|
|
||||||
|
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||||
|
|
||||||
|
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||||
|
|
||||||
|
### OIDC_ONLY deprecated
|
||||||
|
|
||||||
|
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||||
|
|
||||||
|
---
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||||
|
|
||||||
|
## Journey Addon — Travel Journal
|
||||||
|
|
||||||
|
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||||
|
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||||
|
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||||
|
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||||
|
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||||
|
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||||
|
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||||
|
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||||
|
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||||
|
|
||||||
|
### Sharing & Export
|
||||||
|
- **Public share links** — token-based access with language picker, no login required
|
||||||
|
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||||
|
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **Contributors** — invite users as editors or viewers
|
||||||
|
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||||
|
- **Cover image** — upload or pick from journey photos
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||||
|
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||||
|
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP: OAuth 2.1 & Granular Scopes
|
||||||
|
|
||||||
|
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||||
|
|
||||||
|
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||||
|
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||||
|
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
|
||||||
|
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||||
|
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||||
|
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||||
|
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||||
|
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||||
|
- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Redesign
|
||||||
|
|
||||||
|
The dashboard has been rebuilt with a mobile-first design language.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||||
|
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||||
|
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||||
|
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||||
|
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||||
|
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||||
|
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||||
|
|
||||||
|
### Both
|
||||||
|
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||||
|
- **Dark mode** — full dark mode support across all new components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Offline Mode
|
||||||
|
|
||||||
|
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||||
|
|
||||||
|
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||||
|
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||||
|
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||||
|
- **Offline trip planner** — full planner functionality with cached data
|
||||||
|
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||||
|
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||||
|
- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservations Redesign
|
||||||
|
|
||||||
|
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||||
|
|
||||||
|
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||||
|
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||||
|
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||||
|
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||||
|
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
|
||||||
|
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collab Sub-Feature Toggles
|
||||||
|
|
||||||
|
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||||
|
|
||||||
|
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||||
|
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||||
|
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||||
|
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Place Import: KMZ/KML & Naver Maps
|
||||||
|
|
||||||
|
Two new ways to import places into your trips.
|
||||||
|
|
||||||
|
### KMZ/KML Import
|
||||||
|
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||||
|
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||||
|
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||||
|
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||||
|
|
||||||
|
### Naver Maps List Import
|
||||||
|
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||||
|
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||||
|
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Autocomplete
|
||||||
|
|
||||||
|
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||||
|
- **Google Places API** — primary autocomplete provider with location bias
|
||||||
|
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||||
|
- **Bounding box bias** — search results biased to the current map viewport
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy Notification Channel
|
||||||
|
|
||||||
|
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||||
|
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||||
|
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||||
|
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login & Language
|
||||||
|
|
||||||
|
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||||
|
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||||
|
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Granular Auth Toggles
|
||||||
|
|
||||||
|
- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
|
||||||
|
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synology Photos: OTP, SSL Skip & Session Management
|
||||||
|
|
||||||
|
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||||
|
- **Skip SSL verification** — toggle for self-signed certificates
|
||||||
|
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||||
|
- **Session-cleared notification** — routed through unified notification system
|
||||||
|
- **Provider URL hint** — contextual help text for Synology URL format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Atlas Improvements
|
||||||
|
|
||||||
|
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||||
|
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||||
|
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||||
|
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n: Full 15-Language Coverage
|
||||||
|
|
||||||
|
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||||
|
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||||
|
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||||
|
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||||
|
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vacay Improvements
|
||||||
|
|
||||||
|
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||||
|
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||||
|
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||||
|
- **Today marker** — visual indicator for the current day in the calendar
|
||||||
|
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iCal Export Improvements
|
||||||
|
|
||||||
|
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget Improvements
|
||||||
|
|
||||||
|
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||||
|
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||||
|
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner & UX Improvements
|
||||||
|
|
||||||
|
- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
|
||||||
|
- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
|
||||||
|
- **Map multi-category filter** — filter syncs with map view for uncategorized places
|
||||||
|
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||||
|
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||||
|
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||||
|
- **File download button** — all file views now include a download button
|
||||||
|
- **Note modal** — no longer closes on outside click (#480)
|
||||||
|
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||||
|
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||||
|
- **Trip date change** — preserving day content when date range changes
|
||||||
|
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Panel Improvements
|
||||||
|
|
||||||
|
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||||
|
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||||
|
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||||
|
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Improvements
|
||||||
|
|
||||||
|
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||||
|
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
|
||||||
|
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||||
|
- **Bottom nav dark mode** — consistent dark mode styling
|
||||||
|
- **Safe area support** — proper insets for iOS PWA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||||
|
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||||
|
- **Journey** — 89.5% new code coverage
|
||||||
|
- **CI** — client test job added alongside server tests with split coverage artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fixed OIDC-only mode login/logout loop (#491)
|
||||||
|
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||||
|
- Fixed booking date handling and file auth bugs
|
||||||
|
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||||
|
- Fixed streaming response end on client disconnect during asset pipe
|
||||||
|
- Fixed per-day transport positions for multi-day reservations
|
||||||
|
- Fixed stale budget category reset when category no longer exists
|
||||||
|
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||||
|
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||||
|
- Fixed HEIC photo rendering on non-Safari browsers
|
||||||
|
- Fixed CSP path matching for paths ending in /
|
||||||
|
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||||
|
- Fixed budget member avatars lost after updating item fields
|
||||||
|
- Fixed collab notes line break preservation (#608)
|
||||||
|
- Fixed weather archive date handling for future trips (#599)
|
||||||
|
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||||
|
- Fixed ghost Gallery entries in journal timeline and public share
|
||||||
|
- Fixed journey map OSM tile warning (#627)
|
||||||
|
- Fixed content divider placement in journal entries (#624)
|
||||||
|
- Fixed local photos wrong provider label (#625)
|
||||||
|
- Fixed Synology pagination and album scroll leak (#644)
|
||||||
|
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||||
|
- Fixed Nominatim User-Agent and error diagnostics
|
||||||
|
- Fixed map tooltips, journey creation, and contributor avatars
|
||||||
|
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||||
|
- Fixed stale accommodation_id on reservation update (#522)
|
||||||
|
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||||
|
- Fixed MCP safeBroadcast recursive call bug
|
||||||
|
- Fixed Vite module preload polyfill CSP inline script violation
|
||||||
|
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
|
||||||
|
- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
|
||||||
|
- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
|
||||||
|
- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
|
||||||
|
- **Google Maps regex** — replaced too-permissive regex with safer utility function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||||
|
- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
|
||||||
|
- **Docker** — workflow improvements, tag management cleanup
|
||||||
|
- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who contributed to this release:
|
||||||
|
|
||||||
|
- @mauriceboe
|
||||||
|
- @jubnl
|
||||||
|
- @gravitysc
|
||||||
|
- @luojiyin1987
|
||||||
|
- @marco783
|
||||||
|
- @isaiastavares
|
||||||
|
- @tiquis0290
|
||||||
|
- @xenocent
|
||||||
|
- @gfrcsd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Commits | 280+ |
|
||||||
|
| Merged PRs | 49 |
|
||||||
|
| Files changed | 500+ |
|
||||||
|
| Lines added | 108,000+ |
|
||||||
|
| Contributors | 12 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull mauriceboe/trek:3.0.0
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on startup. No manual steps required.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||||
|
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||||
|
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||||
|
|
||||||
+49
-19
@@ -1,31 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS shared-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
RUN npm ci --workspace=shared
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:24-alpine AS client-builder
|
FROM node:24-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
COPY client/package.json ./client/
|
||||||
RUN npm run build
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
rm package-lock.json && \
|
|
||||||
apk del python3 make g++ && \
|
apk del python3 make g++ && \
|
||||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
COPY server/ ./
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
RUN rm -f package-lock.json && \
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -39,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
-11086
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.22",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,9 +12,13 @@
|
|||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trek/shared": "*",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
@@ -35,6 +39,7 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -57,6 +62,14 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { WeatherResult } from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import { isReachable, probeNow } from '../sync/connectivity'
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import en from '../i18n/translations/en'
|
import en from '../i18n/translations/en'
|
||||||
@@ -501,8 +502,8 @@ export const reservationsApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ describe('MapView', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
// Apple-Maps style draws a casing + a core line per segment.
|
||||||
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||||
@@ -155,16 +156,11 @@ describe('MapView', () => {
|
|||||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||||
const routeSegments = [
|
render(<MapView route={route} />)
|
||||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||||
]
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
|
||||||
// Route polyline is rendered
|
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
|
||||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
|
||||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||||
|
|||||||
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route travel time label ──
|
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||||
interface RouteLabelProps {
|
|
||||||
midpoint: [number, number]
|
|
||||||
walkingText: string
|
|
||||||
drivingText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|
||||||
if (!midpoint) return null
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'route-info-pill',
|
|
||||||
html: `<div style="
|
|
||||||
display:flex;align-items:center;gap:5px;
|
|
||||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
|
||||||
color:#fff;border-radius:99px;padding:3px 9px;
|
|
||||||
font-size:9px;font-weight:600;white-space:nowrap;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
||||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
|
||||||
pointer-events:none;
|
|
||||||
position:relative;left:-50%;top:-50%;
|
|
||||||
">
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
|
||||||
${walkingText}
|
|
||||||
</span>
|
|
||||||
<span style="opacity:0.3">|</span>
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
|
||||||
${drivingText}
|
|
||||||
</span>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [0, 0],
|
|
||||||
iconAnchor: [0, 0],
|
|
||||||
})
|
|
||||||
|
|
||||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
|
|||||||
{markers}
|
{markers}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 0 && (
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||||
<>
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||||
{route.map((seg, i) => seg.length > 1 && (
|
<Polyline
|
||||||
<Polyline
|
key={`${i}-casing`}
|
||||||
key={i}
|
positions={seg}
|
||||||
positions={seg}
|
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
color="#111827"
|
/>,
|
||||||
weight={3}
|
<Polyline
|
||||||
opacity={0.9}
|
key={`${i}-core`}
|
||||||
dashArray="6, 5"
|
positions={seg}
|
||||||
/>
|
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
))}
|
/>,
|
||||||
{routeSegments.map((seg, i) => (
|
] : [])}
|
||||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GPX imported route geometries */}
|
{/* GPX imported route geometries */}
|
||||||
{gpxPolylines}
|
{gpxPolylines}
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export function MapViewGL({
|
|||||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
|
|
||||||
// Refs so the reservation overlay always sees the latest callback /
|
// Refs so the reservation overlay always sees the latest callback /
|
||||||
// options without forcing a full overlay rebuild on every prop change.
|
// options without forcing a full overlay rebuild on every prop change.
|
||||||
const onReservationClickRef = useRef(onReservationClick)
|
const onReservationClickRef = useRef(onReservationClick)
|
||||||
@@ -218,16 +217,20 @@ export function MapViewGL({
|
|||||||
// initial route source — kept around so updates can setData() cheaply
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
if (!map.getSource('trip-route')) {
|
if (!map.getSource('trip-route')) {
|
||||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
|
||||||
|
// rounded. Casing is added first so it sits beneath the core line.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-route-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-route',
|
||||||
|
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'trip-route-line',
|
id: 'trip-route-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trip-route',
|
source: 'trip-route',
|
||||||
paint: {
|
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||||
'line-color': '#111827',
|
|
||||||
'line-width': 3,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
'line-dasharray': [2, 1.5],
|
|
||||||
},
|
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -444,34 +447,7 @@ export function MapViewGL({
|
|||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
// Travel-time pills between consecutive places. The GL map accepted the
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
// routeSegments prop but never drew anything, so the labels that Leaflet
|
|
||||||
// shows were missing here (#850). Render them as HTML markers, matching the
|
|
||||||
// Leaflet pill styling.
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current
|
|
||||||
if (!map || !mapReady) return
|
|
||||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
|
||||||
routeLabelMarkersRef.current = []
|
|
||||||
for (const seg of routeSegments) {
|
|
||||||
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.pointerEvents = 'none'
|
|
||||||
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
|
||||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
|
||||||
<span style="opacity:0.3">|</span>
|
|
||||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
|
||||||
</div>`
|
|
||||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
|
||||||
.setLngLat([seg.mid[1], seg.mid[0]])
|
|
||||||
.addTo(map)
|
|
||||||
routeLabelMarkersRef.current.push(m)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
|
||||||
routeLabelMarkersRef.current = []
|
|
||||||
}
|
|
||||||
}, [routeSegments, mapReady])
|
|
||||||
|
|
||||||
// Update GPX geometries
|
// Update GPX geometries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
|
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||||
|
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||||
|
// the matching profile so walking routes follow footpaths, not the road network.
|
||||||
|
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||||
|
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||||
|
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||||
|
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||||
|
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||||
|
const routeCache = new Map<string, RouteWithLegs>()
|
||||||
|
const ROUTE_CACHE_MAX = 200
|
||||||
|
|
||||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
|||||||
const walkingDuration = leg.distance / (5000 / 3600)
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
return {
|
return {
|
||||||
mid, from, to,
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||||
|
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||||
|
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
|
||||||
|
* straight line.
|
||||||
|
*/
|
||||||
|
export async function calculateRouteWithLegs(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
|
||||||
|
): Promise<RouteWithLegs> {
|
||||||
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
|
const cacheKey = `${profile}:${coords}`
|
||||||
|
const cached = routeCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error('Route could not be calculated')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||||
|
|
||||||
|
const route = data.routes[0]
|
||||||
|
const coordinates: [number, number][] = route.geometry.coordinates.map(
|
||||||
|
([lng, lat]: [number, number]) => [lat, lng]
|
||||||
|
)
|
||||||
|
const legs: RouteSegment[] = (route.legs || []).map(
|
||||||
|
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||||
|
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||||
|
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||||
|
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||||
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
|
return {
|
||||||
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
|
walkingText: formatDuration(walkingDuration),
|
||||||
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
|
durationText: formatDuration(leg.duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||||
|
routeCache.set(cacheKey, result)
|
||||||
|
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||||
|
const oldest = routeCache.keys().next().value
|
||||||
|
if (oldest !== undefined) routeCache.delete(oldest)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function formatDistance(meters: number): string {
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
|
|||||||
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
|
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 250 })).toBe(250);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
|
||||||
|
expect(itemWeight({ quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({})).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
|
|||||||
|
|
||||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||||
|
|
||||||
|
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||||
|
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||||
|
(i.weight_grams || 0) * (i.quantity || 1)
|
||||||
|
|
||||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BagCardProps {
|
interface BagCardProps {
|
||||||
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||||
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||||
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||||
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||||
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
// Find the pencil/edit button next to the title
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const editButtons = screen.getAllByRole('button')
|
|
||||||
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
|
|
||||||
// Click the edit (pencil) button — it's the small one near the title
|
|
||||||
// The pencil button is inside the title area with opacity 0.35
|
|
||||||
const titleEl = screen.getByText('Original Title')
|
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
// Enter edit mode
|
// Enter edit mode
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.keyboard('{Escape}')
|
await user.keyboard('{Escape}')
|
||||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||||
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
const titleEl = screen.getByText('Old Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Old Title')
|
const input = await screen.findByDisplayValue('Old Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
|
|
||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
const NOTE_ICONS = [
|
||||||
{ id: 'FileText', Icon: FileText },
|
{ id: 'FileText', Icon: FileText },
|
||||||
@@ -184,6 +184,10 @@ interface DayPlanSidebarProps {
|
|||||||
onExternalTransportDetailHandled?: () => void
|
onExternalTransportDetailHandled?: () => void
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
onNavigateToFiles?: () => void
|
onNavigateToFiles?: () => void
|
||||||
|
routeShown?: boolean
|
||||||
|
routeProfile?: 'driving' | 'walking'
|
||||||
|
onToggleRoute?: () => void
|
||||||
|
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
|
||||||
onAddPlace?: () => void
|
onAddPlace?: () => void
|
||||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||||
@@ -200,6 +204,25 @@ interface DayPlanSidebarProps {
|
|||||||
onScrollTopChange?: (top: number) => void
|
onScrollTopChange?: (top: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
|
function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving' | 'walking' }) {
|
||||||
|
const driving = profile === 'driving'
|
||||||
|
const Icon = driving ? Car : Footprints
|
||||||
|
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||||
|
<div style={line} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon size={11} strokeWidth={2} />
|
||||||
|
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{seg.distanceText}</span>
|
||||||
|
</div>
|
||||||
|
<div style={line} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||||
tripId,
|
tripId,
|
||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
@@ -216,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onAddPlace,
|
onAddPlace,
|
||||||
onAddPlaceToDay,
|
onAddPlaceToDay,
|
||||||
onNavigateToFiles,
|
onNavigateToFiles,
|
||||||
|
routeShown = false,
|
||||||
|
routeProfile = 'driving',
|
||||||
|
onToggleRoute,
|
||||||
|
onSetRouteProfile,
|
||||||
onExpandedDaysChange,
|
onExpandedDaysChange,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
@@ -233,6 +260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
|
||||||
const tripActions = useRef(useTripStore.getState()).current
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditDays = can('day_edit', trip)
|
const canEditDays = can('day_edit', trip)
|
||||||
@@ -251,6 +279,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
const [lockHoverId, setLockHoverId] = useState(null)
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
@@ -472,6 +502,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||||
|
|
||||||
|
// Per-segment driving times for the selected day's connectors. Groups located
|
||||||
|
// places into runs (split at transports), one cached OSRM call per run, keyed by
|
||||||
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
|
useEffect(() => {
|
||||||
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
|
if (!selectedDayId || !routeCalcEnabled || !routeShown) { setRouteLegs({}); return }
|
||||||
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
|
for (const it of merged) {
|
||||||
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
|
} else if (it.type === 'transport') {
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
cur = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
if (runs.length === 0) { setRouteLegs({}); return }
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
legsAbortRef.current = controller
|
||||||
|
;(async () => {
|
||||||
|
const map: Record<number, RouteSegment> = {}
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile })
|
||||||
|
r.legs.forEach((leg, i) => { map[run[i].id] = leg })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) setRouteLegs(map)
|
||||||
|
})()
|
||||||
|
}, [selectedDayId, routeCalcEnabled, routeShown, routeProfile, mergedItemsMap])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -792,13 +858,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleMaps = () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
|
|
||||||
if (url) window.open(url, '_blank')
|
|
||||||
else toast.error(t('dayplan.toast.noGeoPlaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDropOnDay = (e, dayId) => {
|
const handleDropOnDay = (e, dayId) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -1047,6 +1106,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
|
className="dp-day-header"
|
||||||
|
data-selected={isSelected}
|
||||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||||
@@ -1066,16 +1127,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Tages-Badge */}
|
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||||
<div style={{
|
{(() => {
|
||||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
return (
|
||||||
fontSize: 11, fontWeight: 700,
|
<div style={{
|
||||||
}}>
|
flexShrink: 0, alignSelf: 'flex-start',
|
||||||
{index + 1}
|
width: hasWeather ? 34 : 26,
|
||||||
</div>
|
borderRadius: hasWeather ? 11 : '50%',
|
||||||
|
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||||
|
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{hasWeather && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||||
|
<div style={{ padding: '3px 0 4px' }}>
|
||||||
|
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{editingDayId === day.id ? (
|
{editingDayId === day.id ? (
|
||||||
@@ -1093,40 +1172,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
borderBottom: '1.5px solid var(--text-primary)',
|
borderBottom: '1.5px solid var(--text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||||
</span>
|
</span>
|
||||||
{canEditDays && <button
|
{formattedDate && (
|
||||||
onClick={e => startEditTitle(day, e)}
|
<>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
|
||||||
>
|
<span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
|
||||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
{formattedDate}
|
||||||
</button>}
|
</span>
|
||||||
{canEditDays && onAddTransport && (
|
</>
|
||||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
|
||||||
aria-label={t('transport.addTransport')}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: 0.45,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
|
||||||
>
|
|
||||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
|
const hasRentals = getActiveRentalsForDay(day.id).length > 0
|
||||||
|
if (!hasAccs && !hasRentals) return null
|
||||||
|
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||||
|
})()}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
// Sort: check-out first, then ongoing stays, then check-in last
|
// Sort: check-out first, then ongoing stays, then check-in last
|
||||||
@@ -1145,13 +1211,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return dayAccs.map(acc => {
|
return dayAccs.map(acc => {
|
||||||
const isCheckIn = acc.start_day_id === day.id
|
const isCheckIn = acc.start_day_id === day.id
|
||||||
const isCheckOut = acc.end_day_id === day.id
|
const isCheckOut = acc.end_day_id === day.id
|
||||||
const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
|
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
|
||||||
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
|
|
||||||
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
|
|
||||||
return (
|
return (
|
||||||
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
|
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
|
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1161,41 +1225,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const activeRentals = getActiveRentalsForDay(day.id)
|
const activeRentals = getActiveRentalsForDay(day.id)
|
||||||
if (activeRentals.length === 0) return null
|
if (activeRentals.length === 0) return null
|
||||||
return activeRentals.map(r => (
|
return activeRentals.map(r => (
|
||||||
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
<Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cost && (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
|
||||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
|
||||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
|
||||||
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
|
||||||
{day.date && anyGeoPlace && (() => {
|
|
||||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
|
||||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
|
||||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
{canEditDays ? (
|
||||||
onClick={e => openAddNote(day.id, e)}
|
(() => {
|
||||||
aria-label={t('dayplan.addNote')}
|
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
const div = '1px solid var(--border-faint)'
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
return (
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
|
||||||
>
|
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||||
<FileText size={16} strokeWidth={2} />
|
<Pencil size={14} strokeWidth={1.8} />
|
||||||
</button></Tooltip>}
|
</button>
|
||||||
<button
|
{onAddTransport ? (
|
||||||
onClick={e => toggleDay(day.id, e)}
|
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
<Plus size={14} strokeWidth={1.8} />
|
||||||
>
|
</button>
|
||||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
) : <div style={{ borderBottom: div }} />}
|
||||||
</button>
|
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||||
|
<FileText size={14} strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aufgeklappte Orte + Notizen */}
|
{/* Aufgeklappte Orte + Notizen */}
|
||||||
@@ -1607,6 +1680,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1730,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle'}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||||
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
|
// matches how place/note items initiate their drag.
|
||||||
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
|
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||||
setDraggingId(res.id)
|
setDraggingId(res.id)
|
||||||
@@ -1893,7 +1971,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||||
}
|
}
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
@@ -1909,6 +1987,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
|
else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId)
|
||||||
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true)
|
||||||
|
setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dropTargetKey === `end-${day.id}` && (
|
{dropTargetKey === `end-${day.id}` && (
|
||||||
@@ -1919,15 +2000,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{routeInfo && (
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<button
|
||||||
<span>{routeInfo.distance}</span>
|
onClick={() => onToggleRoute?.()}
|
||||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
style={{
|
||||||
<span>{routeInfo.duration}</span>
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
</div>
|
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
|
||||||
)}
|
border: routeShown ? 'none' : '1px solid var(--border-faint)',
|
||||||
|
background: routeShown ? 'var(--accent)' : 'transparent',
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
color: routeShown ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
|
{t('dayplan.route')}
|
||||||
|
</button>
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
@@ -1936,14 +2023,35 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<RotateCcw size={12} strokeWidth={2} />
|
<RotateCcw size={12} strokeWidth={2} />
|
||||||
{t('dayplan.optimize')}
|
{t('dayplan.optimize')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleGoogleMaps} style={{
|
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{(['driving', 'walking'] as const).map(p => {
|
||||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
const ModeIcon = p === 'driving' ? Car : Footprints
|
||||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
const active = routeProfile === p
|
||||||
}}>
|
return (
|
||||||
<ExternalLink size={12} strokeWidth={2} />
|
<button
|
||||||
</button>
|
key={p}
|
||||||
|
onClick={() => onSetRouteProfile?.(p)}
|
||||||
|
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModeIcon size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{routeInfo && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||||
|
<span>{routeInfo.distance}</span>
|
||||||
|
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||||
|
<span>{routeInfo.duration}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
|
|||||||
lng: number | null
|
lng: number | null
|
||||||
date: string
|
date: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
|
||||||
|
stacked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
|
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
|
||||||
const [weather, setWeather] = useState(null)
|
const [weather, setWeather] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
@@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
|||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const isClimate = weather.type === 'climate'
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||||
|
<WeatherIcon main={weather.main} size={13} />
|
||||||
|
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
|||||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
@@ -22,7 +22,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
|
||||||
|
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
|
||||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||||
// updates or non-optimistic deletes always see the latest assignments.
|
// updates or non-optimistic deletes always see the latest assignments.
|
||||||
const currentAssignments = useTripStore.getState().assignments || {}
|
const currentAssignments = useTripStore.getState().assignments || {}
|
||||||
@@ -67,35 +68,52 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
})),
|
})),
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
const segments: [number, number][][] = []
|
// Group consecutive located places into runs, resetting whenever a transport
|
||||||
let currentSeg: [number, number][] = []
|
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||||
|
const runs: { lat: number; lng: number }[][] = []
|
||||||
|
let currentRun: { lat: number; lng: number }[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'place') {
|
if (entry.kind === 'place') {
|
||||||
currentSeg.push([entry.lat, entry.lng])
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||||
} else {
|
} else {
|
||||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
currentSeg = []
|
currentRun = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
const straightLines = (): [number, number][][] =>
|
||||||
|
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
setRoute(null); setRouteSegments([]); return
|
|
||||||
}
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
setRoute(segments.length > 0 ? segments : null)
|
// OSRM road geometry. If route calc is disabled, keep the straight lines.
|
||||||
|
setRoute(straightLines())
|
||||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
routeAbortRef.current = controller
|
routeAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
const polylines: [number, number][][] = []
|
||||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
const allLegs: RouteSegment[] = []
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
allLegs.push(...r.legs)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') throw err
|
||||||
|
// OSRM failed for this run — fall back to a straight line, no times.
|
||||||
|
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||||
else if (!(err instanceof Error)) setRouteSegments([])
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [routeCalcEnabled])
|
}, [routeCalcEnabled, enabled, profile])
|
||||||
|
|
||||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||||
@@ -117,7 +135,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import es from './translations/es'
|
|||||||
import fr from './translations/fr'
|
import fr from './translations/fr'
|
||||||
import hu from './translations/hu'
|
import hu from './translations/hu'
|
||||||
import it from './translations/it'
|
import it from './translations/it'
|
||||||
|
import tr from './translations/tr'
|
||||||
import ru from './translations/ru'
|
import ru from './translations/ru'
|
||||||
import zh from './translations/zh'
|
import zh from './translations/zh'
|
||||||
import zhTw from './translations/zhTw'
|
import zhTw from './translations/zhTw'
|
||||||
@@ -15,6 +16,10 @@ import ar from './translations/ar'
|
|||||||
import br from './translations/br'
|
import br from './translations/br'
|
||||||
import cs from './translations/cs'
|
import cs from './translations/cs'
|
||||||
import pl from './translations/pl'
|
import pl from './translations/pl'
|
||||||
|
import ja from './translations/ja'
|
||||||
|
import ko from './translations/ko'
|
||||||
|
import uk from './translations/uk'
|
||||||
|
import gr from './translations/gr'
|
||||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
@@ -23,7 +28,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
|
|||||||
|
|
||||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
||||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
||||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, ko, uk, gr,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||||
@@ -38,7 +43,7 @@ export function getLocaleForLanguage(language: string): string {
|
|||||||
|
|
||||||
export function getIntlLanguage(language: string): string {
|
export function getIntlLanguage(language: string): string {
|
||||||
if (language === 'br') return 'pt-BR'
|
if (language === 'br') return 'pt-BR'
|
||||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
|
return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja', 'ko', 'uk', 'gr'].includes(language) ? language : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||||
|
{ value: 'tr', label: 'Türkçe', locale: 'tr-TR' },
|
||||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
||||||
|
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
|
||||||
|
{ value: 'ko', label: '한국어', locale: 'ko-KR' },
|
||||||
|
{ value: 'uk', label: 'Українська', locale: 'uk-UA' },
|
||||||
|
{ value: 'gr', label: 'Ελληνικά', locale: 'el-GR' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -812,3 +812,21 @@ img[alt="TREK"] {
|
|||||||
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||||
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
||||||
|
|
||||||
|
/* Day-plan header action grid (edit / +transport / note / collapse) */
|
||||||
|
.dp-day-actions button {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.dp-day-actions button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
/* Reveal the action grid only when hovering the day row (pointer devices).
|
||||||
|
Touch devices (hover: none) keep it visible; the selected day stays visible too. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.dp-day-actions { opacity: 0; transition: opacity 0.12s ease; }
|
||||||
|
.dp-day-header:hover .dp-day-actions,
|
||||||
|
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||||
|
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
||||||
|
// (driving/walking) is per-session and selects which travel time the connectors show.
|
||||||
|
const [routeShown, setRouteShown] = useState(false)
|
||||||
|
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
|
||||||
const [fitKey, setFitKey] = useState<number>(0)
|
const [fitKey, setFitKey] = useState<number>(0)
|
||||||
const initialFitTripId = useRef<number | null>(null)
|
const initialFitTripId = useRef<number | null>(null)
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||||
@@ -398,7 +402,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
@@ -891,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
accommodations={tripAccommodations}
|
accommodations={tripAccommodations}
|
||||||
|
routeShown={routeShown}
|
||||||
|
routeProfile={routeProfile}
|
||||||
|
onToggleRoute={() => setRouteShown(v => !v)}
|
||||||
|
onSetRouteProfile={setRouteProfile}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
onExpandedDaysChange={setExpandedDayIds}
|
onExpandedDaysChange={setExpandedDayIds}
|
||||||
pushUndo={pushUndo}
|
pushUndo={pushUndo}
|
||||||
@@ -1117,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -237,8 +237,19 @@ export interface RouteSegment {
|
|||||||
mid: [number, number]
|
mid: [number, number]
|
||||||
from: [number, number]
|
from: [number, number]
|
||||||
to: [number, number]
|
to: [number, number]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
walkingText: string
|
walkingText: string
|
||||||
drivingText: string
|
drivingText: string
|
||||||
|
distanceText: string
|
||||||
|
durationText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteWithLegs {
|
||||||
|
coordinates: [number, number][]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
|
legs: RouteSegment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteResult {
|
export interface RouteResult {
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import type { RouteSegment } from '../../../src/types';
|
|||||||
|
|
||||||
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
||||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||||
calculateSegments: vi.fn(),
|
calculateRouteWithLegs: vi.fn(),
|
||||||
calculateRoute: vi.fn(),
|
calculateRoute: vi.fn(),
|
||||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||||
generateGoogleMapsUrl: vi.fn(),
|
generateGoogleMapsUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
|
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
||||||
|
|
||||||
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||||
@@ -27,14 +27,23 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
|
|||||||
|
|
||||||
const MOCK_SEGMENTS: RouteSegment[] = [
|
const MOCK_SEGMENTS: RouteSegment[] = [
|
||||||
{
|
{
|
||||||
from: [48.8566, 2.3522],
|
distance: 343000,
|
||||||
to: [51.5074, -0.1278],
|
duration: 12600,
|
||||||
mid: [50.182, 1.1122],
|
distanceText: '343 km',
|
||||||
walkingText: '120 min',
|
durationText: '3 h 30 min',
|
||||||
drivingText: '90 min',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Empty coordinates make the hook fall back to the straight-line geometry,
|
||||||
|
// so the `route` assertions keep checking the raw waypoints while the legs
|
||||||
|
// still flow through to `routeSegments`.
|
||||||
|
const MOCK_ROUTE_WITH_LEGS = {
|
||||||
|
coordinates: [] as [number, number][],
|
||||||
|
distance: 343000,
|
||||||
|
duration: 12600,
|
||||||
|
legs: MOCK_SEGMENTS,
|
||||||
|
};
|
||||||
|
|
||||||
describe('useRouteCalculation', () => {
|
describe('useRouteCalculation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -42,7 +51,7 @@ describe('useRouteCalculation', () => {
|
|||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||||
// Reset trip store assignments so each test starts clean
|
// Reset trip store assignments so each test starts clean
|
||||||
useTripStore.setState({ assignments: {} } as any);
|
useTripStore.setState({ assignments: {} } as any);
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
||||||
@@ -84,7 +93,7 @@ describe('useRouteCalculation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => {
|
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateRouteWithLegs', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||||
@@ -99,11 +108,11 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
expect(calculateSegments).toHaveBeenCalled();
|
expect(calculateRouteWithLegs).toHaveBeenCalled();
|
||||||
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => {
|
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateRouteWithLegs', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||||
@@ -118,7 +127,7 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
expect(calculateSegments).not.toHaveBeenCalled();
|
expect(calculateRouteWithLegs).not.toHaveBeenCalled();
|
||||||
expect(result.current.routeSegments).toEqual([]);
|
expect(result.current.routeSegments).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,13 +172,13 @@ describe('useRouteCalculation', () => {
|
|||||||
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
// Make calculateSegments resolve slowly
|
// Make calculateRouteWithLegs resolve slowly
|
||||||
let resolveSegments!: (val: RouteSegment[]) => void;
|
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||||
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
||||||
return new Promise<RouteSegment[]>((resolve) => {
|
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
|
||||||
resolveSegments = resolve;
|
resolveSegments = resolve;
|
||||||
options?.signal?.addEventListener('abort', () => resolve([]));
|
options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -191,12 +200,12 @@ describe('useRouteCalculation', () => {
|
|||||||
rerender({ dayId: 6 });
|
rerender({ dayId: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// calculateSegments should have been called at least once for day 5
|
// calculateRouteWithLegs should have been called at least once for day 5
|
||||||
// and once more for day 6
|
// and once more for day 6
|
||||||
expect((calculateSegments as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
resolveSegments?.([]);
|
resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
||||||
@@ -204,7 +213,7 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
const abortError = new Error('Aborted');
|
const abortError = new Error('Aborted');
|
||||||
abortError.name = 'AbortError';
|
abortError.name = 'AbortError';
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
@@ -224,7 +233,7 @@ describe('useRouteCalculation', () => {
|
|||||||
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ describe('isRtlLanguage', () => {
|
|||||||
describe('SUPPORTED_LANGUAGES', () => {
|
describe('SUPPORTED_LANGUAGES', () => {
|
||||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||||
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
|
expect(SUPPORTED_LANGUAGES).toHaveLength(20)
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
|
||||||
|
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||||
|
|
||||||
|
describe('@trek/shared resolves in the client toolchain', () => {
|
||||||
|
it('imports and uses a shared schema', () => {
|
||||||
|
expect(idParamSchema.parse('7')).toBe(7);
|
||||||
|
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@trek/shared": ["../shared/src/index.ts"],
|
||||||
|
"@trek/shared/*": ["../shared/src/*"]
|
||||||
|
},
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
Generated
+19492
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@trek/root",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.22",
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"version:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:premajor": "npm version premajor --preid=rc --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:preminor": "npm version preminor --preid=beta --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prepatch": "npm version prepatch --preid=alpha --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prerelease": "npm version prerelease --preid=pre --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"dev": "npm run build --workspace=shared && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
|
||||||
|
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
|
||||||
|
"test": "npm run test --workspace=shared && npm run test --workspace=server && npm run test --workspace=client",
|
||||||
|
"test:cov": "npm run test:coverage --workspace=server && npm run test:coverage --workspace=client",
|
||||||
|
"test:e2e": "npm run test:e2e --workspace=server",
|
||||||
|
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||||
|
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
|
||||||
|
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
-6187
File diff suppressed because it is too large
Load Diff
+34
-5
@@ -1,19 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "@trek/server",
|
||||||
"version": "3.0.22",
|
"version": "3.0.22",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"build": "node scripts/build.mjs",
|
||||||
|
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:ws": "vitest run tests/websocket",
|
"test:ws": "vitest run tests/websocket",
|
||||||
|
"test:parity": "vitest run tests/parity",
|
||||||
|
"test:e2e": "vitest run tests/e2e",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trek/shared": "*",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
|
"@nestjs/common": "^11.1.24",
|
||||||
|
"@nestjs/core": "^11.1.24",
|
||||||
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
@@ -30,22 +43,37 @@
|
|||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"hono": "^4.12.16",
|
"hono": "^4.12.16",
|
||||||
"@hono/node-server": "^1.19.13",
|
"@hono/node-server": "^1.19.13",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"ip-address": "^10.1.1"
|
"ip-address": "^10.1.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"ws": "^8.21.0",
|
||||||
|
"qs": "^6.15.2",
|
||||||
|
"file-type": "^21.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"@nestjs/testing": "^11.1.24",
|
||||||
|
"@swc/core": "^1.15.40",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
@@ -67,6 +95,7 @@
|
|||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tz-lookup": "^6.1.25",
|
"tz-lookup": "^6.1.25",
|
||||||
|
"unplugin-swc": "^1.5.9",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[build] dist ready.');
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { execSync, spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
console.log('[dev] initial build...');
|
||||||
|
execSync('node scripts/build.mjs', { stdio: 'inherit' });
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
|
||||||
|
process.on('SIGINT', stop);
|
||||||
|
process.on('SIGTERM', stop);
|
||||||
|
|
||||||
|
// Start tsc -w and wait for its first "Watching for file changes." before launching
|
||||||
|
// node --watch, so the initial tsc compilation doesn't trigger a spurious restart.
|
||||||
|
const tsc = spawn('npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput'], {
|
||||||
|
stdio: ['ignore', 'pipe', 'inherit'],
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
children.push(tsc);
|
||||||
|
|
||||||
|
let nodeProc = null;
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
tsc.stdout.on('data', (chunk) => {
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
if (!ready && chunk.toString().includes('Watching for file changes')) {
|
||||||
|
ready = true;
|
||||||
|
nodeProc = spawn('node', ['--require', 'tsconfig-paths/register', '--watch', 'dist/index.js'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
children.push(nodeProc);
|
||||||
|
}
|
||||||
|
});
|
||||||
+3
-3
@@ -26,7 +26,6 @@ import airportsRoutes from './routes/airports';
|
|||||||
import filesRoutes from './routes/files';
|
import filesRoutes from './routes/files';
|
||||||
import reservationsRoutes from './routes/reservations';
|
import reservationsRoutes from './routes/reservations';
|
||||||
import dayNotesRoutes from './routes/dayNotes';
|
import dayNotesRoutes from './routes/dayNotes';
|
||||||
import weatherRoutes from './routes/weather';
|
|
||||||
import settingsRoutes from './routes/settings';
|
import settingsRoutes from './routes/settings';
|
||||||
import budgetRoutes from './routes/budget';
|
import budgetRoutes from './routes/budget';
|
||||||
import collabRoutes from './routes/collab';
|
import collabRoutes from './routes/collab';
|
||||||
@@ -135,7 +134,7 @@ export function createApp(): express.Application {
|
|||||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||||
"https://router.project-osrm.org/route/v1/",
|
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||||
],
|
],
|
||||||
workerSrc: ["'self'", "blob:"],
|
workerSrc: ["'self'", "blob:"],
|
||||||
@@ -361,7 +360,8 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/photos', photoRoutes);
|
app.use('/api/photos', photoRoutes);
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/airports', airportsRoutes);
|
app.use('/api/airports', airportsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
// /api/weather is served by the NestJS weather module (see src/nest/weather);
|
||||||
|
// the legacy Express route was decommissioned after the migration (L1).
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/system-notices', systemNoticesRoutes);
|
app.use('/api/system-notices', systemNoticesRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
app.use('/api/backup', backupRoutes);
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { runMigrations } from './migrations';
|
|||||||
import { runSeeds } from './seeds';
|
import { runSeeds } from './seeds';
|
||||||
import { Place, Tag } from '../types';
|
import { Place, Tag } from '../types';
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '../../data');
|
// In test mode each vitest worker gets an isolated in-memory DB so that
|
||||||
if (!fs.existsSync(dataDir)) {
|
// parallel forks can't race on the same file or share migration state.
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = path.join(dataDir, 'travel.db');
|
let dbPath: string;
|
||||||
|
if (isTest) {
|
||||||
|
dbPath = ':memory:';
|
||||||
|
} else {
|
||||||
|
const dataDir = path.join(__dirname, '../../data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
dbPath = path.join(dataDir, 'travel.db');
|
||||||
|
}
|
||||||
|
|
||||||
let _db: Database.Database | null = null;
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
|||||||
+56
-5
@@ -1,7 +1,16 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ExpressAdapter } from '@nestjs/platform-express';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
import { AppModule } from './nest/app.module';
|
||||||
|
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
|
||||||
|
|
||||||
// Create upload and data directories on startup
|
// Create upload and data directories on startup
|
||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
|||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createApp();
|
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
|
||||||
|
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
|
||||||
|
// everything else falls through to this app via a fallback middleware.
|
||||||
|
const legacyApp = createApp();
|
||||||
|
|
||||||
import * as scheduler from './scheduler';
|
import * as scheduler from './scheduler';
|
||||||
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||||
@@ -49,6 +61,11 @@ const onListen = () => {
|
|||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
];
|
];
|
||||||
banner.forEach(l => console.log(l));
|
banner.forEach(l => console.log(l));
|
||||||
|
sLogInfo(
|
||||||
|
NEST_PREFIXES.length
|
||||||
|
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
|
||||||
|
: 'NestJS prefixes: none — all routes served by the legacy Express app',
|
||||||
|
);
|
||||||
if (process.env.APP_URL) {
|
if (process.env.APP_URL) {
|
||||||
let parsedAppUrl: URL | null = null;
|
let parsedAppUrl: URL | null = null;
|
||||||
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||||
@@ -84,9 +101,42 @@ const onListen = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = HOST
|
let server: http.Server;
|
||||||
? app.listen(PORT, HOST, onListen)
|
let nestApp: INestApplication;
|
||||||
: app.listen(PORT, onListen);
|
|
||||||
|
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
||||||
|
const NEST_PREFIXES = getNestPrefixes();
|
||||||
|
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
// Nest runs on its own Express instance (bodyParser off so request bodies reach
|
||||||
|
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
|
||||||
|
// Nest body parsing is safe here: the dispatcher only forwards migrated
|
||||||
|
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
|
||||||
|
// is reached separately and never passes through Nest's parser.
|
||||||
|
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||||
|
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
|
||||||
|
nestApp.use(cookieParser());
|
||||||
|
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
|
||||||
|
await nestApp.init();
|
||||||
|
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||||
|
|
||||||
|
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
|
||||||
|
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
|
||||||
|
// only applies within migrated prefixes.
|
||||||
|
const top = express();
|
||||||
|
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
|
||||||
|
top.use(legacyApp);
|
||||||
|
|
||||||
|
server = http.createServer(top);
|
||||||
|
if (HOST) server.listen(PORT, HOST, onListen);
|
||||||
|
else server.listen(PORT, onListen);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('Fatal: failed to bootstrap server', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
function shutdown(signal: string): void {
|
function shutdown(signal: string): void {
|
||||||
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
|
|||||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
closeMcpSessions();
|
closeMcpSessions();
|
||||||
|
void nestApp?.close();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
sLogInfo('HTTP server closed');
|
sLogInfo('HTTP server closed');
|
||||||
const { closeDb } = require('./db/database');
|
const { closeDb } = require('./db/database');
|
||||||
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
export default app;
|
export default legacyApp;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# NestJS migration layer — module & test guide
|
||||||
|
|
||||||
|
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
|
||||||
|
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
|
||||||
|
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
|
||||||
|
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
|
||||||
|
its shape when migrating a new domain.
|
||||||
|
|
||||||
|
## Module layout (per domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
|
||||||
|
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
|
||||||
|
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
|
||||||
|
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
|
||||||
|
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
|
||||||
|
rollback, no redeploy).
|
||||||
|
|
||||||
|
## Parity is law
|
||||||
|
|
||||||
|
A migrated route must be **byte-identical** for the client: same URL, method,
|
||||||
|
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
|
||||||
|
strings. Where the legacy route returns a hand-written error (e.g. weather's
|
||||||
|
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
|
||||||
|
the controller rather than relying on the generic `ZodValidationPipe` envelope.
|
||||||
|
|
||||||
|
## How to write the tests
|
||||||
|
|
||||||
|
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
|
||||||
|
scoped to `src/nest/**`) requires ≥80%.
|
||||||
|
|
||||||
|
1. **Service / controller unit spec** — `tests/unit/nest/<domain>.controller.test.ts`.
|
||||||
|
Instantiate the controller with a mocked service; assert status codes, the exact
|
||||||
|
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
|
||||||
|
See `weather.controller.test.ts`.
|
||||||
|
|
||||||
|
2. **Parity test** — `tests/parity/<domain>.parity.test.ts`. Mock the shared service
|
||||||
|
identically for both apps, then fire the same request at the Express route and the
|
||||||
|
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
|
||||||
|
assert identical status + body. This is the gate before flipping the toggle.
|
||||||
|
See `weather.parity.test.ts`.
|
||||||
|
|
||||||
|
3. **e2e** — `tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
|
||||||
|
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
|
||||||
|
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
|
||||||
|
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
|
||||||
|
(HTTP/etc.). See `weather.e2e.test.ts`.
|
||||||
|
|
||||||
|
## Definition of Done (per module)
|
||||||
|
|
||||||
|
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
|
||||||
|
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
|
||||||
|
Nest → parity verified on the demo DB → **then** decommission the old Express
|
||||||
|
route/service (separate step, after the toggle is confirmed in prod) → frontend points
|
||||||
|
at the typed contract (Frontend Track).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { HealthController } from './health/health.controller';
|
||||||
|
import { HealthService } from './health/health.service';
|
||||||
|
import { WeatherModule } from './weather/weather.module';
|
||||||
|
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root NestJS module for the incremental migration. Domain modules
|
||||||
|
* (weather, notifications, ...) get registered here as they are migrated.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, WeatherModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
// Global error-envelope normaliser (DI-registered so it also catches
|
||||||
|
// framework-level exceptions like the not-found handler).
|
||||||
|
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { User } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
|
||||||
|
* Use together with JwtAuthGuard (which populates req.user):
|
||||||
|
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { User } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the authenticated user attached by JwtAuthGuard.
|
||||||
|
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): User | undefined => {
|
||||||
|
return context.switchToHttp().getRequest().user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates TREK's existing JWT session — the same httpOnly `trek_session`
|
||||||
|
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
|
||||||
|
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
|
||||||
|
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
|
||||||
|
*
|
||||||
|
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) {
|
||||||
|
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
|
||||||
|
}
|
||||||
|
const user = verifyJwtAndLoadUser(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||||
|
}
|
||||||
|
(req as Request & { user?: unknown }).user = user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises every Nest exception to TREK's legacy error envelope so migrated
|
||||||
|
* routes are byte-identical for the client:
|
||||||
|
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
|
||||||
|
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
|
||||||
|
* This replaces Nest's default { statusCode, message, error } body, which the
|
||||||
|
* TREK client does not expect.
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class TrekExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const res = host.switchToHttp().getResponse<Response>();
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const body = exception.getResponse();
|
||||||
|
|
||||||
|
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
|
||||||
|
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
|
||||||
|
res.status(status).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
|
||||||
|
const message =
|
||||||
|
status < 500
|
||||||
|
? Array.isArray(raw)
|
||||||
|
? raw.join(', ')
|
||||||
|
: String(raw ?? 'Error')
|
||||||
|
: 'Internal server error';
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown/unhandled error — mirror the legacy 500 behaviour.
|
||||||
|
console.error('Unhandled error:', exception);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
|
||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
|
||||||
|
* and returns the parsed, typed value. On failure it throws TREK's error envelope
|
||||||
|
* `{ error: string }` with status 400 — the same shape the legacy routes produce,
|
||||||
|
* so the client's error handling is unaffected.
|
||||||
|
*
|
||||||
|
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
|
constructor(private readonly schema: ZodType) {}
|
||||||
|
|
||||||
|
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues
|
||||||
|
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new HttpException({ error: message }, 400);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so every migrated module can inject DatabaseService without re-importing.
|
||||||
|
* Wraps the existing better-sqlite3 singleton (no new connection).
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [DatabaseService],
|
||||||
|
exports: [DatabaseService],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { db } from '../../db/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injectable wrapper around TREK's existing better-sqlite3 connection.
|
||||||
|
*
|
||||||
|
* `db` is a Proxy onto the singleton connection the legacy app already uses
|
||||||
|
* (WAL enabled), so Nest modules share the exact same connection — no second
|
||||||
|
* connection, no split state, single writer preserved.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseService {
|
||||||
|
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
|
||||||
|
get connection(): Database.Database {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(sql: string): Database.Statement {
|
||||||
|
return db.prepare(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
|
||||||
|
return db.prepare(sql).get(...params) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
|
||||||
|
return db.prepare(sql).all(...params) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
run(sql: string, ...params: unknown[]): Database.RunResult {
|
||||||
|
return db.prepare(sql).run(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
|
||||||
|
transaction<T>(fn: (conn: Database.Database) => T): T {
|
||||||
|
return db.transaction(() => fn(db))();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { User } from '../../types';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
|
||||||
|
// Local demo schema (real domains import their schema from @trek/shared).
|
||||||
|
const echoSchema = z.object({ name: z.string().min(1) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundation smoke endpoints for the co-hosted NestJS app.
|
||||||
|
* Proves: boot, routing, type-based DI, the shared SQLite connection, the
|
||||||
|
* JWT-cookie auth guard, and the Zod validation pipe + error-envelope parity.
|
||||||
|
*
|
||||||
|
* Lives under /api/_nest/* so it never collides with the legacy Express API.
|
||||||
|
*/
|
||||||
|
@Controller('api/_nest')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get('health')
|
||||||
|
getHealth() {
|
||||||
|
return { ok: true, ...this.healthService.info() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guarded: returns the authenticated user, proving JwtAuthGuard + @CurrentUser. */
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
me(@CurrentUser() user: User) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validated: proves the Zod pipe (400 + { error } on failure) and body parsing. */
|
||||||
|
@Post('echo')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
echo(@Body(new ZodValidationPipe(echoSchema)) body: z.infer<typeof echoSchema>) {
|
||||||
|
return { youSent: body };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke service proving NestJS DI works under the chosen runtime AND that the
|
||||||
|
* injected DatabaseService talks to TREK's existing SQLite connection.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
const row = this.database.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
|
||||||
|
return {
|
||||||
|
runtime: 'nestjs',
|
||||||
|
diInjected: true,
|
||||||
|
// Proof the shared connection works: real row count from the existing DB.
|
||||||
|
userCount: row?.n ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Strangler toggle for the incremental NestJS migration.
|
||||||
|
*
|
||||||
|
* `getNestPrefixes()` returns the request path prefixes that NestJS handles;
|
||||||
|
* every other path falls through to the legacy Express app. The default is the
|
||||||
|
* set of prefixes whose Nest modules exist. Operators can override it at runtime
|
||||||
|
* via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
|
||||||
|
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
|
||||||
|
* everything back to the legacy app.
|
||||||
|
*/
|
||||||
|
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
|
||||||
|
|
||||||
|
export function getNestPrefixes(): string[] {
|
||||||
|
const raw = process.env.NEST_PREFIXES;
|
||||||
|
if (raw !== undefined) {
|
||||||
|
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return DEFAULT_NEST_PREFIXES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
|
||||||
|
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
|
||||||
|
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import type { WeatherResult } from '@trek/shared';
|
||||||
|
import { WeatherService } from './weather.service';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { ApiError } from '../../services/weatherService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/weather — first migrated leaf module (the pilot).
|
||||||
|
*
|
||||||
|
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
|
||||||
|
* weather.ts): same paths, query params, status codes and `{ error }` bodies.
|
||||||
|
*
|
||||||
|
* Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
|
||||||
|
* strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
|
||||||
|
* rather than derived from the schema. The Zod contract/types live in
|
||||||
|
* @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
|
||||||
|
* the param is absent, matching the Express destructuring default.
|
||||||
|
*/
|
||||||
|
@Controller('api/weather')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class WeatherController {
|
||||||
|
constructor(private readonly weather: WeatherService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getWeather(
|
||||||
|
@Query('lat') lat?: string,
|
||||||
|
@Query('lng') lng?: string,
|
||||||
|
@Query('date') date?: string,
|
||||||
|
@Query('lang') lang?: string,
|
||||||
|
): Promise<WeatherResult> {
|
||||||
|
if (!lat || !lng) {
|
||||||
|
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.weather.get(lat, lng, date, lang ?? 'de');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('detailed')
|
||||||
|
async getDetailed(
|
||||||
|
@Query('lat') lat?: string,
|
||||||
|
@Query('lng') lng?: string,
|
||||||
|
@Query('date') date?: string,
|
||||||
|
@Query('lang') lang?: string,
|
||||||
|
): Promise<WeatherResult> {
|
||||||
|
if (!lat || !lng || !date) {
|
||||||
|
throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
|
||||||
|
function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
return new HttpException({ error: err.message }, err.status);
|
||||||
|
}
|
||||||
|
console.error(logPrefix, err);
|
||||||
|
return new HttpException({ error: fallback }, 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WeatherController } from './weather.controller';
|
||||||
|
import { WeatherService } from './weather.service';
|
||||||
|
|
||||||
|
/** Weather domain (pilot leaf module). Registered in AppModule. */
|
||||||
|
@Module({
|
||||||
|
controllers: [WeatherController],
|
||||||
|
providers: [WeatherService],
|
||||||
|
})
|
||||||
|
export class WeatherModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { WeatherResult } from '@trek/shared';
|
||||||
|
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin Nest wrapper around the existing weather service. It delegates to the
|
||||||
|
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
|
||||||
|
* the MCP tools use, so behaviour — including the shared in-memory cache and the
|
||||||
|
* Open-Meteo calls — is identical. No logic is duplicated; the upstream service
|
||||||
|
* stays the single source of truth (still consumed by the MCP weather tools).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WeatherService {
|
||||||
|
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
|
||||||
|
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
|
||||||
|
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import express, { Request, Response } from 'express';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
|
||||||
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
|
|
||||||
|
|
||||||
if (!lat || !lng) {
|
|
||||||
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getWeather(lat, lng, date, lang);
|
|
||||||
res.json(result);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
return res.status(err.status).json({ error: err.message });
|
|
||||||
}
|
|
||||||
console.error('Weather error:', err);
|
|
||||||
res.status(500).json({ error: 'Error fetching weather data' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
|
|
||||||
|
|
||||||
if (!lat || !lng || !date) {
|
|
||||||
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getDetailedWeather(lat, lng, date, lang);
|
|
||||||
res.json(result);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
return res.status(err.status).json({ error: err.message });
|
|
||||||
}
|
|
||||||
console.error('Detailed weather error:', err);
|
|
||||||
res.status(500).json({ error: 'Error fetching detailed weather data' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { JWT_SECRET } from '../../src/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared e2e harness for migrated Nest modules.
|
||||||
|
*
|
||||||
|
* Gives each module e2e test a throwaway in-memory SQLite db (the same shape the
|
||||||
|
* shared connection exposes), a seed helper for demo data, and a session-cookie
|
||||||
|
* signer that produces tokens the REAL JwtAuthGuard accepts — so e2e tests cover
|
||||||
|
* the actual auth path end-to-end, not a stubbed guard.
|
||||||
|
*
|
||||||
|
* Wire it in a test with `vi.mock('../../src/db/database', () => ({ db, ... }))`
|
||||||
|
* using the db returned here, then build the Nest app under test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SeededUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
password_version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh in-memory db with the minimal `users` table the auth guard reads. */
|
||||||
|
export function createTempDb(): Database.Database {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
password_version INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a demo user and return its row. */
|
||||||
|
export function seedUser(db: Database.Database, overrides: Partial<SeededUser> = {}): SeededUser {
|
||||||
|
const user: SeededUser = {
|
||||||
|
id: overrides.id ?? 1,
|
||||||
|
username: overrides.username ?? 'e2e-user',
|
||||||
|
email: overrides.email ?? 'e2e@example.test',
|
||||||
|
role: overrides.role ?? 'user',
|
||||||
|
password_version: overrides.password_version ?? 0,
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
).run(user.id, user.username, user.email, user.role, user.password_version);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a `trek_session` token the real guard will accept (matching JWT_SECRET + pv). */
|
||||||
|
export function signSession(userId: number, passwordVersion = 0): string {
|
||||||
|
return jwt.sign({ id: userId, pv: passwordVersion }, JWT_SECRET, { algorithm: 'HS256' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: the Cookie header value for a signed session. */
|
||||||
|
export function sessionCookie(userId: number, passwordVersion = 0): string {
|
||||||
|
return `trek_session=${signSession(userId, passwordVersion)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Weather module e2e — exercises the migrated /api/weather endpoints through the
|
||||||
|
* real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
|
||||||
|
* The weather service is mocked so no real Open-Meteo calls happen.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { createTempDb, seedUser, sessionCookie } from './harness';
|
||||||
|
|
||||||
|
const { db } = vi.hoisted(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const tmp = new Database(':memory:');
|
||||||
|
tmp.exec('PRAGMA journal_mode = WAL');
|
||||||
|
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||||
|
return { db: tmp };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||||
|
|
||||||
|
const { mockGet, mockGetDetailed } = vi.hoisted(() => ({ mockGet: vi.fn(), mockGetDetailed: vi.fn() }));
|
||||||
|
vi.mock('../../src/services/weatherService', async (importActual) => {
|
||||||
|
const actual = await importActual<typeof import('../../src/services/weatherService')>();
|
||||||
|
return { ...actual, getWeather: mockGet, getDetailedWeather: mockGetDetailed };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WeatherModule } from '../../src/nest/weather/weather.module';
|
||||||
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||||
|
|
||||||
|
describe('Weather e2e (real auth guard + temp SQLite)', () => {
|
||||||
|
let server: Server;
|
||||||
|
let app: Awaited<ReturnType<typeof build>>;
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [WeatherModule] }).compile();
|
||||||
|
const nest = moduleRef.createNestApplication();
|
||||||
|
nest.use(cookieParser());
|
||||||
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||||
|
await nest.init();
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
seedUser(db as never, { id: 1 });
|
||||||
|
app = await build();
|
||||||
|
server = app.getHttpServer();
|
||||||
|
mockGet.mockResolvedValue({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
|
||||||
|
mockGetDetailed.mockResolvedValue({ temp: 20, main: 'Rain', description: 'Regen', type: 'forecast', hourly: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 { error, code } without a session cookie', async () => {
|
||||||
|
const res = await request(server).get('/api/weather').query({ lat: '1', lng: '2' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 with an invalid token', async () => {
|
||||||
|
const res = await request(server).get('/api/weather').set('Cookie', 'trek_session=not-a-jwt').query({ lat: '1', lng: '2' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toEqual({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 when authenticated but lat/lng missing', async () => {
|
||||||
|
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lng: '2' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: 'Latitude and longitude are required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('200 with a valid session cookie', async () => {
|
||||||
|
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ temp: 21, main: 'Clear', type: 'current' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('200 on /detailed with a valid session cookie', async () => {
|
||||||
|
const res = await request(server).get('/api/weather/detailed').set('Cookie', sessionCookie(1)).query({ lat: '1', lng: '2', date: '2026-07-01' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ type: 'forecast' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* Weather integration tests.
|
|
||||||
* Covers WEATHER-001 to WEATHER-007.
|
|
||||||
*
|
|
||||||
* External API calls (Open-Meteo) are mocked via vi.mock.
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
||||||
import request from 'supertest';
|
|
||||||
import type { Application } from 'express';
|
|
||||||
|
|
||||||
const { testDb, dbMock } = vi.hoisted(() => {
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const db = new Database(':memory:');
|
|
||||||
db.exec('PRAGMA journal_mode = WAL');
|
|
||||||
db.exec('PRAGMA foreign_keys = ON');
|
|
||||||
db.exec('PRAGMA busy_timeout = 5000');
|
|
||||||
const mock = {
|
|
||||||
db,
|
|
||||||
closeDb: () => {},
|
|
||||||
reinitialize: () => {},
|
|
||||||
getPlaceWithTags: (placeId: number) => {
|
|
||||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
|
||||||
if (!place) return null;
|
|
||||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
|
||||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
|
||||||
},
|
|
||||||
canAccessTrip: (tripId: any, userId: number) =>
|
|
||||||
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
|
|
||||||
isOwner: (tripId: any, userId: number) =>
|
|
||||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
||||||
};
|
|
||||||
return { testDb: db, dbMock: mock };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('../../src/db/database', () => dbMock);
|
|
||||||
vi.mock('../../src/config', () => ({
|
|
||||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
|
||||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
||||||
updateJwtSecret: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Prevent real HTTP calls to Open-Meteo
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({
|
|
||||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
|
||||||
daily: {
|
|
||||||
time: ['2025-06-01'],
|
|
||||||
temperature_2m_max: [25],
|
|
||||||
temperature_2m_min: [18],
|
|
||||||
weathercode: [1],
|
|
||||||
precipitation_sum: [0],
|
|
||||||
windspeed_10m_max: [15],
|
|
||||||
sunrise: ['2025-06-01T06:00'],
|
|
||||||
sunset: ['2025-06-01T21:00'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createApp } from '../../src/app';
|
|
||||||
import { createTables } from '../../src/db/schema';
|
|
||||||
import { runMigrations } from '../../src/db/migrations';
|
|
||||||
import { resetTestDb } from '../helpers/test-db';
|
|
||||||
import { createUser } from '../helpers/factories';
|
|
||||||
import { authCookie } from '../helpers/auth';
|
|
||||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
|
||||||
|
|
||||||
const app: Application = createApp();
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
createTables(testDb);
|
|
||||||
runMigrations(testDb);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetTestDb(testDb);
|
|
||||||
loginAttempts.clear();
|
|
||||||
mfaAttempts.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
testDb.close();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Weather validation', () => {
|
|
||||||
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather?lat=48.8566')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather?lat=48.8566&lng=2.3522');
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Weather with mocked API', () => {
|
|
||||||
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather?lat=48.8566&lng=2.3522')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('temp');
|
|
||||||
expect(res.body).toHaveProperty('main');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 5);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('temp');
|
|
||||||
expect(res.body).toHaveProperty('type');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('temp');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
// Use unique coords to avoid cache from previous tests
|
|
||||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 503,
|
|
||||||
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
|
|
||||||
});
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 3);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(503);
|
|
||||||
expect(res.body).toHaveProperty('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 4);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
expect(res.body).toHaveProperty('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 2);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Override mock with full detailed forecast response
|
|
||||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({
|
|
||||||
daily: {
|
|
||||||
time: [dateStr],
|
|
||||||
temperature_2m_max: [24],
|
|
||||||
temperature_2m_min: [16],
|
|
||||||
weathercode: [1],
|
|
||||||
precipitation_sum: [0],
|
|
||||||
windspeed_10m_max: [12],
|
|
||||||
sunrise: [`${dateStr}T06:00`],
|
|
||||||
sunset: [`${dateStr}T21:00`],
|
|
||||||
precipitation_probability_max: [10],
|
|
||||||
},
|
|
||||||
hourly: {
|
|
||||||
time: [`${dateStr}T12:00`],
|
|
||||||
temperature_2m: [20],
|
|
||||||
precipitation_probability: [5],
|
|
||||||
precipitation: [0],
|
|
||||||
weathercode: [1],
|
|
||||||
windspeed_10m: [10],
|
|
||||||
relativehumidity_2m: [55],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('temp');
|
|
||||||
expect(res.body.type).toBe('forecast');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 502,
|
|
||||||
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
|
|
||||||
});
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 6);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(502);
|
|
||||||
expect(res.body).toHaveProperty('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
|
|
||||||
const { user } = createUser(testDb);
|
|
||||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 7);
|
|
||||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
|
|
||||||
.set('Cookie', authCookie(user.id));
|
|
||||||
expect(res.status).toBe(500);
|
|
||||||
expect(res.body).toHaveProperty('error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import { expect } from 'vitest';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
|
||||||
|
export interface ParityRequest {
|
||||||
|
method?: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||||
|
path: string;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable Nest-vs-Express parity harness.
|
||||||
|
*
|
||||||
|
* Fires the same HTTP request at the legacy Express app and the migrated Nest app
|
||||||
|
* and asserts the response is client-identical — same status code and same JSON
|
||||||
|
* body. With the underlying service mocked identically for both, any difference is
|
||||||
|
* purely framework-layer (routing, validation, error envelope), which is exactly
|
||||||
|
* what a migration must not change. Use one assertion per migrated route/case.
|
||||||
|
*/
|
||||||
|
export async function expectParity(
|
||||||
|
expressServer: Server | Express.Application,
|
||||||
|
nestServer: Server,
|
||||||
|
req: ParityRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const fire = (target: Server | Express.Application) => {
|
||||||
|
const method = req.method ?? 'get';
|
||||||
|
let r = request(target as never)[method](req.path);
|
||||||
|
if (req.query) r = r.query(req.query);
|
||||||
|
if (req.body !== undefined) r = r.send(req.body as object);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]);
|
||||||
|
|
||||||
|
const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`;
|
||||||
|
expect(ne.status, `${label}: status mismatch`).toBe(ex.status);
|
||||||
|
expect(ne.body, `${label}: body mismatch`).toEqual(ex.body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
function context(req: unknown) {
|
||||||
|
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JwtAuthGuard', () => {
|
||||||
|
const guard = new JwtAuthGuard();
|
||||||
|
|
||||||
|
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
guard.canActivate(context({ headers: {}, cookies: {} }));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(HttpException);
|
||||||
|
expect((thrown as HttpException).getStatus()).toBe(401);
|
||||||
|
expect((thrown as HttpException).getResponse()).toEqual({
|
||||||
|
error: 'Access token required',
|
||||||
|
code: 'AUTH_REQUIRED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* DatabaseService — the shared better-sqlite3 provider (F3). Exercises every
|
||||||
|
* helper against the real connection so the typed query surface is covered.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
|
||||||
|
describe('DatabaseService (typed query helpers)', () => {
|
||||||
|
const svc = new DatabaseService();
|
||||||
|
|
||||||
|
it('exposes the shared connection', () => {
|
||||||
|
expect(typeof svc.connection.prepare).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepare + get + all return rows from the live connection', () => {
|
||||||
|
expect(svc.prepare('SELECT 1 AS one').get()).toEqual({ one: 1 });
|
||||||
|
expect(svc.get('SELECT 2 AS two')).toEqual({ two: 2 });
|
||||||
|
expect(svc.all('SELECT 3 AS three')).toEqual([{ three: 3 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('run + transaction operate on a scratch table', () => {
|
||||||
|
svc.run('CREATE TEMP TABLE IF NOT EXISTS _dbsvc_test (n INTEGER)');
|
||||||
|
svc.run('DELETE FROM _dbsvc_test');
|
||||||
|
|
||||||
|
const info = svc.run('INSERT INTO _dbsvc_test (n) VALUES (?)', 41);
|
||||||
|
expect(info.changes).toBe(1);
|
||||||
|
|
||||||
|
const total = svc.transaction((conn) => {
|
||||||
|
conn.prepare('INSERT INTO _dbsvc_test (n) VALUES (?)').run(1);
|
||||||
|
return conn.prepare('SELECT SUM(n) AS s FROM _dbsvc_test').get() as { s: number };
|
||||||
|
});
|
||||||
|
expect(total.s).toBe(42);
|
||||||
|
|
||||||
|
svc.run('DROP TABLE _dbsvc_test');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||||
|
|
||||||
|
function mockHost() {
|
||||||
|
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
|
||||||
|
const host = { switchToHttp: () => ({ getResponse: () => res }) } as never;
|
||||||
|
return { res, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TrekExceptionFilter', () => {
|
||||||
|
const filter = new TrekExceptionFilter();
|
||||||
|
|
||||||
|
it('passes through { error, code } bodies (auth guards) unchanged', () => {
|
||||||
|
const { res, host } = mockHost();
|
||||||
|
filter.catch(new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401), host);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises a string HttpException to { error }', () => {
|
||||||
|
const { res, host } = mockHost();
|
||||||
|
filter.catch(new HttpException('Bad thing', 400), host);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Bad thing' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps unknown errors to 500 { error: Internal server error }', () => {
|
||||||
|
const { res, host } = mockHost();
|
||||||
|
filter.catch(new Error('boom'), host);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||||
|
import { HealthService } from '../../../src/nest/health/health.service';
|
||||||
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
|
||||||
|
describe('Nest dependency injection (vitest + swc)', () => {
|
||||||
|
it('injects HealthService + DatabaseService into HealthController by type', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const controller = moduleRef.get(HealthController);
|
||||||
|
expect(controller.getHealth()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
runtime: 'nestjs',
|
||||||
|
diInjected: true,
|
||||||
|
userCount: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
|
||||||
|
|
||||||
|
describe('strangler toggle', () => {
|
||||||
|
const original = process.env.NEST_PREFIXES;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.NEST_PREFIXES;
|
||||||
|
else process.env.NEST_PREFIXES = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the migrated prefixes (/api/_nest + /api/weather) when NEST_PREFIXES is unset', () => {
|
||||||
|
delete process.env.NEST_PREFIXES;
|
||||||
|
expect(getNestPrefixes()).toEqual(['/api/_nest', '/api/weather']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses NEST_PREFIXES (comma-separated, trimmed)', () => {
|
||||||
|
process.env.NEST_PREFIXES = '/api/weather, /api/airports';
|
||||||
|
expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => {
|
||||||
|
process.env.NEST_PREFIXES = '';
|
||||||
|
expect(getNestPrefixes()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches exact prefixes and subpaths but not lookalikes', () => {
|
||||||
|
const match = makeNestPathMatcher(['/api/_nest']);
|
||||||
|
expect(match('/api/_nest')).toBe(true);
|
||||||
|
expect(match('/api/_nest/health')).toBe(true);
|
||||||
|
expect(match('/api/_nestxyz')).toBe(false);
|
||||||
|
expect(match('/api/health')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { WeatherController } from '../../../src/nest/weather/weather.controller';
|
||||||
|
import { ApiError } from '../../../src/services/weatherService';
|
||||||
|
import type { WeatherService } from '../../../src/nest/weather/weather.service';
|
||||||
|
|
||||||
|
function makeController(svc: Partial<WeatherService>) {
|
||||||
|
return new WeatherController(svc as WeatherService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn`, expecting it to throw an HttpException; return its { status, body }. */
|
||||||
|
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(HttpException);
|
||||||
|
const e = err as HttpException;
|
||||||
|
return { status: e.getStatus(), body: e.getResponse() };
|
||||||
|
}
|
||||||
|
throw new Error('expected the handler to throw');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WeatherController (parity with the legacy /api/weather route)', () => {
|
||||||
|
const sample = { temp: 21, main: 'Clear', description: 'Klar', type: 'current' };
|
||||||
|
|
||||||
|
describe('GET /api/weather', () => {
|
||||||
|
it('400 { error } with the exact legacy message when lat/lng missing', async () => {
|
||||||
|
const c = makeController({ get: vi.fn() });
|
||||||
|
expect(await thrown(() => c.getWeather(undefined, '13.4'))).toEqual({
|
||||||
|
status: 400,
|
||||||
|
body: { error: 'Latitude and longitude are required' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the service result and defaults lang to "de" when absent', async () => {
|
||||||
|
const get = vi.fn().mockResolvedValue(sample);
|
||||||
|
const c = makeController({ get });
|
||||||
|
const res = await c.getWeather('52.5', '13.4', undefined, undefined);
|
||||||
|
expect(res).toEqual(sample);
|
||||||
|
expect(get).toHaveBeenCalledWith('52.5', '13.4', undefined, 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an explicit lang and date through unchanged', async () => {
|
||||||
|
const get = vi.fn().mockResolvedValue(sample);
|
||||||
|
const c = makeController({ get });
|
||||||
|
await c.getWeather('1', '2', '2026-07-01', 'en');
|
||||||
|
expect(get).toHaveBeenCalledWith('1', '2', '2026-07-01', 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an ApiError to its status + { error: message }', async () => {
|
||||||
|
const c = makeController({ get: vi.fn().mockRejectedValue(new ApiError(404, 'Open-Meteo API error')) });
|
||||||
|
expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
|
||||||
|
status: 404,
|
||||||
|
body: { error: 'Open-Meteo API error' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an unexpected error to the exact legacy 500 body', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const c = makeController({ get: vi.fn().mockRejectedValue(new Error('boom')) });
|
||||||
|
expect(await thrown(() => c.getWeather('1', '2'))).toEqual({
|
||||||
|
status: 500,
|
||||||
|
body: { error: 'Error fetching weather data' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/weather/detailed', () => {
|
||||||
|
it('400 { error } with the exact legacy message when date missing', async () => {
|
||||||
|
const c = makeController({ getDetailed: vi.fn() });
|
||||||
|
expect(await thrown(() => c.getDetailed('1', '2', undefined))).toEqual({
|
||||||
|
status: 400,
|
||||||
|
body: { error: 'Latitude, longitude, and date are required' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the detailed result and defaults lang to "de"', async () => {
|
||||||
|
const getDetailed = vi.fn().mockResolvedValue(sample);
|
||||||
|
const c = makeController({ getDetailed });
|
||||||
|
await c.getDetailed('1', '2', '2026-07-01', undefined);
|
||||||
|
expect(getDetailed).toHaveBeenCalledWith('1', '2', '2026-07-01', 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an unexpected error to the exact detailed 500 body', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const c = makeController({ getDetailed: vi.fn().mockRejectedValue(new Error('boom')) });
|
||||||
|
expect(await thrown(() => c.getDetailed('1', '2', '2026-07-01'))).toEqual({
|
||||||
|
status: 500,
|
||||||
|
body: { error: 'Error fetching detailed weather data' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { AppModule } from '../../../src/nest/app.module';
|
||||||
|
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||||
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||||
|
|
||||||
|
function ctx(user: unknown) {
|
||||||
|
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppModule wiring', () => {
|
||||||
|
it('compiles with the global filter + DB provider and resolves the controller', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
.overrideProvider(DatabaseService)
|
||||||
|
.useValue({ get: () => ({ n: 0 }) })
|
||||||
|
.compile();
|
||||||
|
expect(moduleRef.get(HealthController)).toBeInstanceOf(HealthController);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdminGuard', () => {
|
||||||
|
const guard = new AdminGuard();
|
||||||
|
it('allows admins', () => {
|
||||||
|
expect(guard.canActivate(ctx({ role: 'admin' }))).toBe(true);
|
||||||
|
});
|
||||||
|
it('blocks non-admins and anonymous with 403 { error }', () => {
|
||||||
|
expect(() => guard.canActivate(ctx({ role: 'user' }))).toThrow(HttpException);
|
||||||
|
expect(() => guard.canActivate(ctx(undefined))).toThrow(HttpException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DatabaseService (shared connection)', () => {
|
||||||
|
it('runs real queries against the existing SQLite connection', () => {
|
||||||
|
const svc = new DatabaseService();
|
||||||
|
expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
|
||||||
|
expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
|
||||||
|
|
||||||
|
describe('ZodValidationPipe', () => {
|
||||||
|
const pipe = new ZodValidationPipe(z.object({ name: z.string().min(1) }));
|
||||||
|
const meta = {} as never;
|
||||||
|
|
||||||
|
it('returns the parsed value for valid input', () => {
|
||||||
|
expect(pipe.transform({ name: 'x' }, meta)).toEqual({ name: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws TREK { error } envelope with status 400 on invalid input', () => {
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
pipe.transform({ name: '' }, meta);
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(HttpException);
|
||||||
|
expect((thrown as HttpException).getStatus()).toBe(400);
|
||||||
|
expect((thrown as HttpException).getResponse()).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
// Smoke test: proves the server toolchain (tsx / vitest) resolves @trek/shared.
|
||||||
|
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||||
|
|
||||||
|
describe('@trek/shared resolves in the server toolchain', () => {
|
||||||
|
it('imports and uses a shared schema', () => {
|
||||||
|
expect(idParamSchema.parse('7')).toBe(7);
|
||||||
|
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"noEmitOnError": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"sourceMap": false,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests", "**/*.spec.ts", "**/*.test.ts"]
|
||||||
|
}
|
||||||
+14
-10
@@ -3,6 +3,9 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"baseUrl": ".",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
@@ -15,20 +18,21 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
// The MCP SDK's package.json uses a wildcard exports pattern with extension-less targets
|
// The MCP SDK's package.json uses a wildcard exports pattern with extension-less targets
|
||||||
// (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
|
// (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
|
||||||
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
||||||
"paths": {
|
"paths": {
|
||||||
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
|
"@modelcontextprotocol/sdk/server/mcp": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js"],
|
||||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
|
"@modelcontextprotocol/sdk/server/streamableHttp": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
|
"@modelcontextprotocol/sdk/server/auth/router": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
|
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
|
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
|
"@modelcontextprotocol/sdk/server/auth/provider": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
|
"@modelcontextprotocol/sdk/server/auth/clients": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
|
"@modelcontextprotocol/sdk/server/auth/errors": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors.js"],
|
||||||
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
|
"@modelcontextprotocol/sdk/server/auth/types": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types.js"],
|
||||||
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
|
"@modelcontextprotocol/sdk/shared/auth": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth.js"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
+25
-4
@@ -1,6 +1,18 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import swc from 'unplugin-swc';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// SWC transform so NestJS decorator metadata is emitted in tests
|
||||||
|
// (vitest's default esbuild does not emit it -> type-based DI would break).
|
||||||
|
plugins: [
|
||||||
|
swc.vite({
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript', decorators: true },
|
||||||
|
transform: { legacyDecorator: true, decoratorMetadata: true },
|
||||||
|
keepClassNames: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
test: {
|
test: {
|
||||||
root: '.',
|
root: '.',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
@@ -16,24 +28,33 @@ export default defineConfig({
|
|||||||
reporter: ['lcov', 'text'],
|
reporter: ['lcov', 'text'],
|
||||||
reportsDirectory: './coverage',
|
reportsDirectory: './coverage',
|
||||||
include: ['src/**/*.ts'],
|
include: ['src/**/*.ts'],
|
||||||
|
// Coverage gate scoped to the new NestJS code only — the legacy codebase
|
||||||
|
// is intentionally ungated. Raised to the DoD's >=80% bar once the first
|
||||||
|
// module (weather) landed; ratchet further as more modules are migrated.
|
||||||
|
thresholds: {
|
||||||
|
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
// MCP SDK's exports map uses extension-less wildcard targets that neither
|
||||||
|
// Node nor Vite can resolve. Point directly at the CJS dist files.
|
||||||
|
// Paths are relative to the monorepo root (packages are hoisted there).
|
||||||
'@modelcontextprotocol/sdk/server/mcp': new URL(
|
'@modelcontextprotocol/sdk/server/mcp': new URL(
|
||||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
|
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
).pathname,
|
).pathname,
|
||||||
'@modelcontextprotocol/sdk/server/streamableHttp': new URL(
|
'@modelcontextprotocol/sdk/server/streamableHttp': new URL(
|
||||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js',
|
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
).pathname,
|
).pathname,
|
||||||
'@modelcontextprotocol/sdk/inMemory': new URL(
|
'@modelcontextprotocol/sdk/inMemory': new URL(
|
||||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js',
|
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
).pathname,
|
).pathname,
|
||||||
'@modelcontextprotocol/sdk/client/index': new URL(
|
'@modelcontextprotocol/sdk/client/index': new URL(
|
||||||
'./node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js',
|
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
).pathname,
|
).pathname,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# @trek/shared
|
||||||
|
|
||||||
|
Single source of truth for TREK's API contracts, expressed as [Zod](https://zod.dev) schemas
|
||||||
|
and consumed by **both** the server (request validation + inferred DTO types) and the client
|
||||||
|
(typed requests/responses).
|
||||||
|
|
||||||
|
This package is part of the incremental NestJS + React 19 migration
|
||||||
|
(see the "Brownfield Rewrite" board). It is intentionally **dormant** until modules start
|
||||||
|
importing it — adding it changes nothing for users.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **One folder per domain**: `src/<domain>/<domain>.schema.ts` (+ `.spec.ts`).
|
||||||
|
- Domain-agnostic building blocks live in `src/common/`.
|
||||||
|
- A route is only considered **migrated** once its contract lives here.
|
||||||
|
- Schemas are the source of truth; server DTOs and client types are *inferred* from them
|
||||||
|
(`z.infer<typeof schema>`), never hand-duplicated.
|
||||||
|
|
||||||
|
## Consumption (dev)
|
||||||
|
|
||||||
|
Both apps resolve `@trek/shared` to this package's TypeScript source:
|
||||||
|
|
||||||
|
- **Server** (`tsx`): via `paths` in `server/tsconfig.json`.
|
||||||
|
- **Client** (`vite`): via `resolve.alias` in `client/vite.config.ts` (+ `paths` for the type-checker).
|
||||||
|
|
||||||
|
> Production packaging (Docker / workspace wiring) is introduced in card **F2**, when the
|
||||||
|
> server first depends on this package at runtime. Until then prod builds are untouched.
|
||||||
|
|
||||||
|
## Not yet here
|
||||||
|
|
||||||
|
The canonical **error envelope** is finalised in card **F5** (it must match TREK's current
|
||||||
|
Express error responses byte-for-byte), so it is deliberately not invented in F1.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@trek/shared",
|
||||||
|
"version": "3.0.22",
|
||||||
|
"private": true,
|
||||||
|
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||||
|
"lint": "eslint --fix \"src/**/*.ts\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"prettier": "3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic pagination query helper. Individual endpoints opt in by extending
|
||||||
|
* this; it is NOT applied globally (many TREK list endpoints return full sets).
|
||||||
|
* Defaults are conservative and only used where a route already paginates.
|
||||||
|
*/
|
||||||
|
export const paginationQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
perPage: z.coerce.number().int().min(1).max(200).default(50),
|
||||||
|
});
|
||||||
|
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { idSchema, idParamSchema, nonEmptyString, isoDateTime } from './primitives.schema';
|
||||||
|
import { paginationQuerySchema } from './pagination.schema';
|
||||||
|
|
||||||
|
describe('@trek/shared primitives', () => {
|
||||||
|
it('idSchema accepts positive integers, rejects others', () => {
|
||||||
|
expect(idSchema.parse(1)).toBe(1);
|
||||||
|
expect(idSchema.safeParse(0).success).toBe(false);
|
||||||
|
expect(idSchema.safeParse(-3).success).toBe(false);
|
||||||
|
expect(idSchema.safeParse(1.5).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('idParamSchema coerces string params to a positive int', () => {
|
||||||
|
expect(idParamSchema.parse('42')).toBe(42);
|
||||||
|
expect(idParamSchema.safeParse('abc').success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nonEmptyString trims and rejects empty', () => {
|
||||||
|
expect(nonEmptyString.parse(' hi ')).toBe('hi');
|
||||||
|
expect(nonEmptyString.safeParse(' ').success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isoDateTime accepts an ISO timestamp', () => {
|
||||||
|
expect(isoDateTime.safeParse('2026-05-25T08:38:14Z').success).toBe(true);
|
||||||
|
expect(isoDateTime.safeParse('not-a-date').success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('@trek/shared pagination', () => {
|
||||||
|
it('applies defaults and coerces', () => {
|
||||||
|
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||||
|
expect(paginationQuerySchema.parse({ page: '2', perPage: '10' })).toEqual({ page: 2, perPage: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces bounds', () => {
|
||||||
|
expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
|
||||||
|
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primitive, domain-agnostic building blocks shared by every contract.
|
||||||
|
* Domain schemas (trips, places, ...) live in their own folders and reuse these.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** TREK uses auto-increment integer primary keys. */
|
||||||
|
export const idSchema = z.number().int().positive();
|
||||||
|
export type Id = z.infer<typeof idSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric id coming from a URL param / query string. Express hands these over
|
||||||
|
* as strings, so we coerce, then enforce a positive integer.
|
||||||
|
*/
|
||||||
|
export const idParamSchema = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
/** Non-empty, trimmed string. */
|
||||||
|
export const nonEmptyString = z.string().trim().min(1);
|
||||||
|
|
||||||
|
/** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */
|
||||||
|
export const isoDateTime = z.string().datetime({ offset: true });
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @trek/shared — single source of truth for TREK's API contracts.
|
||||||
|
*
|
||||||
|
* Zod schemas defined here are consumed by BOTH the server (validation +
|
||||||
|
* inferred DTO types) and the client (typed requests/responses). A route is
|
||||||
|
* only considered "migrated" once its contract lives in this package.
|
||||||
|
*
|
||||||
|
* Layout: one folder per domain (e.g. src/trip/trip.schema.ts), plus the
|
||||||
|
* domain-agnostic primitives below. See the board card "Module blueprint".
|
||||||
|
*/
|
||||||
|
export * from './common/primitives.schema';
|
||||||
|
export * from './common/pagination.schema';
|
||||||
|
|
||||||
|
// Domain contracts
|
||||||
|
export * from './weather/weather.schema';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
weatherQuerySchema,
|
||||||
|
detailedWeatherQuerySchema,
|
||||||
|
weatherResultSchema,
|
||||||
|
} from './weather.schema';
|
||||||
|
|
||||||
|
describe('weatherQuerySchema', () => {
|
||||||
|
it('accepts lat/lng and defaults lang to "de"', () => {
|
||||||
|
const parsed = weatherQuerySchema.parse({ lat: '52.5', lng: '13.4' });
|
||||||
|
expect(parsed).toEqual({ lat: '52.5', lng: '13.4', lang: 'de' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an explicit lang and optional date', () => {
|
||||||
|
const parsed = weatherQuerySchema.parse({ lat: '1', lng: '2', date: '2026-07-01', lang: 'en' });
|
||||||
|
expect(parsed.lang).toBe('en');
|
||||||
|
expect(parsed.date).toBe('2026-07-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing lat/lng', () => {
|
||||||
|
expect(weatherQuerySchema.safeParse({ lng: '13.4' }).success).toBe(false);
|
||||||
|
expect(weatherQuerySchema.safeParse({ lat: '', lng: '13.4' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detailedWeatherQuerySchema', () => {
|
||||||
|
it('requires a date', () => {
|
||||||
|
expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2' }).success).toBe(false);
|
||||||
|
expect(detailedWeatherQuerySchema.safeParse({ lat: '1', lng: '2', date: '2026-07-01' }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('weatherResultSchema', () => {
|
||||||
|
it('accepts a minimal current-weather result', () => {
|
||||||
|
const r = weatherResultSchema.parse({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
|
||||||
|
expect(r.temp).toBe(21);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a detailed result with hourly entries and a no_forecast error', () => {
|
||||||
|
expect(
|
||||||
|
weatherResultSchema.safeParse({
|
||||||
|
temp: 0, main: '', description: '', type: '', error: 'no_forecast',
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
weatherResultSchema.safeParse({
|
||||||
|
temp: 18, main: 'Rain', description: 'Regen', type: 'forecast',
|
||||||
|
sunrise: '05:30', sunset: '21:10', precipitation_sum: 2.4,
|
||||||
|
hourly: [{ hour: 9, temp: 17, precipitation: 0.1, precipitation_probability: 20, main: 'Clouds', wind: 12, humidity: 80 }],
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weather API contract — single source of truth for the /api/weather endpoints.
|
||||||
|
*
|
||||||
|
* The legacy Express routes treat lat/lng as opaque strings (they are parsed with
|
||||||
|
* parseFloat inside the service) and only check for presence, so the query schemas
|
||||||
|
* mirror that: non-empty strings, not coerced numbers. `lang` defaults to 'de',
|
||||||
|
* matching the Express default.
|
||||||
|
*
|
||||||
|
* The bespoke "X is required" 400 messages are reproduced in the controller, not
|
||||||
|
* derived from these schemas, so the error body stays byte-identical to Express.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const weatherQuerySchema = z.object({
|
||||||
|
lat: z.string().min(1),
|
||||||
|
lng: z.string().min(1),
|
||||||
|
date: z.string().min(1).optional(),
|
||||||
|
lang: z.string().min(1).default('de'),
|
||||||
|
});
|
||||||
|
export type WeatherQuery = z.infer<typeof weatherQuerySchema>;
|
||||||
|
|
||||||
|
/** Detailed weather requires a date (the Express route 400s without it). */
|
||||||
|
export const detailedWeatherQuerySchema = weatherQuerySchema.extend({
|
||||||
|
date: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type DetailedWeatherQuery = z.infer<typeof detailedWeatherQuerySchema>;
|
||||||
|
|
||||||
|
export const hourlyEntrySchema = z.object({
|
||||||
|
hour: z.number(),
|
||||||
|
temp: z.number(),
|
||||||
|
precipitation: z.number(),
|
||||||
|
precipitation_probability: z.number(),
|
||||||
|
main: z.string(),
|
||||||
|
wind: z.number(),
|
||||||
|
humidity: z.number(),
|
||||||
|
});
|
||||||
|
export type HourlyEntry = z.infer<typeof hourlyEntrySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weather response DTO. Fields are optional because the Express service emits
|
||||||
|
* different subsets depending on the request type (current / forecast / climate /
|
||||||
|
* detailed) and on error (`{ ..., error: 'no_forecast' }`).
|
||||||
|
*/
|
||||||
|
export const weatherResultSchema = z.object({
|
||||||
|
temp: z.number(),
|
||||||
|
temp_max: z.number().optional(),
|
||||||
|
temp_min: z.number().optional(),
|
||||||
|
main: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
sunrise: z.string().nullable().optional(),
|
||||||
|
sunset: z.string().nullable().optional(),
|
||||||
|
precipitation_sum: z.number().optional(),
|
||||||
|
precipitation_probability_max: z.number().optional(),
|
||||||
|
wind_max: z.number().optional(),
|
||||||
|
hourly: z.array(hourlyEntrySchema).optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type WeatherResult = z.infer<typeof weatherResultSchema>;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'tsup'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['cjs', 'esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
external: ['zod'],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user