mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
23 Commits
v3.0.17
...
21a841dda5
| Author | SHA1 | Date | |
|---|---|---|---|
| 21a841dda5 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| 177f004740 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
@@ -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
|
||||||
|
|
||||||
+52
-19
@@ -1,28 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS shared-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/ ./
|
RUN npm ci --workspace=shared
|
||||||
RUN npm run build
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine AS client-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
|
# ── 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
|
||||||
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 ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
COPY shared/package.json ./shared/
|
||||||
npm ci --production && \
|
COPY server/package.json ./server/
|
||||||
apk del python3 make g++
|
|
||||||
|
|
||||||
COPY server/ ./
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
npm ci --workspace=server --omit=dev && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
|
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 mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
|
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
|
||||||
@@ -36,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"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Email: **report@liketrek.com**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.17
|
version: 3.0.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.17"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.17",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
"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",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
@@ -34,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": {
|
||||||
@@ -56,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'
|
||||||
@@ -209,7 +210,7 @@ export const oauthApi = {
|
|||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
@@ -407,8 +408,20 @@ export const journeyApi = {
|
|||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||||
@@ -489,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 = {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
|||||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
|||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
|
routeSegments = [],
|
||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
@@ -216,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' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -442,6 +447,8 @@ export function MapViewGL({
|
|||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
|
|
||||||
// Update GPX geometries
|
// Update GPX geometries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
|
|||||||
@@ -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,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
|||||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||||
// offset when the map is pitched. Restrict terrain to satellite.
|
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||||
|
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||||
export function wantsTerrain(style: string): boolean {
|
export function wantsTerrain(style: string): boolean {
|
||||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||||
|
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D can be added to every style now — the standard family has it built-in
|
// 3D can be added to every style now — the standard family has it built-in
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
|||||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
|
|||||||
rightWidth?: number
|
rightWidth?: number
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
|
mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const tripObj = useTripStore((s) => s.trip)
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
{/* ── Reservations for this day's assignments ── */}
|
{/* ── Reservations for this day's assignments ── */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAssignments = assignments[String(day.id)] || []
|
const dayAssignments = assignments[String(day.id)] || []
|
||||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
const dayReservations = reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||||
|
return r.day_id === day.id
|
||||||
|
})
|
||||||
if (dayReservations.length === 0) return null
|
if (dayReservations.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 0 }}>
|
<div style={{ marginBottom: 0 }}>
|
||||||
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
if (!startTime && !endTime) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||||
|
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</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'
|
||||||
@@ -23,10 +23,15 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import {
|
||||||
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||||
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
|
type MergedItem,
|
||||||
|
} from '../../utils/dayMerge'
|
||||||
|
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 },
|
||||||
@@ -179,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
|
||||||
@@ -195,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,
|
||||||
@@ -211,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,
|
||||||
@@ -228,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)
|
||||||
@@ -246,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)
|
||||||
@@ -362,26 +397,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
|
|
||||||
// Get span phase: how a reservation relates to a specific day (by id)
|
|
||||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
if (!startDayId || startDayId === endDayId) return 'single'
|
|
||||||
if (dayId === startDayId) return 'start'
|
|
||||||
if (dayId === endDayId) return 'end'
|
|
||||||
return 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate display time for a reservation on a specific day
|
|
||||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
|
||||||
const phase = getSpanPhase(r, dayId)
|
|
||||||
if (phase === 'end') return r.reservation_end_time || null
|
|
||||||
if (phase === 'middle') return null
|
|
||||||
return r.reservation_time || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get phase label for multi-day badge
|
// Get phase label for multi-day badge
|
||||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||||
if (phase === 'single') return null
|
if (phase === 'single') return null
|
||||||
@@ -406,27 +421,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return { day_id: startId, end_day_id: targetDayId }
|
return { day_id: startId, end_day_id: targetDayId }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) =>
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||||
return reservations.filter(r => {
|
|
||||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
|
||||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
|
||||||
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
|
|
||||||
if (startDayId == null) return false
|
|
||||||
|
|
||||||
if (endDayId !== startDayId) {
|
|
||||||
const startDay = days.find(d => d.id === startDayId)
|
|
||||||
const endDay = days.find(d => d.id === endDayId)
|
|
||||||
const thisDay = days.find(d => d.id === dayId)
|
|
||||||
if (!startDay || !endDay || !thisDay) return false
|
|
||||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
|
||||||
}
|
|
||||||
return startDayId === dayId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||||
const getActiveRentalsForDay = (dayId: number) => {
|
const getActiveRentalsForDay = (dayId: number) => {
|
||||||
@@ -446,20 +442,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getDayAssignments = (dayId) =>
|
const getDayAssignments = (dayId) =>
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
|
||||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
|
||||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
|
||||||
if (!time) return null
|
|
||||||
// ISO-Format "2025-03-30T09:00:00"
|
|
||||||
if (time.includes('T')) {
|
|
||||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
|
||||||
return h * 60 + m
|
|
||||||
}
|
|
||||||
// Einfaches "HH:MM" Format
|
|
||||||
const parts = time.split(':').map(Number)
|
|
||||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute initial day_plan_position for a transport based on time
|
// Compute initial day_plan_position for a transport based on time
|
||||||
const computeTransportPosition = (r, da) => {
|
const computeTransportPosition = (r, da) => {
|
||||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||||
@@ -501,64 +483,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||||
const da = getDayAssignments(dayId)
|
_getMergedItems({
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
dayAssignments: getDayAssignments(dayId),
|
||||||
const transport = getTransportForDay(dayId)
|
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
dayTransports: getTransportForDay(dayId),
|
||||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
dayId,
|
||||||
const baseItems = [
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
})
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
|
|
||||||
// Transports are inserted among places based on time
|
|
||||||
const timedTransports = transport.map(r => ({
|
|
||||||
type: 'transport' as const,
|
|
||||||
data: r,
|
|
||||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
|
||||||
|
|
||||||
if (timedTransports.length === 0) return baseItems
|
|
||||||
if (baseItems.length === 0) {
|
|
||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert transports among places based on per-day position or time
|
|
||||||
const result = [...baseItems]
|
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
|
||||||
const timed = timedTransports[ti]
|
|
||||||
const minutes = timed.minutes
|
|
||||||
|
|
||||||
// Use per-day position if explicitly set by user reorder
|
|
||||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
|
||||||
if (perDayPos != null) {
|
|
||||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find insertion position: after the last place with time <= this transport's time
|
|
||||||
let insertAfterKey = -Infinity
|
|
||||||
for (const item of result) {
|
|
||||||
if (item.type === 'place') {
|
|
||||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
|
||||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
} else if (item.type === 'transport') {
|
|
||||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
|
||||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
|
||||||
const sortKey = insertAfterKey === -Infinity
|
|
||||||
? lastKey + 0.5 + ti * 0.01
|
|
||||||
: insertAfterKey + 0.01 + ti * 0.001
|
|
||||||
|
|
||||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -570,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) => {
|
||||||
@@ -890,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()
|
||||||
@@ -1145,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) }}
|
||||||
@@ -1164,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 ? (
|
||||||
@@ -1191,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
|
||||||
@@ -1243,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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1259,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 */}
|
||||||
@@ -1585,15 +1560,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
}}>
|
}}>
|
||||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||||
{res.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontWeight: 400 }}>
|
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{res.reservation_end_time && ` – ${(() => {
|
if (!st && !et) return null
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
return (
|
||||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
<span style={{ fontWeight: 400 }}>
|
||||||
})()}`}
|
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||||
</span>
|
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||||
)}
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{(() => {
|
{(() => {
|
||||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
if (!meta) return null
|
if (!meta) return null
|
||||||
@@ -1703,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1752,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)
|
||||||
@@ -1820,18 +1802,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{res.title}
|
{res.title}
|
||||||
</span>
|
</span>
|
||||||
{displayTime?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||||
<Clock size={9} strokeWidth={2} />
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
if (!dispTime && !endTime) return null
|
||||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
return (
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
<Clock size={9} strokeWidth={2} />
|
||||||
})()}
|
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||||
</span>
|
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||||
)}
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
@@ -1880,8 +1864,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||||
if (fromReservationId && fromDayId !== day.id) {
|
if (placeId) {
|
||||||
|
// New place dropped onto a note: insert it among the
|
||||||
|
// assignments at the note's position (after the places
|
||||||
|
// above it), so it lands right where the note sits.
|
||||||
|
const tm = getMergedItems(day.id)
|
||||||
|
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||||
|
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id, pos)
|
||||||
|
setDropTargetKey(null); window.__dragData = null
|
||||||
|
} else if (fromReservationId && fromDayId !== day.id) {
|
||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
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
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
@@ -1978,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
|
||||||
@@ -1994,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}` && (
|
||||||
@@ -2004,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',
|
||||||
@@ -2021,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2192,13 +2215,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||||
{res.reservation_time?.includes('T')
|
{(() => {
|
||||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||||
: res.reservation_time
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const dateStr = date
|
||||||
|
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
: ''
|
: ''
|
||||||
}
|
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (dateStr) parts.push(dateStr)
|
||||||
|
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||||
|
return parts.join(', ')
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -169,7 +170,10 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
const category = categories?.find(c => c.id === place.category_id)
|
const category = categories?.find(c => c.id === place.category_id)
|
||||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
const assignmentInDay = selectedDayId
|
||||||
|
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
|
||||||
|
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||||
|
: null
|
||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
const openNow = googleDetails?.open_now ?? null
|
const openNow = googleDetails?.open_now ?? null
|
||||||
@@ -344,7 +348,7 @@ export default function PlaceInspector({
|
|||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -378,21 +382,29 @@ export default function PlaceInspector({
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{res.reservation_time && (
|
{(() => {
|
||||||
<div>
|
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
return (
|
||||||
</div>
|
<>
|
||||||
)}
|
{date && (
|
||||||
{res.reservation_time?.includes('T') && (
|
<div>
|
||||||
<div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
</div>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
)}
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
{(startTime || endTime) && (
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||||
)}
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||||
|
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{res.confirmation_number && (
|
{res.confirmation_number && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||||
|
|||||||
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
|
|||||||
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 25 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Cruise test',
|
||||||
|
type: 'cruise',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: 'T10:00',
|
||||||
|
reservation_end_time: 'T18:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 3 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Car rental',
|
||||||
|
type: 'car',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: '09:00',
|
||||||
|
reservation_end_time: '17:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/09:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
|
||||||
|
const day = buildDay({ date: '2026-07-15', day_number: 1 });
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Flight out',
|
||||||
|
type: 'flight',
|
||||||
|
status: 'confirmed',
|
||||||
|
reservation_time: '2026-07-15T08:30',
|
||||||
|
reservation_end_time: '2026-07-15T10:45',
|
||||||
|
day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/08:30/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
|
|
||||||
interface AssignmentLookupEntry {
|
interface AssignmentLookupEntry {
|
||||||
dayNumber: number
|
dayNumber: number
|
||||||
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const fmtDate = (str) => {
|
const startDt = splitReservationDateTime(r.reservation_time)
|
||||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const fmtDate = (date: string) =>
|
||||||
}
|
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
const fmtTime = (str) => {
|
|
||||||
const d = new Date(str)
|
|
||||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDate = !!r.reservation_time
|
const hasDate = !!startDt.date
|
||||||
const hasTime = r.reservation_time?.includes('T')
|
const hasTime = !!(startDt.time || endDt.time)
|
||||||
const hasCode = !!r.confirmation_number
|
const hasCode = !!r.confirmation_number
|
||||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||||
|
|
||||||
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Date / Time row */}
|
{/* Date / Time row */}
|
||||||
{hasDate && (
|
{(hasDate || hasTime) && (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||||
<div>
|
{hasDate && (
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
{fmtDate(r.reservation_time)}
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{(() => {
|
{fmtDate(startDt.date!)}
|
||||||
const endDatePart = r.reservation_end_time
|
{endDt.date && endDt.date !== startDt.date && (
|
||||||
? r.reservation_end_time.includes('T')
|
<> – {fmtDate(endDt.date)}</>
|
||||||
? r.reservation_end_time.split('T')[0]
|
)}
|
||||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
</div>
|
||||||
? r.reservation_end_time
|
|
||||||
: null
|
|
||||||
: null
|
|
||||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
|
||||||
})() && (
|
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{hasTime && (
|
{hasTime && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
{formatTime(startDt.time, locale, timeFormat)}
|
||||||
|
{endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||||
if (cells.length === 0) return null
|
if (cells.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
status: reservation.status || 'pending',
|
status: reservation.status || 'pending',
|
||||||
start_day_id: reservation.day_id ?? '',
|
start_day_id: reservation.day_id ?? '',
|
||||||
end_day_id: reservation.end_day_id ?? '',
|
end_day_id: reservation.end_day_id ?? '',
|
||||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
meta_airline: meta.airline || '',
|
meta_airline: meta.airline || '',
|
||||||
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||||
if (!time) return null
|
if (!time) return null
|
||||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
return day?.date ? `${day.date}T${time}` : time
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: Record<string, string> = {}
|
const metadata: Record<string, string> = {}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
|||||||
client_id: string
|
client_id: string
|
||||||
redirect_uris: string[]
|
redirect_uris: string[]
|
||||||
allowed_scopes: string[]
|
allowed_scopes: string[]
|
||||||
|
allows_client_credentials: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
client_secret?: string // only present on create
|
client_secret?: string // only present on create
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
const [oauthRotating, setOauthRotating] = useState(false)
|
const [oauthRotating, setOauthRotating] = useState(false)
|
||||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||||
|
|
||||||
// MCP sub-tab state
|
// MCP sub-tab state
|
||||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
}, [mcpEnabled])
|
}, [mcpEnabled])
|
||||||
|
|
||||||
const handleCreateOAuthClient = async () => {
|
const handleCreateOAuthClient = async () => {
|
||||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
if (!oauthNewName.trim()) return
|
||||||
|
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||||
setOauthCreating(true)
|
setOauthCreating(true)
|
||||||
try {
|
try {
|
||||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
const d = await oauthApi.clients.create({
|
||||||
|
name: oauthNewName.trim(),
|
||||||
|
redirect_uris: uris,
|
||||||
|
allowed_scopes: oauthNewScopes,
|
||||||
|
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||||
|
})
|
||||||
setOauthCreatedClient(d.client)
|
setOauthCreatedClient(d.client)
|
||||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||||
setOauthNewName('')
|
setOauthNewName('')
|
||||||
setOauthNewUris('')
|
setOauthNewUris('')
|
||||||
setOauthNewScopes([])
|
setOauthNewScopes([])
|
||||||
|
setOauthIsMachine(false)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('settings.oauth.toast.createError'))
|
toast.error(t('settings.oauth.toast.createError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||||
|
|
||||||
<div className="flex justify-end mb-2">
|
<div className="flex justify-end mb-2">
|
||||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||||
</button>
|
</button>
|
||||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||||
|
{client.allows_client_credentials && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
|
||||||
|
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
|
||||||
|
{t('settings.oauth.badge.machine')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{t('settings.oauth.clientId')}: {client.client_id}
|
{t('settings.oauth.clientId')}: {client.client_id}
|
||||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||||
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
autoFocus />
|
autoFocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
<div>
|
||||||
rows={3}
|
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
</div>
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
</label>
|
||||||
</div>
|
|
||||||
|
{!oauthIsMachine && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||||
|
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||||
|
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||||
|
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||||
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreateOAuthClient}
|
<button onClick={handleCreateOAuthClient}
|
||||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||||
</button>
|
</button>
|
||||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{oauthCreatedClient?.allows_client_credentials && (
|
||||||
|
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||||
|
{t('settings.oauth.modal.machineClientUsage')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
|||||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
|
const imageUrlFailed = useRef(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
alt={place.name}
|
alt={place.name}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => {
|
||||||
|
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||||
|
imageUrlFailed.current = true
|
||||||
|
const photoId = place.google_place_id || place.osm_id!
|
||||||
|
const cacheKey = `refetch:${photoId}`
|
||||||
|
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||||
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setPhotoSrc(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,9 @@ 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 { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
@@ -23,7 +27,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||||
@@ -38,7 +42,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'].includes(language) ? language : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ 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' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
||||||
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
||||||
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
||||||
|
'settings.oauth.modal.machineClient': 'عميل آلي (بدون تسجيل دخول عبر المتصفح)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'استخدام منحة client_credentials — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
|
||||||
|
'settings.oauth.badge.machine': 'آلي',
|
||||||
'settings.account': 'الحساب',
|
'settings.account': 'الحساب',
|
||||||
'settings.about': 'حول',
|
'settings.about': 'حول',
|
||||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||||
@@ -1674,6 +1678,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||||
|
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||||
@@ -1705,8 +1710,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||||
|
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||||
'journey.editor.uploadPhotos': 'رفع صور',
|
'journey.editor.uploadPhotos': 'رفع صور',
|
||||||
'journey.editor.uploading': '...جارٍ الرفع',
|
'journey.editor.uploading': '...جارٍ الرفع',
|
||||||
|
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||||
'journey.editor.fromGallery': 'من المعرض',
|
'journey.editor.fromGallery': 'من المعرض',
|
||||||
'journey.editor.addAnother': 'إضافة آخر',
|
'journey.editor.addAnother': 'إضافة آخر',
|
||||||
'journey.editor.makeFirst': 'جعله الأول',
|
'journey.editor.makeFirst': 'جعله الأول',
|
||||||
|
|||||||
@@ -402,6 +402,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessão revogada',
|
'settings.oauth.toast.revoked': 'Sessão revogada',
|
||||||
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
||||||
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
||||||
|
'settings.oauth.modal.machineClient': 'Cliente de máquina (sem login no navegador)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa o grant client_credentials — sem URIs de redirecionamento. O token é emitido diretamente via client_id + client_secret e age como você dentro dos escopos selecionados.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obter token: POST /oauth/token com grant_type=client_credentials, client_id e client_secret. Sem navegador, sem refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'máquina',
|
||||||
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
@@ -2077,8 +2081,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||||
|
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||||
'journey.editor.uploading': 'Enviando...',
|
'journey.editor.uploading': 'Enviando...',
|
||||||
|
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
|
||||||
'journey.editor.fromGallery': 'Da galeria',
|
'journey.editor.fromGallery': 'Da galeria',
|
||||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||||
'journey.editor.writeStory': 'Escreva sua história...',
|
'journey.editor.writeStory': 'Escreva sua história...',
|
||||||
@@ -2169,6 +2176,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Falha ao excluir',
|
'journey.settings.failedToDelete': 'Falha ao excluir',
|
||||||
'journey.entries.deleteTitle': 'Excluir entrada',
|
'journey.entries.deleteTitle': 'Excluir entrada',
|
||||||
'journey.photosUploaded': '{count} fotos enviadas',
|
'journey.photosUploaded': '{count} fotos enviadas',
|
||||||
|
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
|
||||||
'journey.photosAdded': '{count} fotos adicionadas',
|
'journey.photosAdded': '{count} fotos adicionadas',
|
||||||
'journey.public.notFound': 'Não encontrado',
|
'journey.public.notFound': 'Não encontrado',
|
||||||
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Relace odvolána',
|
'settings.oauth.toast.revoked': 'Relace odvolána',
|
||||||
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
||||||
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
||||||
|
'settings.oauth.modal.machineClient': 'Strojový klient (bez přihlášení v prohlížeči)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Používá grant client_credentials — bez URI pro přesměrování. Token je vydán přímo přes client_id + client_secret a funguje jako vy v rámci vybraných oborů.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Získat token: POST /oauth/token s grant_type=client_credentials, client_id a client_secret. Bez prohlížeče, bez obnovovacího tokenu.',
|
||||||
|
'settings.oauth.badge.machine': 'strojový',
|
||||||
'settings.account': 'Účet',
|
'settings.account': 'Účet',
|
||||||
'settings.about': 'O aplikaci',
|
'settings.about': 'O aplikaci',
|
||||||
'settings.about.reportBug': 'Nahlásit chybu',
|
'settings.about.reportBug': 'Nahlásit chybu',
|
||||||
@@ -2082,8 +2086,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'místa',
|
'journey.synced.places': 'místa',
|
||||||
'journey.synced.synced': 'synchronizováno',
|
'journey.synced.synced': 'synchronizováno',
|
||||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||||
|
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||||
'journey.editor.uploading': 'Nahrávání...',
|
'journey.editor.uploading': 'Nahrávání...',
|
||||||
|
'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
|
||||||
'journey.editor.fromGallery': 'Z galerie',
|
'journey.editor.fromGallery': 'Z galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||||
@@ -2174,6 +2181,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
||||||
'journey.entries.deleteTitle': 'Smazat záznam',
|
'journey.entries.deleteTitle': 'Smazat záznam',
|
||||||
'journey.photosUploaded': '{count} fotografií nahráno',
|
'journey.photosUploaded': '{count} fotografií nahráno',
|
||||||
|
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
|
||||||
'journey.photosAdded': '{count} fotografií přidáno',
|
'journey.photosAdded': '{count} fotografií přidáno',
|
||||||
'journey.public.notFound': 'Nenalezeno',
|
'journey.public.notFound': 'Nenalezeno',
|
||||||
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session widerrufen',
|
'settings.oauth.toast.revoked': 'Session widerrufen',
|
||||||
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
||||||
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
||||||
|
'settings.oauth.modal.machineClient': 'Maschineller Client (kein Browser-Login)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Verwendet den client_credentials Grant — keine Redirect-URIs erforderlich. Das Token wird direkt über client_id + client_secret ausgestellt und handelt in Ihrem Namen innerhalb der gewählten Scopes.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token abrufen: POST /oauth/token mit grant_type=client_credentials, client_id und client_secret. Kein Browser, kein Refresh-Token.',
|
||||||
|
'settings.oauth.badge.machine': 'Maschine',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
'settings.about': 'Über',
|
'settings.about': 'Über',
|
||||||
'settings.about.reportBug': 'Bug melden',
|
'settings.about.reportBug': 'Bug melden',
|
||||||
@@ -2085,8 +2089,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'Orte',
|
'journey.synced.places': 'Orte',
|
||||||
'journey.synced.synced': 'synchronisiert',
|
'journey.synced.synced': 'synchronisiert',
|
||||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||||
|
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||||
'journey.editor.uploading': 'Hochladen...',
|
'journey.editor.uploading': 'Hochladen...',
|
||||||
|
'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
|
||||||
'journey.editor.fromGallery': 'Aus Galerie',
|
'journey.editor.fromGallery': 'Aus Galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||||
@@ -2181,6 +2188,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
||||||
'journey.entries.deleteTitle': 'Eintrag löschen',
|
'journey.entries.deleteTitle': 'Eintrag löschen',
|
||||||
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
||||||
|
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
|
||||||
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
||||||
'journey.public.notFound': 'Nicht gefunden',
|
'journey.public.notFound': 'Nicht gefunden',
|
||||||
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session revoked',
|
'settings.oauth.toast.revoked': 'Session revoked',
|
||||||
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
||||||
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
||||||
|
'settings.oauth.modal.machineClient': 'Machine client (no browser login)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Use client_credentials grant — no redirect URIs needed. The token is issued directly via client_id + client_secret and acts as you within the selected scopes.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Get a token: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret. No browser, no refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'About',
|
'settings.about': 'About',
|
||||||
'settings.about.reportBug': 'Report a Bug',
|
'settings.about.reportBug': 'Report a Bug',
|
||||||
@@ -2111,8 +2115,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||||
|
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||||
'journey.editor.uploadPhotos': 'Upload photos',
|
'journey.editor.uploadPhotos': 'Upload photos',
|
||||||
'journey.editor.uploading': 'Uploading...',
|
'journey.editor.uploading': 'Uploading...',
|
||||||
|
'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
|
||||||
'journey.editor.fromGallery': 'From Gallery',
|
'journey.editor.fromGallery': 'From Gallery',
|
||||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||||
'journey.editor.writeStory': 'Write your story...',
|
'journey.editor.writeStory': 'Write your story...',
|
||||||
@@ -2219,6 +2226,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Failed to delete',
|
'journey.settings.failedToDelete': 'Failed to delete',
|
||||||
'journey.entries.deleteTitle': 'Delete Entry',
|
'journey.entries.deleteTitle': 'Delete Entry',
|
||||||
'journey.photosUploaded': '{count} photos uploaded',
|
'journey.photosUploaded': '{count} photos uploaded',
|
||||||
|
'journey.photosUploadFailed': 'Some photos failed to upload',
|
||||||
'journey.photosAdded': '{count} photos added',
|
'journey.photosAdded': '{count} photos added',
|
||||||
|
|
||||||
// Journey — Public Page
|
// Journey — Public Page
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ const es: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||||
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
||||||
|
'settings.oauth.modal.machineClient': 'Cliente de máquina (sin inicio de sesión en el navegador)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
|
||||||
|
'settings.oauth.badge.machine': 'máquina',
|
||||||
'settings.account': 'Cuenta',
|
'settings.account': 'Cuenta',
|
||||||
'settings.about': 'Acerca de',
|
'settings.about': 'Acerca de',
|
||||||
'settings.about.reportBug': 'Reportar un error',
|
'settings.about.reportBug': 'Reportar un error',
|
||||||
@@ -2084,8 +2088,11 @@ const es: Record<string, string> = {
|
|||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||||
|
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||||
'journey.editor.uploading': 'Subiendo...',
|
'journey.editor.uploading': 'Subiendo...',
|
||||||
|
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||||
'journey.editor.fromGallery': 'Desde galería',
|
'journey.editor.fromGallery': 'Desde galería',
|
||||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||||
@@ -2176,6 +2183,7 @@ const es: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||||
'journey.photosUploaded': '{count} fotos subidas',
|
'journey.photosUploaded': '{count} fotos subidas',
|
||||||
|
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||||
'journey.photosAdded': '{count} fotos añadidas',
|
'journey.photosAdded': '{count} fotos añadidas',
|
||||||
'journey.public.notFound': 'No encontrado',
|
'journey.public.notFound': 'No encontrado',
|
||||||
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ const fr: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session révoquée',
|
'settings.oauth.toast.revoked': 'Session révoquée',
|
||||||
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
||||||
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
||||||
|
'settings.oauth.modal.machineClient': 'Client machine (sans connexion navigateur)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Utilise le grant client_credentials — aucune URI de redirection requise. Le token est émis directement via client_id + client_secret et agit en votre nom dans les portées sélectionnées.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obtenir un token : POST /oauth/token avec grant_type=client_credentials, client_id et client_secret. Sans navigateur, sans token de rafraîchissement.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Compte',
|
'settings.account': 'Compte',
|
||||||
'settings.about': 'À propos',
|
'settings.about': 'À propos',
|
||||||
'settings.about.reportBug': 'Signaler un bug',
|
'settings.about.reportBug': 'Signaler un bug',
|
||||||
@@ -2078,8 +2082,11 @@ const fr: Record<string, string> = {
|
|||||||
'journey.synced.places': 'lieux',
|
'journey.synced.places': 'lieux',
|
||||||
'journey.synced.synced': 'synchronisé',
|
'journey.synced.synced': 'synchronisé',
|
||||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||||
|
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||||
'journey.editor.uploading': 'Envoi...',
|
'journey.editor.uploading': 'Envoi...',
|
||||||
|
'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
|
||||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||||
@@ -2170,6 +2177,7 @@ const fr: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Échec de la suppression',
|
'journey.settings.failedToDelete': 'Échec de la suppression',
|
||||||
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
||||||
'journey.photosUploaded': '{count} photos téléversées',
|
'journey.photosUploaded': '{count} photos téléversées',
|
||||||
|
'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées",
|
||||||
'journey.photosAdded': '{count} photos ajoutées',
|
'journey.photosAdded': '{count} photos ajoutées',
|
||||||
'journey.public.notFound': 'Introuvable',
|
'journey.public.notFound': 'Introuvable',
|
||||||
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
||||||
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
||||||
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
||||||
|
'settings.oauth.modal.machineClient': 'Gépi kliens (böngészős bejelentkezés nélkül)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'client_credentials grant használata — nincs szükség átirányítási URI-kra. A token közvetlenül client_id + client_secret segítségével kerül kiállításra, és a kiválasztott hatókörökön belül az Ön nevében jár el.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token lekérése: POST /oauth/token a grant_type=client_credentials, client_id és client_secret értékekkel. Böngésző és frissítési token nélkül.',
|
||||||
|
'settings.oauth.badge.machine': 'gépi',
|
||||||
'settings.account': 'Fiók',
|
'settings.account': 'Fiók',
|
||||||
'settings.about': 'Névjegy',
|
'settings.about': 'Névjegy',
|
||||||
'settings.about.reportBug': 'Hiba bejelentése',
|
'settings.about.reportBug': 'Hiba bejelentése',
|
||||||
@@ -2079,8 +2083,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'helyszín',
|
'journey.synced.places': 'helyszín',
|
||||||
'journey.synced.synced': 'szinkronizálva',
|
'journey.synced.synced': 'szinkronizálva',
|
||||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||||
|
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||||
'journey.editor.uploading': 'Feltöltés...',
|
'journey.editor.uploading': 'Feltöltés...',
|
||||||
|
'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
|
||||||
'journey.editor.fromGallery': 'Galériából',
|
'journey.editor.fromGallery': 'Galériából',
|
||||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||||
@@ -2171,6 +2178,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
||||||
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
||||||
'journey.photosUploaded': '{count} fotó feltöltve',
|
'journey.photosUploaded': '{count} fotó feltöltve',
|
||||||
|
'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni',
|
||||||
'journey.photosAdded': '{count} fotó hozzáadva',
|
'journey.photosAdded': '{count} fotó hozzáadva',
|
||||||
'journey.public.notFound': 'Nem található',
|
'journey.public.notFound': 'Nem található',
|
||||||
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
||||||
|
|||||||
@@ -387,6 +387,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
||||||
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
||||||
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
||||||
|
'settings.oauth.modal.machineClient': 'Klien mesin (tanpa login browser)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Menggunakan grant client_credentials — tidak perlu URI pengalihan. Token diterbitkan langsung melalui client_id + client_secret dan bertindak sebagai Anda dalam cakupan yang dipilih.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Dapatkan token: POST /oauth/token dengan grant_type=client_credentials, client_id, dan client_secret. Tanpa browser, tanpa refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'mesin',
|
||||||
'settings.account': 'Akun',
|
'settings.account': 'Akun',
|
||||||
'settings.about': 'Tentang',
|
'settings.about': 'Tentang',
|
||||||
'settings.about.reportBug': 'Laporkan Bug',
|
'settings.about.reportBug': 'Laporkan Bug',
|
||||||
@@ -2094,8 +2098,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||||
|
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||||
'journey.editor.uploading': 'Mengunggah...',
|
'journey.editor.uploading': 'Mengunggah...',
|
||||||
|
'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
|
||||||
'journey.editor.fromGallery': 'Dari Galeri',
|
'journey.editor.fromGallery': 'Dari Galeri',
|
||||||
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||||
@@ -2198,6 +2205,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Gagal menghapus',
|
'journey.settings.failedToDelete': 'Gagal menghapus',
|
||||||
'journey.entries.deleteTitle': 'Hapus Entri',
|
'journey.entries.deleteTitle': 'Hapus Entri',
|
||||||
'journey.photosUploaded': '{count} foto diunggah',
|
'journey.photosUploaded': '{count} foto diunggah',
|
||||||
|
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
|
||||||
'journey.photosAdded': '{count} foto ditambahkan',
|
'journey.photosAdded': '{count} foto ditambahkan',
|
||||||
|
|
||||||
// Journey — Public Page
|
// Journey — Public Page
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessione revocata',
|
'settings.oauth.toast.revoked': 'Sessione revocata',
|
||||||
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
||||||
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
||||||
|
'settings.oauth.modal.machineClient': 'Client macchina (senza login nel browser)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa il grant client_credentials — nessun URI di reindirizzamento necessario. Il token viene emesso direttamente tramite client_id + client_secret e agisce come te negli ambiti selezionati.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Ottieni token: POST /oauth/token con grant_type=client_credentials, client_id e client_secret. Senza browser, senza token di aggiornamento.',
|
||||||
|
'settings.oauth.badge.machine': 'macchina',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'Informazioni',
|
'settings.about': 'Informazioni',
|
||||||
'settings.about.reportBug': 'Segnala un bug',
|
'settings.about.reportBug': 'Segnala un bug',
|
||||||
@@ -2079,8 +2083,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'luoghi',
|
'journey.synced.places': 'luoghi',
|
||||||
'journey.synced.synced': 'sincronizzato',
|
'journey.synced.synced': 'sincronizzato',
|
||||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||||
|
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||||
'journey.editor.uploadPhotos': 'Carica foto',
|
'journey.editor.uploadPhotos': 'Carica foto',
|
||||||
'journey.editor.uploading': 'Caricamento...',
|
'journey.editor.uploading': 'Caricamento...',
|
||||||
|
'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
|
||||||
'journey.editor.fromGallery': 'Dalla galleria',
|
'journey.editor.fromGallery': 'Dalla galleria',
|
||||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||||
@@ -2171,6 +2178,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
||||||
'journey.entries.deleteTitle': 'Elimina voce',
|
'journey.entries.deleteTitle': 'Elimina voce',
|
||||||
'journey.photosUploaded': '{count} foto caricate',
|
'journey.photosUploaded': '{count} foto caricate',
|
||||||
|
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
|
||||||
'journey.photosAdded': '{count} foto aggiunte',
|
'journey.photosAdded': '{count} foto aggiunte',
|
||||||
'journey.public.notFound': 'Non trovato',
|
'journey.public.notFound': 'Non trovato',
|
||||||
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,10 @@ const nl: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
||||||
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
||||||
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
||||||
|
'settings.oauth.modal.machineClient': 'Machineclient (zonder browserinlog)',
|
||||||
|
'settings.oauth.modal.machineClientHint': "Gebruikt de client_credentials grant — geen redirect-URI's nodig. Het token wordt direct verstrekt via client_id + client_secret en handelt namens jou binnen de geselecteerde scopes.",
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token ophalen: POST /oauth/token met grant_type=client_credentials, client_id en client_secret. Geen browser, geen vernieuwingstoken.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'Over',
|
'settings.about': 'Over',
|
||||||
'settings.about.reportBug': 'Bug melden',
|
'settings.about.reportBug': 'Bug melden',
|
||||||
@@ -2078,8 +2082,11 @@ const nl: Record<string, string> = {
|
|||||||
'journey.synced.places': 'plaatsen',
|
'journey.synced.places': 'plaatsen',
|
||||||
'journey.synced.synced': 'gesynchroniseerd',
|
'journey.synced.synced': 'gesynchroniseerd',
|
||||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||||
|
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||||
'journey.editor.uploading': 'Uploaden...',
|
'journey.editor.uploading': 'Uploaden...',
|
||||||
|
'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
|
||||||
'journey.editor.fromGallery': 'Uit galerij',
|
'journey.editor.fromGallery': 'Uit galerij',
|
||||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||||
@@ -2170,6 +2177,7 @@ const nl: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
||||||
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
||||||
'journey.photosUploaded': "{count} foto's geüpload",
|
'journey.photosUploaded': "{count} foto's geüpload",
|
||||||
|
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
|
||||||
'journey.photosAdded': "{count} foto's toegevoegd",
|
'journey.photosAdded': "{count} foto's toegevoegd",
|
||||||
'journey.public.notFound': 'Niet gevonden',
|
'journey.public.notFound': 'Niet gevonden',
|
||||||
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
||||||
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
||||||
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
||||||
|
'settings.oauth.modal.machineClient': 'Klient maszynowy (bez logowania przez przeglądarkę)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Używa grantu client_credentials — nie są potrzebne URI przekierowania. Token jest wystawiany bezpośrednio przez client_id + client_secret i działa w Twoim imieniu w ramach wybranych zakresów.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Pobierz token: POST /oauth/token z grant_type=client_credentials, client_id i client_secret. Bez przeglądarki, bez tokenu odświeżania.',
|
||||||
|
'settings.oauth.badge.machine': 'maszynowy',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
'settings.about': 'O aplikacji',
|
'settings.about': 'O aplikacji',
|
||||||
'settings.about.reportBug': 'Zgłoś błąd',
|
'settings.about.reportBug': 'Zgłoś błąd',
|
||||||
@@ -2071,8 +2075,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'miejsca',
|
'journey.synced.places': 'miejsca',
|
||||||
'journey.synced.synced': 'zsynchronizowane',
|
'journey.synced.synced': 'zsynchronizowane',
|
||||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||||
|
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||||
'journey.editor.uploading': 'Przesyłanie...',
|
'journey.editor.uploading': 'Przesyłanie...',
|
||||||
|
'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
|
||||||
'journey.editor.fromGallery': 'Z galerii',
|
'journey.editor.fromGallery': 'Z galerii',
|
||||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||||
@@ -2163,6 +2170,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
||||||
'journey.entries.deleteTitle': 'Usuń wpis',
|
'journey.entries.deleteTitle': 'Usuń wpis',
|
||||||
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
||||||
|
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
|
||||||
'journey.photosAdded': '{count} zdjęć dodanych',
|
'journey.photosAdded': '{count} zdjęć dodanych',
|
||||||
'journey.public.notFound': 'Nie znaleziono',
|
'journey.public.notFound': 'Nie znaleziono',
|
||||||
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ const ru: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
||||||
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
||||||
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
||||||
|
'settings.oauth.modal.machineClient': 'Машинный клиент (без входа через браузер)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Использует грант client_credentials — URI перенаправления не требуются. Токен выдаётся напрямую через client_id + client_secret и действует от вашего имени в пределах выбранных областей.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Получить токен: POST /oauth/token с grant_type=client_credentials, client_id и client_secret. Без браузера, без токена обновления.',
|
||||||
|
'settings.oauth.badge.machine': 'машинный',
|
||||||
'settings.account': 'Аккаунт',
|
'settings.account': 'Аккаунт',
|
||||||
'settings.about': 'О приложении',
|
'settings.about': 'О приложении',
|
||||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||||
@@ -2078,8 +2082,11 @@ const ru: Record<string, string> = {
|
|||||||
'journey.synced.places': 'мест',
|
'journey.synced.places': 'мест',
|
||||||
'journey.synced.synced': 'синхронизировано',
|
'journey.synced.synced': 'синхронизировано',
|
||||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||||
|
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||||
'journey.editor.uploading': 'Загрузка...',
|
'journey.editor.uploading': 'Загрузка...',
|
||||||
|
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||||
'journey.editor.fromGallery': 'Из галереи',
|
'journey.editor.fromGallery': 'Из галереи',
|
||||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||||
@@ -2170,6 +2177,7 @@ const ru: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Не удалось удалить',
|
'journey.settings.failedToDelete': 'Не удалось удалить',
|
||||||
'journey.entries.deleteTitle': 'Удалить запись',
|
'journey.entries.deleteTitle': 'Удалить запись',
|
||||||
'journey.photosUploaded': '{count} фото загружено',
|
'journey.photosUploaded': '{count} фото загружено',
|
||||||
|
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
|
||||||
'journey.photosAdded': '{count} фото добавлено',
|
'journey.photosAdded': '{count} фото добавлено',
|
||||||
'journey.public.notFound': 'Не найдено',
|
'journey.public.notFound': 'Не найдено',
|
||||||
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,10 @@ const zh: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': '会话已撤销',
|
'settings.oauth.toast.revoked': '会话已撤销',
|
||||||
'settings.oauth.toast.revokeError': '撤销会话失败',
|
'settings.oauth.toast.revokeError': '撤销会话失败',
|
||||||
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
||||||
|
'settings.oauth.modal.machineClient': '机器客户端(无需浏览器登录)',
|
||||||
|
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授权——无需重定向 URI。令牌通过 client_id + client_secret 直接颁发,并在所选范围内以您的身份运行。',
|
||||||
|
'settings.oauth.modal.machineClientUsage': '获取令牌:向 /oauth/token 发送 POST 请求,携带 grant_type=client_credentials、client_id 和 client_secret。无需浏览器,无刷新令牌。',
|
||||||
|
'settings.oauth.badge.machine': '机器',
|
||||||
'settings.account': '账户',
|
'settings.account': '账户',
|
||||||
'settings.about': '关于',
|
'settings.about': '关于',
|
||||||
'settings.about.reportBug': '报告错误',
|
'settings.about.reportBug': '报告错误',
|
||||||
@@ -2078,8 +2082,11 @@ const zh: Record<string, string> = {
|
|||||||
'journey.synced.places': '个地点',
|
'journey.synced.places': '个地点',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||||
|
'journey.editor.uploadFailed': '照片上传失败',
|
||||||
'journey.editor.uploadPhotos': '上传照片',
|
'journey.editor.uploadPhotos': '上传照片',
|
||||||
'journey.editor.uploading': '上传中...',
|
'journey.editor.uploading': '上传中...',
|
||||||
|
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||||
'journey.editor.fromGallery': '从相册',
|
'journey.editor.fromGallery': '从相册',
|
||||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||||
'journey.editor.writeStory': '写下你的故事...',
|
'journey.editor.writeStory': '写下你的故事...',
|
||||||
@@ -2170,6 +2177,7 @@ const zh: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': '删除失败',
|
'journey.settings.failedToDelete': '删除失败',
|
||||||
'journey.entries.deleteTitle': '删除条目',
|
'journey.entries.deleteTitle': '删除条目',
|
||||||
'journey.photosUploaded': '{count} 张照片已上传',
|
'journey.photosUploaded': '{count} 张照片已上传',
|
||||||
|
'journey.photosUploadFailed': '部分照片上传失败',
|
||||||
'journey.photosAdded': '{count} 张照片已添加',
|
'journey.photosAdded': '{count} 张照片已添加',
|
||||||
'journey.public.notFound': '未找到',
|
'journey.public.notFound': '未找到',
|
||||||
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ const zhTw: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
||||||
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
||||||
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
||||||
|
'settings.oauth.modal.machineClient': '機器客戶端(無需瀏覽器登入)',
|
||||||
|
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授權——無需重新導向 URI。令牌透過 client_id + client_secret 直接簽發,並在所選範圍內以您的身份運行。',
|
||||||
|
'settings.oauth.modal.machineClientUsage': '取得令牌:向 /oauth/token 發送 POST 請求,攜帶 grant_type=client_credentials、client_id 和 client_secret。無需瀏覽器,無重整令牌。',
|
||||||
|
'settings.oauth.badge.machine': '機器',
|
||||||
'settings.account': '賬戶',
|
'settings.account': '賬戶',
|
||||||
'settings.about': '關於',
|
'settings.about': '關於',
|
||||||
'settings.about.reportBug': '回報錯誤',
|
'settings.about.reportBug': '回報錯誤',
|
||||||
@@ -2036,8 +2040,11 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.synced.places': '個地點',
|
'journey.synced.places': '個地點',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||||
|
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||||
'journey.editor.uploadPhotos': '上傳照片',
|
'journey.editor.uploadPhotos': '上傳照片',
|
||||||
'journey.editor.uploading': '上傳中...',
|
'journey.editor.uploading': '上傳中...',
|
||||||
|
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||||
'journey.editor.fromGallery': '從相簿',
|
'journey.editor.fromGallery': '從相簿',
|
||||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||||
'journey.editor.writeStory': '寫下你的故事...',
|
'journey.editor.writeStory': '寫下你的故事...',
|
||||||
@@ -2128,6 +2135,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': '刪除失敗',
|
'journey.settings.failedToDelete': '刪除失敗',
|
||||||
'journey.entries.deleteTitle': '刪除條目',
|
'journey.entries.deleteTitle': '刪除條目',
|
||||||
'journey.photosUploaded': '{count} 張照片已上傳',
|
'journey.photosUploaded': '{count} 張照片已上傳',
|
||||||
|
'journey.photosUploadFailed': '部分照片上傳失敗',
|
||||||
'journey.photosAdded': '{count} 張照片已新增',
|
'journey.photosAdded': '{count} 張照片已新增',
|
||||||
'journey.public.notFound': '未找到',
|
'journey.public.notFound': '未找到',
|
||||||
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { formatLocationName } from '../utils/formatters'
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||||
|
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
|||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
import { getApiErrorMessage } from '../types'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||||
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
|
|||||||
}
|
}
|
||||||
return entryId
|
return entryId
|
||||||
}}
|
}}
|
||||||
onUploadPhotos={async (entryId, formData) => {
|
onUploadPhotos={async (entryId, files, cbs) => {
|
||||||
return await uploadPhotos(entryId, formData)
|
return await uploadPhotos(entryId, files, cbs)
|
||||||
}}
|
}}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setEditingEntry(null)
|
setEditingEntry(null)
|
||||||
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
|
const galleryUploading = galleryProgress !== null
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// check which providers are enabled AND connected for the current user
|
// check which providers are enabled AND connected for the current user
|
||||||
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryProgress({ done: 0, total: files.length })
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const normalized = await normalizeImageFiles(files)
|
||||||
for (const f of files) formData.append('photos', f)
|
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
})
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||||
|
} else {
|
||||||
|
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||||
|
}
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error(t('journey.settings.coverFailed'))
|
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||||
} finally {
|
} finally {
|
||||||
setGalleryUploading(false)
|
setGalleryProgress(null)
|
||||||
}
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{galleryUploading ? (
|
{galleryUploading ? (
|
||||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={12} /> {t('common.upload')}</>
|
<><Plus size={12} /> {t('common.upload')}</>
|
||||||
)}
|
)}
|
||||||
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
galleryPhotos: GalleryPhoto[]
|
galleryPhotos: GalleryPhoto[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [title, setTitle] = useState(entry.title || '')
|
const [title, setTitle] = useState(entry.title || '')
|
||||||
const [story, setStory] = useState(entry.story || '')
|
const [story, setStory] = useState(entry.story || '')
|
||||||
@@ -2191,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
})
|
})
|
||||||
// upload queued files after entry is created
|
// upload queued files after entry is created
|
||||||
if (pendingFiles.length > 0 && entryId) {
|
if (pendingFiles.length > 0 && entryId) {
|
||||||
const formData = new FormData()
|
const filesToUpload = pendingFiles
|
||||||
for (const f of pendingFiles) formData.append('photos', f)
|
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||||
await onUploadPhotos(entryId, formData)
|
try {
|
||||||
|
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||||
|
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||||
|
})
|
||||||
|
setPendingFiles(failed)
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||||
|
} finally {
|
||||||
|
setUploadProgress(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// link gallery photos that were picked before save
|
// link gallery photos that were picked before save
|
||||||
if (pendingLinkIds.length > 0 && entryId) {
|
if (pendingLinkIds.length > 0 && entryId) {
|
||||||
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
// Queue files locally until Save so cancel/close actually discards. This
|
// Queue files locally until Save so cancel/close actually discards. This
|
||||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
const normalized = await normalizeImageFiles(files)
|
||||||
|
setPendingFiles(prev => [...prev, ...normalized])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={saving}
|
||||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploadProgress ? (
|
||||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { createElement } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
import { splitReservationDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
function createMarkerIcon(place: any) {
|
function createMarkerIcon(place: any) {
|
||||||
@@ -184,14 +185,16 @@ export default function SharedTripPage() {
|
|||||||
{sortedDays.map((day: any, di: number) => {
|
{sortedDays.map((day: any, di: number) => {
|
||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||||
|
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = getMergedItems({
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
dayAssignments: da,
|
||||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
dayNotes: notes,
|
||||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
dayTransports: dayTransport,
|
||||||
].sort((a, b) => a.k - b.k)
|
dayId: day.id,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
@@ -212,12 +215,12 @@ export default function SharedTripPage() {
|
|||||||
|
|
||||||
{selectedDay === day.id && merged.length > 0 && (
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{merged.map((item: any, idx: number) => {
|
{merged.map((item: any) => {
|
||||||
if (item.type === 'transport') {
|
if (item.type === 'transport') {
|
||||||
const r = item.data
|
const r = item.data
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||||
let sub = ''
|
let sub = ''
|
||||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
@@ -274,8 +277,9 @@ export default function SharedTripPage() {
|
|||||||
{(reservations || []).map((r: any) => {
|
{(reservations || []).map((r: any) => {
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
const time = rTime ?? ''
|
||||||
|
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||||
return (
|
return (
|
||||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1003,6 +1011,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||||
collapsed={dayDetailCollapsed}
|
collapsed={dayDetailCollapsed}
|
||||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||||
|
mobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -1116,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); setMobileSidebarOpen(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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { journeyApi } from '../api/client';
|
||||||
import { useJourneyStore } from './journeyStore';
|
import { useJourneyStore } from './journeyStore';
|
||||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||||
|
|
||||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
|||||||
useJourneyStore.setState({ current: detail });
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
server.use(
|
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||||
http.post('/api/journeys/entries/100/photos', () =>
|
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||||
HttpResponse.json({ photos: [newPhoto] })
|
// layer directly so this test exercises store state management only.
|
||||||
)
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||||
);
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result.succeeded).toHaveLength(1);
|
||||||
expect(result[0].id).toBe(91);
|
expect(result.succeeded[0].id).toBe(91);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
expect(storedEntry?.photos).toHaveLength(2);
|
expect(storedEntry?.photos).toHaveLength(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/journeys/entries/100/photos', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
|
expect(result.succeeded).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0]).toBe(file);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
|
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||||
|
let callCount = 0;
|
||||||
|
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||||
|
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||||
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return { photos: [photo1] } as any;
|
||||||
|
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||||
|
});
|
||||||
|
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||||
|
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||||
|
expect(result.succeeded).toHaveLength(1);
|
||||||
|
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(1);
|
||||||
|
void photo2; // referenced to avoid lint warning
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
|
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
|
|
||||||
export interface Journey {
|
export interface Journey {
|
||||||
id: number
|
id: number
|
||||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
|||||||
deleteEntry: (entryId: number) => Promise<void>
|
deleteEntry: (entryId: number) => Promise<void>
|
||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadPhotos: async (entryId, formData) => {
|
uploadPhotos: async (entryId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
return uploadFilesResilient<JourneyPhoto>(
|
||||||
const photos = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current) return s
|
const fd = new FormData()
|
||||||
return {
|
fd.append('photos', file)
|
||||||
current: {
|
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||||
...s.current,
|
const photos: JourneyPhoto[] = data.photos || []
|
||||||
entries: s.current.entries.map(e =>
|
const gallery: GalleryPhoto[] = data.gallery || []
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
set(s => {
|
||||||
),
|
if (!s.current) return s
|
||||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
return {
|
||||||
},
|
current: {
|
||||||
}
|
...s.current,
|
||||||
})
|
entries: s.current.entries.map(e =>
|
||||||
return photos
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...gallery],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
return uploadFilesResilient<GalleryPhoto>(
|
||||||
const photos: GalleryPhoto[] = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current || s.current.id !== journeyId) return s
|
const fd = new FormData()
|
||||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
fd.append('photos', file)
|
||||||
})
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||||
return photos
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
|||||||
accommodation_start_day_id?: number | null
|
accommodation_start_day_id?: number | null
|
||||||
accommodation_end_day_id?: number | null
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
|
day_positions?: Record<number, number> | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
endpoints?: ReservationEndpoint[]
|
endpoints?: ReservationEndpoint[]
|
||||||
@@ -236,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 {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function looksLikeHeic(file: File): boolean {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFile(file: File): Promise<File> {
|
||||||
|
if (!looksLikeHeic(file)) return file
|
||||||
|
const { isHeic, heicTo } = await import('heic-to')
|
||||||
|
if (!(await isHeic(file))) return file
|
||||||
|
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||||
|
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||||
|
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||||
|
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
|
describe('parseTimeToMinutes', () => {
|
||||||
|
it('parses HH:MM string', () => {
|
||||||
|
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ISO datetime string', () => {
|
||||||
|
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseTimeToMinutes(null)).toBeNull()
|
||||||
|
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSpanPhase', () => {
|
||||||
|
it('returns single when start === end', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns start for the departure day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns end for the arrival day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns middle for days in between', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayTimeForDay', () => {
|
||||||
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
it('returns reservation_time on start day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns reservation_end_time on end day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for middle day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTransportForDay', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, day_number: 1 },
|
||||||
|
{ id: 2, day_number: 2 },
|
||||||
|
{ id: 3, day_number: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('excludes hotel (rendered via accommodation path)', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes tour booking on the correct day', () => {
|
||||||
|
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes restaurant, event, and other bookings by day_id', () => {
|
||||||
|
const reservations = [
|
||||||
|
{ id: 30, type: 'restaurant', day_id: 2 },
|
||||||
|
{ id: 31, type: 'event', day_id: 2 },
|
||||||
|
{ id: 32, type: 'other', day_id: 2 },
|
||||||
|
]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes single-day transport on the correct day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes multi-day transport on all spanned days', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes transport linked to an assignment on that day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMergedItems', () => {
|
||||||
|
it('merges places and notes sorted by sortKey', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||||
|
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||||
|
]
|
||||||
|
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||||
|
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||||
|
expect(result[0].data.id).toBe(1)
|
||||||
|
expect(result[1].data.id).toBe(10)
|
||||||
|
expect(result[2].data.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts transport by time when no per-day position is set', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||||
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('per-day position overrides time-based insertion', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
// Transport at 10:30 would normally go between the two places
|
||||||
|
// but per-day position 1.5 puts it after the second place
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
expect(types).toEqual(['place', 'place', 'transport'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
export interface MergedItem {
|
||||||
|
type: 'place' | 'note' | 'transport'
|
||||||
|
sortKey: number
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||||
|
if (!time) return null
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpanPhase(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null },
|
||||||
|
dayId: number
|
||||||
|
): 'single' | 'start' | 'middle' | 'end' {
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
if (!startDayId || startDayId === endDayId) return 'single'
|
||||||
|
if (dayId === startDayId) return 'start'
|
||||||
|
if (dayId === endDayId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayTimeForDay(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
|
dayId: number
|
||||||
|
): string | null {
|
||||||
|
const phase = getSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
|
export function getTransportForDay(opts: {
|
||||||
|
reservations: any[]
|
||||||
|
dayId: number
|
||||||
|
dayAssignmentIds: number[]
|
||||||
|
days: Array<{ id: number; day_number?: number }>
|
||||||
|
}): any[] {
|
||||||
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
|
const getDayOrder = (id: number): number => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||||
|
}
|
||||||
|
const thisDayOrder = getDayOrder(dayId)
|
||||||
|
|
||||||
|
return reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
|
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
|
||||||
|
if (startDayId == null) return false
|
||||||
|
|
||||||
|
if (endDayId !== startDayId) {
|
||||||
|
const startOrder = getDayOrder(startDayId)
|
||||||
|
const endOrder = getDayOrder(endDayId)
|
||||||
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
|
}
|
||||||
|
return startDayId === dayId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
|
export function getMergedItems(opts: {
|
||||||
|
dayAssignments: any[]
|
||||||
|
dayNotes: any[]
|
||||||
|
dayTransports: any[]
|
||||||
|
dayId: number
|
||||||
|
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||||
|
}): MergedItem[] {
|
||||||
|
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||||
|
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||||
|
|
||||||
|
const baseItems: MergedItem[] = [
|
||||||
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||||
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
const timedTransports = transport.map(r => ({
|
||||||
|
type: 'transport' as const,
|
||||||
|
data: r,
|
||||||
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
|
})).sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
|
if (timedTransports.length === 0) return baseItems
|
||||||
|
if (baseItems.length === 0) {
|
||||||
|
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transports among base items based on per-day position or time
|
||||||
|
const result = [...baseItems]
|
||||||
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
|
const timed = timedTransports[ti]
|
||||||
|
const minutes = timed.minutes
|
||||||
|
|
||||||
|
// Per-day position takes precedence (set by user reorder)
|
||||||
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
|
if (perDayPos != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||||
|
let insertAfterKey = -Infinity
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
} else if (item.type === 'transport') {
|
||||||
|
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||||
|
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||||
|
const sortKey = insertAfterKey === -Infinity
|
||||||
|
? lastKey + 0.5 + ti * 0.01
|
||||||
|
: insertAfterKey + 0.01 + ti * 0.001
|
||||||
|
|
||||||
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { splitReservationDateTime } from './formatters'
|
||||||
|
|
||||||
|
describe('splitReservationDateTime', () => {
|
||||||
|
it('parses full ISO datetime', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses full datetime with seconds', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses date-only string', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare HH:MM (new dateless format)', () => {
|
||||||
|
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare single-digit hour time', () => {
|
||||||
|
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||||
|
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null date for T-prefixed without valid date', () => {
|
||||||
|
const result = splitReservationDateTime('T23:59')
|
||||||
|
expect(result.date).toBeNull()
|
||||||
|
expect(result.time).toBe('23:59')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for null input', () => {
|
||||||
|
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for undefined input', () => {
|
||||||
|
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for empty string', () => {
|
||||||
|
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for unrecognized string', () => {
|
||||||
|
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
|||||||
} catch { return timeStr }
|
} catch { return timeStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||||
|
if (!value) return { date: null, time: null }
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
if (value.includes('T')) {
|
||||||
|
const [d, t] = value.split('T')
|
||||||
|
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||||
|
}
|
||||||
|
if (isoDate.test(value)) return { date: value, time: null }
|
||||||
|
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||||
|
return { date: null, time: null }
|
||||||
|
}
|
||||||
|
|
||||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||||
const da = assignments[String(dayId)] || []
|
const da = assignments[String(dayId)] || []
|
||||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AxiosProgressEvent } from 'axios'
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
failed: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResilientResult<T> {
|
||||||
|
succeeded: T[]
|
||||||
|
failed: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOpts {
|
||||||
|
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function isRetryable(err: unknown): boolean {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const status = (err as { response?: { status?: number } }).response?.status
|
||||||
|
if (status !== undefined && status >= 400 && status < 500) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFilesResilient<T>(
|
||||||
|
files: File[],
|
||||||
|
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||||
|
cbs?: {
|
||||||
|
concurrency?: number
|
||||||
|
retries?: number
|
||||||
|
onProgress?: (p: UploadProgress) => void
|
||||||
|
onUploaded?: (items: T[]) => void
|
||||||
|
},
|
||||||
|
): Promise<ResilientResult<T>> {
|
||||||
|
const concurrency = cbs?.concurrency ?? 3
|
||||||
|
const maxRetries = cbs?.retries ?? 2
|
||||||
|
|
||||||
|
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||||
|
const loadedMap = new Map<number, number>()
|
||||||
|
let doneCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
const emitProgress = () => {
|
||||||
|
if (!cbs?.onProgress) return
|
||||||
|
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||||
|
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||||
|
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded: T[] = []
|
||||||
|
const failedFiles: File[] = []
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const i = idx++
|
||||||
|
if (i >= files.length) break
|
||||||
|
const file = files[i]
|
||||||
|
const idempotencyKey = crypto.randomUUID()
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
|
||||||
|
let items: T[] | null = null
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) await sleep(400 * attempt)
|
||||||
|
try {
|
||||||
|
items = await uploadOne(file, {
|
||||||
|
idempotencyKey,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
loadedMap.set(i, e.loaded)
|
||||||
|
emitProgress()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (!isRetryable(err) || attempt === maxRetries) {
|
||||||
|
items = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items !== null) {
|
||||||
|
succeeded.push(...items)
|
||||||
|
cbs?.onUploaded?.(items)
|
||||||
|
loadedMap.set(i, file.size)
|
||||||
|
doneCount++
|
||||||
|
} else {
|
||||||
|
failedFiles.push(file)
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
|
||||||
|
return { succeeded, failed: failedFiles }
|
||||||
|
}
|
||||||
@@ -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(19)
|
||||||
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,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
modulePreload: { polyfill: false },
|
modulePreload: { polyfill: true },
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
+9215
-2183
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
-7216
File diff suppressed because it is too large
Load Diff
+39
-7
@@ -1,19 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "@trek/server",
|
||||||
"version": "3.0.17",
|
"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",
|
||||||
@@ -25,25 +38,43 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jimp": "^1.6.1",
|
"jimp": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ldapts": "^8.1.7",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"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.12",
|
"hono": "^4.12.16",
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"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",
|
||||||
@@ -65,6 +96,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
+10
-5
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import multer from 'multer';
|
||||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||||
@@ -25,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';
|
||||||
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
|
|||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||||
connectSrc: [
|
connectSrc: [
|
||||||
@@ -134,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:"],
|
||||||
@@ -360,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);
|
||||||
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
|
|||||||
revocation_endpoint: `${base}/oauth/revoke`,
|
revocation_endpoint: `${base}/oauth/revoke`,
|
||||||
registration_endpoint: `${base}/oauth/register`,
|
registration_endpoint: `${base}/oauth/register`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
code_challenge_methods_supported: ['S256'],
|
||||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||||
scopes_supported: ALL_SCOPES,
|
scopes_supported: ALL_SCOPES,
|
||||||
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
|
|||||||
} else {
|
} else {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
}
|
}
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||||
|
return res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
const status = err.statusCode || err.status || 500;
|
const status = err.statusCode || err.status || 500;
|
||||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||||
const message = status < 500 ? err.message : 'Internal server error';
|
const message = status < 500 ? err.message : 'Internal server error';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
|
|||||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||||
},
|
},
|
||||||
|
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
|
||||||
|
// clients to skip the browser consent flow entirely and obtain tokens directly
|
||||||
|
// via client_id + client_secret. Flag is immutable after creation so existing
|
||||||
|
// authorization-code clients are not silently upgraded.
|
||||||
|
() => {
|
||||||
|
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
|
||||||
|
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
},
|
||||||
|
// Drop stale atlas cache rows for territories that used to resolve to their
|
||||||
|
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
|
||||||
|
// etc.) before their own bounding boxes existed. The next atlas stats request
|
||||||
|
// re-resolves any place inside these boxes with the corrected country code.
|
||||||
|
() => {
|
||||||
|
const enclaveBoxes: [number, number, number, number][] = [
|
||||||
|
[113.83, 22.15, 114.43, 22.56], // HK
|
||||||
|
[113.53, 22.10, 113.60, 22.21], // MO
|
||||||
|
[12.40, 43.89, 12.52, 43.99], // SM
|
||||||
|
[12.44, 41.90, 12.46, 41.91], // VA
|
||||||
|
[7.40, 43.72, 7.44, 43.75], // MC
|
||||||
|
[9.47, 47.05, 9.64, 47.27], // LI
|
||||||
|
[-5.36, 36.11, -5.33, 36.16], // GI
|
||||||
|
[-67.30, 17.88, -65.22, 18.53], // PR
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const del = db.prepare(
|
||||||
|
`DELETE FROM place_regions WHERE place_id IN (
|
||||||
|
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
|
||||||
|
del.run(minLat, maxLat, minLng, maxLng);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('no such table')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
|||||||
if (params.state) qs.set('state', params.state);
|
if (params.state) qs.set('state', params.state);
|
||||||
if (params.resource) qs.set('resource', params.resource.href);
|
if (params.resource) qs.set('resource', params.resource.href);
|
||||||
|
|
||||||
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||||
|
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Not called because skipLocalPkceValidation = true.
|
// Not called because skipLocalPkceValidation = true.
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_place_accommodation',
|
'create_place_accommodation',
|
||||||
{
|
{
|
||||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
|
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||||
return { place, accommodation };
|
return { place, accommodation };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_place',
|
'create_place',
|
||||||
{
|
{
|
||||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
|
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||||
safeBroadcast(tripId, 'place:created', { place });
|
safeBroadcast(tripId, 'place:created', { place });
|
||||||
return ok({ place });
|
return ok({ place });
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_and_assign_place',
|
'create_and_assign_place',
|
||||||
{
|
{
|
||||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
|
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||||
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
|
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||||
return { place, assignment };
|
return { place, assignment };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createReservation, getReservation, updateReservation, deleteReservation,
|
createReservation, getReservation, updateReservation, deleteReservation,
|
||||||
updatePositions as updateReservationPositions,
|
updatePositions as updateReservationPositions,
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_reservation',
|
'create_reservation',
|
||||||
{
|
{
|
||||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||||
|
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
|
|
||||||
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const metadata = price != null ? { price: String(price) } : undefined;
|
||||||
|
|
||||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||||
title, type, reservation_time, location, confirmation_number,
|
title, type, reservation_time, location, confirmation_number,
|
||||||
notes, day_id, place_id, assignment_id,
|
notes, day_id, place_id, assignment_id,
|
||||||
create_accommodation: createAccommodation,
|
create_accommodation: createAccommodation,
|
||||||
|
metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accommodationCreated) {
|
if (accommodationCreated) {
|
||||||
safeBroadcast(tripId, 'accommodation:created', {});
|
safeBroadcast(tripId, 'accommodation:created', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||||
|
name: title,
|
||||||
|
category: budget_category || type,
|
||||||
|
total_price: price,
|
||||||
|
});
|
||||||
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
|
}
|
||||||
|
|
||||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||||
return ok({ reservation });
|
return ok({ reservation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
|||||||
import {
|
import {
|
||||||
createReservation, deleteReservation, getReservation, updateReservation,
|
createReservation, deleteReservation, getReservation, updateReservation,
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_transport',
|
'create_transport',
|
||||||
{
|
{
|
||||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||||
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||||
endpoints: endpointSchema,
|
endpoints: endpointSchema,
|
||||||
needs_review: z.boolean().optional(),
|
needs_review: z.boolean().optional(),
|
||||||
|
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
|
||||||
|
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
if (end_day_id && !getDay(end_day_id, tripId))
|
||||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||||
|
|
||||||
|
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||||
|
if (price != null) meta.price = String(price);
|
||||||
|
|
||||||
const { reservation } = createReservation(tripId, {
|
const { reservation } = createReservation(tripId, {
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
day_id: start_day_id,
|
day_id: start_day_id,
|
||||||
end_day_id: end_day_id ?? start_day_id,
|
end_day_id: end_day_id ?? start_day_id,
|
||||||
status: status ?? 'pending',
|
status: status ?? 'pending',
|
||||||
metadata,
|
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||||
endpoints,
|
endpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||||
|
name: title,
|
||||||
|
category: budget_category || type,
|
||||||
|
total_price: price,
|
||||||
|
});
|
||||||
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
|
}
|
||||||
|
|
||||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||||
return ok({ reservation });
|
return ok({ reservation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user