mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 23:01:48 +00:00
Compare commits
23 Commits
v3.0.18
...
1cecaa1d30
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cecaa1d30 | |||
| c375e0d6f7 | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/dist
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -102,16 +102,15 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update package.json files and Helm chart
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
|
||||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
|
|||||||
@@ -8,10 +8,33 @@ on:
|
|||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
name: Server Tests
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,12 +44,24 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd server && npm ci
|
run: npm ci --workspace shared && npm ci --workspace server
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck (informational)
|
||||||
|
# Pre-existing type errors in the NestJS rewrite; surfaces them without
|
||||||
|
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
|
||||||
|
continue-on-error: true
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd server && npm run test:coverage
|
run: cd server && npm run test:coverage
|
||||||
@@ -48,12 +83,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd client && npm ci
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
run: cd client && npm run test:coverage
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
server/public/*
|
server/public/*
|
||||||
!server/public/.gitkeep
|
!server/public/.gitkeep
|
||||||
|
|
||||||
|
|||||||
+49
-19
@@ -1,31 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS shared-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
RUN npm ci --workspace=shared
|
||||||
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:24-alpine AS client-builder
|
FROM node:24-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
COPY client/package.json ./client/
|
||||||
RUN npm run build
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
|
||||||
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
rm package-lock.json && \
|
|
||||||
apk del python3 make g++ && \
|
apk del python3 make g++ && \
|
||||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
COPY server/ ./
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
RUN rm -f package-lock.json && \
|
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -39,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
-11079
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -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
@@ -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', () => {
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ describe('MapView', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
// Apple-Maps style draws a casing + a core line per segment.
|
||||||
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||||
@@ -155,16 +156,11 @@ describe('MapView', () => {
|
|||||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||||
const routeSegments = [
|
render(<MapView route={route} />)
|
||||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||||
]
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
|
||||||
// Route polyline is rendered
|
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
|
||||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
|
||||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||||
|
|||||||
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route travel time label ──
|
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||||
interface RouteLabelProps {
|
|
||||||
midpoint: [number, number]
|
|
||||||
walkingText: string
|
|
||||||
drivingText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|
||||||
if (!midpoint) return null
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'route-info-pill',
|
|
||||||
html: `<div style="
|
|
||||||
display:flex;align-items:center;gap:5px;
|
|
||||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
|
||||||
color:#fff;border-radius:99px;padding:3px 9px;
|
|
||||||
font-size:9px;font-weight:600;white-space:nowrap;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
||||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
|
||||||
pointer-events:none;
|
|
||||||
position:relative;left:-50%;top:-50%;
|
|
||||||
">
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
|
||||||
${walkingText}
|
|
||||||
</span>
|
|
||||||
<span style="opacity:0.3">|</span>
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
|
||||||
${drivingText}
|
|
||||||
</span>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [0, 0],
|
|
||||||
iconAnchor: [0, 0],
|
|
||||||
})
|
|
||||||
|
|
||||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
|
|||||||
{markers}
|
{markers}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 0 && (
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||||
<>
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||||
{route.map((seg, i) => seg.length > 1 && (
|
<Polyline
|
||||||
<Polyline
|
key={`${i}-casing`}
|
||||||
key={i}
|
positions={seg}
|
||||||
positions={seg}
|
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
color="#111827"
|
/>,
|
||||||
weight={3}
|
<Polyline
|
||||||
opacity={0.9}
|
key={`${i}-core`}
|
||||||
dashArray="6, 5"
|
positions={seg}
|
||||||
/>
|
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
))}
|
/>,
|
||||||
{routeSegments.map((seg, i) => (
|
] : [])}
|
||||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GPX imported route geometries */}
|
{/* GPX imported route geometries */}
|
||||||
{gpxPolylines}
|
{gpxPolylines}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
|||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
|
routeSegments = [],
|
||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
@@ -216,16 +217,20 @@ export function MapViewGL({
|
|||||||
// initial route source — kept around so updates can setData() cheaply
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
if (!map.getSource('trip-route')) {
|
if (!map.getSource('trip-route')) {
|
||||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
|
||||||
|
// rounded. Casing is added first so it sits beneath the core line.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-route-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-route',
|
||||||
|
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'trip-route-line',
|
id: 'trip-route-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trip-route',
|
source: 'trip-route',
|
||||||
paint: {
|
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||||
'line-color': '#111827',
|
|
||||||
'line-width': 3,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
'line-dasharray': [2, 1.5],
|
|
||||||
},
|
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -442,6 +447,8 @@ export function MapViewGL({
|
|||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
|
|
||||||
// Update GPX geometries
|
// Update GPX geometries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
|
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||||
|
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||||
|
// the matching profile so walking routes follow footpaths, not the road network.
|
||||||
|
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||||
|
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||||
|
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||||
|
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||||
|
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||||
|
const routeCache = new Map<string, RouteWithLegs>()
|
||||||
|
const ROUTE_CACHE_MAX = 200
|
||||||
|
|
||||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
|||||||
const walkingDuration = leg.distance / (5000 / 3600)
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
return {
|
return {
|
||||||
mid, from, to,
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||||
|
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||||
|
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
|
||||||
|
* straight line.
|
||||||
|
*/
|
||||||
|
export async function calculateRouteWithLegs(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
|
||||||
|
): Promise<RouteWithLegs> {
|
||||||
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
|
const cacheKey = `${profile}:${coords}`
|
||||||
|
const cached = routeCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error('Route could not be calculated')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||||
|
|
||||||
|
const route = data.routes[0]
|
||||||
|
const coordinates: [number, number][] = route.geometry.coordinates.map(
|
||||||
|
([lng, lat]: [number, number]) => [lat, lng]
|
||||||
|
)
|
||||||
|
const legs: RouteSegment[] = (route.legs || []).map(
|
||||||
|
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||||
|
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||||
|
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||||
|
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||||
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
|
return {
|
||||||
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
|
walkingText: formatDuration(walkingDuration),
|
||||||
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
|
durationText: formatDuration(leg.duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||||
|
routeCache.set(cacheKey, result)
|
||||||
|
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||||
|
const oldest = routeCache.keys().next().value
|
||||||
|
if (oldest !== undefined) routeCache.delete(oldest)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function formatDistance(meters: number): string {
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
|||||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||||
// offset when the map is pitched. Restrict terrain to satellite.
|
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||||
|
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||||
export function wantsTerrain(style: string): boolean {
|
export function wantsTerrain(style: string): boolean {
|
||||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||||
|
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D can be added to every style now — the standard family has it built-in
|
// 3D can be added to every style now — the standard family has it built-in
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
|||||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||||
return `
|
return `
|
||||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||||
|
|||||||
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
|
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 250 })).toBe(250);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
|
||||||
|
expect(itemWeight({ quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({})).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
|
|||||||
|
|
||||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||||
|
|
||||||
|
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||||
|
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||||
|
(i.weight_grams || 0) * (i.quantity || 1)
|
||||||
|
|
||||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BagCardProps {
|
interface BagCardProps {
|
||||||
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||||
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||||
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||||
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||||
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
|
|||||||
rightWidth?: number
|
rightWidth?: number
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
|
mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const tripObj = useTripStore((s) => s.trip)
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
{/* ── Reservations for this day's assignments ── */}
|
{/* ── Reservations for this day's assignments ── */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAssignments = assignments[String(day.id)] || []
|
const dayAssignments = assignments[String(day.id)] || []
|
||||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
const dayReservations = reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||||
|
return r.day_id === day.id
|
||||||
|
})
|
||||||
if (dayReservations.length === 0) return null
|
if (dayReservations.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 0 }}>
|
<div style={{ marginBottom: 0 }}>
|
||||||
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
if (!startTime && !endTime) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||||
|
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
// Find the pencil/edit button next to the title
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const editButtons = screen.getAllByRole('button')
|
|
||||||
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
|
|
||||||
// Click the edit (pencil) button — it's the small one near the title
|
|
||||||
// The pencil button is inside the title area with opacity 0.35
|
|
||||||
const titleEl = screen.getByText('Original Title')
|
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
// Enter edit mode
|
// Enter edit mode
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.keyboard('{Escape}')
|
await user.keyboard('{Escape}')
|
||||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||||
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
const titleEl = screen.getByText('Old Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Old Title')
|
const input = await screen.findByDisplayValue('Old Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
|
|
||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
|||||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
|
const imageUrlFailed = useRef(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
alt={place.name}
|
alt={place.name}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => {
|
||||||
|
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||||
|
imageUrlFailed.current = true
|
||||||
|
const photoId = place.google_place_id || place.osm_id!
|
||||||
|
const cacheKey = `refetch:${photoId}`
|
||||||
|
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||||
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setPhotoSrc(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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')}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { journeyApi } from '../api/client';
|
||||||
import { useJourneyStore } from './journeyStore';
|
import { useJourneyStore } from './journeyStore';
|
||||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||||
|
|
||||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
|||||||
useJourneyStore.setState({ current: detail });
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
server.use(
|
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||||
http.post('/api/journeys/entries/100/photos', () =>
|
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||||
HttpResponse.json({ photos: [newPhoto] })
|
// layer directly so this test exercises store state management only.
|
||||||
)
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||||
);
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result.succeeded).toHaveLength(1);
|
||||||
expect(result[0].id).toBe(91);
|
expect(result.succeeded[0].id).toBe(91);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
expect(storedEntry?.photos).toHaveLength(2);
|
expect(storedEntry?.photos).toHaveLength(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/journeys/entries/100/photos', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
|
expect(result.succeeded).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0]).toBe(file);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
|
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||||
|
let callCount = 0;
|
||||||
|
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||||
|
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||||
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return { photos: [photo1] } as any;
|
||||||
|
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||||
|
});
|
||||||
|
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||||
|
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||||
|
expect(result.succeeded).toHaveLength(1);
|
||||||
|
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(1);
|
||||||
|
void photo2; // referenced to avoid lint warning
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
|
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
|
|
||||||
export interface Journey {
|
export interface Journey {
|
||||||
id: number
|
id: number
|
||||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
|||||||
deleteEntry: (entryId: number) => Promise<void>
|
deleteEntry: (entryId: number) => Promise<void>
|
||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadPhotos: async (entryId, formData) => {
|
uploadPhotos: async (entryId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
return uploadFilesResilient<JourneyPhoto>(
|
||||||
const photos = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current) return s
|
const fd = new FormData()
|
||||||
return {
|
fd.append('photos', file)
|
||||||
current: {
|
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||||
...s.current,
|
const photos: JourneyPhoto[] = data.photos || []
|
||||||
entries: s.current.entries.map(e =>
|
const gallery: GalleryPhoto[] = data.gallery || []
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
set(s => {
|
||||||
),
|
if (!s.current) return s
|
||||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
return {
|
||||||
},
|
current: {
|
||||||
}
|
...s.current,
|
||||||
})
|
entries: s.current.entries.map(e =>
|
||||||
return photos
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...gallery],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
return uploadFilesResilient<GalleryPhoto>(
|
||||||
const photos: GalleryPhoto[] = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current || s.current.id !== journeyId) return s
|
const fd = new FormData()
|
||||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
fd.append('photos', file)
|
||||||
})
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||||
return photos
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
|||||||
+11
-1
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { splitReservationDateTime } from './formatters'
|
||||||
|
|
||||||
|
describe('splitReservationDateTime', () => {
|
||||||
|
it('parses full ISO datetime', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses full datetime with seconds', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses date-only string', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare HH:MM (new dateless format)', () => {
|
||||||
|
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare single-digit hour time', () => {
|
||||||
|
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||||
|
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null date for T-prefixed without valid date', () => {
|
||||||
|
const result = splitReservationDateTime('T23:59')
|
||||||
|
expect(result.date).toBeNull()
|
||||||
|
expect(result.time).toBe('23:59')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for null input', () => {
|
||||||
|
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for undefined input', () => {
|
||||||
|
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for empty string', () => {
|
||||||
|
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for unrecognized string', () => {
|
||||||
|
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
|||||||
} catch { return timeStr }
|
} catch { return timeStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||||
|
if (!value) return { date: null, time: null }
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
if (value.includes('T')) {
|
||||||
|
const [d, t] = value.split('T')
|
||||||
|
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||||
|
}
|
||||||
|
if (isoDate.test(value)) return { date: value, time: null }
|
||||||
|
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||||
|
return { date: null, time: null }
|
||||||
|
}
|
||||||
|
|
||||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||||
const da = assignments[String(dayId)] || []
|
const da = assignments[String(dayId)] || []
|
||||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AxiosProgressEvent } from 'axios'
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
failed: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResilientResult<T> {
|
||||||
|
succeeded: T[]
|
||||||
|
failed: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOpts {
|
||||||
|
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function isRetryable(err: unknown): boolean {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const status = (err as { response?: { status?: number } }).response?.status
|
||||||
|
if (status !== undefined && status >= 400 && status < 500) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFilesResilient<T>(
|
||||||
|
files: File[],
|
||||||
|
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||||
|
cbs?: {
|
||||||
|
concurrency?: number
|
||||||
|
retries?: number
|
||||||
|
onProgress?: (p: UploadProgress) => void
|
||||||
|
onUploaded?: (items: T[]) => void
|
||||||
|
},
|
||||||
|
): Promise<ResilientResult<T>> {
|
||||||
|
const concurrency = cbs?.concurrency ?? 3
|
||||||
|
const maxRetries = cbs?.retries ?? 2
|
||||||
|
|
||||||
|
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||||
|
const loadedMap = new Map<number, number>()
|
||||||
|
let doneCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
const emitProgress = () => {
|
||||||
|
if (!cbs?.onProgress) return
|
||||||
|
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||||
|
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||||
|
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded: T[] = []
|
||||||
|
const failedFiles: File[] = []
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const i = idx++
|
||||||
|
if (i >= files.length) break
|
||||||
|
const file = files[i]
|
||||||
|
const idempotencyKey = crypto.randomUUID()
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
|
||||||
|
let items: T[] | null = null
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) await sleep(400 * attempt)
|
||||||
|
try {
|
||||||
|
items = await uploadOne(file, {
|
||||||
|
idempotencyKey,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
loadedMap.set(i, e.loaded)
|
||||||
|
emitProgress()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (!isRetryable(err) || attempt === maxRetries) {
|
||||||
|
items = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items !== null) {
|
||||||
|
succeeded.push(...items)
|
||||||
|
cbs?.onUploaded?.(items)
|
||||||
|
loadedMap.set(i, file.size)
|
||||||
|
doneCount++
|
||||||
|
} else {
|
||||||
|
failedFiles.push(file)
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
|
||||||
|
return { succeeded, failed: failedFiles }
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ describe('isRtlLanguage', () => {
|
|||||||
describe('SUPPORTED_LANGUAGES', () => {
|
describe('SUPPORTED_LANGUAGES', () => {
|
||||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||||
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
|
expect(SUPPORTED_LANGUAGES).toHaveLength(20)
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
|
||||||
|
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||||
|
|
||||||
|
describe('@trek/shared resolves in the client toolchain', () => {
|
||||||
|
it('imports and uses a shared schema', () => {
|
||||||
|
expect(idParamSchema.parse('7')).toBe(7);
|
||||||
|
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@trek/shared": ["../shared/src/index.ts"],
|
||||||
|
"@trek/shared/*": ["../shared/src/*"]
|
||||||
|
},
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+19799
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@trek/root",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.22",
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"version:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:premajor": "npm version premajor --preid=rc --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:preminor": "npm version preminor --preid=beta --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prepatch": "npm version prepatch --preid=alpha --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prerelease": "npm version prerelease --preid=pre --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"dev": "npm run build --workspace=shared && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
|
||||||
|
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
|
||||||
|
"test": "npm run test --workspace=shared && npm run test --workspace=server && npm run test --workspace=client",
|
||||||
|
"test:cov": "npm run test:coverage --workspace=server && npm run test:coverage --workspace=client",
|
||||||
|
"test:e2e": "npm run test:e2e --workspace=server",
|
||||||
|
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||||
|
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
|
||||||
|
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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) })
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
-6187
File diff suppressed because it is too large
Load Diff
+35
-6
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[build] dist ready.');
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { execSync, spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
console.log('[dev] initial build...');
|
||||||
|
execSync('node scripts/build.mjs', { stdio: 'inherit' });
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
|
||||||
|
process.on('SIGINT', stop);
|
||||||
|
process.on('SIGTERM', stop);
|
||||||
|
|
||||||
|
// Start tsc -w and wait for its first "Watching for file changes." before launching
|
||||||
|
// node --watch, so the initial tsc compilation doesn't trigger a spurious restart.
|
||||||
|
const tsc = spawn('npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput'], {
|
||||||
|
stdio: ['ignore', 'pipe', 'inherit'],
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
children.push(tsc);
|
||||||
|
|
||||||
|
let nodeProc = null;
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
tsc.stdout.on('data', (chunk) => {
|
||||||
|
process.stdout.write(chunk);
|
||||||
|
if (!ready && chunk.toString().includes('Watching for file changes')) {
|
||||||
|
ready = true;
|
||||||
|
nodeProc = spawn('node', ['--require', 'tsconfig-paths/register', '--watch', 'dist/index.js'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
children.push(nodeProc);
|
||||||
|
}
|
||||||
|
});
|
||||||
+10
-5
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import multer from 'multer';
|
||||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||||
@@ -25,7 +26,6 @@ import airportsRoutes from './routes/airports';
|
|||||||
import filesRoutes from './routes/files';
|
import filesRoutes from './routes/files';
|
||||||
import reservationsRoutes from './routes/reservations';
|
import reservationsRoutes from './routes/reservations';
|
||||||
import dayNotesRoutes from './routes/dayNotes';
|
import dayNotesRoutes from './routes/dayNotes';
|
||||||
import weatherRoutes from './routes/weather';
|
|
||||||
import settingsRoutes from './routes/settings';
|
import settingsRoutes from './routes/settings';
|
||||||
import budgetRoutes from './routes/budget';
|
import budgetRoutes from './routes/budget';
|
||||||
import collabRoutes from './routes/collab';
|
import collabRoutes from './routes/collab';
|
||||||
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
|
|||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||||
connectSrc: [
|
connectSrc: [
|
||||||
@@ -134,7 +134,7 @@ export function createApp(): express.Application {
|
|||||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||||
"https://router.project-osrm.org/route/v1/",
|
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||||
],
|
],
|
||||||
workerSrc: ["'self'", "blob:"],
|
workerSrc: ["'self'", "blob:"],
|
||||||
@@ -360,7 +360,8 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/photos', photoRoutes);
|
app.use('/api/photos', photoRoutes);
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/airports', airportsRoutes);
|
app.use('/api/airports', airportsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
// /api/weather is served by the NestJS weather module (see src/nest/weather);
|
||||||
|
// the legacy Express route was decommissioned after the migration (L1).
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/system-notices', systemNoticesRoutes);
|
app.use('/api/system-notices', systemNoticesRoutes);
|
||||||
app.use('/api/backup', backupRoutes);
|
app.use('/api/backup', backupRoutes);
|
||||||
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
|
|||||||
revocation_endpoint: `${base}/oauth/revoke`,
|
revocation_endpoint: `${base}/oauth/revoke`,
|
||||||
registration_endpoint: `${base}/oauth/register`,
|
registration_endpoint: `${base}/oauth/register`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
code_challenge_methods_supported: ['S256'],
|
||||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||||
scopes_supported: ALL_SCOPES,
|
scopes_supported: ALL_SCOPES,
|
||||||
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
|
|||||||
} else {
|
} else {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
}
|
}
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||||
|
return res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
const status = err.statusCode || err.status || 500;
|
const status = err.statusCode || err.status || 500;
|
||||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||||
const message = status < 500 ? err.message : 'Internal server error';
|
const message = status < 500 ? err.message : 'Internal server error';
|
||||||
|
|||||||
@@ -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(', ')}`);
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { runMigrations } from './migrations';
|
|||||||
import { runSeeds } from './seeds';
|
import { runSeeds } from './seeds';
|
||||||
import { Place, Tag } from '../types';
|
import { Place, Tag } from '../types';
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '../../data');
|
// In test mode each vitest worker gets an isolated in-memory DB so that
|
||||||
if (!fs.existsSync(dataDir)) {
|
// parallel forks can't race on the same file or share migration state.
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
const isTest = process.env.NODE_ENV === 'test';
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = path.join(dataDir, 'travel.db');
|
let dbPath: string;
|
||||||
|
if (isTest) {
|
||||||
|
dbPath = ':memory:';
|
||||||
|
} else {
|
||||||
|
const dataDir = path.join(__dirname, '../../data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
dbPath = path.join(dataDir, 'travel.db');
|
||||||
|
}
|
||||||
|
|
||||||
let _db: Database.Database | null = null;
|
let _db: Database.Database | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
|
|||||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||||
},
|
},
|
||||||
|
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
|
||||||
|
// clients to skip the browser consent flow entirely and obtain tokens directly
|
||||||
|
// via client_id + client_secret. Flag is immutable after creation so existing
|
||||||
|
// authorization-code clients are not silently upgraded.
|
||||||
|
() => {
|
||||||
|
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
|
||||||
|
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||||
|
},
|
||||||
|
// Drop stale atlas cache rows for territories that used to resolve to their
|
||||||
|
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
|
||||||
|
// etc.) before their own bounding boxes existed. The next atlas stats request
|
||||||
|
// re-resolves any place inside these boxes with the corrected country code.
|
||||||
|
() => {
|
||||||
|
const enclaveBoxes: [number, number, number, number][] = [
|
||||||
|
[113.83, 22.15, 114.43, 22.56], // HK
|
||||||
|
[113.53, 22.10, 113.60, 22.21], // MO
|
||||||
|
[12.40, 43.89, 12.52, 43.99], // SM
|
||||||
|
[12.44, 41.90, 12.46, 41.91], // VA
|
||||||
|
[7.40, 43.72, 7.44, 43.75], // MC
|
||||||
|
[9.47, 47.05, 9.64, 47.27], // LI
|
||||||
|
[-5.36, 36.11, -5.33, 36.16], // GI
|
||||||
|
[-67.30, 17.88, -65.22, 18.53], // PR
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
const del = db.prepare(
|
||||||
|
`DELETE FROM place_regions WHERE place_id IN (
|
||||||
|
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
|
||||||
|
del.run(minLat, maxLat, minLng, maxLng);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('no such table')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
+56
-5
@@ -1,7 +1,16 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ExpressAdapter } from '@nestjs/platform-express';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
import { AppModule } from './nest/app.module';
|
||||||
|
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
|
||||||
|
|
||||||
// Create upload and data directories on startup
|
// Create upload and data directories on startup
|
||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
|||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = createApp();
|
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
|
||||||
|
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
|
||||||
|
// everything else falls through to this app via a fallback middleware.
|
||||||
|
const legacyApp = createApp();
|
||||||
|
|
||||||
import * as scheduler from './scheduler';
|
import * as scheduler from './scheduler';
|
||||||
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||||
@@ -49,6 +61,11 @@ const onListen = () => {
|
|||||||
'──────────────────────────────────────',
|
'──────────────────────────────────────',
|
||||||
];
|
];
|
||||||
banner.forEach(l => console.log(l));
|
banner.forEach(l => console.log(l));
|
||||||
|
sLogInfo(
|
||||||
|
NEST_PREFIXES.length
|
||||||
|
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
|
||||||
|
: 'NestJS prefixes: none — all routes served by the legacy Express app',
|
||||||
|
);
|
||||||
if (process.env.APP_URL) {
|
if (process.env.APP_URL) {
|
||||||
let parsedAppUrl: URL | null = null;
|
let parsedAppUrl: URL | null = null;
|
||||||
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||||
@@ -84,9 +101,42 @@ const onListen = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = HOST
|
let server: http.Server;
|
||||||
? app.listen(PORT, HOST, onListen)
|
let nestApp: INestApplication;
|
||||||
: app.listen(PORT, onListen);
|
|
||||||
|
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
||||||
|
const NEST_PREFIXES = getNestPrefixes();
|
||||||
|
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
// Nest runs on its own Express instance (bodyParser off so request bodies reach
|
||||||
|
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
|
||||||
|
// Nest body parsing is safe here: the dispatcher only forwards migrated
|
||||||
|
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
|
||||||
|
// is reached separately and never passes through Nest's parser.
|
||||||
|
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||||
|
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
|
||||||
|
nestApp.use(cookieParser());
|
||||||
|
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
|
||||||
|
await nestApp.init();
|
||||||
|
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||||
|
|
||||||
|
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
|
||||||
|
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
|
||||||
|
// only applies within migrated prefixes.
|
||||||
|
const top = express();
|
||||||
|
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
|
||||||
|
top.use(legacyApp);
|
||||||
|
|
||||||
|
server = http.createServer(top);
|
||||||
|
if (HOST) server.listen(PORT, HOST, onListen);
|
||||||
|
else server.listen(PORT, onListen);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('Fatal: failed to bootstrap server', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
function shutdown(signal: string): void {
|
function shutdown(signal: string): void {
|
||||||
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
|
|||||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
closeMcpSessions();
|
closeMcpSessions();
|
||||||
|
void nestApp?.close();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
sLogInfo('HTTP server closed');
|
sLogInfo('HTTP server closed');
|
||||||
const { closeDb } = require('./db/database');
|
const { closeDb } = require('./db/database');
|
||||||
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
|
|||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
export default app;
|
export default legacyApp;
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
|||||||
if (params.state) qs.set('state', params.state);
|
if (params.state) qs.set('state', params.state);
|
||||||
if (params.resource) qs.set('resource', params.resource.href);
|
if (params.resource) qs.set('resource', params.resource.href);
|
||||||
|
|
||||||
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||||
|
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Not called because skipLocalPkceValidation = true.
|
// Not called because skipLocalPkceValidation = true.
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_place_accommodation',
|
'create_place_accommodation',
|
||||||
{
|
{
|
||||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
|
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
|||||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||||
confirmation: z.string().max(100).optional(),
|
confirmation: z.string().max(100).optional(),
|
||||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||||
return { place, accommodation };
|
return { place, accommodation };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_place',
|
'create_place',
|
||||||
{
|
{
|
||||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
|
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||||
safeBroadcast(tripId, 'place:created', { place });
|
safeBroadcast(tripId, 'place:created', { place });
|
||||||
return ok({ place });
|
return ok({ place });
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
if (W) server.registerTool(
|
if (W) server.registerTool(
|
||||||
'create_and_assign_place',
|
'create_and_assign_place',
|
||||||
{
|
{
|
||||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
|
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||||
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
|||||||
website: z.string().max(500).optional(),
|
website: z.string().max(500).optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||||
|
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
|
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||||
try {
|
try {
|
||||||
const run = db.transaction(() => {
|
const run = db.transaction(() => {
|
||||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||||
return { place, assignment };
|
return { place, assignment };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
createReservation, getReservation, updateReservation, deleteReservation,
|
createReservation, getReservation, updateReservation, deleteReservation,
|
||||||
updatePositions as updateReservationPositions,
|
updatePositions as updateReservationPositions,
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_reservation',
|
'create_reservation',
|
||||||
{
|
{
|
||||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||||
|
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||||
|
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
|
|
||||||
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
|||||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const metadata = price != null ? { price: String(price) } : undefined;
|
||||||
|
|
||||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||||
title, type, reservation_time, location, confirmation_number,
|
title, type, reservation_time, location, confirmation_number,
|
||||||
notes, day_id, place_id, assignment_id,
|
notes, day_id, place_id, assignment_id,
|
||||||
create_accommodation: createAccommodation,
|
create_accommodation: createAccommodation,
|
||||||
|
metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (accommodationCreated) {
|
if (accommodationCreated) {
|
||||||
safeBroadcast(tripId, 'accommodation:created', {});
|
safeBroadcast(tripId, 'accommodation:created', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||||
|
name: title,
|
||||||
|
category: budget_category || type,
|
||||||
|
total_price: price,
|
||||||
|
});
|
||||||
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
|
}
|
||||||
|
|
||||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||||
return ok({ reservation });
|
return ok({ reservation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
|||||||
import {
|
import {
|
||||||
createReservation, deleteReservation, getReservation, updateReservation,
|
createReservation, deleteReservation, getReservation, updateReservation,
|
||||||
} from '../../services/reservationService';
|
} from '../../services/reservationService';
|
||||||
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||||
import { getDay } from '../../services/dayService';
|
import { getDay } from '../../services/dayService';
|
||||||
import {
|
import {
|
||||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'create_transport',
|
'create_transport',
|
||||||
{
|
{
|
||||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: z.number().int().positive(),
|
tripId: z.number().int().positive(),
|
||||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||||
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||||
endpoints: endpointSchema,
|
endpoints: endpointSchema,
|
||||||
needs_review: z.boolean().optional(),
|
needs_review: z.boolean().optional(),
|
||||||
|
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
|
||||||
|
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
|
||||||
},
|
},
|
||||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
},
|
},
|
||||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
|
||||||
if (isDemoUser(userId)) return demoDenied();
|
if (isDemoUser(userId)) return demoDenied();
|
||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
if (end_day_id && !getDay(end_day_id, tripId))
|
if (end_day_id && !getDay(end_day_id, tripId))
|
||||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||||
|
|
||||||
|
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||||
|
if (price != null) meta.price = String(price);
|
||||||
|
|
||||||
const { reservation } = createReservation(tripId, {
|
const { reservation } = createReservation(tripId, {
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
|||||||
day_id: start_day_id,
|
day_id: start_day_id,
|
||||||
end_day_id: end_day_id ?? start_day_id,
|
end_day_id: end_day_id ?? start_day_id,
|
||||||
status: status ?? 'pending',
|
status: status ?? 'pending',
|
||||||
metadata,
|
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||||
endpoints,
|
endpoints,
|
||||||
needs_review,
|
needs_review,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||||
|
name: title,
|
||||||
|
category: budget_category || type,
|
||||||
|
total_price: price,
|
||||||
|
});
|
||||||
|
safeBroadcast(tripId, 'budget:created', { item });
|
||||||
|
}
|
||||||
|
|
||||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||||
return ok({ reservation });
|
return ok({ reservation });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# NestJS migration layer — module & test guide
|
||||||
|
|
||||||
|
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
|
||||||
|
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
|
||||||
|
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
|
||||||
|
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
|
||||||
|
its shape when migrating a new domain.
|
||||||
|
|
||||||
|
## Module layout (per domain)
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
|
||||||
|
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
|
||||||
|
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
|
||||||
|
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
|
||||||
|
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
|
||||||
|
rollback, no redeploy).
|
||||||
|
|
||||||
|
## Parity is law
|
||||||
|
|
||||||
|
A migrated route must be **byte-identical** for the client: same URL, method,
|
||||||
|
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
|
||||||
|
strings. Where the legacy route returns a hand-written error (e.g. weather's
|
||||||
|
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
|
||||||
|
the controller rather than relying on the generic `ZodValidationPipe` envelope.
|
||||||
|
|
||||||
|
## How to write the tests
|
||||||
|
|
||||||
|
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
|
||||||
|
scoped to `src/nest/**`) requires ≥80%.
|
||||||
|
|
||||||
|
1. **Service / controller unit spec** — `tests/unit/nest/<domain>.controller.test.ts`.
|
||||||
|
Instantiate the controller with a mocked service; assert status codes, the exact
|
||||||
|
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
|
||||||
|
See `weather.controller.test.ts`.
|
||||||
|
|
||||||
|
2. **Parity test** — `tests/parity/<domain>.parity.test.ts`. Mock the shared service
|
||||||
|
identically for both apps, then fire the same request at the Express route and the
|
||||||
|
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
|
||||||
|
assert identical status + body. This is the gate before flipping the toggle.
|
||||||
|
See `weather.parity.test.ts`.
|
||||||
|
|
||||||
|
3. **e2e** — `tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
|
||||||
|
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
|
||||||
|
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
|
||||||
|
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
|
||||||
|
(HTTP/etc.). See `weather.e2e.test.ts`.
|
||||||
|
|
||||||
|
## Definition of Done (per module)
|
||||||
|
|
||||||
|
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
|
||||||
|
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
|
||||||
|
Nest → parity verified on the demo DB → **then** decommission the old Express
|
||||||
|
route/service (separate step, after the toggle is confirmed in prod) → frontend points
|
||||||
|
at the typed contract (Frontend Track).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { HealthController } from './health/health.controller';
|
||||||
|
import { HealthService } from './health/health.service';
|
||||||
|
import { WeatherModule } from './weather/weather.module';
|
||||||
|
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root NestJS module for the incremental migration. Domain modules
|
||||||
|
* (weather, notifications, ...) get registered here as they are migrated.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, WeatherModule],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
// Global error-envelope normaliser (DI-registered so it also catches
|
||||||
|
// framework-level exceptions like the not-found handler).
|
||||||
|
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import type { User } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
|
||||||
|
* Use together with JwtAuthGuard (which populates req.user):
|
||||||
|
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { User } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the authenticated user attached by JwtAuthGuard.
|
||||||
|
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
|
||||||
|
*/
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_data: unknown, context: ExecutionContext): User | undefined => {
|
||||||
|
return context.switchToHttp().getRequest().user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates TREK's existing JWT session — the same httpOnly `trek_session`
|
||||||
|
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
|
||||||
|
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
|
||||||
|
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
|
||||||
|
*
|
||||||
|
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<Request>();
|
||||||
|
const token = extractToken(req);
|
||||||
|
if (!token) {
|
||||||
|
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
|
||||||
|
}
|
||||||
|
const user = verifyJwtAndLoadUser(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||||
|
}
|
||||||
|
(req as Request & { user?: unknown }).user = user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises every Nest exception to TREK's legacy error envelope so migrated
|
||||||
|
* routes are byte-identical for the client:
|
||||||
|
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
|
||||||
|
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
|
||||||
|
* This replaces Nest's default { statusCode, message, error } body, which the
|
||||||
|
* TREK client does not expect.
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class TrekExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const res = host.switchToHttp().getResponse<Response>();
|
||||||
|
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const body = exception.getResponse();
|
||||||
|
|
||||||
|
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
|
||||||
|
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
|
||||||
|
res.status(status).json(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
|
||||||
|
const message =
|
||||||
|
status < 500
|
||||||
|
? Array.isArray(raw)
|
||||||
|
? raw.join(', ')
|
||||||
|
: String(raw ?? 'Error')
|
||||||
|
: 'Internal server error';
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown/unhandled error — mirror the legacy 500 behaviour.
|
||||||
|
console.error('Unhandled error:', exception);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
|
||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
|
||||||
|
* and returns the parsed, typed value. On failure it throws TREK's error envelope
|
||||||
|
* `{ error: string }` with status 400 — the same shape the legacy routes produce,
|
||||||
|
* so the client's error handling is unaffected.
|
||||||
|
*
|
||||||
|
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
|
constructor(private readonly schema: ZodType) {}
|
||||||
|
|
||||||
|
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues
|
||||||
|
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new HttpException({ error: message }, 400);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so every migrated module can inject DatabaseService without re-importing.
|
||||||
|
* Wraps the existing better-sqlite3 singleton (no new connection).
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [DatabaseService],
|
||||||
|
exports: [DatabaseService],
|
||||||
|
})
|
||||||
|
export class DatabaseModule {}
|
||||||
@@ -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
Reference in New Issue
Block a user