mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
33 Commits
| 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 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 |
@@ -2,6 +2,7 @@ node_modules
|
||||
client/node_modules
|
||||
server/node_modules
|
||||
client/dist
|
||||
shared/dist
|
||||
data
|
||||
uploads
|
||||
.git
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main`
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have updated documentation if needed
|
||||
|
||||
@@ -32,6 +32,30 @@ jobs:
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
// Wiki-only PRs are exempt — clear label and skip
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pull.number,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = new Date(pull.created_at);
|
||||
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
|
||||
|
||||
|
||||
@@ -102,16 +102,15 @@ jobs:
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update package.json files and Helm chart
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
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 tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
@@ -27,6 +27,33 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Wiki-only PRs are exempt from branch enforcement
|
||||
const files = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
files.push(...data);
|
||||
if (data.length < 100) break;
|
||||
}
|
||||
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
|
||||
if (allWiki) {
|
||||
console.log('All changed files are under wiki/ — skipping enforcement.');
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
|
||||
@@ -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]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
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:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,12 +44,24 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: server/package-lock.json
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- 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
|
||||
run: cd server && npm run test:coverage
|
||||
@@ -48,12 +83,15 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: client/package-lock.json
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- 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
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
@@ -3,6 +3,10 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
shared/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
# Generated PWA icons (built from SVG via prebuild)
|
||||
client/public/icons/*.png
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
||||
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
|
||||
3. **No breaking changes** — Backwards compatibility is non-negotiable
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
+52
-19
@@ -1,28 +1,60 @@
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
RUN npm ci --workspace=shared
|
||||
COPY shared/ ./shared/
|
||||
RUN npm run build --workspace=shared
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||
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
|
||||
|
||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
# better-sqlite3 native addon requires build tools; purged after install.
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
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 && \
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 />
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
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
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.14
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.14"
|
||||
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",
|
||||
"version": "3.0.14",
|
||||
"name": "@trek/client",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,12 +12,17 @@
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||
"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": {
|
||||
"@trek/shared": "*",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
@@ -34,6 +39,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -56,6 +62,14 @@
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"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
-1
@@ -218,7 +218,7 @@ export default function App() {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
|
||||
+131
-66
@@ -1,5 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
import br from '../i18n/translations/br'
|
||||
import de from '../i18n/translations/de'
|
||||
@@ -33,6 +35,7 @@ function translateRateLimit(): string {
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 8000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -42,24 +45,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
export function isAuthPublicPath(pathname: string): boolean {
|
||||
@@ -68,36 +71,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
async function unregisterSWAndReload(): Promise<void> {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker?.getRegistration()
|
||||
if (reg) await reg.unregister()
|
||||
} catch { /* ignore */ }
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
(response) => {
|
||||
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||
// as a CORS error with no response object. Probe the health endpoint to
|
||||
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||
if (!error.response && navigator.onLine) {
|
||||
await probeNow()
|
||||
// Both the original request and the health probe failed while the device
|
||||
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||
// down would also land here, but only reloads once).
|
||||
if (!isReachable()) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
error.message = translated
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
error.message = translated
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
@@ -142,6 +193,7 @@ export const oauthApi = {
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
resource?: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
@@ -153,12 +205,13 @@ export const oauthApi = {
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
resource?: string
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||
apiClient.post('/oauth/clients', data).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),
|
||||
},
|
||||
@@ -215,11 +268,11 @@ export const placesApi = {
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -313,7 +366,7 @@ export const adminApi = {
|
||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||
@@ -322,7 +375,7 @@ export const adminApi = {
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
sendTestNotification: (data: Record<string, unknown>) =>
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
@@ -355,8 +408,20 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/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 }) =>
|
||||
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),
|
||||
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),
|
||||
@@ -387,7 +452,7 @@ export const journeyApi = {
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
@@ -437,13 +502,13 @@ export const reservationsApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => 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),
|
||||
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): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
@@ -529,21 +594,21 @@ export const notificationsApi = {
|
||||
|
||||
export const inAppNotificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||
unreadCount: () =>
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||
markRead: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||
markUnread: (id: number) =>
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||
markAllRead: () =>
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||
deleteAll: () =>
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||
respond: (id: number, response: 'positive' | 'negative') =>
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('budget.title')}
|
||||
</h2>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div style={{ width: 150 }}>
|
||||
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={setCurrency}
|
||||
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Download size={14} strokeWidth={2.5} /> CSV
|
||||
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
|
||||
@@ -128,7 +128,8 @@ describe('MapView', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
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', () => {
|
||||
@@ -155,16 +156,11 @@ describe('MapView', () => {
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
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-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 unknown as [number, number][][]
|
||||
render(<MapView route={route} />)
|
||||
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
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} />
|
||||
}
|
||||
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -216,16 +217,20 @@ export function MapViewGL({
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
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({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: {
|
||||
'line-color': '#111827',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9,
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
@@ -442,6 +447,8 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
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'
|
||||
|
||||
// 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. */
|
||||
export async function calculateRoute(
|
||||
waypoints: Waypoint[],
|
||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
||||
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (meters < 1000) {
|
||||
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'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||
// 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 {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
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)}`
|
||||
return `
|
||||
<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 { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
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(() => {
|
||||
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 }
|
||||
|
||||
/** 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface BagCardProps {
|
||||
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 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 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 + itemWeight(i), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
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 />
|
||||
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
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
|
||||
return (
|
||||
<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={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<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>
|
||||
|
||||
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 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 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 + itemWeight(i), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
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} />
|
||||
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
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
|
||||
return (
|
||||
<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={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
|
||||
rightWidth?: number
|
||||
collapsed?: boolean
|
||||
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 can = useCanDo()
|
||||
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" }
|
||||
|
||||
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={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
{/* ── Reservations for this day's assignments ── */}
|
||||
{(() => {
|
||||
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
|
||||
return (
|
||||
<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>
|
||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||
if (!startTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find the pencil/edit button next to the title
|
||||
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 user.click(screen.getByLabelText('Edit'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||
})
|
||||
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
|
||||
const onUpdateDayTitle = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
// Enter edit mode
|
||||
const titleEl = screen.getByText('Original Title')
|
||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
||||
if (pencilBtn) await user.click(pencilBtn)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Original Title')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Title')
|
||||
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const titleEl = screen.getByText('Original Title')
|
||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
||||
if (pencilBtn) await user.click(pencilBtn)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Original Title')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
|
||||
const onUpdateDayTitle = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
const titleEl = screen.getByText('Old Title')
|
||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
||||
if (pencilBtn) await user.click(pencilBtn)
|
||||
await user.click(screen.getByLabelText('Edit'))
|
||||
const input = await screen.findByDisplayValue('Old Title')
|
||||
await user.clear(input)
|
||||
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 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 }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
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 { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import Markdown from 'react-markdown'
|
||||
@@ -23,10 +23,15 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
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 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 = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -179,6 +184,10 @@ interface DayPlanSidebarProps {
|
||||
onExternalTransportDetailHandled?: () => void
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
routeShown?: boolean
|
||||
routeProfile?: 'driving' | 'walking'
|
||||
onToggleRoute?: () => void
|
||||
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
@@ -195,6 +204,25 @@ interface DayPlanSidebarProps {
|
||||
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({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
@@ -211,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onNavigateToFiles,
|
||||
routeShown = false,
|
||||
routeProfile = 'driving',
|
||||
onToggleRoute,
|
||||
onSetRouteProfile,
|
||||
onExpandedDaysChange,
|
||||
pushUndo,
|
||||
canUndo = false,
|
||||
@@ -228,6 +260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
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 can = useCanDo()
|
||||
const canEditDays = can('day_edit', trip)
|
||||
@@ -246,6 +279,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
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
|
||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
@@ -406,27 +421,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
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
|
||||
})
|
||||
}
|
||||
const getTransportForDay = (dayId: number) =>
|
||||
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
@@ -446,20 +442,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getDayAssignments = (dayId) =>
|
||||
(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
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
@@ -501,64 +483,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||
const baseItems = [
|
||||
...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)
|
||||
}
|
||||
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||
_getMergedItems({
|
||||
dayAssignments: getDayAssignments(dayId),
|
||||
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||
dayTransports: getTransportForDay(dayId),
|
||||
dayId,
|
||||
getDisplayTime: getDisplayTimeForDay,
|
||||
})
|
||||
|
||||
// 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
|
||||
@@ -570,6 +502,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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) => {
|
||||
e?.stopPropagation()
|
||||
_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) => {
|
||||
e.preventDefault()
|
||||
@@ -1145,6 +1106,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
className="dp-day-header"
|
||||
data-selected={isSelected}
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
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)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||
>
|
||||
{/* Tages-Badge */}
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||
{(() => {
|
||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0, alignSelf: 'flex-start',
|
||||
width: hasWeather ? 34 : 26,
|
||||
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 }}>
|
||||
{editingDayId === day.id ? (
|
||||
@@ -1191,40 +1172,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
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 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
{canEditDays && <button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{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>
|
||||
{formattedDate && (
|
||||
<>
|
||||
<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' }}>
|
||||
{formattedDate}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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))
|
||||
// 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 => {
|
||||
const isCheckIn = acc.start_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 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)'
|
||||
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
|
||||
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' }}>
|
||||
<Hotel size={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 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={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||
<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>
|
||||
)
|
||||
})
|
||||
@@ -1259,41 +1225,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const activeRentals = getActiveRentalsForDay(day.id)
|
||||
if (activeRentals.length === 0) return null
|
||||
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' }}>
|
||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<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={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
</span>
|
||||
))
|
||||
})()}
|
||||
</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>
|
||||
|
||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
aria-label={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button></Tooltip>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
||||
</button>
|
||||
{canEditDays ? (
|
||||
(() => {
|
||||
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
|
||||
const div = '1px solid var(--border-faint)'
|
||||
return (
|
||||
<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 }}>
|
||||
<Pencil size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
{onAddTransport ? (
|
||||
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : <div style={{ borderBottom: div }} />}
|
||||
<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>
|
||||
|
||||
{/* Aufgeklappte Orte + Notizen */}
|
||||
@@ -1585,15 +1560,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${(() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
})()}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!st && !et) return null
|
||||
return (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
@@ -1703,6 +1680,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1752,6 +1730,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
onDragStart={e => {
|
||||
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'
|
||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||
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' }}>
|
||||
{res.title}
|
||||
</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 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
||||
})()}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!dispTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<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}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
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))
|
||||
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
|
||||
@@ -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'))) }
|
||||
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) {
|
||||
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
|
||||
@@ -1994,6 +1987,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||
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}` && (
|
||||
@@ -2004,15 +2000,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{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 style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
onClick={() => onToggleRoute?.()}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
|
||||
border: routeShown ? 'none' : '1px solid var(--border-faint)',
|
||||
background: routeShown ? 'var(--accent)' : 'transparent',
|
||||
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={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
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} />
|
||||
{t('dayplan.optimize')}
|
||||
</button>
|
||||
<button onClick={handleGoogleMaps} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ExternalLink size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{(['driving', 'walking'] as const).map(p => {
|
||||
const ModeIcon = p === 'driving' ? Car : Footprints
|
||||
const active = routeProfile === p
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -2192,13 +2215,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||
<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' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
{(() => {
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const dateStr = date
|
||||
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||
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 style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -169,7 +170,10 @@ export default function PlaceInspector({
|
||||
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
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 openNow = googleDetails?.open_now ?? null
|
||||
@@ -344,7 +348,7 @@ export default function PlaceInspector({
|
||||
{/* Description / 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' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</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>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<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(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</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>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<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 && (
|
||||
<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 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 remarkBreaks from 'remark-breaks'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const fmtDate = (str) => {
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + '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 startDt = splitReservationDateTime(r.reservation_time)
|
||||
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||
const fmtDate = (date: string) =>
|
||||
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const hasDate = !!r.reservation_time
|
||||
const hasTime = r.reservation_time?.includes('T')
|
||||
const hasDate = !!startDt.date
|
||||
const hasTime = !!(startDt.time || endDt.time)
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{(() => {
|
||||
const endDatePart = r.reservation_end_time
|
||||
? r.reservation_end_time.includes('T')
|
||||
? r.reservation_end_time.split('T')[0]
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||
? r.reservation_end_time
|
||||
: null
|
||||
: null
|
||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||
})() && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
{(hasDate || hasTime) && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
{hasDate && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(startDt.date!)}
|
||||
{endDt.date && endDt.date !== startDt.date && (
|
||||
<> – {fmtDate(endDt.date)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasTime && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||
<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>
|
||||
)}
|
||||
@@ -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.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.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_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_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: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<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 { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
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',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
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 => {
|
||||
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> = {}
|
||||
|
||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
allows_client_credentials: boolean
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [oauthRotating, setOauthRotating] = useState(false)
|
||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||
|
||||
// MCP sub-tab state
|
||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
if (!oauthNewName.trim()) return
|
||||
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = 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 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,
|
||||
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||
})
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
setOauthIsMachine(false)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} 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>
|
||||
|
||||
<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">
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<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)' }}>
|
||||
{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>
|
||||
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!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>
|
||||
<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')}
|
||||
</button>
|
||||
<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">
|
||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||
</button>
|
||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
|
||||
lng: number | null
|
||||
date: string
|
||||
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 [loading, setLoading] = 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 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) {
|
||||
return (
|
||||
<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) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const imageUrlFailed = useRef(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
alt={place.name}
|
||||
decoding="async"
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
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 { 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
|
||||
* 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 [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
@@ -22,7 +22,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
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
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
@@ -67,35 +68,52 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
const segments: [number, number][][] = []
|
||||
let currentSeg: [number, number][] = []
|
||||
// Group consecutive located places into runs, resetting whenever a transport
|
||||
// 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) {
|
||||
if (entry.kind === 'place') {
|
||||
currentSeg.push([entry.lat, entry.lng])
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||
} else {
|
||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
||||
currentSeg = []
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
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) {
|
||||
setRoute(null); setRouteSegments([]); return
|
||||
}
|
||||
setRoute(segments.length > 0 ? segments : null)
|
||||
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||
|
||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||
// OSRM road geometry. If route calc is disabled, keep the straight lines.
|
||||
setRoute(straightLines())
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
routeAbortRef.current = controller
|
||||
try {
|
||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
||||
const polylines: [number, number][][] = []
|
||||
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) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||
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
|
||||
// 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 }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import es from './translations/es'
|
||||
import fr from './translations/fr'
|
||||
import hu from './translations/hu'
|
||||
import it from './translations/it'
|
||||
import tr from './translations/tr'
|
||||
import ru from './translations/ru'
|
||||
import zh from './translations/zh'
|
||||
import zhTw from './translations/zhTw'
|
||||
@@ -15,6 +16,9 @@ import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
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'
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -38,7 +42,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
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 {
|
||||
|
||||
@@ -12,8 +12,12 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||
{ value: 'tr', label: 'Türkçe', locale: 'tr-TR' },
|
||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||
{ 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
|
||||
|
||||
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.revokeError': 'فشل إلغاء الجلسة',
|
||||
'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.about': 'حول',
|
||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||
@@ -1674,6 +1678,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||
@@ -1705,8 +1710,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'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.revokeError': 'Falha ao revogar sessão',
|
||||
'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.',
|
||||
|
||||
// Login
|
||||
@@ -2077,8 +2081,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'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.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.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||
'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.entries.deleteTitle': 'Excluir entrada',
|
||||
'journey.photosUploaded': '{count} fotos enviadas',
|
||||
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
|
||||
'journey.photosAdded': '{count} fotos adicionadas',
|
||||
'journey.public.notFound': 'Não encontrado',
|
||||
'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.revokeError': 'Odvolání relace 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.about': 'O aplikaci',
|
||||
'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.synced': 'synchronizováno',
|
||||
'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.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.allPhotosAdded': 'Všechny fotky již přidány',
|
||||
'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.entries.deleteTitle': 'Smazat záznam',
|
||||
'journey.photosUploaded': '{count} fotografií nahráno',
|
||||
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
|
||||
'journey.photosAdded': '{count} fotografií přidáno',
|
||||
'journey.public.notFound': 'Nenalezeno',
|
||||
'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.revokeError': 'Session konnte nicht widerrufen 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.about': 'Über',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2085,8 +2089,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||
'journey.editor.uploadPhotos': 'Fotos 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.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||
'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.entries.deleteTitle': 'Eintrag löschen',
|
||||
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
||||
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
|
||||
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
||||
'journey.public.notFound': 'Nicht gefunden',
|
||||
'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.revokeError': 'Failed to revoke session',
|
||||
'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.about': 'About',
|
||||
'settings.about.reportBug': 'Report a Bug',
|
||||
@@ -2111,8 +2115,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'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.allPhotosAdded': 'All photos already added',
|
||||
'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.entries.deleteTitle': 'Delete Entry',
|
||||
'journey.photosUploaded': '{count} photos uploaded',
|
||||
'journey.photosUploadFailed': 'Some photos failed to upload',
|
||||
'journey.photosAdded': '{count} photos added',
|
||||
|
||||
// Journey — Public Page
|
||||
|
||||
@@ -326,6 +326,10 @@ const es: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||
'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.about': 'Acerca de',
|
||||
'settings.about.reportBug': 'Reportar un error',
|
||||
@@ -2084,8 +2088,11 @@ const es: Record<string, string> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'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.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
@@ -2176,6 +2183,7 @@ const es: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||
'journey.photosUploaded': '{count} fotos subidas',
|
||||
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||
'journey.photosAdded': '{count} fotos añadidas',
|
||||
'journey.public.notFound': 'No encontrado',
|
||||
'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.revokeError': 'Impossible de révoquer la session',
|
||||
'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.about': 'À propos',
|
||||
'settings.about.reportBug': 'Signaler un bug',
|
||||
@@ -2078,8 +2082,11 @@ const fr: Record<string, string> = {
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'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.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.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||
@@ -2170,6 +2177,7 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Échec de la suppression',
|
||||
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
||||
'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.public.notFound': 'Introuvable',
|
||||
'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.revokeError': 'A munkamenet visszavoná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.about': 'Névjegy',
|
||||
'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.synced': 'szinkronizálva',
|
||||
'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.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.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||
'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.entries.deleteTitle': 'Bejegyzés törlése',
|
||||
'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.public.notFound': 'Nem található',
|
||||
'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.revokeError': 'Gagal mencabut sesi',
|
||||
'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.about': 'Tentang',
|
||||
'settings.about.reportBug': 'Laporkan Bug',
|
||||
@@ -2094,8 +2098,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'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.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||
@@ -2198,6 +2205,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Gagal menghapus',
|
||||
'journey.entries.deleteTitle': 'Hapus Entri',
|
||||
'journey.photosUploaded': '{count} foto diunggah',
|
||||
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
|
||||
'journey.photosAdded': '{count} foto ditambahkan',
|
||||
|
||||
// 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.revokeError': 'Impossibile revocare la sessione',
|
||||
'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.about': 'Informazioni',
|
||||
'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.synced': 'sincronizzato',
|
||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'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.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||
'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.entries.deleteTitle': 'Elimina voce',
|
||||
'journey.photosUploaded': '{count} foto caricate',
|
||||
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
|
||||
'journey.photosAdded': '{count} foto aggiunte',
|
||||
'journey.public.notFound': 'Non trovato',
|
||||
'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.revokeError': 'Sessie kon niet worden ingetrokken',
|
||||
'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.about': 'Over',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2078,8 +2082,11 @@ const nl: Record<string, string> = {
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s 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.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||
@@ -2170,6 +2177,7 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
||||
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
||||
'journey.photosUploaded': "{count} foto's geüpload",
|
||||
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
|
||||
'journey.photosAdded': "{count} foto's toegevoegd",
|
||||
'journey.public.notFound': 'Niet gevonden',
|
||||
'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.revokeError': 'Nie udało się unieważnić sesji',
|
||||
'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.about': 'O aplikacji',
|
||||
'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.synced': 'zsynchronizowane',
|
||||
'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.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.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||
'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.entries.deleteTitle': 'Usuń wpis',
|
||||
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
||||
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
|
||||
'journey.photosAdded': '{count} zdjęć dodanych',
|
||||
'journey.public.notFound': 'Nie znaleziono',
|
||||
'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.revokeError': 'Не удалось отозвать сессию',
|
||||
'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.about': 'О приложении',
|
||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||
@@ -2078,8 +2082,11 @@ const ru: Record<string, string> = {
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||
@@ -2170,6 +2177,7 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Не удалось удалить',
|
||||
'journey.entries.deleteTitle': 'Удалить запись',
|
||||
'journey.photosUploaded': '{count} фото загружено',
|
||||
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
|
||||
'journey.photosAdded': '{count} фото добавлено',
|
||||
'journey.public.notFound': 'Не найдено',
|
||||
'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.revokeError': '撤销会话失败',
|
||||
'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.about': '关于',
|
||||
'settings.about.reportBug': '报告错误',
|
||||
@@ -2078,8 +2082,11 @@ const zh: Record<string, string> = {
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||
'journey.editor.uploadFailed': '照片上传失败',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||
'journey.editor.writeStory': '写下你的故事...',
|
||||
@@ -2170,6 +2177,7 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '删除失败',
|
||||
'journey.entries.deleteTitle': '删除条目',
|
||||
'journey.photosUploaded': '{count} 张照片已上传',
|
||||
'journey.photosUploadFailed': '部分照片上传失败',
|
||||
'journey.photosAdded': '{count} 张照片已添加',
|
||||
'journey.public.notFound': '未找到',
|
||||
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
||||
|
||||
@@ -384,6 +384,10 @@ const zhTw: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
||||
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
||||
'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.about': '關於',
|
||||
'settings.about.reportBug': '回報錯誤',
|
||||
@@ -2036,8 +2040,11 @@ const zhTw: Record<string, string> = {
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||
'journey.editor.writeStory': '寫下你的故事...',
|
||||
@@ -2128,6 +2135,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '刪除失敗',
|
||||
'journey.entries.deleteTitle': '刪除條目',
|
||||
'journey.photosUploaded': '{count} 張照片已上傳',
|
||||
'journey.photosUploadFailed': '部分照片上傳失敗',
|
||||
'journey.photosAdded': '{count} 張照片已新增',
|
||||
'journey.public.notFound': '未找到',
|
||||
'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 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; }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
|
||||
startConnectivityProbe()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
|
||||
}
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={async (entryId, formData) => {
|
||||
return await uploadPhotos(entryId, formData)
|
||||
onUploadPhotos={async (entryId, files, cbs) => {
|
||||
return await uploadPhotos(entryId, files, cbs)
|
||||
}}
|
||||
onDone={() => {
|
||||
setEditingEntry(null)
|
||||
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
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()
|
||||
|
||||
// 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 files = e.target.files
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
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()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.coverFailed'))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
setGalleryProgress(null)
|
||||
}
|
||||
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"
|
||||
>
|
||||
{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')}</>
|
||||
)}
|
||||
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
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()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
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
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
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 [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
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 [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const formData = new FormData()
|
||||
for (const f of pendingFiles) formData.append('photos', f)
|
||||
await onUploadPhotos(entryId, formData)
|
||||
const filesToUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||
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
|
||||
if (pendingLinkIds.length > 0 && entryId) {
|
||||
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
if (!files?.length) return
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// 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 (
|
||||
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{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.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.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
|
||||
@@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
|
||||
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
||||
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,21 +60,21 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||
),
|
||||
http.get('/api/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
|
||||
setSearch('?oidc_code=testcode123');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/oauth/authorize?client_id=foo&state=xyz',
|
||||
{ replace: true },
|
||||
'/oauth/consent?client_id=foo&state=xyz',
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
|
||||
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
|
||||
setSearch('?oidc_error=token_failed');
|
||||
render(<LoginPage />);
|
||||
|
||||
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -117,15 +117,29 @@ export default function LoginPage(): React.ReactElement {
|
||||
return
|
||||
}
|
||||
|
||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||
authApi.getAppConfig?.()
|
||||
.then((config: AppConfig) => {
|
||||
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||
return { config, fromCache: false }
|
||||
})
|
||||
.catch(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
||||
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
|
||||
} catch { return { config: null as AppConfig | null, fromCache: false } }
|
||||
})
|
||||
.then(({ config, fromCache }) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
// Skip auto-redirect when config is from cache — network is unreliable
|
||||
// and auto-redirecting to the IdP could loop if the proxy changed.
|
||||
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||
window.location.href = '/api/auth/oidc/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [navigate, t, noRedirect])
|
||||
|
||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||
|
||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||
|
||||
function setSearchParams(search: string) {
|
||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
||||
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||
}
|
||||
|
||||
const VALIDATE_OK = {
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
const state = params.get('state') || ''
|
||||
const codeChallenge = params.get('code_challenge') || ''
|
||||
const ccMethod = params.get('code_challenge_method') || ''
|
||||
const resource = params.get('resource') || undefined
|
||||
|
||||
// Load auth state once, then validate
|
||||
useEffect(() => {
|
||||
@@ -43,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
validateRequest()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authLoading, isAuthenticated])
|
||||
|
||||
async function validateRequest() {
|
||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
response_type: 'code',
|
||||
resource,
|
||||
})
|
||||
setValidation(result)
|
||||
|
||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: ccMethod,
|
||||
approved,
|
||||
resource,
|
||||
})
|
||||
setPageState('done')
|
||||
window.location.href = result.redirect
|
||||
@@ -111,20 +114,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
|
||||
function toggleScope(s: string) {
|
||||
setSelectedScopes(prev =>
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||
)
|
||||
}
|
||||
|
||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||
setSelectedScopes(prev =>
|
||||
allSelected
|
||||
? prev.filter(s => !groupScopes.includes(s))
|
||||
: [...new Set([...prev, ...groupScopes])]
|
||||
allSelected
|
||||
? prev.filter(s => !groupScopes.includes(s))
|
||||
: [...new Set([...prev, ...groupScopes])]
|
||||
)
|
||||
}
|
||||
|
||||
function handleLoginRedirect() {
|
||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||
}
|
||||
|
||||
@@ -145,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
|
||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
</p>
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageState === 'login_required') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
</p>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoginRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLoginRedirect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
<LogIn className="w-4 h-4" />
|
||||
Sign in to TREK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// pageState === 'consent'
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||
|
||||
{/* Left panel — app identity + actions */}
|
||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — selectable scopes */}
|
||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||
<div className="space-y-6">
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
{/* Left panel — app identity + actions */}
|
||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||
{validation?.client?.name || clientId}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
This application is requesting access to your TREK account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
/* DCR client — user selects which scopes to grant */
|
||||
<div className="space-y-3">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="mt-8 space-y-2">
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Only grant access to applications you trust. Your data stays on your server.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => submitConsent(true)}
|
||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
{submitting
|
||||
? 'Authorizing…'
|
||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||
? 'Select at least one scope'
|
||||
: validation?.scopeSelectable
|
||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||
: 'Approve Access'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitConsent(false)}
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — selectable scopes */}
|
||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||
<div className="space-y-6">
|
||||
{Object.keys(scopesByGroup).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||
</p>
|
||||
|
||||
{validation?.scopeSelectable ? (
|
||||
/* DCR client — user selects which scopes to grant */
|
||||
<div className="space-y-3">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||
return (
|
||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allGroupSelected}
|
||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||
</span>
|
||||
</label>
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<label
|
||||
key={s}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(s)}
|
||||
onChange={() => toggleScope(s)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
</label>
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<label
|
||||
key={s}
|
||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedScopes.includes(s)}
|
||||
onChange={() => toggleScope(s)}
|
||||
className="mt-0.5 rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Settings-created client — scopes are fixed, show read-only */
|
||||
<div className="space-y-5">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
/* Settings-created client — scopes are fixed, show read-only */
|
||||
<div className="space-y-5">
|
||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||
<div key={group}>
|
||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||
<div className="space-y-1.5">
|
||||
{groupScopes.map(s => {
|
||||
const keys = SCOPE_GROUPS[s]
|
||||
return (
|
||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Always-available tools — granted regardless of scopes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Always included
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||
].map(({ name, desc }) => (
|
||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import { createElement } from 'react'
|
||||
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 { 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 }
|
||||
|
||||
function createMarkerIcon(place: any) {
|
||||
@@ -184,14 +185,16 @@ export default function SharedTripPage() {
|
||||
{sortedDays.map((day: any, di: number) => {
|
||||
const da = assignments[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 merged = [
|
||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||
].sort((a, b) => a.k - b.k)
|
||||
const merged = getMergedItems({
|
||||
dayAssignments: da,
|
||||
dayNotes: notes,
|
||||
dayTransports: dayTransport,
|
||||
dayId: day.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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') {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
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 = ''
|
||||
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(' · ')
|
||||
@@ -274,8 +277,9 @@ export default function SharedTripPage() {
|
||||
{(reservations || []).map((r: any) => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
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 = 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 { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||
const time = rTime ?? ''
|
||||
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
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 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 [editingTransport, setEditingTransport] = useState<Reservation | 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 initialFitTripId = useRef<number | 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])
|
||||
|
||||
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 changed = dayId !== selectedDayId
|
||||
@@ -891,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
routeShown={routeShown}
|
||||
routeProfile={routeProfile}
|
||||
onToggleRoute={() => setRouteShown(v => !v)}
|
||||
onSetRouteProfile={setRouteProfile}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
onExpandedDaysChange={setExpandedDayIds}
|
||||
pushUndo={pushUndo}
|
||||
@@ -1003,6 +1011,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
@@ -1116,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{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 }} />
|
||||
}
|
||||
</div>
|
||||
@@ -1174,7 +1183,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{activeTab === 'dateien' && (
|
||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<FileManager
|
||||
files={files || []}
|
||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyApi } from '../api/client';
|
||||
import { useJourneyStore } from './journeyStore';
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||
|
||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.json({ photos: [newPhoto] })
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(91);
|
||||
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||
// 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, [file]);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(91);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
|
||||
export interface Journey {
|
||||
id: number
|
||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
||||
deleteEntry: (entryId: number) => Promise<void>
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadPhotos: async (entryId, formData) => {
|
||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
||||
const photos = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
uploadPhotos: async (entryId, files, cbs) => {
|
||||
return uploadFilesResilient<JourneyPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||
const photos: JourneyPhoto[] = data.photos || []
|
||||
const gallery: GalleryPhoto[] = data.gallery || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...gallery],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
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
|
||||
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||
return uploadFilesResilient<GalleryPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
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) => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const PROBE_INTERVAL_MS = 30_000
|
||||
const PROBE_TIMEOUT_MS = 1_500
|
||||
|
||||
let reachable = true
|
||||
const listeners = new Set<(v: boolean) => void>()
|
||||
|
||||
function setReachable(v: boolean): void {
|
||||
if (reachable === v) return
|
||||
reachable = v
|
||||
listeners.forEach(fn => fn(v))
|
||||
}
|
||||
|
||||
async function probe(): Promise<void> {
|
||||
if (!navigator.onLine) { setReachable(false); return }
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||
const res = await fetch('/api/health', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
clearTimeout(t)
|
||||
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
setReachable(res.ok && ct.includes('application/json'))
|
||||
} catch {
|
||||
setReachable(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function startConnectivityProbe(): void {
|
||||
probe()
|
||||
setInterval(probe, PROBE_INTERVAL_MS)
|
||||
window.addEventListener('online', probe)
|
||||
window.addEventListener('offline', () => setReachable(false))
|
||||
}
|
||||
|
||||
export function isReachable(): boolean { return reachable }
|
||||
export function probeNow(): Promise<void> { return probe() }
|
||||
export function onChange(fn: (v: boolean) => void): () => void {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
day_positions?: Record<number, number> | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
endpoints?: ReservationEndpoint[]
|
||||
@@ -236,8 +237,19 @@ export interface RouteSegment {
|
||||
mid: [number, number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
distance: number
|
||||
duration: number
|
||||
walkingText: string
|
||||
drivingText: string
|
||||
distanceText: string
|
||||
durationText?: string
|
||||
}
|
||||
|
||||
export interface RouteWithLegs {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
legs: RouteSegment[]
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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 {
|
||||
const da = assignments[String(dayId)] || []
|
||||
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
|
||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||
calculateSegments: vi.fn(),
|
||||
calculateRouteWithLegs: vi.fn(),
|
||||
calculateRoute: vi.fn(),
|
||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||
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> {
|
||||
// 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[] = [
|
||||
{
|
||||
from: [48.8566, 2.3522],
|
||||
to: [51.5074, -0.1278],
|
||||
mid: [50.182, 1.1122],
|
||||
walkingText: '120 min',
|
||||
drivingText: '90 min',
|
||||
distance: 343000,
|
||||
duration: 12600,
|
||||
distanceText: '343 km',
|
||||
durationText: '3 h 30 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', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -42,7 +51,7 @@ describe('useRouteCalculation', () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||
// Reset trip store assignments so each test starts clean
|
||||
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', () => {
|
||||
@@ -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 });
|
||||
|
||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
@@ -99,11 +108,11 @@ describe('useRouteCalculation', () => {
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(calculateSegments).toHaveBeenCalled();
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalled();
|
||||
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 });
|
||||
|
||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
@@ -118,7 +127,7 @@ describe('useRouteCalculation', () => {
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(calculateSegments).not.toHaveBeenCalled();
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled();
|
||||
expect(result.current.routeSegments).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -163,13 +172,13 @@ describe('useRouteCalculation', () => {
|
||||
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
// Make calculateSegments resolve slowly
|
||||
let resolveSegments!: (val: RouteSegment[]) => void;
|
||||
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
// Make calculateRouteWithLegs resolve slowly
|
||||
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
||||
return new Promise<RouteSegment[]>((resolve) => {
|
||||
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((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 });
|
||||
});
|
||||
|
||||
// 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
|
||||
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
|
||||
resolveSegments?.([]);
|
||||
resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
|
||||
});
|
||||
|
||||
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');
|
||||
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 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 () => {
|
||||
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 p2 = buildPlace({ lat: 20, lng: 20 });
|
||||
|
||||
@@ -91,8 +91,12 @@ describe('isRtlLanguage', () => {
|
||||
describe('SUPPORTED_LANGUAGES', () => {
|
||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||
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: '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: 'العربية' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@trek/shared": ["../shared/src/index.ts"],
|
||||
"@trek/shared/*": ["../shared/src/*"]
|
||||
},
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
+27
-4
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
||||
{
|
||||
// API calls — prefer network, fall back to cache
|
||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-data',
|
||||
@@ -90,7 +90,7 @@ export default defineConfig({
|
||||
],
|
||||
build: {
|
||||
sourcemap: false,
|
||||
modulePreload: { polyfill: false },
|
||||
modulePreload: { polyfill: true },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
@@ -110,7 +110,30 @@ export default defineConfig({
|
||||
'/mcp': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||
'/oauth/authorize': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/token': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/register': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth/revoke': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/.well-known': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
PORT=3001 # Port to run the server on
|
||||
# HOST=0.0.0.0 # Bind address for the HTTP server. Only set this when running TREK from sources or via the Proxmox community script — never in Docker (the container handles binding).
|
||||
NODE_ENV=development # development = development mode; production = production mode
|
||||
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
|
||||
# Auto-generated and persisted to ./data/.encryption_key if not set.
|
||||
|
||||
@@ -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",
|
||||
"version": "3.0.14",
|
||||
"name": "@trek/server",
|
||||
"version": "3.0.22",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
"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:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:ws": "vitest run tests/websocket",
|
||||
"test:parity": "vitest run tests/parity",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trek/shared": "*",
|
||||
"tsconfig-paths": "^4.2.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",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -25,25 +38,43 @@
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapts": "^8.1.7",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.12",
|
||||
"@hono/node-server": "^1.19.13"
|
||||
"hono": "^4.12.16",
|
||||
"@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": {
|
||||
"@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/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -65,6 +96,7 @@
|
||||
"nodemon": "^3.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user