Compare commits

...

23 Commits

Author SHA1 Message Date
Dimitris Kafetzis 1cecaa1d30 Merge c375e0d6f7 into 126f2df21b 2026-05-27 00:26:58 +02:00
Dkafetzis c375e0d6f7 feat(i18n): add Greek translation 2026-05-27 00:26:37 +02:00
Julien G. 126f2df21b chore: move i18n to shared package (#1066)
* chore: move i18n to shared package

* chore: move server translations to shared package and apply linter and prettier on entire shared package
2026-05-26 20:27:29 +02:00
Maurice 324d930ca3 remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
2026-05-26 16:21:10 +02:00
Maurice e050814c42 feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
Julien G. c130ed41be chore: fix monorepo build pipeline and migrate shared to built package (#1056)
* chore: fix monorepo build pipeline and migrate shared to built package

- Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e)
  that delegate to actual scripts in shared/server/client workspaces
- shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import
  from the built dist instead of raw TS source via path aliases
- server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules
  paths); fix MCP SDK path aliases to point to root node_modules (../node_modules)
- server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done,
  eliminating the spurious restart on every dev startup
- client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer
  needed now that shared is a proper package)
- Consolidate package-lock.json at the workspace root; drop per-workspace lock files

* chore: fix test script to reflect root package.json

* chore: add missing lint and prettier script in root package.json

* fix(ci): build shared before tests; fix vitest MCP SDK alias paths

vitest.config.ts aliases pointed at ./node_modules/ (server-local) but
packages are hoisted to the root node_modules/ in the npm workspace —
changed to ../node_modules/.

CI jobs now install and build shared before running server/client tests
so that @trek/shared's dist/ exists when vitest resolves the package.

* fix(docker): update Dockerfile and CI for monorepo workspace structure

Dockerfile:
- Add shared-builder stage that produces @trek/shared dist before
  client and server stages need it
- Each build stage carries root package.json + package-lock.json so npm
  can resolve @trek/shared as a workspace dependency
- Production stage installs via workspace context (npm ci --workspace=server
  --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly
- Copy server/tsconfig.json into the image so tsconfig-paths/register can
  find the MCP SDK path aliases at runtime
- CMD cds into /app/server before starting node so tsconfig-paths baseUrl
  resolves and ../node_modules points to /app/node_modules
- Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data

docker.yml version-bump:
- Replace manual per-workspace cd+npm-version calls with single:
  npm version --workspaces --include-workspace-root --no-git-tag-version
  (mirrors the version:* scripts in root package.json)
- git add now references root package-lock.json; adds shared/package.json

.dockerignore: add shared/dist
package.json: fix version:prerelease preid (alpha → pre)

* fix(tests): use in-memory SQLite per worker in test mode

vitest pool:forks spawns parallel worker processes that all called
initDb() on the same data/travel.db, causing SQLite "database is locked"
and "duplicate column name" races.

When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations
run independently with no file contention.

* chore(ci): add ACT guards to skip DockerHub steps in local act runs

act sets ACT=true automatically. Guards added:
- docker login: if: ${{ !env.ACT }}
- build outputs: type=docker (local load) when ACT, push-by-digest when CI
- digest export/upload: if: ${{ !env.ACT }}
- merge job: if: ${{ !env.ACT }}
- release-helm job (docker.yml): if: ${{ !env.ACT }}
- version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard

Run locally with:
  ./bin/act -j build -W .github/workflows/docker.yml \
    -P ubuntu-latest=catthehacker/ubuntu:act-latest

* fix(ci): move ACT guards to step level; add guards to security.yml

env context is invalid in job-level if conditions — moved all ACT
guards down to individual steps. Also guards docker login + scout
in security.yml so act can run the build-only part of that workflow.

* fix(ci): skip git fetch and tag logic in act (no remote access in local containers)

* Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)"

This reverts commit 67cf290cda.

* Revert "fix(ci): move ACT guards to step level; add guards to security.yml"

This reverts commit f92b95e054.

* Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs"

This reverts commit 797183de08.

* fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries

npm prunes libc-constrained optional deps to the host libc (glibc) when
generating the lockfile, leaving no musl entry for Alpine containers.
Declaring the x64/arm64 musl variants as explicit root optionalDependencies
forces them into the lockfile so npm ci on Alpine can install them.

Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp
icon generation) for both linux/amd64 and linux/arm64 CI targets.

* fix(docker): copy client dist into server/public so the server resolves static files correctly

The server runs from /app/server and serves static files relative to that
directory, so the client build output must land at /app/server/public, not /app/public.
2026-05-25 21:44:58 +02:00
Maurice db5c403239 i18n: register Korean + add Ukrainian translation (#1055)
Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys).
2026-05-25 18:37:15 +02:00
SkyLostTR bd29fcb0c0 Add Turkish (tr) translation + language registry (#1029)
Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:26:29 +02:00
sss3978 be71cae0d3 feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:22:39 +02:00
ppuassi ee2089e81d feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
2026-05-25 18:22:35 +02:00
gzor 352f94612d fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
2026-05-25 17:59:54 +02:00
Maurice 0257e4e71e feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
2026-05-25 17:00:58 +02:00
Maurice 0b218d53b2 Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
github-actions[bot] e27be5c965 chore: bump version to 3.0.22 [skip ci] 2026-05-24 23:13:41 +00:00
Julien G. 86ee8044da v3.0.22 Bug Fixes & Improvements (#1041)
Bundles the v3.0.22 bug fixes and improvements. See the release notes for the full list.
2026-05-25 01:13:20 +02:00
Maurice 75772445a7 Update security contact email in SECURITY.md 2026-05-24 19:39:53 +02:00
github-actions[bot] bfe6664ac4 chore: bump version to 3.0.21 [skip ci] 2026-05-15 22:53:13 +00:00
Julien G. 117942f45e v3.0.21 Bug Fixes (#998)
* fix(journey): remove photo upload count limit and surface upload errors (#997)

Removes the arbitrary 10-file cap on journey entry photo uploads and 20-file
cap on gallery uploads. MulterErrors now return proper 4xx responses instead
of 500, and the client surfaces the server error message via toast rather than
silently trapping the user in the post editor overlay.

* fix(planner): remove correct assignment when place assigned to same day multiple times

When a place was assigned to the same day more than once, the "Remove from day"
button in PlaceInspector always deleted the first assignment (Array.find on
place.id) instead of the currently selected one. Now prefers selectedAssignmentId
when available.

Fixes #1005

* fix(map): enable 3D terrain for Mapbox outdoors style in trip planner

wantsTerrain() only matched satellite styles, so the outdoors-v12 style
was flat in the planner despite showing correct 3D terrain in the settings
preview. Added outdoors-v12 to the allowlist; marker drift is already
handled by syncMarkerAltitudes().

Fixes #1002

* fix(maps): send Referer header on Google API calls when APP_URL is set

Supports HTTP referrer restrictions on GCP API keys. Documents the
restriction types and photo troubleshooting steps in the wiki.
2026-05-16 00:53:02 +02:00
Julien G. e7211325df Add asset.download permission to Photo Providers 2026-05-15 23:16:34 +02:00
github-actions[bot] 7e49f3467c chore: bump version to 3.0.20 [skip ci] 2026-05-13 08:35:23 +00:00
jubnl 93b51a0bf5 fix(csp): allow unsafe-eval for HEIC image conversion 2026-05-13 10:34:57 +02:00
github-actions[bot] 5b710a429a chore: bump version to 3.0.19 [skip ci] 2026-05-13 08:13:30 +00:00
Julien G. da3cba2de3 v3.0.19 Bug Fixes (#992)
* fix(mcp): replace relative oauth constent redirect by absolute redirect derived from APP_URL (#987)

* feat(journey): convert HEIC/HEIF uploads to JPEG for cross-platform compatibility

HEIC is an Apple-only format not recognised as an image by many browsers
and platforms. heic-to (lazy-loaded) now converts HEIC/HEIF files to JPEG
before upload in both the gallery and entry editor photo pickers.
Embedded metadata (EXIF, GPS) may be lost during conversion — documented
in the Journey Journal wiki page.

* fix(journey): skip heic-to import for non-HEIC files to avoid test env failures

* fix(notifications): prevent double-escaping HTML in password reset emails

buildPasswordResetHtml passed a pre-built HTML block to buildEmailHtml,
which then escaped it again — rendering raw tags as plain text in the email.
2026-05-13 10:13:17 +02:00
1016 changed files with 82213 additions and 53892 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules
client/node_modules client/node_modules
server/node_modules server/node_modules
client/dist client/dist
shared/dist
data data
uploads uploads
.git .git
+3 -4
View File
@@ -102,16 +102,15 @@ jobs:
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)" echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart # Update all workspace + root package.json files and the root lockfile in one shot
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd .. npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag # Commit and tag
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION" git tag "v$NEW_VERSION"
git push origin main --follow-tags git push origin main --follow-tags
+45 -7
View File
@@ -8,10 +8,33 @@ on:
branches: [main, dev] branches: [main, dev]
paths: paths:
- 'server/**' - 'server/**'
- '.github/workflows/test.yml'
- 'client/**' - 'client/**'
- 'shared/**'
- '.github/workflows/test.yml'
jobs: jobs:
shared-contracts:
name: Shared Contracts (Zod)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared
- name: Typecheck
run: cd shared && npm run typecheck
- name: Run tests
run: cd shared && npm test
server-tests: server-tests:
name: Server Tests name: Server Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -21,12 +44,24 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 22 node-version: 24
cache: npm cache: npm
cache-dependency-path: server/package-lock.json cache-dependency-path: package-lock.json
- name: Install dependencies - name: Install dependencies
run: cd server && npm ci run: npm ci --workspace shared && npm ci --workspace server
- name: Build shared
run: npm run build --workspace=shared
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Typecheck (informational)
# Pre-existing type errors in the NestJS rewrite; surfaces them without
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
continue-on-error: true
run: cd server && npm run typecheck
- name: Run tests - name: Run tests
run: cd server && npm run test:coverage run: cd server && npm run test:coverage
@@ -48,12 +83,15 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: 22 node-version: 24
cache: npm cache: npm
cache-dependency-path: client/package-lock.json cache-dependency-path: package-lock.json
- name: Install dependencies - name: Install dependencies
run: cd client && npm ci run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run tests - name: Run tests
run: cd client && npm run test:coverage run: cd client && npm run test:coverage
+2
View File
@@ -3,6 +3,8 @@ node_modules/
# Build output # Build output
client/dist/ client/dist/
server/dist/
shared/dist/
server/public/* server/public/*
!server/public/.gitkeep !server/public/.gitkeep
+49 -19
View File
@@ -1,31 +1,60 @@
# Stage 1: Build React client # ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
RUN npm ci --workspace=shared
COPY shared/ ./shared/
RUN npm run build --workspace=shared
# ── Stage 2: client ──────────────────────────────────────────────────────────
FROM node:24-alpine AS client-builder FROM node:24-alpine AS client-builder
WORKDIR /app/client WORKDIR /app
COPY client/package*.json ./ COPY package.json package-lock.json ./
RUN npm ci COPY shared/package.json ./shared/
COPY client/ ./ COPY client/package.json ./client/
RUN npm run build RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
RUN npm run build --workspace=client
# Stage 2: Production server # ── Stage 3: server ──────────────────────────────────────────────────────────
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
RUN npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
# Timezone support + native deps (better-sqlite3 needs build tools) # Workspace manifests only — source never enters this stage.
COPY server/package*.json ./ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools; purged after install.
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \ RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \ npm ci --workspace=server --omit=dev && \
rm package-lock.json && \
apk del python3 make g++ && \ apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY server/ ./ COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=client-builder /app/client/dist ./public # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY --from=client-builder /app/client/public/fonts ./public/fonts COPY server/tsconfig.json ./server/
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
RUN rm -f package-lock.json && \ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ ln -s /app/uploads /app/server/uploads && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \ ln -s /app/data /app/server/data && \
chown -R node:node /app chown -R node:node /app
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -39,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1 CMD wget -qO- http://localhost:3000/api/health || exit 1
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"] # cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
+1 -1
View File
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br /> <br />
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a> <a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp; &nbsp;
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly: If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue 1. **Do not** open a public issue
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch** 2. Email: **report@liketrek.com**
3. Include a description of the vulnerability and steps to reproduce 3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible. You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.18 version: 3.0.22
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.18" appVersion: "3.0.22"
+27
View File
@@ -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"
]
}
-11079
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "@trek/client",
"version": "3.0.18", "version": "3.0.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -12,12 +12,17 @@
"test:unit": "vitest run tests/unit", "test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}", "test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
}, },
"dependencies": { "dependencies": {
"@trek/shared": "*",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
@@ -34,6 +39,7 @@
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"zod": "^4.3.6",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
@@ -56,6 +62,14 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0", "vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4" "vitest": "^3.2.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"eslint": "^10.2.1",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2"
} }
} }
+40 -25
View File
@@ -1,31 +1,34 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import type { WeatherResult } from '@trek/shared'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity' import { isReachable, probeNow } from '../sync/connectivity'
import en from '../i18n/translations/en' const RATE_LIMIT_MESSAGES: Record<string, string> = {
import br from '../i18n/translations/br' en: 'Too many attempts. Please try again later.',
import de from '../i18n/translations/de' de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
import es from '../i18n/translations/es' es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
import fr from '../i18n/translations/fr' fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
import it from '../i18n/translations/it' hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
import nl from '../i18n/translations/nl' nl: 'Te veel pogingen. Probeer het later opnieuw.',
import pl from '../i18n/translations/pl' br: 'Muitas tentativas. Tente novamente mais tarde.',
import cs from '../i18n/translations/cs' cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
import hu from '../i18n/translations/hu' pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
import ru from '../i18n/translations/ru' ru: 'Слишком много попыток. Попробуйте позже.',
import zh from '../i18n/translations/zh' zh: '尝试次数过多,请稍后再试。',
import zhTw from '../i18n/translations/zhTw' 'zh-TW': '嘗試次數過多,請稍後再試。',
import ar from '../i18n/translations/ar' it: 'Troppi tentativi. Riprova più tardi.',
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = { ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar, id: 'Terlalu banyak percobaan. Coba lagi nanti.',
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
uk: 'Занадто багато спроб. Спробуйте пізніше.',
} }
function translateRateLimit(): string { function translateRateLimit(): string {
const fallback = 'Too many attempts. Please try again later.' const fallback = RATE_LIMIT_MESSAGES['en']!
try { try {
const lang = localStorage.getItem('app_language') || 'en' const lang = localStorage.getItem('app_language') || 'en'
const table = rateLimitTranslations[lang] || rateLimitTranslations.en return RATE_LIMIT_MESSAGES[lang] ?? fallback
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
} catch { } catch {
return fallback return fallback
} }
@@ -209,7 +212,7 @@ export const oauthApi = {
clients: { clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data), list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
apiClient.post('/oauth/clients', data).then(r => r.data), apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
@@ -407,8 +410,20 @@ export const journeyApi = {
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
timeout: 0,
onUploadProgress: opts?.onUploadProgress,
signal: opts?.signal,
}).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
@@ -489,8 +504,8 @@ export const reservationsApi = {
} }
export const weatherApi = { export const weatherApi = {
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data), get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
} }
export const configApi = { export const configApi = {
@@ -20,7 +20,6 @@ type Defaults = {
temperature_unit?: string temperature_unit?: string
dark_mode?: string | boolean dark_mode?: string | boolean
time_format?: string time_format?: string
route_calculation?: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_tile_url?: string map_tile_url?: string
} }
@@ -208,22 +207,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </OptionRow>
{/* Route Calculation */}
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
{([
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
] as const).map(opt => (
<OptionButton
key={String(opt.value)}
active={defaults.route_calculation === opt.value}
onClick={() => save({ route_calculation: opt.value })}
>
{opt.label}
</OptionButton>
))}
</OptionRow>
{/* Blur Booking Codes */} {/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}> <OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([ {([
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
return ( return (
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}> <div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* Top bar */} {/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0"> <div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<button <button
@@ -102,19 +102,19 @@ describe('BottomNav', () => {
expect(screen.queryByText('testuser')).not.toBeInTheDocument(); expect(screen.queryByText('testuser')).not.toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => { it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />); render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument(); expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => { it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />); render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument(); expect(await screen.findByText('Profil')).toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => { it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) }); seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, { seedStore(useAddonStore, {
addons: [ addons: [
@@ -124,9 +124,9 @@ describe('BottomNav', () => {
], ],
}); });
render(<BottomNav />); render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument(); expect(await screen.findByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument(); expect(await screen.findByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument(); expect(await screen.findByText('Journal de voyage')).toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => { it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
+7 -11
View File
@@ -128,7 +128,8 @@ describe('MapView', () => {
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />) render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy() // Apple-Maps style draws a casing + a core line per segment.
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
}) })
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => { it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
@@ -155,16 +156,11 @@ describe('MapView', () => {
expect(screen.getByTestId('cluster-group')).toBeTruthy() expect(screen.getByTestId('cluster-group')).toBeTruthy()
}) })
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => { it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][] const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
const routeSegments = [ render(<MapView route={route} />)
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' }, // The route is drawn; per-segment times now live in the day sidebar, not on the map.
] expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
render(<MapView route={route} routeSegments={routeSegments} />)
// Route polyline is rendered
expect(screen.getByTestId('polyline')).toBeTruthy()
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
// so we just assert the polyline is there, exercising the routeSegments.map path
}) })
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => { it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
+14 -55
View File
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
return null return null
} }
// ── Route travel time label ── // Travel times are shown in the day sidebar (per-segment connectors), not on the map.
interface RouteLabelProps {
midpoint: [number, number]
walkingText: string
drivingText: string
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
if (!midpoint) return null
const icon = L.divIcon({
className: 'route-info-pill',
html: `<div style="
display:flex;align-items:center;gap:5px;
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
color:#fff;border-radius:99px;padding:3px 9px;
font-size:9px;font-weight:600;white-space:nowrap;
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
box-shadow:0 2px 12px rgba(0,0,0,0.3);
pointer-events:none;
position:relative;left:-50%;top:-50%;
">
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
${walkingText}
</span>
<span style="opacity:0.3">|</span>
<span style="display:flex;align-items:center;gap:2px">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
${drivingText}
</span>
</div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
})
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
}
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
{markers} {markers}
</MarkerClusterGroup> </MarkerClusterGroup>
{route && route.length > 0 && ( {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
<> {route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
{route.map((seg, i) => seg.length > 1 && ( <Polyline
<Polyline key={`${i}-casing`}
key={i} positions={seg}
positions={seg} pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
color="#111827" />,
weight={3} <Polyline
opacity={0.9} key={`${i}-core`}
dashArray="6, 5" positions={seg}
/> pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
))} />,
{routeSegments.map((seg, i) => ( ] : [])}
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
</>
)}
{/* GPX imported route geometries */} {/* GPX imported route geometries */}
{gpxPolylines} {gpxPolylines}
+13 -6
View File
@@ -132,6 +132,7 @@ export function MapViewGL({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
route = null, route = null,
routeSegments = [],
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -216,16 +217,20 @@ export function MapViewGL({
// initial route source — kept around so updates can setData() cheaply // initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) { if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
// rounded. Casing is added first so it sits beneath the core line.
map.addLayer({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({ map.addLayer({
id: 'trip-route-line', id: 'trip-route-line',
type: 'line', type: 'line',
source: 'trip-route', source: 'trip-route',
paint: { paint: { 'line-color': '#0a84ff', 'line-width': 5 },
'line-color': '#111827',
'line-width': 3,
'line-opacity': 0.9,
'line-dasharray': [2, 1.5],
},
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
}) })
} }
@@ -442,6 +447,8 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features }) src.setData({ type: 'FeatureCollection', features })
}, [route]) }, [route])
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries // Update GPX geometries
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
+75 -1
View File
@@ -1,7 +1,21 @@
import type { RouteResult, RouteSegment, Waypoint } from '../../types' import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1' const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
// the matching profile so walking routes follow footpaths, not the road network.
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute( export async function calculateRoute(
waypoints: Waypoint[], waypoints: Waypoint[],
@@ -116,12 +130,72 @@ export async function calculateSegments(
const walkingDuration = leg.distance / (5000 / 3600) const walkingDuration = leg.distance / (5000 / 3600)
return { return {
mid, from, to, mid, from, to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
} }
}) })
} }
/**
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
* straight line.
*/
export async function calculateRouteWithLegs(
waypoints: Waypoint[],
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
): Promise<RouteWithLegs> {
if (!waypoints || waypoints.length < 2) {
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const cacheKey = `${profile}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
if (!response.ok) throw new Error('Route could not be calculated')
const data = await response.json()
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
const route = data.routes[0]
const coordinates: [number, number][] = route.geometry.coordinates.map(
([lng, lat]: [number, number]) => [lat, lng]
)
const legs: RouteSegment[] = (route.legs || []).map(
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
const walkingDuration = leg.distance / (5000 / 3600)
return {
mid, from, to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
distanceText: formatDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
}
)
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
return result
}
function formatDistance(meters: number): string { function formatDistance(meters: number): string {
if (meters < 1000) { if (meters < 1000) {
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
+6 -4
View File
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite' return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
} }
// Terrain is only genuinely useful for the satellite imagery styles — on // Terrain is only genuinely useful for styles that benefit from elevation
// clean flat styles like streets/light/dark it nudges route lines onto // data. On flat vector styles (streets/light/dark) it nudges route lines
// the DEM while our HTML markers stay at Z=0, which causes the visible // onto the DEM while HTML markers stay at Z=0, causing a visible drift
// offset when the map is pitched. Restrict terrain to satellite. // when the map is pitched. Satellite and Outdoors are the intended styles
// for terrain; markers are re-pinned by syncMarkerAltitudes().
export function wantsTerrain(style: string): boolean { export function wantsTerrain(style: string): boolean {
return style === 'mapbox://styles/mapbox/satellite-v9' return style === 'mapbox://styles/mapbox/satellite-v9'
|| style === 'mapbox://styles/mapbox/satellite-streets-v12' || style === 'mapbox://styles/mapbox/satellite-streets-v12'
|| style === 'mapbox://styles/mapbox/outdoors-v12'
} }
// 3D can be added to every style now — the standard family has it built-in // 3D can be added to every style now — the standard family has it built-in
+2 -1
View File
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi } from '../../api/client' import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
function renderLucideIcon(icon:LucideIcon, props = {}) { function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '' if (!_renderToStaticMarkup) return ''
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const phase = pdfGetSpanPhase(r, day.id) const phase = pdfGetSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase) const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id) const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : '' const time = splitReservationDateTime(displayTime).time ?? ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}` const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return ` return `
<div class="note-card" style="border-left: 3px solid ${color};"> <div class="note-card" style="border-left: 3px solid ${color};">
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel'; import PackingListPanel, { itemWeight } from './PackingListPanel';
describe('itemWeight (bag total weight calc)', () => {
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
});
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
expect(itemWeight({ weight_grams: 250 })).toBe(250);
});
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
expect(itemWeight({ quantity: 5 })).toBe(0);
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
expect(itemWeight({})).toBe(0);
});
});
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null } interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
(i.weight_grams || 0) * (i.quantity || 1)
// ── Bag Card ────────────────────────────────────────────────────────────── // ── Bag Card ──────────────────────────────────────────────────────────────
interface BagCardProps { interface BagCardProps {
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{bags.map(bag => { {bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id) const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return ( return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact /> <BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* Unassigned */} {/* Unassigned */}
{(() => { {(() => {
const unassigned = items.filter(i => !i.bag_id) const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null if (unassigned.length === 0) return null
return ( return (
<div style={{ marginBottom: 14, opacity: 0.6 }}> <div style={{ marginBottom: 14, opacity: 0.6 }}>
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
</div> </div>
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{bags.map(bag => { {bags.map(bag => {
const bagItems = items.filter(i => i.bag_id === bag.id) const bagItems = items.filter(i => i.bag_id === bag.id)
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
return ( return (
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} /> <BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
{/* Unassigned */} {/* Unassigned */}
{(() => { {(() => {
const unassigned = items.filter(i => !i.bag_id) const unassigned = items.filter(i => !i.bag_id)
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
if (unassigned.length === 0) return null if (unassigned.length === 0) return null
return ( return (
<div style={{ marginBottom: 16, opacity: 0.6 }}> <div style={{ marginBottom: 16, opacity: 0.6 }}>
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}> <div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
<span>{t('packing.totalWeight')}</span> <span>{t('packing.totalWeight')}</span>
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span> <span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
</div> </div>
</div> </div>
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder' import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
rightWidth?: number rightWidth?: number
collapsed?: boolean collapsed?: boolean
onToggleCollapse?: () => void onToggleCollapse?: () => void
mobile?: boolean
} }
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) { export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
const can = useCanDo() const can = useCanDo()
const tripObj = useTripStore((s) => s.trip) const tripObj = useTripStore((s) => s.trip)
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}> <div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{/* ── Reservations for this day's assignments ── */} {/* ── Reservations for this day's assignments ── */}
{(() => { {(() => {
const dayAssignments = assignments[String(day.id)] || [] const dayAssignments = assignments[String(day.id)] || []
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id)) const dayReservations = reservations.filter(r => {
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
return r.day_id === day.id
})
if (dayReservations.length === 0) return null if (dayReservations.length === 0) return null
return ( return (
<div style={{ marginBottom: 0 }}> <div style={{ marginBottom: 0 }}>
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span> <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>} {linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
</div> </div>
{r.reservation_time?.includes('T') && ( {(() => {
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}> const { time: startTime } = splitReservationDateTime(r.reservation_time)
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })} const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
{r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`} if (!startTime && !endTime) return null
</span> return (
)} <span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{startTime ? formatTime12(startTime, is12h) : ''}
{endTime ? ` ${formatTime12(endTime, is12h)}` : ''}
</span>
)
})()}
</div> </div>
) )
})} })}
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find the pencil/edit button next to the title await user.click(screen.getByLabelText('Edit'))
const editButtons = screen.getAllByRole('button')
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
// Click the edit (pencil) button — it's the small one near the title
// The pencil button is inside the title area with opacity 0.35
const titleEl = screen.getByText('Original Title')
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
await waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument() expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
}) })
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn() const onUpdateDayTitle = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
// Enter edit mode // Enter edit mode
const titleEl = screen.getByText('Original Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Original Title') const input = await screen.findByDisplayValue('Original Title')
await user.clear(input) await user.clear(input)
await user.type(input, 'New Title') await user.type(input, 'New Title')
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const titleEl = screen.getByText('Original Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Original Title') const input = await screen.findByDisplayValue('Original Title')
await user.keyboard('{Escape}') await user.keyboard('{Escape}')
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument() expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn() const onUpdateDayTitle = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
const titleEl = screen.getByText('Old Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Old Title') const input = await screen.findByDisplayValue('Old Title')
await user.clear(input) await user.clear(input)
await user.type(input, 'New Title') await user.type(input, 'New Title')
+257 -131
View File
@@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Footprints, Route as RouteIcon } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
import { downloadTripPDF } from '../PDF/TripPDF' import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator' import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
@@ -28,10 +28,10 @@ import {
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem, type MergedItem,
} from '../../utils/dayMerge' } from '../../utils/dayMerge'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types'
const NOTE_ICONS = [ const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText }, { id: 'FileText', Icon: FileText },
@@ -184,6 +184,10 @@ interface DayPlanSidebarProps {
onExternalTransportDetailHandled?: () => void onExternalTransportDetailHandled?: () => void
onAddReservation: () => void onAddReservation: () => void
onNavigateToFiles?: () => void onNavigateToFiles?: () => void
routeShown?: boolean
routeProfile?: 'driving' | 'walking'
onToggleRoute?: () => void
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
onAddPlace?: () => void onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
@@ -200,6 +204,25 @@ interface DayPlanSidebarProps {
onScrollTopChange?: (top: number) => void onScrollTopChange?: (top: number) => void
} }
/** Slim travel-time connector shown between two consecutive located stops in a day. */
function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving' | 'walking' }) {
const driving = profile === 'driving'
const Icon = driving ? Car : Footprints
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
<div style={line} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
<Icon size={11} strokeWidth={2} />
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
<span style={{ opacity: 0.4 }}>·</span>
<span>{seg.distanceText}</span>
</div>
<div style={line} />
</div>
)
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({ const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripId, tripId,
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
@@ -216,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAddPlace, onAddPlace,
onAddPlaceToDay, onAddPlaceToDay,
onNavigateToFiles, onNavigateToFiles,
routeShown = false,
routeProfile = 'driving',
onToggleRoute,
onSetRouteProfile,
onExpandedDaysChange, onExpandedDaysChange,
pushUndo, pushUndo,
canUndo = false, canUndo = false,
@@ -251,6 +278,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [isCalculating, setIsCalculating] = useState(false) const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null) const [routeInfo, setRouteInfo] = useState(null)
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
const legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null) const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set()) const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null) const [lockHoverId, setLockHoverId] = useState(null)
@@ -472,6 +501,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations, transportPosVersion]) }, [days, assignments, dayNotes, reservations, transportPosVersion])
// Per-segment driving times for the selected day's connectors. Groups located
// places into runs (split at transports), one cached OSRM call per run, keyed by
// the start place's assignment id. Shares RouteCalculator's cache with the map.
useEffect(() => {
if (legsAbortRef.current) legsAbortRef.current.abort()
if (!selectedDayId || !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, routeShown, routeProfile, mergedItemsMap])
const openAddNote = (dayId, e) => { const openAddNote = (dayId, e) => {
e?.stopPropagation() e?.stopPropagation()
_openAddNote(dayId, getMergedItems, (id) => { _openAddNote(dayId, getMergedItems, (id) => {
@@ -792,13 +857,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}) })
} }
const handleGoogleMaps = () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
if (url) window.open(url, '_blank')
else toast.error(t('dayplan.toast.noGeoPlaces'))
}
const handleDropOnDay = (e, dayId) => { const handleDropOnDay = (e, dayId) => {
e.preventDefault() e.preventDefault()
@@ -1047,6 +1105,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}> <div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */} {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div <div
className="dp-day-header"
data-selected={isSelected}
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
@@ -1066,16 +1126,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
> >
{/* Tages-Badge */} {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
<div style={{ {(() => {
width: 26, height: 26, borderRadius: '50%', flexShrink: 0, const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)', const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)', const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
display: 'flex', alignItems: 'center', justifyContent: 'center', return (
fontSize: 11, fontWeight: 700, <div style={{
}}> flexShrink: 0, alignSelf: 'flex-start',
{index + 1} width: hasWeather ? 34 : 26,
</div> borderRadius: hasWeather ? 11 : '50%',
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
}}>
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
{index + 1}
</div>
{hasWeather && (
<>
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
<div style={{ padding: '3px 0 4px' }}>
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
</div>
</>
)}
</div>
)
})()}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? ( {editingDayId === day.id ? (
@@ -1093,40 +1171,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
borderBottom: '1.5px solid var(--text-primary)', borderBottom: '1.5px solid var(--text-primary)',
}} }}
/> />
) : ( ) : (<>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}> <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })} {day.title || t('dayplan.dayN', { n: index + 1 })}
</span> </span>
{canEditDays && <button {formattedDate && (
onClick={e => startEditTitle(day, e)} <>
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }} <span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
> <span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" /> {formattedDate}
</button>} </span>
{canEditDays && onAddTransport && ( </>
<Tooltip label={t('transport.addTransport')} placement="top">
<button
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
aria-label={t('transport.addTransport')}
style={{
flexShrink: 0,
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
opacity: 0.45,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
>
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
</Tooltip>
)} )}
</div>
{(() => {
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
const hasRentals = getActiveRentalsForDay(day.id).length > 0
if (!hasAccs && !hasRentals) return null
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
})()}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
{(() => { {(() => {
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
// Sort: check-out first, then ongoing stays, then check-in last // Sort: check-out first, then ongoing stays, then check-in last
@@ -1145,13 +1210,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return dayAccs.map(acc => { return dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id const isCheckIn = acc.start_day_id === day.id
const isCheckOut = acc.end_day_id === day.id const isCheckOut = acc.end_day_id === day.id
const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)' const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return ( return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}> <span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} /> <Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span> <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span> </span>
) )
}) })
@@ -1161,41 +1224,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const activeRentals = getActiveRentalsForDay(day.id) const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null if (activeRentals.length === 0) return null
return activeRentals.map(r => ( return activeRentals.map(r => (
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> <span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} /> <Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span> <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span> </span>
)) ))
})()} })()}
</div> </div>
</>
)}
{cost && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>
</div>
)} )}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
{day.date && anyGeoPlace && (() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
})()}
</div>
</div> </div>
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button {canEditDays ? (
onClick={e => openAddNote(day.id, e)} (() => {
aria-label={t('dayplan.addNote')} const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} const div = '1px solid var(--border-faint)'
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} return (
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'} <div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
> <button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<FileText size={16} strokeWidth={2} /> <Pencil size={14} strokeWidth={1.8} />
</button></Tooltip>} </button>
<button {onAddTransport ? (
onClick={e => toggleDay(day.id, e)} <button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} <Plus size={14} strokeWidth={1.8} />
> </button>
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />} ) : <div style={{ borderBottom: div }} />}
</button> <button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
<FileText size={14} strokeWidth={1.8} />
</button>
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
</button>
</div>
)
})()
) : (
<button onClick={e => toggleDay(day.id, e)} style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
</button>
)}
</div> </div>
{/* Aufgeklappte Orte + Notizen */} {/* Aufgeklappte Orte + Notizen */}
@@ -1487,15 +1559,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}}> }}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()} {(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span> <span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time?.includes('T') && ( {(() => {
<span style={{ fontWeight: 400 }}> const { time: st } = splitReservationDateTime(res.reservation_time)
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} const { time: et } = splitReservationDateTime(res.reservation_end_time)
{res.reservation_end_time && ` ${(() => { if (!st && !et) return null
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time) return (
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) <span style={{ fontWeight: 400 }}>
})()}`} {st ? formatTime(st, locale, timeFormat) : ''}
</span> {et ? ` ${formatTime(et, locale, timeFormat)}` : ''}
)} </span>
)
})()}
{(() => { {(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null if (!meta) return null
@@ -1605,6 +1679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</button> </button>
)} )}
</div> </div>
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
</React.Fragment> </React.Fragment>
) )
} }
@@ -1654,6 +1729,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
draggable={canEditDays && spanPhase !== 'middle'} draggable={canEditDays && spanPhase !== 'middle'}
onDragStart={e => { onDragStart={e => {
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
// setData is required for the drag to start reliably (Firefox) and
// matches how place/note items initiate their drag.
e.dataTransfer.setData('reservationId', String(res.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
setDraggingId(res.id) setDraggingId(res.id)
@@ -1722,18 +1801,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{res.title} {res.title}
</span> </span>
{displayTime?.includes('T') && ( {(() => {
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}> const { time: dispTime } = splitReservationDateTime(displayTime)
<Clock size={9} strokeWidth={2} /> const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} if (!dispTime && !endTime) return null
{spanPhase === 'single' && res.reservation_end_time && (() => { return (
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time) <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
return ` ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}` <Clock size={9} strokeWidth={2} />
})()} {dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} {spanPhase === 'single' && endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
</span> {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
)} </span>
)
})()}
</div> </div>
{subtitle && ( {subtitle && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -1782,8 +1863,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (fromReservationId && fromDayId !== day.id) { if (placeId) {
// New place dropped onto a note: insert it among the
// assignments at the note's position (after the places
// above it), so it lands right where the note sits.
const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
onAssignToDay?.(parseInt(placeId), day.id, pos)
setDropTargetKey(null); window.__dragData = null
} else if (fromReservationId && fromDayId !== day.id) {
const r = reservations.find(x => x.id === Number(fromReservationId)) const r = reservations.find(x => x.id === Number(fromReservationId))
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
@@ -1880,7 +1970,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
} }
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
@@ -1896,6 +1986,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId) else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId)
handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true)
setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}} }}
> >
{dropTargetKey === `end-${day.id}` && ( {dropTargetKey === `end-${day.id}` && (
@@ -1906,15 +1999,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && ( {isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
{routeInfo && ( <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}> <button
<span>{routeInfo.distance}</span> onClick={() => onToggleRoute?.()}
<span style={{ color: 'var(--text-faint)' }}>·</span> style={{
<span>{routeInfo.duration}</span> flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
</div> padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
)} border: routeShown ? 'none' : '1px solid var(--border-faint)',
background: routeShown ? 'var(--accent)' : 'transparent',
<div style={{ display: 'flex', gap: 6 }}> color: routeShown ? 'var(--accent-text)' : 'var(--text-secondary)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
<button onClick={handleOptimize} style={{ <button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none', padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
@@ -1923,14 +2022,35 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<RotateCcw size={12} strokeWidth={2} /> <RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')} {t('dayplan.optimize')}
</button> </button>
<button onClick={handleGoogleMaps} style={{ <div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
display: 'flex', alignItems: 'center', justifyContent: 'center', {(['driving', 'walking'] as const).map(p => {
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8, const ModeIcon = p === 'driving' ? Car : Footprints
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit', const active = routeProfile === p
}}> return (
<ExternalLink size={12} strokeWidth={2} /> <button
</button> key={p}
onClick={() => onSetRouteProfile?.(p)}
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
background: active ? 'var(--accent)' : 'transparent',
color: active ? 'var(--accent-text)' : 'var(--text-secondary)',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
)
})}
</div>
</div> </div>
{routeInfo && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
</div> </div>
)} )}
@@ -2094,13 +2214,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div> <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
{res.reservation_time?.includes('T') {(() => {
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) const { date, time } = splitReservationDateTime(res.reservation_time)
: res.reservation_time const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) const dateStr = date
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: '' : ''
} const timeStr = time ? formatTime(time, locale, timeFormat) : ''
{res.reservation_end_time?.includes('T') && ` ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
const parts: string[] = []
if (dateStr) parts.push(dateStr)
if (timeStr) parts.push(timeStr + (endStr ? ` ${endStr}` : ''))
return parts.join(', ')
})()}
</div> </div>
</div> </div>
<div style={{ <div style={{
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime } from '../../utils/formatters'
const detailsCache = new Map() const detailsCache = new Map()
@@ -169,7 +170,10 @@ export default function PlaceInspector({
const category = categories?.find(c => c.id === place.category_id) const category = categories?.find(c => c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : [] const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null const assignmentInDay = selectedDayId
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
?? dayAssignments.find(a => a.place?.id === place.id))
: null
const openingHours = googleDetails?.opening_hours || null const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null const openNow = googleDetails?.open_now ?? null
@@ -344,7 +348,7 @@ export default function PlaceInspector({
{/* Description / Summary */} {/* Description / Summary */}
{(place.description || googleDetails?.summary) && ( {(place.description || googleDetails?.summary) && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}> <div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div> </div>
)} )}
@@ -378,21 +382,29 @@ export default function PlaceInspector({
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span> <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
</div> </div>
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{res.reservation_time && ( {(() => {
<div> const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div> const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div> return (
</div> <>
)} {date && (
{res.reservation_time?.includes('T') && ( <div>
<div> <div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div> <div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}> </div>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} )}
{res.reservation_end_time && ` ${res.reservation_end_time}`} {(startTime || endTime) && (
</div> <div>
</div> <div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
)} <div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
{endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''}
</div>
</div>
)}
</>
)
})()}
{res.confirmation_number && ( {res.confirmation_number && (
<div> <div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div> <div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
@@ -27,7 +27,7 @@ beforeEach(() => {
resetAllStores(); resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } }); seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
}); });
describe('ReservationsPanel', () => { describe('ReservationsPanel', () => {
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
}); });
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => { it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } }); seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' }); const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />); render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123'); const codeEl = screen.getByText('ABC123');
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => { it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } }); seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' }); const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />); render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123'); const codeEl = screen.getByText('ABC123');
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
expect(screen.getByText('Pending 2')).toBeInTheDocument(); expect(screen.getByText('Pending 2')).toBeInTheDocument();
expect(screen.getByText('Pending 3')).toBeInTheDocument(); expect(screen.getByText('Pending 3')).toBeInTheDocument();
}); });
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 25 } as any);
const r = buildReservation({
title: 'Cruise test',
type: 'cruise',
status: 'pending',
reservation_time: 'T10:00',
reservation_end_time: 'T18:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/10:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 3 } as any);
const r = buildReservation({
title: 'Car rental',
type: 'car',
status: 'pending',
reservation_time: '09:00',
reservation_end_time: '17:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/09:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
const day = buildDay({ date: '2026-07-15', day_number: 1 });
const r = buildReservation({
title: 'Flight out',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-07-15T08:30',
reservation_end_time: '2026-07-15T10:45',
day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/08:30/)).toBeInTheDocument();
});
}); });
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks' import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
interface AssignmentLookupEntry { interface AssignmentLookupEntry {
dayNumber: number dayNumber: number
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
} }
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const fmtDate = (str) => { const startDt = splitReservationDateTime(r.reservation_time)
const dateOnly = str.includes('T') ? str.split('T')[0] : str const endDt = splitReservationDateTime(r.reservation_end_time)
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) const fmtDate = (date: string) =>
} new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
const fmtTime = (str) => {
const d = new Date(str)
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}
const hasDate = !!r.reservation_time const hasDate = !!startDt.date
const hasTime = r.reservation_time?.includes('T') const hasTime = !!(startDt.time || endDt.time)
const hasCode = !!r.confirmation_number const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div> </div>
)} )}
{/* Date / Time row */} {/* Date / Time row */}
{hasDate && ( {(hasDate || hasTime) && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}> <div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
<div> {hasDate && (
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
{fmtDate(r.reservation_time)} <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{(() => { {fmtDate(startDt.date!)}
const endDatePart = r.reservation_end_time {endDt.date && endDt.date !== startDt.date && (
? r.reservation_end_time.includes('T') <> {fmtDate(endDt.date)}</>
? r.reservation_end_time.split('T')[0] )}
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time) </div>
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</div> </div>
</div> )}
{hasTime && ( {hasTime && (
<div> <div>
<div style={fieldLabelStyle}>{t('reservations.time')}</div> <div style={fieldLabelStyle}>{t('reservations.time')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''} {formatTime(startDt.time, locale, timeFormat)}
{endDt.time ? ` ${formatTime(endDt.time, locale, timeFormat)}` : ''}
</div> </div>
</div> </div>
)} )}
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') }) if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null if (cells.length === 0) return null
return ( return (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}> <div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
status: reservation.status || 'pending', status: reservation.status || 'pending',
start_day_id: reservation.day_id ?? '', start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '', end_day_id: reservation.end_day_id ?? '',
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '', departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '', arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
confirmation_number: reservation.confirmation_number || '', confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '', notes: reservation.notes || '',
meta_airline: meta.airline || '', meta_airline: meta.airline || '',
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const buildTime = (day: Day | undefined, time: string): string | null => { const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null if (!time) return null
return day?.date ? `${day.date}T${time}` : `T${time}` return day?.date ? `${day.date}T${time}` : time
} }
const metadata: Record<string, string> = {} const metadata: Record<string, string> = {}
@@ -161,29 +161,6 @@ describe('DisplaySettingsTab', () => {
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
}); });
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
render(<DisplaySettingsTab />);
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
render(<DisplaySettingsTab />);
const onButtons = screen.getAllByText(/^On$/i);
const routeCalcOnBtn = onButtons[0].closest('button')!;
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
});
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
render(<DisplaySettingsTab />);
const offButtons = screen.getAllByText(/^Off$/i);
await user.click(offButtons[0]);
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
});
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => { it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument(); expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
@@ -214,36 +214,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div> </div>
</div> </div>
{/* Route Calculation */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Booking route labels */} {/* Booking route labels */}
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
@@ -69,6 +69,7 @@ interface OAuthClient {
client_id: string client_id: string
redirect_uris: string[] redirect_uris: string[]
allowed_scopes: string[] allowed_scopes: string[]
allows_client_credentials: boolean
created_at: string created_at: string
client_secret?: string // only present on create client_secret?: string // only present on create
} }
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
const [oauthRotating, setOauthRotating] = useState(false) const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker // oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({}) const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
const [oauthIsMachine, setOauthIsMachine] = useState(false)
// MCP sub-tab state // MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth') const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
}, [mcpEnabled]) }, [mcpEnabled])
const handleCreateOAuthClient = async () => { const handleCreateOAuthClient = async () => {
if (!oauthNewName.trim() || !oauthNewUris.trim()) return if (!oauthNewName.trim()) return
if (!oauthIsMachine && !oauthNewUris.trim()) return
setOauthCreating(true) setOauthCreating(true)
try { try {
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes }) const d = await oauthApi.clients.create({
name: oauthNewName.trim(),
redirect_uris: uris,
allowed_scopes: oauthNewScopes,
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
})
setOauthCreatedClient(d.client) setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('') setOauthNewName('')
setOauthNewUris('') setOauthNewUris('')
setOauthNewScopes([]) setOauthNewScopes([])
setOauthIsMachine(false)
} catch { } catch {
toast.error(t('settings.oauth.toast.createError')) toast.error(t('settings.oauth.toast.createError'))
} finally { } finally {
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p> <p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2"> <div className="flex justify-end mb-2">
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }} <button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700"> className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')} <Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button> </button>
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} /> <KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p> <div className="flex items-center gap-2">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
{client.allows_client_credentials && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
{t('settings.oauth.badge.machine')}
</span>
)}
</div>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}> <p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id} {t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span> <span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
autoFocus /> autoFocus />
</div> </div>
<div> <label className="flex items-start gap-2.5 cursor-pointer">
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label> <input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)} className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')} <div>
rows={3} <span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400" <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} /> </div>
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p> </label>
</div>
{!oauthIsMachine && (
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
)}
<div> <div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label> <label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateOAuthClient} <button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating} disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"> className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')} {oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button> </button>
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
{oauthCreatedClient?.allows_client_credentials && (
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.modal.machineClientUsage')}
</div>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }} <button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700"> className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
lng: number | null lng: number | null
date: string date: string
compact?: boolean compact?: boolean
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
stacked?: boolean
} }
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) { export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
const [weather, setWeather] = useState(null) const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
const unit = isFahrenheit ? '°F' : '°C' const unit = isFahrenheit ? '°F' : '°C'
const isClimate = weather.type === 'climate' const isClimate = weather.type === 'climate'
if (stacked) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
<WeatherIcon main={weather.main} size={13} />
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
</div>
)
}
if (compact) { if (compact) {
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
+13 -1
View File
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null) const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const imageUrlFailed = useRef(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
alt={place.name} alt={place.name}
decoding="async" decoding="async"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)} onError={() => {
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
imageUrlFailed.current = true
const photoId = place.google_place_id || place.osm_id!
const cacheKey = `refetch:${photoId}`
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
)
} else {
setPhotoSrc(null)
}
}}
/> />
</div> </div>
) )
+40 -25
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore' import { useTripStore } from '../store/tripStore'
import { calculateSegments } from '../components/Map/RouteCalculator' import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import type { TripStoreState } from '../store/tripStore' import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types' import type { RouteSegment, RouteResult } from '../types'
@@ -9,20 +8,20 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
/** /**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from * Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route, and optionally fetches per-segment * day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes. * road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/ */
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) { export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
const [route, setRoute] = useState<[number, number][][] | null>(null) const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null) const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]) const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null) const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations) const reservationsForSignature = useTripStore((s) => s.reservations)
const updateRouteForDay = useCallback(async (dayId: number | null) => { const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort() if (routeAbortRef.current) routeAbortRef.current.abort()
if (!dayId) { setRoute(null); setRouteSegments([]); return } // Route is manual: only compute when explicitly enabled (the "show route" toggle).
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic // Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments. // updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {} const currentAssignments = useTripStore.getState().assignments || {}
@@ -67,35 +66,51 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
})), })),
].sort((a, b) => a.pos - b.pos) ].sort((a, b) => a.pos - b.pos)
const segments: [number, number][][] = [] // Group consecutive located places into runs, resetting whenever a transport
let currentSeg: [number, number][] = [] // appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
const runs: { lat: number; lng: number }[][] = []
let currentRun: { lat: number; lng: number }[] = []
for (const entry of entries) { for (const entry of entries) {
if (entry.kind === 'place') { if (entry.kind === 'place') {
currentSeg.push([entry.lat, entry.lng]) currentRun.push({ lat: entry.lat, lng: entry.lng })
} else { } else {
if (currentSeg.length >= 2) segments.push([...currentSeg]) if (currentRun.length >= 2) runs.push(currentRun)
currentSeg = [] currentRun = []
} }
} }
if (currentSeg.length >= 2) segments.push(currentSeg) if (currentRun.length >= 2) runs.push(currentRun)
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[] const straightLines = (): [number, number][][] =>
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
setRoute(straightLines())
if (segments.length === 0 && geocodedWaypoints.length < 2) {
setRoute(null); setRouteSegments([]); return
}
setRoute(segments.length > 0 ? segments : null)
if (!routeCalcEnabled) { setRouteSegments([]); return }
const controller = new AbortController() const controller = new AbortController()
routeAbortRef.current = controller routeAbortRef.current = controller
try { try {
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal }) const polylines: [number, number][][] = []
if (!controller.signal.aborted) setRouteSegments(calcSegments) const allLegs: RouteSegment[] = []
for (const run of runs) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
allLegs.push(...r.legs)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// OSRM failed for this run — fall back to a straight line, no times.
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
}
}
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
else if (!(err instanceof Error)) setRouteSegments([]) if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
} }
}, [routeCalcEnabled]) }, [enabled, profile])
// Stable signature for transport reservations on the selected day — changes when a transport // Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders. // is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -117,7 +132,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId) updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature]) }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
} }
+55 -53
View File
@@ -1,52 +1,48 @@
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react' import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de' import en from '@trek/shared/i18n/en'
import en from './translations/en' import type { SupportedLanguageCode } from '@trek/shared'
import es from './translations/es' import {
import fr from './translations/fr' SUPPORTED_LANGUAGES,
import hu from './translations/hu' getLocaleForLanguage,
import it from './translations/it' getIntlLanguage,
import ru from './translations/ru' isRtlLanguage,
import zh from './translations/zh' } from '@trek/shared'
import zhTw from './translations/zhTw' import type { TranslationStrings } from '@trek/shared/i18n'
import nl from './translations/nl'
import id from './translations/id'
import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES } export { SUPPORTED_LANGUAGES }
type TranslationStrings = Record<string, string | { name: string; category: string }[]> // One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
// Only the active locale is fetched; en is always available synchronously as the fallback.
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
const translations: Record<SupportedLanguageCode, TranslationStrings> = { en: () => Promise.resolve({ default: en }),
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, de: () => import('@trek/shared/i18n/de'),
es: () => import('@trek/shared/i18n/es'),
fr: () => import('@trek/shared/i18n/fr'),
hu: () => import('@trek/shared/i18n/hu'),
it: () => import('@trek/shared/i18n/it'),
tr: () => import('@trek/shared/i18n/tr'),
ru: () => import('@trek/shared/i18n/ru'),
zh: () => import('@trek/shared/i18n/zh'),
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
nl: () => import('@trek/shared/i18n/nl'),
id: () => import('@trek/shared/i18n/id'),
ar: () => import('@trek/shared/i18n/ar'),
br: () => import('@trek/shared/i18n/br'),
cs: () => import('@trek/shared/i18n/cs'),
pl: () => import('@trek/shared/i18n/pl'),
ja: () => import('@trek/shared/i18n/ja'),
ko: () => import('@trek/shared/i18n/ko'),
uk: () => import('@trek/shared/i18n/uk'),
gr: () => import('@trek/shared/i18n/gr'),
} }
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. // Re-export pure helpers that live in shared so downstream consumers can import them
const LOCALES: Record<string, string> = Object.fromEntries( // through this module without changing their import path.
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale]) export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
)
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string { // Detects the user's preferred language from browser/OS settings.
return LOCALES[language] || LOCALES.en // Returns null if no supported language matches.
}
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'
}
export function isRtlLanguage(language: string): boolean {
return RTL_LANGUAGES.has(language)
}
// Detects the user's preferred language from the browser/OS settings and maps
// it to one of the supported language codes. Returns null if no match is found.
export function detectBrowserLanguage(): string | null { export function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null if (typeof navigator === 'undefined') return null
const browserLangs = navigator.languages?.length const browserLangs = navigator.languages?.length
@@ -55,17 +51,14 @@ export function detectBrowserLanguage(): string | null {
const supported = SUPPORTED_LANGUAGES.map(l => l.value) const supported = SUPPORTED_LANGUAGES.map(l => l.value)
for (const lang of browserLangs) { for (const lang of browserLangs) {
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase()) const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
if (exactMatch) return exactMatch if (exactMatch) return exactMatch
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly. // pt-BR has no exact match (our code is 'br'), so map it explicitly.
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the // pt-PT and bare 'pt' are NOT mapped — they fall through to null.
// server default or 'en' fallback apply instead.
if (lang.toLowerCase() === 'pt-br') return 'br' if (lang.toLowerCase() === 'pt-br') return 'br'
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive const prefix = lang.split('-')[0]?.toLowerCase()
const prefix = lang.split('-')[0].toLowerCase()
const prefixMatch = supported.find(s => s.toLowerCase() === prefix) const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
if (prefixMatch) return prefixMatch if (prefixMatch) return prefixMatch
} }
@@ -87,18 +80,27 @@ interface TranslationProviderProps {
export function TranslationProvider({ children }: TranslationProviderProps) { export function TranslationProvider({ children }: TranslationProviderProps) {
const language = useSettingsStore((s) => s.settings.language) || 'en' const language = useSettingsStore((s) => s.settings.language) || 'en'
const [strings, setStrings] = useState<TranslationStrings>(en)
useEffect(() => { useEffect(() => {
document.documentElement.lang = language document.documentElement.lang = language
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr' document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
}, [language]) }, [language])
const value = useMemo((): TranslationContextValue => { useEffect(() => {
const strings = translations[language] || translations.en const loader = localeLoaders[language as SupportedLanguageCode]
const fallback = translations.en if (!loader) return
let cancelled = false
loader().then(mod => {
if (!cancelled) setStrings(mod.default)
})
return () => { cancelled = true }
}, [language])
const value = useMemo((): TranslationContextValue => {
function t(key: string, params?: Record<string, string | number>): string { function t(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? fallback[key] ?? key) as string let val: string = (strings[key] ?? en[key] ?? key) as string
if (params) { if (params) {
Object.entries(params).forEach(([k, v]) => { Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
@@ -108,7 +110,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
} }
return { t, language, locale: getLocaleForLanguage(language) } return { t, language, locale: getLocaleForLanguage(language) }
}, [language]) }, [strings, language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider> return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
} }
+4 -21
View File
@@ -1,21 +1,4 @@
export const SUPPORTED_LANGUAGES = [ // Canonical language registry now lives in @trek/shared. Re-exported here so
{ value: 'de', label: 'Deutsch', locale: 'de-DE' }, // existing imports of './supportedLanguages' continue to work unchanged.
{ value: 'en', label: 'English', locale: 'en-US' }, export { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@trek/shared'
{ value: 'es', label: 'Español', locale: 'es-ES' }, export type { SupportedLanguageCode } from '@trek/shared'
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -812,3 +812,21 @@ img[alt="TREK"] {
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; } .collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; } .collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; } .collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
/* Day-plan header action grid (edit / +transport / note / collapse) */
.dp-day-actions button {
color: var(--text-faint);
background: transparent;
transition: background-color 0.12s ease, color 0.12s ease;
}
.dp-day-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Reveal the action grid only when hovering the day row (pointer devices).
Touch devices (hover: none) keep it visible; the selected day stays visible too. */
@media (hover: hover) {
.dp-day-actions { opacity: 0; transition: opacity 0.12s ease; }
.dp-day-header:hover .dp-day-actions,
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
}
-1
View File
@@ -857,7 +857,6 @@ describe('DashboardPage', () => {
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
route_calculation: false,
blur_booking_codes: false, blur_booking_codes: false,
dashboard_currency: 'on', dashboard_currency: 'on',
dashboard_timezone: 'on', dashboard_timezone: 'on',
+45 -22
View File
@@ -1,5 +1,7 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters' import { formatLocationName } from '../utils/formatters'
import { normalizeImageFiles } from '../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { getApiErrorMessage } from '../types'
const GRADIENTS = [ const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
} }
return entryId return entryId
}} }}
onUploadPhotos={async (entryId, formData) => { onUploadPhotos={async (entryId, files, cbs) => {
return await uploadPhotos(entryId, formData) return await uploadPhotos(entryId, files, cbs)
}} }}
onDone={() => { onDone={() => {
setEditingEntry(null) setEditingEntry(null)
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const [showPicker, setShowPicker] = useState(false) const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null) const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false) const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
const galleryUploading = galleryProgress !== null
const toast = useToast() const toast = useToast()
// check which providers are enabled AND connected for the current user // check which providers are enabled AND connected for the current user
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files const files = e.target.files
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryProgress({ done: 0, total: files.length })
try { try {
const formData = new FormData() const normalized = await normalizeImageFiles(files)
for (const f of files) formData.append('photos', f) const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
await journeyApi.uploadGalleryPhotos(journeyId, formData) onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
toast.success(t('journey.photosUploaded', { count: files.length })) })
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
} else {
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
}
onRefresh() onRefresh()
} catch { } catch (err) {
toast.error(t('journey.settings.coverFailed')) toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
} finally { } finally {
setGalleryUploading(false) setGalleryProgress(null)
} }
e.target.value = '' e.target.value = ''
} }
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50" className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
> >
{galleryUploading ? ( {galleryUploading ? (
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</> <><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
) : ( ) : (
<><Plus size={12} /> {t('common.upload')}</> <><Plus size={12} /> {t('common.upload')}</>
)} )}
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery') : t('journey.picker.newGallery')
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
{/* Header */} {/* Header */}
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
galleryPhotos: GalleryPhoto[] galleryPhotos: GalleryPhoto[]
onClose: () => void onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number> onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
onDone: () => void onDone: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [title, setTitle] = useState(entry.title || '') const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '') const [story, setStory] = useState(entry.story || '')
@@ -2191,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}) })
// upload queued files after entry is created // upload queued files after entry is created
if (pendingFiles.length > 0 && entryId) { if (pendingFiles.length > 0 && entryId) {
const formData = new FormData() const filesToUpload = pendingFiles
for (const f of pendingFiles) formData.append('photos', f) setUploadProgress({ done: 0, total: filesToUpload.length })
await onUploadPhotos(entryId, formData) try {
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
})
setPendingFiles(failed)
if (failed.length > 0) {
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
}
} catch (err) {
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
} finally {
setUploadProgress(null)
}
} }
// link gallery photos that were picked before save // link gallery photos that were picked before save
if (pendingLinkIds.length > 0 && entryId) { if (pendingLinkIds.length > 0 && entryId) {
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (!files?.length) return if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This // Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence. // keeps photo behavior consistent with text fields — no silent persistence.
setPendingFiles(prev => [...prev, ...Array.from(files)]) const normalized = await normalizeImageFiles(files)
setPendingFiles(prev => [...prev, ...normalized])
} }
return ( return (
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={uploading} disabled={saving}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50" className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
> >
{uploading ? ( {uploadProgress ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</> <><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
) : ( ) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</> <><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)} )}
+5 -3
View File
@@ -12,6 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder' import { isDayInAccommodationRange } from '../utils/dayOrder'
import { getTransportForDay, getMergedItems } from '../utils/dayMerge' import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
import { splitReservationDateTime } from '../utils/formatters'
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -219,7 +220,7 @@ export default function SharedTripPage() {
const r = item.data const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' const time = splitReservationDateTime(r.reservation_time).time ?? ''
let sub = '' let sub = ''
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ') else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
@@ -276,8 +277,9 @@ export default function SharedTripPage() {
{(reservations || []).map((r: any) => { {(reservations || []).map((r: any) => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const TIcon = TRANSPORT_ICONS[r.type] || Ticket const TIcon = TRANSPORT_ICONS[r.type] || Ticket
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' const time = rTime ?? ''
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
return ( return (
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}> <div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
+12 -3
View File
@@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false) const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null) const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null) const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
const [fitKey, setFitKey] = useState<number>(0) const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null) const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null) const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
@@ -398,7 +402,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}) })
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds]) }, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId) const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const handleSelectDay = useCallback((dayId, skipFit) => { const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId const changed = dayId !== selectedDayId
@@ -826,7 +830,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
hasInspector={!!selectedPlace} hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace} hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations} reservations={reservations}
showReservationStats={settings.route_calculation !== false} showReservationStats={true}
visibleConnectionIds={visibleConnections} visibleConnectionIds={visibleConnections}
onReservationClick={(rid) => { onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid) const r = reservations.find(x => x.id === rid)
@@ -891,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)} onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations} accommodations={tripAccommodations}
routeShown={routeShown}
routeProfile={routeProfile}
onToggleRoute={() => setRouteShown(v => !v)}
onSetRouteProfile={setRouteProfile}
onNavigateToFiles={() => handleTabChange('dateien')} onNavigateToFiles={() => handleTabChange('dateien')}
onExpandedDaysChange={setExpandedDayIds} onExpandedDaysChange={setExpandedDayIds}
pushUndo={pushUndo} pushUndo={pushUndo}
@@ -1003,6 +1011,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)} rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
collapsed={dayDetailCollapsed} collapsed={dayDetailCollapsed}
onToggleCollapse={() => setDayDetailCollapsed(c => !c)} onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
mobile={isMobile}
/> />
) )
})()} })()}
@@ -1116,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
} }
</div> </div>
+57 -8
View File
@@ -1,6 +1,7 @@
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015 // FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server'; import { server } from '../../tests/helpers/msw/server';
import { journeyApi } from '../api/client';
import { useJourneyStore } from './journeyStore'; import { useJourneyStore } from './journeyStore';
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore'; import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
useJourneyStore.setState({ current: detail }); useJourneyStore.setState({ current: detail });
const newPhoto = buildPhoto({ id: 91, entry_id: 100 }); const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
server.use( // MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
http.post('/api/journeys/entries/100/photos', () => // emit upload progress events, which hangs in jsdom+Node. Spy on the API
HttpResponse.json({ photos: [newPhoto] }) // layer directly so this test exercises store state management only.
) const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
); const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData()); const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
expect(result).toHaveLength(1); expect(result.succeeded).toHaveLength(1);
expect(result[0].id).toBe(91); expect(result.succeeded[0].id).toBe(91);
expect(result.failed).toHaveLength(0);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100); const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
expect(storedEntry?.photos).toHaveLength(2); expect(storedEntry?.photos).toHaveLength(2);
spy.mockRestore();
});
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
const entry = buildEntry({ id: 100, photos: [] });
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
useJourneyStore.setState({ current: detail });
server.use(
http.post('/api/journeys/entries/100/photos', () =>
HttpResponse.error()
)
);
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
expect(result.succeeded).toHaveLength(0);
expect(result.failed).toHaveLength(1);
expect(result.failed[0]).toBe(file);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
expect(storedEntry?.photos).toHaveLength(0);
});
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
const entry = buildEntry({ id: 100, photos: [] });
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
useJourneyStore.setState({ current: detail });
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
let callCount = 0;
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
callCount++;
if (callCount === 1) return { photos: [photo1] } as any;
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
});
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
expect(result.succeeded).toHaveLength(1);
expect(result.succeeded[0].id).toBe(photo1.id);
expect(result.failed).toHaveLength(1);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
expect(storedEntry?.photos).toHaveLength(1);
void photo2; // referenced to avoid lint warning
spy.mockRestore();
}); });
// ── deletePhoto ────────────────────────────────────────────────────────── // ── deletePhoto ──────────────────────────────────────────────────────────
+44 -26
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { journeyApi } from '../api/client' import { journeyApi } from '../api/client'
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
export interface Journey { export interface Journey {
id: number id: number
@@ -121,8 +122,8 @@ interface JourneyState {
deleteEntry: (entryId: number) => Promise<void> deleteEntry: (entryId: number) => Promise<void>
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void> reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]> uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void> unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void> deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
deletePhoto: (photoId: number) => Promise<void> deletePhoto: (photoId: number) => Promise<void>
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
} }
}, },
uploadPhotos: async (entryId, formData) => { uploadPhotos: async (entryId, files, cbs) => {
const data = await journeyApi.uploadPhotos(entryId, formData) return uploadFilesResilient<JourneyPhoto>(
const photos = data.photos || [] files,
set(s => { async (file, opts) => {
if (!s.current) return s const fd = new FormData()
return { fd.append('photos', file)
current: { const data = await journeyApi.uploadPhotos(entryId, fd, opts)
...s.current, const photos: JourneyPhoto[] = data.photos || []
entries: s.current.entries.map(e => const gallery: GalleryPhoto[] = data.gallery || []
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e set(s => {
), if (!s.current) return s
gallery: [...(s.current.gallery || []), ...(data.gallery || [])], return {
}, current: {
} ...s.current,
}) entries: s.current.entries.map(e =>
return photos e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
),
gallery: [...(s.current.gallery || []), ...gallery],
},
}
})
return photos
},
{ onProgress: cbs?.onProgress },
)
}, },
uploadGalleryPhotos: async (journeyId, formData) => { uploadGalleryPhotos: async (journeyId, files, cbs) => {
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData) return uploadFilesResilient<GalleryPhoto>(
const photos: GalleryPhoto[] = data.photos || [] files,
set(s => { async (file, opts) => {
if (!s.current || s.current.id !== journeyId) return s const fd = new FormData()
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } } fd.append('photos', file)
}) const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
return photos const photos: GalleryPhoto[] = data.photos || []
set(s => {
if (!s.current || s.current.id !== journeyId) return s
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
})
return photos
},
{ onProgress: cbs?.onProgress },
)
}, },
unlinkPhoto: async (entryId, journeyPhotoId) => { unlinkPhoto: async (entryId, journeyPhotoId) => {
+11 -1
View File
@@ -215,7 +215,6 @@ export interface Settings {
temperature_unit: string temperature_unit: string
time_format: string time_format: string
show_place_description: boolean show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_booking_labels?: boolean map_booking_labels?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' map_provider?: 'leaflet' | 'mapbox-gl'
@@ -237,8 +236,19 @@ export interface RouteSegment {
mid: [number, number] mid: [number, number]
from: [number, number] from: [number, number]
to: [number, number] to: [number, number]
distance: number
duration: number
walkingText: string walkingText: string
drivingText: string drivingText: string
distanceText: string
durationText?: string
}
export interface RouteWithLegs {
coordinates: [number, number][]
distance: number
duration: number
legs: RouteSegment[]
} }
export interface RouteResult { export interface RouteResult {
+17
View File
@@ -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))
}
+17 -1
View File
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 }, { id: 3, day_number: 3 },
] ]
it('excludes non-transport types', () => { it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }] const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0) 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', () => { it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }] 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: 1, dayAssignmentIds: [], days })).toHaveLength(1)
+1 -1
View File
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
const thisDayOrder = getDayOrder(dayId) const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => { return reservations.filter(r => {
if (!TRANSPORT_TYPES.has(r.type)) return false if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id const startDayId = r.day_id
+50
View File
@@ -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 })
})
})
+12
View File
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
} catch { return timeStr } } catch { return timeStr }
} }
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
if (!value) return { date: null, time: null }
const isoDate = /^\d{4}-\d{2}-\d{2}$/
if (value.includes('T')) {
const [d, t] = value.split('T')
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
}
if (isoDate.test(value)) return { date: value, time: null }
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
return { date: null, time: null }
}
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null { export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
const da = assignments[String(dayId)] || [] const da = assignments[String(dayId)] || []
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0) const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
+106
View File
@@ -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 }
}
-1
View File
@@ -258,7 +258,6 @@ export function buildSettings(overrides: Partial<Settings> = {}): Settings {
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
route_calculation: false,
blur_booking_codes: false, blur_booking_codes: false,
...overrides, ...overrides,
}; };
@@ -1,7 +1,6 @@
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation'; import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { useTripStore } from '../../../src/store/tripStore'; import { useTripStore } from '../../../src/store/tripStore';
import { buildAssignment, buildPlace } from '../../helpers/factories'; import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore'; import type { TripStoreState } from '../../../src/store/tripStore';
@@ -9,13 +8,13 @@ import type { RouteSegment } from '../../../src/types';
// Mock the RouteCalculator module to avoid real OSRM fetch calls // Mock the RouteCalculator module to avoid real OSRM fetch calls
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateSegments: vi.fn(), calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(), calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints), optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(), generateGoogleMapsUrl: vi.fn(),
})); }));
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator'); const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> { function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
// Also populate the real Zustand store so updateRouteForDay (which reads from // Also populate the real Zustand store so updateRouteForDay (which reads from
@@ -27,22 +26,29 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
const MOCK_SEGMENTS: RouteSegment[] = [ const MOCK_SEGMENTS: RouteSegment[] = [
{ {
from: [48.8566, 2.3522], distance: 343000,
to: [51.5074, -0.1278], duration: 12600,
mid: [50.182, 1.1122], distanceText: '343 km',
walkingText: '120 min', durationText: '3 h 30 min',
drivingText: '90 min',
}, },
]; ];
// Empty coordinates make the hook fall back to the straight-line geometry,
// so the `route` assertions keep checking the raw waypoints while the legs
// still flow through to `routeSegments`.
const MOCK_ROUTE_WITH_LEGS = {
coordinates: [] as [number, number][],
distance: 343000,
duration: 12600,
legs: MOCK_SEGMENTS,
};
describe('useRouteCalculation', () => { describe('useRouteCalculation', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: route_calculation disabled
useSettingsStore.setState({ settings: { route_calculation: false } as any });
// Reset trip store assignments so each test starts clean // Reset trip store assignments so each test starts clean
useTripStore.setState({ assignments: {} } as any); useTripStore.setState({ assignments: {} } as any);
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS); (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
}); });
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => { it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
@@ -84,9 +90,7 @@ describe('useRouteCalculation', () => {
]); ]);
}); });
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => { it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 }); const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 }); const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 }); const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
@@ -99,32 +103,11 @@ describe('useRouteCalculation', () => {
await act(async () => {}); await act(async () => {});
expect(calculateSegments).toHaveBeenCalled(); expect(calculateRouteWithLegs).toHaveBeenCalled();
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS); expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
}); });
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => {
useSettingsStore.setState({ settings: { route_calculation: false } as any });
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
const store = buildMockStore({ '5': [a1, a2] });
const { result } = renderHook(() =>
useRouteCalculation(store as TripStoreState, 5)
);
await act(async () => {});
expect(calculateSegments).not.toHaveBeenCalled();
expect(result.current.routeSegments).toEqual([]);
});
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => { it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 10, lng: 10 }); const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
// order_index 1 comes before 0 in the array, but should be sorted // order_index 1 comes before 0 in the array, but should be sorted
@@ -161,15 +144,14 @@ describe('useRouteCalculation', () => {
}); });
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => { it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
// Make calculateSegments resolve slowly // Make calculateRouteWithLegs resolve slowly
let resolveSegments!: (val: RouteSegment[]) => void; let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce( (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
(_waypoints: unknown[], options: { signal?: AbortSignal }) => { (_waypoints: unknown[], options: { signal?: AbortSignal }) => {
return new Promise<RouteSegment[]>((resolve) => { return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
resolveSegments = resolve; resolveSegments = resolve;
options?.signal?.addEventListener('abort', () => resolve([])); options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
}); });
} }
); );
@@ -191,20 +173,19 @@ describe('useRouteCalculation', () => {
rerender({ dayId: 6 }); rerender({ dayId: 6 });
}); });
// calculateSegments should have been called at least once for day 5 // calculateRouteWithLegs should have been called at least once for day 5
// and once more for day 6 // and once more for day 6
expect((calculateSegments as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1); expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
// Cleanup // Cleanup
resolveSegments?.([]); resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
}); });
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => { it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const abortError = new Error('Aborted'); const abortError = new Error('Aborted');
abortError.name = 'AbortError'; abortError.name = 'AbortError';
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError); (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
const p1 = buildPlace({ lat: 10, lng: 10 }); const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
@@ -222,9 +203,8 @@ describe('useRouteCalculation', () => {
}); });
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => { it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error')); (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const p1 = buildPlace({ lat: 10, lng: 10 }); const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
@@ -273,7 +253,6 @@ describe('useRouteCalculation', () => {
}); });
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => { it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 10, lng: 10 }); const p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
+5 -1
View File
@@ -91,8 +91,12 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => { describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(15) expect(SUPPORTED_LANGUAGES).toHaveLength(20)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
}) })
}) })
+10
View File
@@ -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 });
});
});
+5
View File
@@ -7,6 +7,11 @@
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"baseUrl": ".",
"paths": {
"@trek/shared": ["../shared/src/index.ts"],
"@trek/shared/*": ["../shared/src/*"]
},
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
+1 -1
View File
@@ -90,7 +90,7 @@ export default defineConfig({
], ],
build: { build: {
sourcemap: false, sourcemap: false,
modulePreload: { polyfill: false }, modulePreload: { polyfill: true },
}, },
server: { server: {
port: 5173, port: 5173,
+19799
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* Extracts client locale files into per-namespace files under shared/src/i18n/{locale}/.
* Run with: npx tsx scripts/migrate-i18n.mts
*
* Safe to re-run locale dirs are cleaned first. Hand-authored files
* (types.ts, languages.ts, index.ts) in shared/src/i18n/ are never touched.
*/
import { mkdir, rm, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const TRANSLATIONS_DIR = join(ROOT, 'client/src/i18n/translations')
const I18N_OUT = join(ROOT, 'shared/src/i18n')
// Maps locale code → source filename (without .ts) in client/src/i18n/translations/
const LOCALE_FILE_MAP: Record<string, string> = {
de: 'de', en: 'en', es: 'es', fr: 'fr', hu: 'hu',
it: 'it', tr: 'tr', ru: 'ru', zh: 'zh', 'zh-TW': 'zhTw',
nl: 'nl', id: 'id', ar: 'ar', br: 'br', cs: 'cs',
pl: 'pl', ja: 'ja', ko: 'ko', uk: 'uk',
}
type TranslationValue = string | { name: string; category: string }[]
type LocaleStrings = Record<string, TranslationValue>
async function loadLocale(code: string): Promise<LocaleStrings> {
const filename = LOCALE_FILE_MAP[code]
if (!filename) throw new Error(`Unknown locale code: ${code}`)
const file = join(TRANSLATIONS_DIR, `${filename}.ts`)
const mod = await import(pathToFileURL(file).href)
return mod.default as LocaleStrings
}
function serializeValue(value: TranslationValue, innerIndent: string): string {
if (Array.isArray(value)) {
// Pretty-print the array then re-indent each line after the first
const lines = JSON.stringify(value, null, 2).split('\n')
return lines.map((l, i) => (i === 0 ? l : innerIndent + l)).join('\n')
}
return JSON.stringify(value)
}
async function writeLocaleDir(code: string, strings: LocaleStrings): Promise<void> {
const outDir = join(I18N_OUT, code)
await mkdir(outDir, { recursive: true })
// Group keys by top-level namespace prefix (everything before the first dot)
const namespaces = new Map<string, Array<[string, TranslationValue]>>()
for (const [key, value] of Object.entries(strings)) {
const ns = key.split('.')[0] ?? key
if (!namespaces.has(ns)) namespaces.set(ns, [])
namespaces.get(ns)!.push([key, value])
}
// Write one file per namespace
for (const [ns, entries] of namespaces) {
const lines: string[] = [
`import type { TranslationStrings } from '../types'`,
``,
`const ${ns}: TranslationStrings = {`,
...entries.map(([k, v]) => ` ${JSON.stringify(k)}: ${serializeValue(v, ' ')},`),
`}`,
`export default ${ns}`,
]
await writeFile(join(outDir, `${ns}.ts`), lines.join('\n') + '\n')
}
// Write index.ts that merges all namespace files into a single locale object
const nsNames = [...namespaces.keys()]
const indexLines: string[] = [
...nsNames.map(ns => `import ${ns} from './${ns}'`),
``,
`const locale = {`,
...nsNames.map(ns => ` ...${ns},`),
`}`,
`export default locale`,
]
await writeFile(join(outDir, 'index.ts'), indexLines.join('\n') + '\n')
}
async function main(): Promise<void> {
console.log('Loading English base...')
const en = await loadLocale('en')
const codes = Object.keys(LOCALE_FILE_MAP)
// Clean existing locale dirs; leave hand-authored files (types.ts, languages.ts, index.ts) alone
await Promise.all(codes.map(code => rm(join(I18N_OUT, code), { recursive: true, force: true })))
for (const code of codes) {
process.stdout.write(`Processing ${code}...`)
let strings = await loadLocale(code)
if (code === 'ar') {
// ar.ts spreads en — keep only keys that ar actually translates (value differs from en)
const pruned: LocaleStrings = {}
for (const [key, val] of Object.entries(strings)) {
if (JSON.stringify(val) !== JSON.stringify(en[key])) {
pruned[key] = val
}
}
strings = pruned
console.log(` ${Object.keys(strings).length} own keys (pruned from ${Object.keys(en).length} en total)`)
} else {
const nsCount = new Set(Object.keys(strings).map(k => k.split('.')[0])).size
console.log(` ${Object.keys(strings).length} keys, ${nsCount} namespaces`)
}
await writeLocaleDir(code, strings)
}
console.log('\nDone! Run: cd shared && npm run build')
}
main().catch(err => { console.error(err); process.exit(1) })
+18
View File
@@ -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"
]
}
-6187
View File
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -1,19 +1,32 @@
{ {
"name": "trek-server", "name": "@trek/server",
"version": "3.0.18", "version": "3.0.22",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --require tsconfig-paths/register dist/index.js",
"dev": "tsx watch src/index.ts", "dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"start:prod": "node --require tsconfig-paths/register dist/index.js",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:unit": "vitest run tests/unit", "test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration", "test:integration": "vitest run tests/integration",
"test:ws": "vitest run tests/websocket", "test:ws": "vitest run tests/websocket",
"test:parity": "vitest run tests/parity",
"test:e2e": "vitest run tests/e2e",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@trek/shared": "*",
"tsconfig-paths": "^4.2.0",
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/platform-express": "^11.1.24",
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
@@ -30,22 +43,37 @@
"nodemailer": "^8.0.5", "nodemailer": "^8.0.5",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"semver": "^7.7.4", "semver": "^7.7.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"undici": "^7.0.0", "undici": "^7.0.0",
"unzipper": "^0.12.3", "unzipper": "^0.12.3",
"uuid": "^14.0.0", "uuid": "^14.0.0",
"ws": "^8.19.0", "ws": "^8.21.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"overrides": { "overrides": {
"hono": "^4.12.16", "hono": "^4.12.16",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"ip-address": "^10.1.1" "ip-address": "^10.1.1",
"multer": "^2.1.1",
"ws": "^8.21.0",
"qs": "^6.15.2",
"file-type": "^21.3.4"
}, },
"devDependencies": { "devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"prettier": "^3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"eslint": "^9.18.0",
"eslint-config-flat-gitignore": "^2.3.0",
"@nestjs/testing": "^11.1.24",
"@swc/core": "^1.15.40",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
@@ -67,6 +95,7 @@
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"tz-lookup": "^6.1.25", "tz-lookup": "^6.1.25",
"unplugin-swc": "^1.5.9",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }
+9
View File
@@ -0,0 +1,9 @@
import { execSync } from 'node:child_process';
try {
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
} catch {
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
}
console.log('[build] dist ready.');
+32
View File
@@ -0,0 +1,32 @@
import { execSync, spawn } from 'node:child_process';
console.log('[dev] initial build...');
execSync('node scripts/build.mjs', { stdio: 'inherit' });
const children = [];
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
// Start tsc -w and wait for its first "Watching for file changes." before launching
// node --watch, so the initial tsc compilation doesn't trigger a spurious restart.
const tsc = spawn('npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput'], {
stdio: ['ignore', 'pipe', 'inherit'],
shell: true,
});
children.push(tsc);
let nodeProc = null;
let ready = false;
tsc.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
if (!ready && chunk.toString().includes('Watching for file changes')) {
ready = true;
nodeProc = spawn('node', ['--require', 'tsconfig-paths/register', '--watch', 'dist/index.js'], {
stdio: 'inherit',
shell: true,
});
children.push(nodeProc);
}
});
+10 -5
View File
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog'; import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
@@ -25,7 +26,6 @@ import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files'; import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations'; import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes'; import dayNotesRoutes from './routes/dayNotes';
import weatherRoutes from './routes/weather';
import settingsRoutes from './routes/settings'; import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget'; import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab'; import collabRoutes from './routes/collab';
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"], imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [ connectSrc: [
@@ -134,7 +134,7 @@ export function createApp(): express.Application {
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson", "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1/", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
], ],
workerSrc: ["'self'", "blob:"], workerSrc: ["'self'", "blob:"],
@@ -360,7 +360,8 @@ export function createApp(): express.Application {
app.use('/api/photos', photoRoutes); app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes); app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes); app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes); // /api/weather is served by the NestJS weather module (see src/nest/weather);
// the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes); app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes); app.use('/api/backup', backupRoutes);
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
revocation_endpoint: `${base}/oauth/revoke`, revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`, registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'], response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'], grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'], code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES, scopes_supported: ALL_SCOPES,
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500; const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error'; const message = status < 500 ? err.message : 'Internal server error';
+1 -4
View File
@@ -1,6 +1,7 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { SUPPORTED_LANGUAGE_CODES as SUPPORTED_LANG_CODES } from '@trek/shared';
const dataDir = path.resolve(__dirname, '../data'); const dataDir = path.resolve(__dirname, '../data');
@@ -101,10 +102,6 @@ export const ENCRYPTION_KEY = _encryptionKey;
// DEFAULT_LANGUAGE sets the language shown on the login page before the user // DEFAULT_LANGUAGE sets the language shown on the login page before the user
// selects one. Only applies when the user has no saved language preference. // selects one. Only applies when the user has no saved language preference.
// Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
// Kept duplicated here because server and client are separate npm packages.
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en'; const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) { if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`); console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
+13 -5
View File
@@ -6,12 +6,20 @@ import { runMigrations } from './migrations';
import { runSeeds } from './seeds'; import { runSeeds } from './seeds';
import { Place, Tag } from '../types'; import { Place, Tag } from '../types';
const dataDir = path.join(__dirname, '../../data'); // In test mode each vitest worker gets an isolated in-memory DB so that
if (!fs.existsSync(dataDir)) { // parallel forks can't race on the same file or share migration state.
fs.mkdirSync(dataDir, { recursive: true }); const isTest = process.env.NODE_ENV === 'test';
}
const dbPath = path.join(dataDir, 'travel.db'); let dbPath: string;
if (isTest) {
dbPath = ':memory:';
} else {
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
dbPath = path.join(dataDir, 'travel.db');
}
let _db: Database.Database | null = null; let _db: Database.Database | null = null;
+36
View File
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`) db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`); db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
}, },
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
// clients to skip the browser consent flow entirely and obtain tokens directly
// via client_id + client_secret. Flag is immutable after creation so existing
// authorization-code clients are not silently upgraded.
() => {
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Drop stale atlas cache rows for territories that used to resolve to their
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
// etc.) before their own bounding boxes existed. The next atlas stats request
// re-resolves any place inside these boxes with the corrected country code.
() => {
const enclaveBoxes: [number, number, number, number][] = [
[113.83, 22.15, 114.43, 22.56], // HK
[113.53, 22.10, 113.60, 22.21], // MO
[12.40, 43.89, 12.52, 43.99], // SM
[12.44, 41.90, 12.46, 41.91], // VA
[7.40, 43.72, 7.44, 43.75], // MC
[9.47, 47.05, 9.64, 47.27], // LI
[-5.36, 36.11, -5.33, 36.16], // GI
[-67.30, 17.88, -65.22, 18.53], // PR
];
try {
const del = db.prepare(
`DELETE FROM place_regions WHERE place_id IN (
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
)`
);
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
del.run(minLat, maxLat, minLng, maxLng);
}
} catch (err: any) {
if (!err.message?.includes('no such table')) throw err;
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+56 -5
View File
@@ -1,7 +1,16 @@
import 'reflect-metadata';
import 'dotenv/config'; import 'dotenv/config';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app'; import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
// Create upload and data directories on startup // Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads'); const uploadsDir = path.join(__dirname, '../uploads');
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}); });
const app = createApp(); // Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler'; import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications'; import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -49,6 +61,11 @@ const onListen = () => {
'──────────────────────────────────────', '──────────────────────────────────────',
]; ];
banner.forEach(l => console.log(l)); banner.forEach(l => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
: 'NestJS prefixes: none — all routes served by the legacy Express app',
);
if (process.env.APP_URL) { if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null; let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ } try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -84,9 +101,42 @@ const onListen = () => {
}); });
}; };
const server = HOST let server: http.Server;
? app.listen(PORT, HOST, onListen) let nestApp: INestApplication;
: app.listen(PORT, onListen);
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
const NEST_PREFIXES = getNestPrefixes();
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
async function bootstrap(): Promise<void> {
// Nest runs on its own Express instance (bodyParser off so request bodies reach
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
// Nest body parsing is safe here: the dispatcher only forwards migrated
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
// is reached separately and never passes through Nest's parser.
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
nestApp.use(cookieParser());
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
await nestApp.init();
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
// only applies within migrated prefixes.
const top = express();
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
top.use(legacyApp);
server = http.createServer(top);
if (HOST) server.listen(PORT, HOST, onListen);
else server.listen(PORT, onListen);
}
bootstrap().catch((err) => {
console.error('Fatal: failed to bootstrap server', err);
process.exit(1);
});
// Graceful shutdown // Graceful shutdown
function shutdown(signal: string): void { function shutdown(signal: string): void {
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
sLogInfo(`${signal} received — shutting down gracefully...`); sLogInfo(`${signal} received — shutting down gracefully...`);
scheduler.stop(); scheduler.stop();
closeMcpSessions(); closeMcpSessions();
void nestApp?.close();
server.close(() => { server.close(() => {
sLogInfo('HTTP server closed'); sLogInfo('HTTP server closed');
const { closeDb } = require('./db/database'); const { closeDb } = require('./db/database');
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));
export default app; export default legacyApp;
+2 -1
View File
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
if (params.state) qs.set('state', params.state); if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href); if (params.resource) qs.set('resource', params.resource.href);
res.redirect(302, `/oauth/consent?${qs.toString()}`); const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
}, },
// Not called because skipLocalPkceValidation = true. // Not called because skipLocalPkceValidation = true.
+5 -3
View File
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
server.registerTool( server.registerTool(
'create_place_accommodation', 'create_place_accommodation',
{ {
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.', description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(), confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes }); const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation }; return { place, accommodation };
}); });
+10 -6
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_place', 'create_place',
{ {
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.', description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_and_assign_place', 'create_and_assign_place',
{ {
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.', description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'), dayId: z.number().int().positive().describe('Day to assign the place to'),
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'), assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => { async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment }; return { place, assignment };
}); });
+18 -2
View File
@@ -6,6 +6,7 @@ import {
createReservation, getReservation, updateReservation, deleteReservation, createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions, updatePositions as updateReservationPositions,
} from '../../services/reservationService'; } from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService'; import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService'; import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import { import {
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
server.registerTool( server.registerTool(
'create_reservation', 'create_reservation',
{ {
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.', description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => { async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined } ? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined; : undefined;
const metadata = price != null ? { price: String(price) } : undefined;
const { reservation, accommodationCreated } = createReservation(tripId, { const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number, title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id, notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation, create_accommodation: createAccommodation,
metadata,
}); });
if (accommodationCreated) { if (accommodationCreated) {
safeBroadcast(tripId, 'accommodation:created', {}); safeBroadcast(tripId, 'accommodation:created', {});
} }
if (price != null && price > 0) {
const item = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: budget_category || type,
total_price: price,
});
safeBroadcast(tripId, 'budget:created', { item });
}
safeBroadcast(tripId, 'reservation:created', { reservation }); safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
+19 -3
View File
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
import { import {
createReservation, deleteReservation, getReservation, updateReservation, createReservation, deleteReservation, getReservation, updateReservation,
} from '../../services/reservationService'; } from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService'; import { getDay } from '../../services/dayService';
import { import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
server.registerTool( server.registerTool(
'create_transport', 'create_transport',
{ {
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.', description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']), type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema, endpoints: endpointSchema,
needs_review: z.boolean().optional(), needs_review: z.boolean().optional(),
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId)) if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
const meta: Record<string, string> = { ...(metadata ?? {}) };
if (price != null) meta.price = String(price);
const { reservation } = createReservation(tripId, { const { reservation } = createReservation(tripId, {
title, title,
type, type,
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
day_id: start_day_id, day_id: start_day_id,
end_day_id: end_day_id ?? start_day_id, end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending', status: status ?? 'pending',
metadata, metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints, endpoints,
needs_review, needs_review,
}); });
if (price != null && price > 0) {
const item = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: budget_category || type,
total_price: price,
});
safeBroadcast(tripId, 'budget:created', { item });
}
safeBroadcast(tripId, 'reservation:created', { reservation }); safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
+58
View File
@@ -0,0 +1,58 @@
# NestJS migration layer — module & test guide
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
its shape when migrating a new domain.
## Module layout (per domain)
```
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
```
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
rollback, no redeploy).
## Parity is law
A migrated route must be **byte-identical** for the client: same URL, method,
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
strings. Where the legacy route returns a hand-written error (e.g. weather's
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
the controller rather than relying on the generic `ZodValidationPipe` envelope.
## How to write the tests
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
scoped to `src/nest/**`) requires ≥80%.
1. **Service / controller unit spec**`tests/unit/nest/<domain>.controller.test.ts`.
Instantiate the controller with a mocked service; assert status codes, the exact
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
See `weather.controller.test.ts`.
2. **Parity test**`tests/parity/<domain>.parity.test.ts`. Mock the shared service
identically for both apps, then fire the same request at the Express route and the
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
assert identical status + body. This is the gate before flipping the toggle.
See `weather.parity.test.ts`.
3. **e2e**`tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
(HTTP/etc.). See `weather.e2e.test.ts`.
## Definition of Done (per module)
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
Nest → parity verified on the demo DB → **then** decommission the old Express
route/service (separate step, after the toggle is confirmed in prod) → frontend points
at the typed contract (Frontend Track).
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule],
controllers: [HealthController],
providers: [
HealthService,
// Global error-envelope normaliser (DI-registered so it also catches
// framework-level exceptions like the not-found handler).
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
],
})
export class AppModule {}
+19
View File
@@ -0,0 +1,19 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
/**
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
* Use together with JwtAuthGuard (which populates req.user):
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
*/
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
if (!req.user || req.user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
return true;
}
}
@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { User } from '../../types';
/**
* Resolves the authenticated user attached by JwtAuthGuard.
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
},
);
+28
View File
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
/**
* Validates TREK's existing JWT session the same httpOnly `trek_session`
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
*
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(token);
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
(req as Request & { user?: unknown }).user = user;
return true;
}
}
@@ -0,0 +1,42 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
/**
* Normalises every Nest exception to TREK's legacy error envelope so migrated
* routes are byte-identical for the client:
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
* This replaces Nest's default { statusCode, message, error } body, which the
* TREK client does not expect.
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
res.status(status).json(body);
return;
}
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
res.status(status).json({ error: message });
return;
}
// Unknown/unhandled error — mirror the legacy 500 behaviour.
console.error('Unhandled error:', exception);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -0,0 +1,26 @@
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
import type { ZodType } from 'zod';
/**
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
* and returns the parsed, typed value. On failure it throws TREK's error envelope
* `{ error: string }` with status 400 the same shape the legacy routes produce,
* so the client's error handling is unaffected.
*
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
*/
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
const message = result.error.issues
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
.join('; ');
throw new HttpException({ error: message }, 400);
}
return result.data;
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
/**
* Global so every migrated module can inject DatabaseService without re-importing.
* Wraps the existing better-sqlite3 singleton (no new connection).
*/
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { db } from '../../db/database';
/**
* Injectable wrapper around TREK's existing better-sqlite3 connection.
*
* `db` is a Proxy onto the singleton connection the legacy app already uses
* (WAL enabled), so Nest modules share the exact same connection no second
* connection, no split state, single writer preserved.
*/
@Injectable()
export class DatabaseService {
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
get connection(): Database.Database {
return db;
}
prepare(sql: string): Database.Statement {
return db.prepare(sql);
}
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
return db.prepare(sql).get(...params) as T | undefined;
}
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
return db.prepare(sql).all(...params) as T[];
}
run(sql: string, ...params: unknown[]): Database.RunResult {
return db.prepare(sql).run(...params);
}
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
transaction<T>(fn: (conn: Database.Database) => T): T {
return db.transaction(() => fn(db))();
}
}

Some files were not shown because too many files have changed in this diff Show More