diff --git a/.github/workflows/lint-prettier.yml b/.github/workflows/lint-prettier.yml index 41c746117..b5677fc63 100644 --- a/.github/workflows/lint-prettier.yml +++ b/.github/workflows/lint-prettier.yml @@ -2,6 +2,11 @@ name: Lint & Prettier on: pull_request: branches: [main, dev] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5b2cfe08b..c522f18c1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -9,6 +9,10 @@ on: permissions: pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: scout: # Docker Hub secrets are not exposed to pull requests from forks, so the diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a19cbbc3b..293d8fd92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,46 +1,91 @@ -name: Tests +name: Tests & SonarQube permissions: contents: read +# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so +# each test suite runs once per event instead of once for the old test.yml and +# once more for Sonar's coverage. Pushes to dev and main produce branch +# analysis; pull requests get PR analysis with decoration on the PR itself +# (the scanner picks the context up from the Actions environment). +# +# Job layout — three tiers: +# - Preflight: plugin-facts, i18n-parity and the quality jobs +# (server-quality, client-quality, plugin-sdk-quality) hold the cheap +# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all +# of them, so a gate failure stops the run before the expensive coverage +# fan-out spends ~15 runner-minutes on a result the gates already sank. +# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract +# runs the MinIO contract suite. Coverage jobs run tests only — with the +# gates cleared in preflight, they can only fail because tests failed. +# - Scan `needs:` exactly the four coverage jobs. A coverage job failing +# must stop the scan — a missing lcov file would make every line of that +# package report as uncovered, and the dashboard would show a collapse +# that did not happen. (The preflight gates stop it transitively.) +# - Fork PRs don't receive secrets, so the two jobs that need them skip +# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). +# Everything else runs on fork PRs so outside contributors still get the +# full test signal. +# +# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually +# exclusive with CI-based analysis, and it cannot import the lcov coverage +# this workflow produces. on: + push: + branches: [main, dev] pull_request: branches: [main, dev] + # A PR that touches none of these can't change the tests or the analysis, + # so don't spend the coverage fan-out on it. Pushes stay unfiltered so the + # branch view is always refreshed from the merge result. paths: - 'server/**' - 'client/**' - 'shared/**' - 'plugin-sdk/**' - # npm workspaces hoist every dependency into the single root lockfile, so - # a dependency bump touches none of the four directories above — and a - # native binding like better-sqlite3 is exactly what needs the suite. + # npm workspaces hoist every dependency into the single root lockfile, + # so a dependency bump touches none of the four directories above — and + # a native binding like better-sqlite3 is exactly what needs the suite. - 'package.json' - 'package-lock.json' - 'Dockerfile' - 'docker-compose.yml' - - '.github/workflows/test.yml' + - 'sonar-project.properties' + - '.github/workflows/sonarqube.yml' + workflow_dispatch: + +# A newer push to the same branch/PR supersedes the running one — cancel it +# rather than spending the coverage fan-out on a stale commit. Safe here +# because the Sonar scan job runs last and only the latest commit matters. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: plugin-facts: name: Plugin Permission Facts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: + # 22 matches the TREK server runtime (same choice as the plugin-sdk + # jobs and publish-plugin-sdk.yml). node-version: 22 cache: npm + cache-dependency-path: package-lock.json - name: Install dependencies run: npm ci --workspace server # The plugin permission facts live once, in - # server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's - # generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it - # and checked in, because publish-plugin-sdk builds from a standalone checkout with - # no server present. This fails the PR if a copy drifts, if a new permission has no - # admin.plugins.perm.* translation, or if the CLI has no hint for it. + # server/src/nest/plugins/protocol/envelope.ts. Every other copy — the + # SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is + # generated from it and checked in, because publish-plugin-sdk builds + # from a standalone checkout with no server present. This fails the run + # if a copy drifts, if a new permission has no admin.plugins.perm.* + # translation, or if the CLI has no hint for it. - name: Check the generated plugin facts are in sync run: cd server && npm run check:plugin-facts @@ -58,32 +103,9 @@ jobs: - name: Check i18n key parity run: node shared/scripts/i18n-parity.mjs --strict - shared-contracts: - name: Shared Contracts (Zod) + server-quality: + name: Server Types & Lint runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci --workspace shared - - - name: Typecheck - run: cd shared && npm run typecheck - - - name: Run tests - run: cd shared && npm test - - server-tests: - name: Server Tests - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 @@ -96,18 +118,15 @@ jobs: - name: Install dependencies run: npm ci - - name: Ensure @swc/core's Linux binary for unplugin-swc - # The lockfile was generated on Windows and omits @swc/core's Linux - # optional native binary, so npm ci/install skips it on the runner. - # Install the matching version explicitly so the server's SWC transform - # (server/vitest.config.ts) can load. - run: | - SWC_VERSION=$(node -p "require('@swc/core/package.json').version") - npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" - - name: Build shared run: npm run build --workspace=shared + - name: Typecheck shared + # Rides along here rather than in shared-coverage: this job already + # pays the root install and shared build, and a coverage job may only + # fail because tests failed (it gates the scan). + run: cd shared && npm run typecheck + - name: Build server (tsc -> dist) run: cd server && npm run build @@ -123,30 +142,73 @@ jobs: - name: Typecheck tests # tests/ is outside the build tsconfig, so a service that grows a # constructor parameter does not break its hand-wired call sites at - # compile time: the argument arrives as undefined and throws at runtime, - # if anything reads it at all. This is the step that catches it. + # compile time: the argument arrives as undefined and throws at + # runtime, if anything reads it at all. This is the step that + # catches it. run: cd server && npm run typecheck:tests - name: Lint run: cd server && npm run lint:check - - name: Run tests - run: cd server && npm run test:coverage + client-quality: + name: Client Types & Lint + runs-on: ubuntu-latest - - name: Upload coverage - if: success() - uses: actions/upload-artifact@v6 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 with: - name: backend-coverage - path: server/coverage/ - retention-days: 7 + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --workspace shared && npm ci --workspace client + + - name: Build shared + run: npm run build --workspace=shared + + - name: Typecheck + run: cd client && npm run typecheck + + - name: Lint + run: cd client && npm run lint:check + + - name: Page pattern check + run: cd client && npm run lint:pages + + plugin-sdk-quality: + name: Plugin SDK Types + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin-sdk + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + # plugin-sdk is standalone (own lockfile, not a root workspace), so it + # installs from its own directory. + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck s3-contract: name: S3 Driver Contract (MinIO) runs-on: ubuntu-latest - # Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty - # and the AIStor container would fail to boot. - if: github.event.pull_request.head.repo.full_name == github.repository + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + # Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and + # the AIStor container would fail to boot. Pushes and manual dispatches do + # have secrets, so only fork PRs are filtered out. (The implicit success() + # on this expression also keeps the job skipped when a preflight fails.) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - uses: actions/checkout@v6 @@ -210,14 +272,10 @@ jobs: TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts - client-quality: - # Split out of client-tests: the suite takes ~11 minutes, and having the - # gates in front of it meant a single lint finding threw away the whole test - # signal for that run. Both jobs pay the install/build, which is cheap next - # to running the two in series. - name: Client Types & Lint + server-coverage: + name: Server Coverage runs-on: ubuntu-latest - + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] steps: - uses: actions/checkout@v6 @@ -228,24 +286,34 @@ jobs: cache-dependency-path: package-lock.json - name: Install dependencies - run: npm ci --workspace shared && npm ci --workspace client + run: npm ci + + - name: Ensure @swc/core's Linux binary for unplugin-swc + # The lockfile was generated on Windows and omits @swc/core's Linux + # optional native binary, so npm ci/install skips it on the runner. + # Install the matching version explicitly so the server's SWC transform + # (server/vitest.config.ts) can load. + run: | + SWC_VERSION=$(node -p "require('@swc/core/package.json').version") + npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" - name: Build shared run: npm run build --workspace=shared - - name: Typecheck - run: cd client && npm run typecheck + - name: Run tests with coverage + run: cd server && npm run test:coverage - - name: Lint - run: cd client && npm run lint:check + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-server-coverage + path: server/coverage/ + retention-days: 1 - - name: Page pattern check - run: cd client && npm run lint:pages - - client-tests: - name: Client Tests + client-coverage: + name: Client Coverage runs-on: ubuntu-latest - + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] steps: - uses: actions/checkout@v6 @@ -255,19 +323,142 @@ jobs: cache: npm cache-dependency-path: package-lock.json + # Only the shared and client workspaces — a full root npm ci is not + # needed, and the scoped install keeps @testing-library/dom (a peer of + # @testing-library/react) in the tree without the re-resolution dance + # the folded coverage job used to need. - name: Install dependencies run: npm ci --workspace shared && npm ci --workspace client - name: Build shared run: npm run build --workspace=shared - - name: Run tests + - name: Run tests with coverage run: cd client && npm run test:coverage - name: Upload coverage - if: success() uses: actions/upload-artifact@v6 with: - name: frontend-coverage + name: sonar-client-coverage path: client/coverage/ - retention-days: 7 + retention-days: 1 + + shared-coverage: + name: Shared Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + 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: Run tests with coverage + run: cd shared && npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-shared-coverage + path: shared/coverage/ + retention-days: 1 + + plugin-sdk-coverage: + name: Plugin SDK Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + defaults: + run: + working-directory: plugin-sdk + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + # 22 matches the TREK server runtime and has node:sqlite, which the + # dev-server tests exercise (same choice as publish-plugin-sdk.yml). + node-version: 22 + + # plugin-sdk is standalone (own lockfile, not a root workspace), so it + # installs from its own directory. + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-plugin-sdk-coverage + path: plugin-sdk/coverage/ + retention-days: 1 + + scan: + name: Scan + runs-on: ubuntu-latest + # Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Coverage jobs failing must stop the scan: a missing lcov file would make + # every line of that package report as uncovered, and the dashboard would + # show a collapse that did not happen. The preflight gates already stop it + # transitively through these four, so they aren't repeated here. + needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage] + + steps: + - uses: actions/checkout@v6 + with: + # Sonar reads git blame to date each issue and to decide what counts + # as new code. A shallow clone leaves it blind to both. + fetch-depth: 0 + + - name: Download server coverage + uses: actions/download-artifact@v6 + with: + name: sonar-server-coverage + path: server/coverage/ + + - name: Download client coverage + uses: actions/download-artifact@v6 + with: + name: sonar-client-coverage + path: client/coverage/ + + - name: Download shared coverage + uses: actions/download-artifact@v6 + with: + name: sonar-shared-coverage + path: shared/coverage/ + + - name: Download plugin-sdk coverage + uses: actions/download-artifact@v6 + with: + name: sonar-plugin-sdk-coverage + path: plugin-sdk/coverage/ + + - name: Scan + uses: SonarSource/sonarqube-scan-action@v8 + env: + # SonarQube Cloud is the scan action's default host, so no + # SONAR_HOST_URL — the token is a sonarcloud.io token for the + # liketrek org. + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + # New Code is configured as "previous version", so this string is what + # draws that line — changing it re-bases every "new code" metric. + # Pinned rather than read from package.json on purpose: package.json is + # still on 3.4.1 while the project has been analysed as 4.0.0 all along, + # and taking the lower number would silently move the baseline. Bump it + # here as part of the release, together with the version bump. + # + # Everything else — sources, tests, exclusions, the documented + # false-positive list — lives in sonar-project.properties so it stays + # reviewable in a PR rather than buried in workflow YAML. + args: -Dsonar.projectVersion=4.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 5864762e9..9fa2d44b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What TREK is -A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces: +A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces: - **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run. -- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics. -- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime. +- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime. - **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB. **`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root. @@ -17,16 +16,16 @@ Each package has its own `CLAUDE.md` with internals; this file covers the monore ## Commands -Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=`, not `npm -w `**. +Run from the repo root unless noted. **Use `npm run ... --workspace=`, not `npm -w `**. ```bash -npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently -npm run build # build shared → nest-mcp → server → client (order matters) -npm run lint # lint all four workspaces -npm run format # prettier --write all four +npm run dev # build shared, then watch shared + server + client concurrently +npm run build # build shared → server → client (order matters) +npm run lint # lint all three workspaces +npm run format # prettier --write all three ``` -Tests (the user runs client tests as `cd client && npm run test`): +Tests: ```bash npm run test # all workspaces @@ -35,7 +34,7 @@ npm run test:unit --workspace=server # tests/unit only npm run test:integration --workspace=server npm run test:ws --workspace=server # websocket tests npm run test:e2e # server e2e (boots Nest against temp SQLite) -npm run test:cov # coverage, server + client +npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk cd client && npm run test # client vitest run cd client && npm run e2e # Playwright cd client && npm run lint:pages # enforce the Page pattern (see below) @@ -89,12 +88,12 @@ Offline-first, with a layered data flow. A Page never owns state directly: - A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src//`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up. - **i18n locale files live in `shared/src/i18n//`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys. -## Project direction (from the 2026 full-repo audit) +## Project direction -A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written: +These principles come out of a verified 2026 full-repo audit and shape how all new code should be written: - **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger. -- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's feature components calling the raw API past the offline core, still ~190 files). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. +- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. - **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip. - **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state. - **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery. @@ -105,7 +104,7 @@ A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` ( - **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes). - **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed. - **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR. -- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**. +- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages. - One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%). - When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes. diff --git a/README.md b/README.md index 35e54d882..3d25c2354 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa   Docker   +Sonar Quality Gate +  +GitHub Actions Workflow Status +  Discord   Roadmap -
+  Ko-fi   BMAC @@ -33,7 +37,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa Latest Release Docker Pulls Stars - --- @@ -238,64 +241,15 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A

Docker Compose (production)

-
-Full compose example with secure defaults - -```yaml -services: - app: - image: mauriceboe/trek:latest - container_name: trek - read_only: true - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - cap_add: - - CHOWN - - SETUID - - SETGID - tmpfs: - - /tmp:noexec,nosuid,size=64m - ports: - - "3000:3000" - environment: - - NODE_ENV=production - - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32 - - TZ=${TZ:-UTC} - - LOG_LEVEL=${LOG_LEVEL:-info} - - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} - - APP_URL=${APP_URL:-} # required for OIDC + email links - # - FORCE_HTTPS=true # behind a TLS-terminating proxy - # - TRUST_PROXY=1 - # - OIDC_ISSUER=https://auth.example.com - # - OIDC_CLIENT_ID=trek - # - OIDC_CLIENT_SECRET=supersecret - # - OIDC_DISPLAY_NAME=SSO - # - OIDC_ADMIN_CLAIM=groups - # - OIDC_ADMIN_VALUE=app-trek-admins - volumes: - - ./data:/app/data - - ./uploads:/app/uploads - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 15s -``` - -Then: +The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml) +with secure defaults and every option documented inline. Download it, then: ```bash docker compose up -d ``` -**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work. - -
+See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose) +for the full walkthrough.
@@ -309,6 +263,8 @@ helm install trek trek/trek See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values. +
+

Install as App (PWA)

TREK works as a Progressive Web App — no App Store needed. @@ -323,36 +279,10 @@ TREK then launches fullscreen with its own icon, just like a native app. ## Updating -**Docker Compose:** +See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose, +Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note. -```bash -docker compose pull && docker compose up -d -``` - -**Docker run** — reuse the original volume paths: - -```bash -docker pull mauriceboe/trek -docker rm -f trek -docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek -``` - -> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container. - -Your data stays in the mounted `data` and `uploads` volumes — updates never touch it. - -> [!IMPORTANT] -> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved. - -

Rotating the Encryption Key

- -If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`): - -```bash -docker exec -it trek node --import tsx scripts/migrate-encryption.ts -``` - -The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed). +

Reverse Proxy

@@ -432,175 +362,8 @@ Caddy handles TLS and WebSockets automatically. ## Environment variables -> [!NOTE] -> Variables are validated at startup (fail-fast). An unset or blank variable -> always falls back to its default, but a variable set to a malformed value -> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot -> with a report listing every offending variable. Boolean switches accept -> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing). - -
-Full reference - -
- -| Variable | Description | Default | -|----------|-------------|---------| -| **Core** | | | -| `PORT` | Server port | `3000` | -| `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | -| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | -| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | -| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` | -| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` | -| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | -| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | -| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | -| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | -| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` | -| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` | -| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | -| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | -| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — | -| **OIDC / SSO** | | | -| `OIDC_ISSUER` | OpenID Connect provider URL | — | -| `OIDC_CLIENT_ID` | OIDC client ID | — | -| `OIDC_CLIENT_SECRET` | OIDC client secret | — | -| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | -| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` | -| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — | -| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | -| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` | -| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — | -| **Initial setup** | | | -| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` | -| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | -| **Other** | | | -| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` | -| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — | -| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — | -| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | -| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | -| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | -| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` | - -
- -
- -

Storage

- -TREK separates *what* it stores (eight content categories — trip documents, -journey photos, cover images, profile pictures, place images, the two photo -caches, and backups) from *where* it stores it (named backends). Out of the -box everything lives on local disk (`uploads/` and `data/backups`). From -**Admin → Storage** you can: - -- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage, - MinIO/AIStor) and assign any category to them. -- **Replicate a backend**: edit it and pick **Mirror targets** — every write is - then also copied to each target. The classic off-site backup setup is: - add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror - target, save. Replica writes happen one after another during each upload, so - a slow or unreachable target slows every upload of every category on that - backend — fine for backups, worth weighing for hot categories. Existing - objects are not replicated when you add a target — press **Sync now** on - the backend to copy them (one sync at a time; progress and cancel in the - panel). -- **Watch the Health strip**: replica failures never fail the original - request; they are recorded and shown there instead. All-clear means every - replicated write landed. Failures also notify admins over the configured - notification channels (first failure immediately, then hourly summaries) — - tune it under Admin → Notifications. -- **Test** any backend from its row (for a replicated backend it probes the - primary and each target individually). Targets must be saved before Test - can probe them. -- **Usage**: per-category and per-backend object counts and sizes, scanned - nightly and on demand. - -Backend credentials are encrypted at rest. Without an explicit -`ENCRYPTION_KEY` the implicit key from the data directory is used — that -works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY` -explicitly is recommended for credentialed backends. On an existing install -that has been running without an explicit key, do not just set a new one — -that would orphan already-stored secrets; use the key-rotation procedure (see -*Rotating the Encryption Key* under Updating) to move to an explicit key. - -Notes and limits: reassigning a populated category prompts to move its -existing objects — accepting copies everything to the new backend, flips the -category, then sweeps any writes that raced the copy; source objects are kept -(not deleted) and reported as reclaimable (count + size) to reclaim manually; -declining routes only new writes to the new backend, leaving old objects in -place as before. Only one storage job (a mirror sync or a category migration) -runs at a time, cancelling before the flip is always safe, and a failed copy -never flips the category; media categories on S3 work but every served byte -proxies through the server — the proxy answers conditional GETs (ETag / -Last-Modified) and a single `Range` request, while a multi-range request is -answered with the full object; AWS buckets -with Object-Lock or checksum-requiring policies -reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be -addressed by IP or `localhost` unless configured for virtual-hosted buckets. -The legacy `/uploads/photos` directory from older TREK versions is still -served and included in backups, but it is not a configurable category. -Running the storage contract suite against a live MinIO/AIStor is a -license-gated procedure — CI runs it automatically for same-repo pull -requests (fork PRs skip it, since they have no access to the license -secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s -header for the manual procedure. - -### Provisioning at first boot (seed file) - -For infrastructure-as-code setups, mount a `storage-config.json` into the -data directory — it is imported **once**, on the first boot that has no -stored storage configuration, and loudly ignored afterwards: - -```jsonc -// storage-config.json — secrets may be plaintext (encrypted on import) -// or already-encrypted enc:v1: values. -{ - "backends": [ - { "name": "off-site", "type": "s3", "options": { - "endpoint": "https://s3.example.com", "bucket": "trek", - "accessKeyId": "…", "secretAccessKey": "…" } }, - { "name": "backups-mirror", "type": "mirror", "options": { - "primary": "backups-local", "replicas": ["off-site"] } } - ], - "categories": { "backups": "backups-mirror" } -} -``` - -(The panel presents this exact setup as **Mirror targets** on `backups-local`.) - -```yaml -# docker-compose: add under the trek service's volumes - - ./storage-config.json:/app/data/storage-config.json:ro -``` - -An invalid seed file aborts boot with the exact validation error — an -actively-provisioning operator sees the problem instead of a silent default. - -### Recovery and restore - -To reset storage configuration to the built-in defaults (or to re-import a -seed file): stop the server, run -`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`, -and start it again. - -Backups include the storage configuration (it lives in the database). If your -only backup sits on S3 and the credentials for it sit inside that backup: -start a fresh instance, enter the S3 credentials in **Admin → Storage** (or -mount a seed file), then restore the backup from the Backup panel. - -## Data & Backups - -- **Database** — SQLite, stored in `./data/travel.db` -- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage** -- **Logs** — `./data/logs/trek.log` (auto-rotated) -- **Backups** — create and restore via Admin Panel -- **Auto-Backups** — configurable schedule and retention in Admin Panel - -
+Every variable, its default and what it does: see +[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables). ## Data sources @@ -609,6 +372,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md) for full third-party attributions. +
+ ## License TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence. diff --git a/client/CLAUDE.md b/client/CLAUDE.md new file mode 100644 index 000000000..57337de94 --- /dev/null +++ b/client/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only. + +## Commands (run from `client/`) + +```bash +npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001 +npm run build # prebuild generates PWA icons, then vite build +npm run typecheck # tsc --noEmit +npm run lint # eslint . +npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs) +npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx}) +npm run test:unit # tests/unit +npm run test:integration # tests/integration + co-located src tests +npm run test:coverage # v8 coverage +npm run e2e # Playwright +npm run e2e:report # open last Playwright report +npm run shots # Playwright screenshots project (shots:promote to accept) +npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate) +``` + +Single test: + +```bash +npx vitest run src/store/slices/budgetSlice.test.ts +npx vitest run -t "optimistically adds the place" +npx vitest watch src/pages/DashboardPage.test.tsx +``` + +The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both. + +## Page pattern (enforced — `lint:pages` will fail otherwise) + +Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use()` hook** under `src/pages//`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives. + +## Data flow — offline-first + +The layering is **store → repo → api | Dexie**, and writes go through a mutation queue: + +- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state. +- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly. +- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection. +- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access. +- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles. + +## Direction (from the 2026 audit) + +The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts: + +- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/.ts`, not the `client.ts` god module. +- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly. +- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`). +- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation). +- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug. +- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss. +- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects. +- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code. +- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces. +- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter. +- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate. +- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `` styling. + +## Other big-picture pieces + +- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure. +- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`. +- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens//` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated. +- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill. +- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build. +- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / ``; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`. + +## Tests + +vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`). diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index 71837160a..e33de84f8 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -111,7 +111,7 @@ describe('client > request interceptor', () => { expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key') }) - it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => { + it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => { const realCrypto = globalThis.crypto vi.stubGlobal('crypto', { getRandomValues: realCrypto.getRandomValues.bind(realCrypto), @@ -120,9 +120,30 @@ describe('client > request interceptor', () => { const sink: InternalAxiosRequestConfig[] = [] await apiClient.post('/probe', {}, { adapter: okAdapter(sink) }) - const key = String(sink[0].headers['X-Idempotency-Key']) - expect(key).toMatch(/^[a-z0-9]+$/) - expect(key).not.toMatch(/-/) + // randomUUID needs a secure context, so on the plain-http installs the + // README documents this branch is what actually runs. getRandomValues has no + // such requirement, so the fallback is still a full v4 UUID. + expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + }) + + it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => { + vi.stubGlobal('crypto', undefined) + + const keys = new Set() + for (let i = 0; i < 50; i++) { + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.post('/probe', {}, { adapter: okAdapter(sink) }) + keys.add(String(sink[0].headers['X-Idempotency-Key'])) + } + + // The old fallback was Math.random().toString(36).slice(2), which is not + // length-stable: 0.5 yields a single character and 0 yields the empty string. + // An empty key makes the server skip deduplication entirely, so a retried + // write applies twice. + for (const k of keys) expect(k.length).toBeGreaterThan(16) + expect(keys.size).toBe(50) }) it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d3c60819c..09e90df47 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import type { Place } from '../types' +import { randomId } from '../utils/randomId' import { weatherResultSchema, type WeatherResult, inAppListResultSchema, type InAppListResult, @@ -151,10 +152,7 @@ apiClient.interceptors.request.use( // The mutation queue sets its own pre-generated key; skip if already set. const method = (config.method ?? '').toLowerCase() if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) { - const key = typeof crypto !== 'undefined' && crypto.randomUUID - ? crypto.randomUUID() - : Math.random().toString(36).slice(2) - config.headers['X-Idempotency-Key'] = key + config.headers['X-Idempotency-Key'] = randomId() } return config }, @@ -533,7 +531,12 @@ export const adminApi = { apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), - pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), + // `version` pins the exact version to install (the rollback path); omitted, the server + // resolves the newest TREK-compatible version itself. + pluginUpdate: (id: string, version?: string) => + apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data), + // Release a per-plugin update hold (set by a deliberate non-latest install). + pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data), // Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the // full key the admin was shown (not a fingerprint): the server compares it exactly, so // it can refuse if the registry entry was re-keyed again since the dialog rendered. diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index cfffacead..14afcc882 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -98,7 +98,7 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => { render(); - await screen.findByText('No addons available'); + expect(await screen.findByText('No addons available')).toBeInTheDocument(); }); it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c30e92fb9..c5ea9e6ce 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - @@ -470,7 +471,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { {installed.length > 0 && (
{installed.map(name => ( -
{installedHere ? ( - ) : ( - )} @@ -526,7 +527,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { )} - @@ -589,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} {!hideToggle && ( - )} {expanded && hidden > 0 && ( - @@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() { {new Date(session.created_at).toLocaleDateString(locale)} - - @@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() { {/* Delete MCP token modal */} {deleteConfirmId !== null && (
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> + role="button" tabIndex={0} aria-label={t('common.close')} + onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) } + }}>

{t('admin.mcpTokens.deleteTitle')}

{t('admin.mcpTokens.deleteMessage')}

- - diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 371df1556..693925b0c 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -201,6 +201,9 @@ function registryEntry(over: Record = {}) { id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify', type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null, screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + // The real browse response always carries latestCompatible (server-side hostCompat); + // mirror `latest` by default, exactly like a fully-compatible entry. + latestCompatible: (over.latest as string | undefined) ?? '2.0.0', ...over, } } @@ -1087,6 +1090,235 @@ describe('AdminPluginsPanel — updates', () => { }) }) +/** + * Update availability must follow what this TREK can actually INSTALL (latestCompatible, + * computed server-side), never the absolute newest published version — a banner counting + * versions the update endpoint would refuse nags the admin toward a guaranteed 400. + */ +describe('AdminPluginsPanel — compatible updates only', () => { + const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false }) + + it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument() + }) +}) + +/** + * The update hold (#plugins): a deliberate non-latest install sets updateHold on the row, + * which takes it out of the banner count and "Update all" — a rollback the admin just + * made must not be nagged straight back. The row says so and offers to resume. + */ +describe('AdminPluginsPanel — update hold', () => { + const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true }) + + it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => { + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => { + let resumed = false + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => { + resumed = true + return HttpResponse.json({ updateHold: false }) + })) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /resume updates/i })) + + await waitFor(() => expect(resumed).toBe(true)) + expect(await screen.findByText('Updates resumed')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => { + const updated: string[] = [] + panelWith( + [held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })], + { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }, + ) + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)) + return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] }) + })) + withToast() + + expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /update all/i })) + + await waitFor(() => expect(updated).toEqual(['trek-ntfy'])) + }) +}) + +/** + * The version picker (#plugins): install any published version from the Discover detail + * modal, and roll an installed plugin back through the update pipeline (which owns the + * stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version — + * the picker only renders them, it never re-derives range logic. + */ +describe('AdminPluginsPanel — version picker', () => { + const VERSIONS = [ + { version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false }, + { version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + { version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + ] + const detailWithVersions = () => ({ + ...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }), + size: 1024, publishedAt: null, manifest: null, versions: VERSIONS, + }) + + function mockDiscoverDetail() { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => + HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + ) + } + + it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => { + let body: unknown = null + mockDiscoverDetail() + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' }) + })) + withToast() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })) + + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => { + mockDiscoverDetail() + render() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i })) + + // The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot. + expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /roll back/i })) + + await waitFor(() => expect(body).toEqual({ version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i })) + + await waitFor(() => expect(body).toEqual({ version: '2.0.0' })) + expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => { + panelWith([ + plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }), + plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }), + ], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions()))) + withToast() + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load')) + expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + + const current = await screen.findByTestId('version-row-2.0.0') + expect(within(current).getByText('Installed')).toBeInTheDocument() + expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument() + }) +}) + describe('AdminPluginsPanel — capability and dependency chips', () => { it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => { panelWith([plugin({ @@ -1405,7 +1637,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => { const user = userEvent.setup() - mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }]) + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) render() await screen.findByText('Alpha Widget') await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument()) @@ -1423,7 +1655,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => { const user = userEvent.setup() - mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9' }]) + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) render() await screen.findByText('Alpha Widget') @@ -1880,8 +2112,8 @@ describe('AdminPluginsPanel — update all', () => { plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })], })), http.get('*/api/admin/plugins/registry', () => HttpResponse.json([ - { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, - { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, + { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, + { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, ])), http.post('*/api/admin/plugins/:id/update', async ({ params }) => { started.push(String(params.id)) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 4ca8ae1f1..9ba077f3f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -5,7 +5,7 @@ import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' @@ -63,6 +63,8 @@ interface PluginRow { keyFingerprint?: string | null /** Why an update was refused, if one was. `version` is the version that was refused. */ updateBlock?: { code: string; detail: string | null; version: string | null } | null + /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ + updateHold?: boolean } interface RegistryItem { id: string @@ -93,9 +95,21 @@ interface RegistryItem { /** The full key — public, and carried in full because re-trust compares it exactly. */ authorPublicKey?: string | null } +/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ +interface VersionInfo { + version: string + publishedAt: string | null + size: number | null + signed: boolean + /** The declared TREK requirement, or null when the entry carries no bounds at all. */ + trek: string | null + compatible: boolean +} interface RegistryDetail extends RegistryItem { size: number | null publishedAt: string | null + /** Every published version, newest first — the version picker's data. */ + versions?: VersionInfo[] manifest: { // Optional because a registry may omit an empty list — the detail view has to // degrade rather than throw halfway through its render. @@ -187,7 +201,7 @@ const PERM_KEYS = PLUGIN_PERMISSIONS const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] function isNewer(a: string, b: string): boolean { - const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) + const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0) const pa = nums(a), pb = nums(b) for (let i = 0; i < 3; i++) { const x = pa[i] || 0, y = pb[i] || 0 @@ -392,6 +406,12 @@ export default function AdminPluginsPanel() { const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) + // A picked version OLDER than the installed one — held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) @@ -420,10 +440,13 @@ export default function AdminPluginsPanel() { // Index the registry once per fetch: the version map the update badges read, plus the // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible — the newest version THIS TREK can install (computed + // server-side) — not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. const indexRegistry = (items: RegistryItem[]) => { const vers: Record = {} const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest }) + items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) setLatest(vers) setRegById(byId) } @@ -551,7 +574,21 @@ export default function AdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) } - const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: + // the admin just rolled it back, and the banner nagging them straight back would make + // the rollback fight the UI. The row carries its own "paused" marker + resume instead. + const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) + const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id] + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null + if (latest[p.id] === reg.latest) return null + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) + return range ? { version: reg.latest, range } : null + } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the @@ -639,9 +676,11 @@ export default function AdminPluginsPanel() { .finally(() => { setBusy(null); refresh() }) } - const runUpdate = (p: PluginRow) => { + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) - return adminApi.pluginUpdate(p.id) + return adminApi.pluginUpdate(p.id, version) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -673,6 +712,27 @@ export default function AdminPluginsPanel() { .finally(() => { setRetrusting(false); refresh() }) } + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null) + setVersionPicker({ plugin: p, versions: null, failed: false }) + adminApi.pluginDetail(p.id) + .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) + .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) + } + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand — + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return + const p = versionPicker.plugin + setVersionPicker(null) + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) + else void runUpdate(p, version) + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) @@ -739,7 +799,7 @@ export default function AdminPluginsPanel() {
)} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} - {menu &&
setMenu(null)} />} + {menu &&
setMenu(null)} />} {/* Header */}
@@ -795,11 +855,11 @@ export default function AdminPluginsPanel() {
- - @@ -842,11 +902,11 @@ export default function AdminPluginsPanel() { valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} /> - - @@ -871,7 +931,7 @@ export default function AdminPluginsPanel() {
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - @@ -885,9 +945,12 @@ export default function AdminPluginsPanel() { ) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onChangeVersion={() => openVersionPicker(p)} + onResume={() => void resumeUpdates(p)} onReviewBlock={() => setSignatureBlock({ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, code: p.updateBlock!.code, detail: p.updateBlock!.detail, @@ -909,11 +972,11 @@ export default function AdminPluginsPanel() { {/* Error-log modal */} {errorsFor && ( -
setErrorsFor(null)}> -
e.stopPropagation()}> +
setErrorsFor(null)}> +
e.stopPropagation()}>
{errorsFor.id} — {t('admin.plugins.errorLog')} - +
{errorsFor.rows.length === 0 ?

{t('admin.plugins.noErrors')}

: @@ -931,11 +994,11 @@ export default function AdminPluginsPanel() { {/* Operator-supplied egress hosts */} {egressFor && ( -
setEgressFor(null)}> -
e.stopPropagation()}> +
setEgressFor(null)}> +
e.stopPropagation()}>
{egressFor.id} — {t('admin.plugins.allowedHosts')} - +
{!egressFor.supported ? ( @@ -949,7 +1012,7 @@ export default function AdminPluginsPanel() { {egressFor.hosts.map(h => (
{h} -

{t('admin.plugins.empty')}

-
) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void - hasUpdate: boolean; latestVer?: string; blocked: boolean - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void + hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) @@ -1158,12 +1235,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })} -
)} + {/* A newer version this TREK can't run — informational, never a button: the fix + is a TREK upgrade, so the row must not nag or offer a doomed install. */} + {newerIncompatible && ( +
+ + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} +
+ )} + {/* Held: the admin deliberately installed a non-latest version, so updates are + paused rather than nagged — resuming is one click, right where the pause shows. */} + {p.updateHold && ( +
+ + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} + +
+ )} {p.status === 'error' && p.last_error ? (
{p.last_error} @@ -1181,7 +1278,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( - @@ -1221,7 +1318,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
- @@ -1233,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> + )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} @@ -1275,7 +1376,7 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties { function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( - +
@@ -1426,7 +1527,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local

{item.author}{item.latest ? ` · v${item.latest}` : ''}

-
+ + {/* Every published version, installable individually. The compat verdict per + version is SERVER-computed — an incompatible one is explained, never offered. */} + {(detail?.versions?.length ?? 0) > 1 && ( +
+

{t('admin.plugins.versionsTitle')}

+
+ {detail!.versions!.map(v => ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {v.compatible ? ( + !installed && ( + + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ))} +
+
+ )}
@@ -1548,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) { return
{k}
{v}
} +/** + * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat + * verdict: the installed version is marked, a compatible one gets a Switch action (the + * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is + * explained and never offered. + */ +function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { + plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null + t: T; locale: string; onPick: (version: string) => void; onClose: () => void +}) { + return ( +
+
e.stopPropagation()}> +
+

{t('admin.plugins.versionPickerTitle', { name: plugin.name })}

+ +
+
+ {failed &&

{t('admin.plugins.detailError')}

} + {!failed && !versions &&

{t('common.loading')}

} + {versions?.length === 0 &&

{t('admin.plugins.noVersions')}

} + {versions?.map(v => { + const current = v.version === plugin.version + return ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {current ? ( + {t('admin.plugins.installed')} + ) : v.compatible ? ( + + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ) + })} +
+
+
+ ) +} + /** * A signature check refused an install/update. * @@ -1582,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { : 'admin.plugins.sig.invalidBody' return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1606,12 +1784,12 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { )}
- {offerRetrust && ( - @@ -1646,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { t: T; onApprove: () => void; onLater: () => void }) { return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1685,8 +1863,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { )}
- - + +
@@ -1706,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), ] return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1726,7 +1904,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl : t('admin.plugins.dep.requires', { version: r.constraint })}
- @@ -1737,7 +1915,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl )}
- +
@@ -1763,7 +1941,7 @@ function SecurityInfo({ t }: { t: T }) {

{t('admin.plugins.reviewedMeaning')}

-
- -
@@ -274,14 +274,14 @@ export default function BackupPanel() {
- - - -
-
- - diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index 365303efa..e73523c05 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -86,7 +86,7 @@ function OptionButton({ children: React.ReactNode }) { return ( -
- @@ -171,8 +171,8 @@ export default function PackingTemplateManager() { setCreateName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }} placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} /> - - + +
)} @@ -187,7 +187,7 @@ export default function PackingTemplateManager() {
{/* Template row */}
- @@ -197,14 +197,14 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }} className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" /> ) : ( - toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name} + )} {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')} - -
@@ -228,11 +228,11 @@ export default function PackingTemplateManager() { {cat.name} )} {catItems.length} - - -
@@ -246,15 +246,15 @@ export default function PackingTemplateManager() { setEditItemName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }} className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> - - + + ) : ( <> {item.name} - - )} @@ -268,9 +268,9 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }} placeholder={t('admin.packingTemplates.itemName')} className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> - -
)} @@ -287,11 +287,11 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} placeholder={t('admin.packingTemplates.categoryName')} className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" /> - - + +
) : ( - diff --git a/client/src/components/Admin/PermissionsPanel.test.tsx b/client/src/components/Admin/PermissionsPanel.test.tsx index fb7323ecf..d53ab7464 100644 --- a/client/src/components/Admin/PermissionsPanel.test.tsx +++ b/client/src/components/Admin/PermissionsPanel.test.tsx @@ -269,6 +269,6 @@ describe('PermissionsPanel', () => { ), ); renderPanel(); - await screen.findByText('Error'); + expect(await screen.findByText('Error')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index acab4f0c6..6b2b149b7 100644 --- a/client/src/components/Admin/PermissionsPanel.tsx +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {

{t('perm.subtitle')}

- -

@@ -364,7 +364,7 @@ export default function AdminStoragePanel(): React.ReactElement { {t(`storage.source.${row.source}`)} - {row.source !== 'env' && ( - )} {row.source === 'settings' && ( - )} @@ -433,7 +433,7 @@ export default function AdminStoragePanel(): React.ReactElement { failed: String(backfill.failed), })}

- - -
@@ -552,7 +552,7 @@ export default function AdminStoragePanel(): React.ReactElement { total: String(m.total), })}

- - - -
) : task.items.length > 0 ? (
-
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && ( -
{task.status !== 'running' && ( - @@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro placeholder={t('budget.categoryName')} style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} /> -
)} - ) } diff --git a/client/src/components/Budget/BudgetPanelMemberChips.test.tsx b/client/src/components/Budget/BudgetPanelMemberChips.test.tsx index 50c913999..3e7b4a04f 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.test.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.test.tsx @@ -22,6 +22,16 @@ function setup(props: Partial[0]> = {}) { return { onSetMembers, onTogglePaid, ...utils } } +/** + * The member picker trigger. A chip that can be clicked is a real button too, + * so it is the icon that tells the trigger apart from the chips before it. + */ +function picker(): HTMLElement { + const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users')) + if (!trigger) throw new Error('no picker trigger rendered') + return trigger +} + describe('ChipWithTooltip', () => { it('FE-W4BMC-001: falls back to the uppercased initial', () => { const { container } = render() @@ -76,7 +86,7 @@ describe('BudgetMemberChips', () => { expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('B')).toBeInTheDocument() - expect(screen.getByRole('button')).toBeInTheDocument() + expect(picker()).toBeInTheDocument() }) it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => { @@ -114,9 +124,9 @@ describe('BudgetMemberChips', () => { it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => { setup() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(picker()) - const rows = screen.getAllByRole('button').slice(1) + const rows = screen.getAllByRole('button').slice(2) expect(rows).toHaveLength(2) expect(rows[0]).toHaveTextContent('ada') expect(rows[0].querySelector('.lucide-check')).not.toBeNull() @@ -127,52 +137,52 @@ describe('BudgetMemberChips', () => { it('FE-W4BMC-012: picking an unassigned member adds them', () => { const { onSetMembers } = setup() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(picker()) - fireEvent.click(screen.getAllByRole('button')[2]) + fireEvent.click(screen.getAllByRole('button')[3]) expect(onSetMembers).toHaveBeenCalledWith([1, 2]) }) it('FE-W4BMC-013: picking an assigned member removes them', () => { const { onSetMembers } = setup() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(picker()) - fireEvent.click(screen.getAllByRole('button')[1]) + fireEvent.click(screen.getAllByRole('button')[2]) expect(onSetMembers).toHaveBeenCalledWith([]) }) it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => { setup() - const trigger = screen.getByRole('button') + const trigger = picker() fireEvent.click(trigger) - fireEvent.mouseDown(screen.getAllByRole('button')[1]) - expect(screen.getAllByRole('button')).toHaveLength(3) + fireEvent.mouseDown(screen.getAllByRole('button')[2]) + expect(screen.getAllByRole('button')).toHaveLength(4) fireEvent.mouseDown(trigger) - expect(screen.getAllByRole('button')).toHaveLength(3) + expect(screen.getAllByRole('button')).toHaveLength(4) fireEvent.mouseDown(document.body) - expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.getAllByRole('button')).toHaveLength(2) }) it('FE-W4BMC-015: the trigger toggles the picker closed again', () => { setup() - const trigger = screen.getByRole('button') + const trigger = picker() fireEvent.click(trigger) - expect(screen.getAllByRole('button')).toHaveLength(3) + expect(screen.getAllByRole('button')).toHaveLength(4) fireEvent.click(trigger) - expect(screen.getAllByRole('button')).toHaveLength(1) + expect(screen.getAllByRole('button')).toHaveLength(2) }) it('FE-W4BMC-016: the non-compact variant renders larger chips', () => { setup({ compact: false }) expect(screen.getByText('A')).toHaveStyle({ width: '30px' }) - expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px') + expect(picker().style.width).toBe('28px') }) }) diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx index 376464f12..b8bb8a244 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -22,7 +22,7 @@ interface ChipWithTooltipProps { export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) + const ref = useRef(null) const onEnter = () => { if (ref.current) { @@ -35,22 +35,34 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: const borderColor = paid ? '#22c55e' : 'var(--border-primary)' const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + // Identical for both shells — a clickable chip is a real button so it can be + // tabbed to and pressed, a decorative one stays a plain div. + const chipStyle = { + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', + padding: 0, fontFamily: 'inherit', + } + const inner = avatarUrl + ? + : label?.[0]?.toUpperCase() + return ( <> -
setHover(false)} - onClick={onClick} - style={{ - width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, - background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', - overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', - transition: 'border-color 0.15s, background 0.15s', - }}> - {avatarUrl - ? - : label?.[0]?.toUpperCase() - } -
+ {onClick ? ( + + ) : ( +
{ ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)} + style={chipStyle}> + {inner} +
+ )} {hover && createPortal(
))} {!readOnly && ( -
diff --git a/client/src/components/Budget/BudgetPanelRingAvatar.tsx b/client/src/components/Budget/BudgetPanelRingAvatar.tsx index 585bfece8..3c116d049 100644 --- a/client/src/components/Budget/BudgetPanelRingAvatar.tsx +++ b/client/src/components/Budget/BudgetPanelRingAvatar.tsx @@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn overflow: 'hidden', fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor, }}> - {avatarUrl ? : username?.[0]?.toUpperCase()} + {avatarUrl ? {username : username?.[0]?.toUpperCase()}
) diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx index 70abfae18..0b9d77866 100644 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has {/* Settlement dropdown inside the total card */} {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
-
{canEdit && (
- - ))}
-
{fmt(f.amount)} - {canEdit && } + {canEdit && }
))} @@ -589,7 +589,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {t('costs.youPaid')} · {fmt0(totals.myPaid)}
{canEdit && ( - )} @@ -628,7 +628,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.settleUp')} {(settlement?.flows || []).length}
{canEdit && ( - + )}
{SettleFlows()} @@ -638,7 +638,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.expenses')}
-
{(['all', 'mine', 'owed'] as const).map(f => ( - + ))}
@@ -705,8 +705,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } return (
- - + +
) } @@ -839,8 +839,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{canEdit && (
- - + +
)}
@@ -970,7 +970,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) const [saving, setSaving] = useState(false) - const amt = parseFloat(amount) || 0 + const amt = Number.parseFloat(amount) || 0 const valid = amt > 0 && fromId !== toId const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) @@ -991,8 +991,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav - - + +
}>
@@ -1116,8 +1116,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return calculateTicketShares(ticketItems) }, [ticketItems]) - const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0) - const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0) + const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) const each = participants.size > 0 ? totalNum / participants.size : 0 const equalShares = useMemo(() => { @@ -1130,13 +1130,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const enteredSum = [...participants] .filter(id => customAmounts[id]) - .reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0) + .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const remaining = Math.max(0, totalNum - enteredSum) return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) }, [totalNum, participants, customAmounts, editing]) - const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0) + const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) const valid = name.trim().length > 0 && payersOk && ( isTicketMode @@ -1263,7 +1263,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo // dropping the payer would store the entry with a total of 0. const payerList = multiPayer ? [...payerIds] - .map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 })) + .map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })) .filter(p => p.amount > 0) : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] // A receipt line can name somebody who is not ticked as a participant. Sending @@ -1275,7 +1275,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const memberList = memberIds.map(id => ({ user_id: id, amount: splitMode === 'custom' - ? (parseFloat(customAmounts[id]) || 0) + ? (Number.parseFloat(customAmounts[id]) || 0) : splitMode === 'ticket' ? (ticketInfo.shares[id] || 0) : null @@ -1315,8 +1315,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo - - + +
}> {/* Two columns on desktop: what the expense was on the left, how it is @@ -1370,7 +1370,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {COST_CATEGORY_LIST.map(c => { const Icon = c.Icon; const on = cat === c.key return ( - {own && canEdit && ( - } - {canEdit && {categories.map(cat => ( - } -
+ )} {a.original_name}
diff --git a/client/src/components/Collab/CollabNotesAuthedImg.tsx b/client/src/components/Collab/CollabNotesAuthedImg.tsx index c54cd6a49..799eb9820 100644 --- a/client/src/components/Collab/CollabNotesAuthedImg.tsx +++ b/client/src/components/Collab/CollabNotesAuthedImg.tsx @@ -1,10 +1,22 @@ import { useState, useEffect } from 'react' import { getAuthUrl } from '../../api/authUrl' -export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { +export function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { const [authSrc, setAuthSrc] = useState('') useEffect(() => { getAuthUrl(src, 'download').then(setAuthSrc) }, [src]) - return authSrc ? {alt} : null + if (!authSrc) return null + // A clickable thumbnail gets a real button around the image so it is + // reachable by keyboard. The caller's style stays on the outer box, so the + // hover transform/transition still animates the element that carries them. + if (onClick) { + return ( + + ) + } + return {alt} } diff --git a/client/src/components/Collab/CollabNotesCard.tsx b/client/src/components/Collab/CollabNotesCard.tsx index e9fae8547..7eaa2ff11 100644 --- a/client/src/components/Collab/CollabNotesCard.tsx +++ b/client/src/components/Collab/CollabNotesCard.tsx @@ -80,26 +80,26 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi display: 'flex', gap: 2, }}> {note.content && ( - )} - {canEdit && } - {canEdit && } - {canEdit &&
+ ) })} {(note.attachments?.length || 0) > (note.website ? 1 : 2) && ( diff --git a/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx b/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx index c54a79ff7..8e4482e16 100644 --- a/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx +++ b/client/src/components/Collab/CollabNotesCategorySettingsModal.tsx @@ -69,17 +69,17 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT, - }} onClick={onClose}> + }} role="presentation" onClick={onClose}>
e.stopPropagation()}> + }} role="presentation" onClick={e => e.stopPropagation()}> {/* Header */}

{t('collab.notes.categorySettings') || 'Category Settings'}

-
@@ -96,7 +96,7 @@ export function CategorySettingsModal({ onClose, categories, categoryColors, onS {/* Color swatches */}
{NOTE_COLORS.map(c => ( - ) } diff --git a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx index 78d2c9150..acf48e058 100644 --- a/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx +++ b/client/src/components/Collab/CollabNotesFilePreviewPortal.tsx @@ -28,10 +28,10 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const openInNewTab = () => openFile(rawUrl).catch(() => {}) return createPortal( -
+
{isImage ? ( /* Image lightbox — floating controls */ -
e.stopPropagation()}> +
e.stopPropagation()}> {authUrl ? {file.original_name} : @@ -39,30 +39,30 @@ export function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
{file.original_name}
- - + +
) : ( /* Document viewer — card with header */ -
e.stopPropagation()}> +
e.stopPropagation()}>
{file.original_name}
- - + +
{(isPdf || isTxt) ? (

- +

) : (
- +
)}
diff --git a/client/src/components/Collab/CollabNotesFormModal.tsx b/client/src/components/Collab/CollabNotesFormModal.tsx index e6b6cd2ac..289995c8e 100644 --- a/client/src/components/Collab/CollabNotesFormModal.tsx +++ b/client/src/components/Collab/CollabNotesFormModal.tsx @@ -84,6 +84,7 @@ export function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategor }} >
{ const active = mobileTab === tab.id return ( - @@ -122,13 +122,13 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { {/* Multi choice toggle */}
diff --git a/client/src/components/Files/FileManagerFilesView.tsx b/client/src/components/Files/FileManagerFilesView.tsx index 377db7102..20fbd26cf 100644 --- a/client/src/components/Files/FileManagerFilesView.tsx +++ b/client/src/components/Files/FileManagerFilesView.tsx @@ -51,7 +51,7 @@ export function FilesView(S: FileManagerState) { { id: 'doc', label: t('files.filterDocs') }, ...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []), ].map(tab => ( - - -
{/* Main image + nav */} -
{ if (e.target === e.currentTarget) onClose() }}> {navBtn('left', goPrev, hasPrev)} {fileIsVideo ? ( -
e.stopPropagation()}> +
) : ( - imgSrc && {file.original_name} e.stopPropagation()} /> + imgSrc && {file.original_name} )} {navBtn('right', goNext, hasNext)}
{/* Thumbnail strip */} {files.length > 1 && ( -
e.stopPropagation()}> +
{files.map((f, i) => ( setIndex(i)} /> ))} @@ -156,7 +160,7 @@ function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; }, [file.url, fileIsVideo, visible]) return ( - - - diff --git a/client/src/components/Files/FileManagerPdfPreviewModal.tsx b/client/src/components/Files/FileManagerPdfPreviewModal.tsx index 9c3c97f54..77c208845 100644 --- a/client/src/components/Files/FileManagerPdfPreviewModal.tsx +++ b/client/src/components/Files/FileManagerPdfPreviewModal.tsx @@ -8,31 +8,33 @@ export function PdfPreviewModal(S: FileManagerState) { const { previewFile, setPreviewFile, previewFileUrl, toast, t } = S return createPortal(
setPreviewFile(null)} >
e.stopPropagation()} >
{previewFile.original_name}
- - - +

diff --git a/client/src/components/Files/FileManagerRow.tsx b/client/src/components/Files/FileManagerRow.tsx index 79fda174d..632b332fb 100644 --- a/client/src/components/Files/FileManagerRow.tsx +++ b/client/src/components/Files/FileManagerRow.tsx @@ -34,10 +34,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea className="group" > {/* Icon or thumbnail */} -
!isTrash && openFile(file)} style={{ - flexShrink: 0, width: 36, height: 36, borderRadius: 8, + flexShrink: 0, width: 36, height: 36, borderRadius: 8, padding: 0, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden', }} @@ -48,13 +51,13 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?' const isPdf = file.mime_type === 'application/pdf' return ( -
+ {ext} -
+ ) })() } -
+ {/* Info */}
@@ -63,12 +66,14 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea )} {!isTrash && file.starred ? : null} - !isTrash && openFile(file)} - style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }} + style={{ fontWeight: 500, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer', textAlign: 'left', minWidth: 0 }} > {file.original_name} - +
{file.description && ( @@ -76,7 +81,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea )}
- {file.file_size && {formatSize(file.file_size)}} + {!!file.file_size && {formatSize(file.file_size)}} {formatDateWithLocale(file.created_at, locale)} {linkedPlaces.map(p => ( @@ -87,7 +92,7 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea ? : ))} - {file.note_id && ( + {!!file.note_id && ( )}
@@ -97,34 +102,34 @@ export function FileRow(p: FileManagerState & { file: TripFile; isTrash?: boolea
{isTrash ? ( <> - {can('file_delete', trip) && } - {can('file_delete', trip) && } ) : ( <> - - {can('file_edit', trip) && } - - - {can('file_delete', trip) && } diff --git a/client/src/components/Files/FileManagerToolbar.tsx b/client/src/components/Files/FileManagerToolbar.tsx index 085d7626f..2c77017e1 100644 --- a/client/src/components/Files/FileManagerToolbar.tsx +++ b/client/src/components/Files/FileManagerToolbar.tsx @@ -36,7 +36,7 @@ export function FileManagerToolbar(S: FileManagerState) { : tab.id === 'collab' ? files.filter(f => f.note_id).length : 0 return ( -
@@ -72,10 +72,10 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on

{t('journey.contributors.noUsers')}

)} {filtered.map(u => ( -
setSelectedUserId(u.id)} - className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${ + className={`w-full text-left flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${ selectedUserId === u.id ? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent' @@ -93,7 +93,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
)} -
+ ))}
@@ -102,7 +102,7 @@ export default function ContributorInviteDialog({ journeyId, existingUserIds, on
{(['viewer', 'editor'] as const).map(r => ( -
- -
@@ -81,7 +81,7 @@ export function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }:
)}
- {menuOpen && createPortal( <> -
setMenuOpen(false)} /> +
setMenuOpen(false)} />
- - + +
, document.body, @@ -113,15 +113,15 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{!readOnly && (
- {menuOpen && createPortal( <> -
setMenuOpen(false)} /> +
setMenuOpen(false)} />
- - + +
, document.body, @@ -185,6 +185,9 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick? return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined} className={`rounded-[18px] px-3.5 py-3 flex items-center gap-3 transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:-translate-y-0.5 cursor-pointer' : ''}`} style={{ border: '1.5px dashed var(--vg-line2)', background: 'var(--vg-surf2)' }} > @@ -212,6 +215,9 @@ export function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } } : undefined} className={`bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-colors duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${onClick ? 'hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer' : ''}`} >
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index cf51a6ba8..ad10e65fc 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse, delay } from 'msw' +import { localIsoDate } from '../../utils/localDate' import userEvent from '@testing-library/user-event' import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' import { server } from '../../../tests/helpers/msw/server' @@ -516,7 +517,7 @@ describe('EntryEditor', () => { entry_date: '', photos: undefined as unknown as JourneyPhoto[], })) - const today = new Date().toISOString().split('T')[0] + const today = localIsoDate() // local, matching the editor's default — not the UTC date const label = new Date(today + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 0608df282..2f6c33237 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState, useRef } from 'react' +import { localIsoDate } from '../../utils/localDate' import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' @@ -33,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const toast = useToast() const [title, setTitle] = useState(entry.title || '') const [story, setStory] = useState(entry.story || '') - const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0]) + const [entryDate, setEntryDate] = useState(entry.entry_date || localIsoDate()) const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '') const [locationName, setLocationName] = useState(entry.location_name || '') const [locationLat, setLocationLat] = useState(entry.location_lat ?? null) @@ -78,7 +79,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const isDirty = ( title !== (entry.title || '') || story !== (entry.story || '') || - entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) || + entryDate !== (entry.entry_date || localIsoDate()) || entryTime !== (entry.entry_time?.slice(0, 5) || '') || locationName !== (entry.location_name || '') || (locationLat ?? null) !== (entry.location_lat ?? null) || @@ -208,7 +209,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, setPendingFiles(prev => [...prev, ...normalized]) } - const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN }) + const contextLocation = isValidGeoPoint({ lat: locationLat ?? Number.NaN, lng: locationLng ?? Number.NaN }) ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } : null @@ -255,7 +256,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

-
@@ -274,7 +275,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, { (e.target as HTMLInputElement).value = '' }} className="hidden" /> { (e.target as HTMLInputElement).value = '' }} className="hidden" />
- {galleryPhotos.length > 0 && ( - )} - - ))} {availableGalleryPhotos.length === 0 && (
{t('journey.editor.allPhotosAdded')}
@@ -366,7 +369,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,

{pendingProviderGroups.length > 0 && ( - )} @@ -379,7 +382,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{availableProviders.map(provider => ( - )} - )}
))} - )}
))} -
{showLocationResults && locationResults.length > 0 && ( <> -
setShowLocationResults(false)} /> +
setShowLocationResults(false)} />
{locationResults.map((r, i) => ( - - +
diff --git a/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx b/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx index d26b53733..26ca3d7f9 100644 --- a/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx +++ b/client/src/components/Journey/JourneyDetailPageExpandableStory.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, type HTMLAttributes } from 'react' import { ChevronRight } from 'lucide-react' import { useTranslation } from '../../i18n' import JournalBody from './JournalBody' @@ -23,11 +23,24 @@ export function ExpandableStory({ story }: { story: string }) { } }) + // The text block toggles the same clamp as the Show more / Show less buttons + // below it. It only takes focus while there is something to toggle, so a + // short story stays plain text instead of an empty stop in the tab order. + const toggle: HTMLAttributes = clamped || expanded + ? { + role: 'button', + tabIndex: 0, + 'aria-expanded': expanded, + onClick: () => setExpanded(e => !e), + onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded(v => !v) } }, + } + : {} + return (
{ if (clamped || expanded) setExpanded(e => !e) }} + {...toggle} className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${ expanded ? '' : 'line-clamp-3 md:line-clamp-[9]' } ${clamped || expanded ? 'cursor-pointer' : ''}`} @@ -35,7 +48,7 @@ export function ExpandableStory({ story }: { story: string }) {
{clamped && !expanded && ( - )} {expanded && ( - {/* Connector line */} {showConnector && ( diff --git a/client/src/components/Journey/JourneyDetailPagePhotoGrid.tsx b/client/src/components/Journey/JourneyDetailPagePhotoGrid.tsx index 25d8e4cd1..2123c209d 100644 --- a/client/src/components/Journey/JourneyDetailPagePhotoGrid.tsx +++ b/client/src/components/Journey/JourneyDetailPagePhotoGrid.tsx @@ -1,18 +1,31 @@ -import { Image } from 'lucide-react' +import { Image, Play } from 'lucide-react' import type { JourneyPhoto } from '../../store/journeyStore' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' -export function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) { +export function PhotoImg({ photo, className, style }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties }) { const src = photoUrl(photo, 'thumbnail') + const isVideo = photo.media_type === 'video' + return ( - + > + + + {isVideo && ( +
+ + + +
+ )} +
) } @@ -22,9 +35,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick if (count === 1) { return ( -
onClick(0)}> +
+ ) } @@ -32,7 +45,9 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick return (
{photos.slice(0, 2).map((p, i) => ( - onClick(i)} /> + ))}
) @@ -40,14 +55,14 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick return (
-
onClick(0)}> +
+
-
onClick(1)}> +
-
onClick(2)}> + +
+
) -} +} \ No newline at end of file diff --git a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx index 9e5c47ea0..c144541a5 100644 --- a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx @@ -144,6 +144,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset className={embedded ? 'w-full h-full min-h-0 flex flex-col overflow-hidden' : 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'} + role="presentation" onClick={embedded ? undefined : onClose} onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }} > @@ -152,6 +153,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset ? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden' : '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)' }} + role="presentation" onClick={e => e.stopPropagation()} > @@ -160,7 +162,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset

{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}

-
} @@ -176,7 +178,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset { id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') }, { id: 'album' as const, label: t('journey.picker.albums') }, ].map(f => ( - @@ -243,7 +245,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset {filter === 'album' && (
{albums.map((a: any) => ( - {addToOpen && ( <> -
setAddToOpen(false)} /> +
setAddToOpen(false)} />
-
+ ) })}
@@ -421,10 +427,10 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset {t('journey.picker.selected')}
- -
@@ -105,7 +105,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
-
-
))} {journey.trips.length === 0 &&

{t('journey.trips.noTripsLinkedSettings')}

} -
))} - - - - +
@@ -278,7 +278,7 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, {/* Add Trip — sits inside the backdrop, so its clicks have to be kept from bubbling into the close/discard handler underneath */} {showAddTrip && ( -
e.stopPropagation()}> +
e.stopPropagation()}> t.trip_id)} diff --git a/client/src/components/Journey/JourneyDetailPageVerdictSection.tsx b/client/src/components/Journey/JourneyDetailPageVerdictSection.tsx index 23fb3141b..f7ea2794a 100644 --- a/client/src/components/Journey/JourneyDetailPageVerdictSection.tsx +++ b/client/src/components/Journey/JourneyDetailPageVerdictSection.tsx @@ -9,7 +9,7 @@ export function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] return (
{/* Header — clickable on mobile */} - - - {!readOnly && (
- - + {photos.length > 1 && (
{t('mobileJourney.photosCount', { count: photos.length })}
)} + {/* Photo strip for multiple photos */} {photos.length > 1 && (
{photos.map((p, i) => ( - onPhotoClick(photos, i)} - /> + > + + + {p.media_type === 'video' && ( +
+ +
+ )} + ))}
)}
)} - {/* Content */}
@@ -224,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo )}
-
+
) } diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index d7d43378d..c118c97cf 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -46,7 +46,7 @@ export default function MobileMapTimeline({ const [activeIndex, setActiveIndex] = useState(0) const entryDayMeta = useMemo(() => { - const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())] + const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)))] const counters = new Map() return entries.map((e: any) => { const dayIdx = uniqueDates.indexOf(e.entry_date) @@ -171,7 +171,7 @@ export default function MobileMapTimeline({ /> {!readOnly && onAddEntry && (
- - {user?.role === 'admin' && ( -
)} - )} - - - - +
diff --git a/client/src/components/Packing/ApplyTemplateButton.tsx b/client/src/components/Packing/ApplyTemplateButton.tsx index 7fa920f98..01de45335 100644 --- a/client/src/components/Packing/ApplyTemplateButton.tsx +++ b/client/src/components/Packing/ApplyTemplateButton.tsx @@ -59,7 +59,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa return (
- )} {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`} @@ -74,34 +75,39 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, kg ) : bag.weight_limit_grams ? ( - canEdit && setEditingLimit(true)} title={t('packing.bagLimit')} - style={{ cursor: canEdit ? 'text' : 'default' }}> + ) : canEdit ? ( - ) : null} - {canEdit && } + {canEdit && }
{/* Members */}
- {(bag.members || []).map(m => ( - canEdit && toggleMember(m.user_id)} style={{ cursor: canEdit ? 'pointer' : 'default', display: 'inline-flex' }}> - {m.avatar ? ( - {m.username} - ) : ( - - {m.username[0].toUpperCase()} - - )} - - ))} + {(bag.members || []).map(m => { + const face = m.avatar ? ( + {m.username} + ) : ( + + {m.username[0].toUpperCase()} + + ) + if (!canEdit) return {face} + return ( + + ) + })} {canEdit && ( - )} @@ -110,7 +116,7 @@ export function BagCard({ bag, bagItems, totalWeight, pct, tripId, tripMembers, {tripMembers.map(m => { const isSelected = memberIds.includes(m.id) return ( -
diff --git a/client/src/components/Packing/PackingListPanelBagModal.tsx b/client/src/components/Packing/PackingListPanelBagModal.tsx index 650ce8632..8f43d2dbc 100644 --- a/client/src/components/Packing/PackingListPanelBagModal.tsx +++ b/client/src/components/Packing/PackingListPanelBagModal.tsx @@ -15,12 +15,17 @@ export function BagModal(S: PackingState) { const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1) return (
setShowBagModal(false)}> -
setShowBagModal(false)} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowBagModal(false) } + }}> +
e.stopPropagation()}>

{t('packing.bags')}

- +
{bags.map(bag => { @@ -66,13 +71,13 @@ export function BagModal(S: PackingState) { onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} placeholder={t('packing.bagName')} style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none' }} /> -
) : ( -
) : ( - diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx index dfe17fc3b..299d40849 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx @@ -377,7 +377,10 @@ describe('KategorieGruppe — assignees', () => { }) fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!) - fireEvent.click(within(screen.getByRole('button', { name: /alice/ })).getByText('alice').closest('button')!) + // Scoped to the dropdown: an assigned member also has a chip button carrying + // their name, so an unscoped role query matches two elements. + const dropdown = within(container.querySelector('svg.lucide-user-plus')!.closest('div')!) + fireEvent.click(dropdown.getByRole('button', { name: /alice/ })) expect(onSetAssignees).toHaveBeenCalledWith('Gear', [1]) }) @@ -388,8 +391,9 @@ describe('KategorieGruppe — assignees', () => { assignees: [{ user_id: 1, username: 'owner' }], }) fireEvent.click(container.querySelector('svg.lucide-user-plus')!.closest('button')!) - const assigned = screen.getByRole('button', { name: /owner/ }) - const free = screen.getByRole('button', { name: /alice/ }) + const dropdown = within(container.querySelector('svg.lucide-user-plus')!.closest('div')!) + const assigned = dropdown.getByRole('button', { name: /owner/ }) + const free = dropdown.getByRole('button', { name: /alice/ }) fireEvent.mouseEnter(free) expect(free.style.background).toBe('var(--bg-tertiary)') diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index 8c2bad168..45570ad78 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -115,7 +115,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen return (
- @@ -137,35 +137,46 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen {/* Assignee chips */}
- {assignees.map(a => ( -
{ e.stopPropagation(); if (canEdit) onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }} - > -
{ + // The chip is only ever clickable for an editor, so read-only members + // get the plain div back rather than a focusable stop that does nothing. + const chip = ( + <> +
+ {a.username[0]} +
+
+ {a.username} +
+ + ) + if (!canEdit) return
{chip}
+ return ( +
-
- {a.username} -
-
- ))} + {chip} + + ) + })} {canEdit && (
- @@ -244,7 +255,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen const rect = menuBtnRef.current?.getBoundingClientRect(); return ( <> -
setShowMenu(false)} /> +
setShowMenu(false)} />
{canEdit && } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />} } label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> @@ -300,18 +311,18 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} /> {/* disabled while the field is empty, so no extra guard here */} - -
) : ( - - + +
)} {inlineHeader && canEdit && ( - )} {inlineHeader && canEdit && abgehakt > 0 && ( -
)} {inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && ( - )} {bagTrackingEnabled && ( - -
) : ( -
- - + ) : ( + + {item.name} )} @@ -200,7 +209,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa readOnly={!canEdit} onValueChange={async raw => { if (!canEdit) return - const v = raw === '' ? null : parseInt(raw) + const v = raw === '' ? null : Number.parseInt(raw) try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) } }} placeholder="—" @@ -209,7 +218,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa g
- )} {bags.map(b => ( -
) : ( - - @@ -344,7 +353,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa {canEdit && (
- {open && pos && createPortal( <> -
setOpen(false)} /> +
setOpen(false)} />
{ const on = recipientIds.includes(m.id) return ( - @@ -117,7 +117,7 @@ export default function PackingShareControl({ item, tripMembers, currentUserId, function Row({ icon, label, sub, active, onClick }: { icon: React.ReactNode; label: string; sub: string; active: boolean; onClick: () => void }) { return ( -
@@ -371,13 +373,13 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
- -
@@ -149,7 +151,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI onChange={handleInputChange} /> -
fileInputRef.current?.click()} onDragOver={handleDragOver} onDragEnter={handleDragOver} @@ -173,7 +176,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI ) : ( {t('reservations.import.dropHere')} )} -
+ {error && (
@@ -184,13 +187,13 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI {/* Footer */}
- - )} @@ -182,14 +186,14 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri )} {!collapsed && formattedDate &&
{formattedDate}
}
- - } - {canEditDays && }
@@ -454,12 +458,17 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays,
{linked.title}
{confirmed ? t('reservations.confirmed') : t('reservations.pending')} - {linked.confirmation_number && { if (blurCodes) e.currentTarget.style.filter = 'none' }} onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }} onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }} - style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }} - >#{linked.confirmation_number}} + style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default', background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'inherit' }} + >#{linked.confirmation_number}}
@@ -468,7 +477,7 @@ function AccommodationList({ dayAccommodations, day, reservations, canEditDays, ) })} {/* Add another hotel */} - {canEditDays && }
) : ( - canEditDays ?
@@ -584,7 +596,7 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD size="sm" />
- {categories.map(c => ( - - ) }) })()} @@ -1697,10 +1731,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const activeRentals = getActiveRentalsForDay(day.id) if (activeRentals.length === 0) return null return activeRentals.map(r => ( - { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}> + )) })()}
@@ -1720,26 +1754,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {/* Public transit search (#1065) — replaced the rename pencil, which moved next to the day name in the day detail view. */} {onPlanTransit ? ( - ) :
} {onAddTransport ? ( - ) :
} - -
) })() ) : ( - )} @@ -1765,7 +1799,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const toLegIndex = legPart ? Number(legPart.slice(3)) : null if (placeId) { - onAssignToDay?.(parseInt(placeId), day.id) + onAssignToDay?.(Number.parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } @@ -1791,7 +1825,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP } if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } if (placeId) { - onAssignToDay?.(parseInt(placeId), day.id) + onAssignToDay?.(Number.parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { @@ -1815,7 +1849,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const targetId = hotelLegs[day.id]?.top?.targetId const connector = return canEditDays && targetId != null ? ( -
openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} style={{ cursor: 'pointer' }}> +
openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }}> {connector}
) : connector @@ -1889,11 +1923,18 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP } const moveUp = (e) => { e.stopPropagation(); arrowMove('up') } const moveDown = (e) => { e.stopPropagation(); arrowMove('down') } + const selectPlace = () => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) } return (
{ if (!canEditDays || dragDisabled) { e.preventDefault(); return } @@ -1909,7 +1950,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { const pos = placeItems.findIndex(i => i.data.id === assignment.id) - onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined) + onAssignToDay?.(Number.parseInt(placeId), day.id, pos >= 0 ? pos : undefined) setDropTargetKey(null); window.__dragData = null } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) @@ -1949,7 +1990,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP } }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} - onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} + onClick={selectPlace} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + selectPlace() + }} onContextMenu={e => { // Flat entries, one per app: the menu has room to // grow and a submenu would need a component that @@ -1997,11 +2044,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {canEditDays && !dragDisabled &&
} -
{ e.stopPropagation(); toggleLock(assignment.id) }} onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }} onMouseLeave={() => setLockHoverId(null)} - style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }} + style={{ position: 'relative', flexShrink: 0, cursor: 'pointer', display: 'flex', background: 'none', border: 'none', padding: 0, font: 'inherit' }} > {/* Hover/locked overlay */} @@ -2029,7 +2078,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP : t('planner.keepPosition')}
)} -
+
{cat && (() => { @@ -2144,7 +2193,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP marginLeft: pi > 0 ? -4 : 0, flexShrink: 0, overflow: 'hidden', }}> - {p.avatar ? : p.username?.[0]?.toUpperCase()} + {p.avatar ? {p.username : p.username?.[0]?.toUpperCase()}
))} {assignment.participants.length > 5 && ( @@ -2154,15 +2203,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP )}
{canEditDays &&
- -
} {canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && ( -
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => )} {routeLegs[day.id]?.[assignment.id] && (canEditDays ? ( -
openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} style={{ cursor: 'pointer' }}> +
openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }}>
) : ( @@ -2246,22 +2295,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const displayTime = getDisplayTimeForDay(res, day.id) const legKey = res.__leg ? `leg${res.__leg.index}` : 'x' + const openTransportRow = () => { + const target = reservations.find(x => x.id === res.id) ?? res + // A transit journey opens its own journey view — the rich + // stop-by-stop breakdown with its booking fields, never the + // generic edit form (#1065). + if (transitMeta) { + if (onOpenTransit) onOpenTransit(target) + else setTransportDetail(target) + return + } + if (!canEditDays) return + if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) + else onEditReservation?.(target) + } + return (
{ - const target = reservations.find(x => x.id === res.id) ?? res - // A transit journey opens its own journey view — the rich - // stop-by-stop breakdown with its booking fields, never the - // generic edit form (#1065). - if (transitMeta) { - if (onOpenTransit) onOpenTransit(target) - else setTransportDetail(target) - return - } - if (!canEditDays) return - if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) - else onEditReservation?.(target) + // Opening the booking has no other trigger, so the row is + // the control; the buttons inside it answer for themselves. + role="button" + tabIndex={0} + onClick={openTransportRow} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + openTransportRow() }} onDragOver={e => { e.preventDefault(); e.stopPropagation() @@ -2289,7 +2350,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const insertAfter = e.clientY > rect.top + rect.height / 2 const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { - onAssignToDay?.(parseInt(placeId), day.id) + onAssignToDay?.(Number.parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r2 = reservations.find(x => x.id === Number(fromReservationId)) if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } @@ -2432,7 +2493,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id const connector = return canEditDays && nextPlaceId != null ? ( -
openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} style={{ cursor: 'pointer' }}> +
openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }}> {connector}
) : connector @@ -2464,7 +2525,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP 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) + onAssignToDay?.(Number.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)) @@ -2549,12 +2610,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP every note a shorter line for the whole time you are not hovering. */} {canEditDays &&
- - + +
} {canEditDays &&
- - + +
}
@@ -2566,7 +2627,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const targetId = hotelLegs[day.id]?.bottom?.targetId const connector = return canEditDays && targetId != null ? ( -
openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} style={{ cursor: 'pointer' }}> +
openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }}> {connector}
) : connector @@ -2580,7 +2641,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Neuer Ort von der Orte-Liste if (placeId) { - onAssignToDay?.(parseInt(placeId), day.id) + onAssignToDay?.(Number.parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (fromReservationId && fromDayId !== day.id) { @@ -2618,7 +2679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
- {/* Open the day's stops as a route in Google Maps (planned order). #1255 */} -
@@ -58,7 +58,7 @@ export function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onA
)} {filtered.slice(0, 20).map(p => ( -
{onAddNew && ( - - diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index e41a20b0d..94a1833ef 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -94,7 +94,7 @@ export function DayPlanSidebarToolbar({ const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll') return ( - + ) : ( +
{f.value}
+ )}
) })} @@ -215,12 +229,13 @@ export function DayPlanSidebarTransportDetailModal({
{t('files.title')}
{resFiles.map(f => ( -
{ setTransportDetail(null); onNavigateToFiles?.() }} className="bg-surface-tertiary" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', - borderRadius: 8, cursor: 'pointer', + borderRadius: 8, cursor: 'pointer', border: 'none', textAlign: 'left', fontFamily: 'inherit', transition: 'background 0.1s', }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} @@ -231,7 +246,7 @@ export function DayPlanSidebarTransportDetailModal({ {f.original_name} -
+ ))}
@@ -241,7 +256,7 @@ export function DayPlanSidebarTransportDetailModal({ {/* Aktionen */}
{onEdit && ( - )} - - {isGpx && (
@@ -285,7 +288,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini {t('places.gpxImportTypes')}
{(['waypoints', 'routes', 'tracks'] as const).map(key => ( -
{(['points', 'paths'] as const).map(key => ( -
)}
-
@@ -47,13 +48,13 @@ export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }: {categories.map(c => { const CatIcon = getCategoryIcon(c.icon) return ( - ) })} - diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index c8a52e48d..a760588dc 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -30,7 +30,7 @@ export function PlacesHeader(S: SidebarState) { } = S return (
- {canEditPlaces && - )} @@ -162,7 +162,7 @@ export function PlacesHeader(S: SidebarState) { : `${categoryFilters.size} ${t('places.categoriesSelected')}` return (
- {categories.length > 0 && } {/* Minimum-stars filter (#1435), same floors as the collections bar. */} - }
diff --git a/client/src/components/Planner/PlacesSidebarListImportModal.tsx b/client/src/components/Planner/PlacesSidebarListImportModal.tsx index dc0c613dd..9582ae162 100644 --- a/client/src/components/Planner/PlacesSidebarListImportModal.tsx +++ b/client/src/components/Planner/PlacesSidebarListImportModal.tsx @@ -10,11 +10,13 @@ export function ListImportModal(S: SidebarState) { } = S return createPortal(
{ setListImportOpen(false); setListImportUrl('') }} className="bg-[rgba(0,0,0,0.4)]" style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} >
e.stopPropagation()} className="bg-surface-card" style={{ borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} @@ -25,7 +27,7 @@ export function ListImportModal(S: SidebarState) { {hasMultipleListImportProviders && (
{availableListImportProviders.map(provider => ( -
)}
- - {/* Edit */} {canEditPlaces && ( - - + ) +} + interface ReservationCardProps { r: Reservation tripId: number @@ -229,7 +258,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo maxWidth: 140, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', }}>{r.title} {canEdit && ( - )} {canEdit && ( -
@@ -463,10 +466,10 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('files.title')}
@@ -497,12 +500,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo {/* Delete confirmation */} {showDeleteConfirm && createPortal( -
setShowDeleteConfirm(false)}> -
- - @@ -561,7 +564,7 @@ function Section({ title, count, children, defaultOpen = true, accent, storageKe }, [open, storageKey]) return (
-
{canEdit && ( -
)} {r.travelers && r.travelers.length > 0 && ( -
e.stopPropagation()}> +
e.stopPropagation()}>
)} @@ -666,7 +672,7 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The card itself is clickable, so keep frame interactions from opening it. */} {detailPlugins.length > 0 && ( -
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> +
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> {detailPlugins.map(p => (
@@ -675,13 +681,13 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
)} {confirmOpen && createPortal( -
{ e.stopPropagation(); setConfirmOpen(false) }}> -
e.stopPropagation()}> +
{ e.stopPropagation(); setConfirmOpen(false) }}> +
e.stopPropagation()}>
{t('reservations.confirm.deleteTitle')}
{t('reservations.confirm.deleteBody', { name: r.title })}
- - + +
, @@ -837,7 +843,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme <>
- )} - {onImport && bookingImportAvailable && ( - )} {onAirTrailImport && airTrailAvailable && ( - diff --git a/client/src/components/Planner/SubscribeLinks.tsx b/client/src/components/Planner/SubscribeLinks.tsx index e5eed99ab..38ae62c3d 100644 --- a/client/src/components/Planner/SubscribeLinks.tsx +++ b/client/src/components/Planner/SubscribeLinks.tsx @@ -114,7 +114,7 @@ function UrlRow({ label, hint, url, copied, onCopy }: { }}> {url}
- )} @@ -198,7 +198,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
{canEdit && ( - )} diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index dd8478b67..981a68f1d 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -149,7 +149,7 @@ function StopPicker({ label, value, onPick, quickPicks, near, placeholder }: {
{results.length > 0 ? results.map((r, i) => ( - )) : quickPicks.map((p, i) => ( - - + +
@@ -539,9 +539,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations = )}
- - - + + +
@@ -552,7 +552,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations = {MODE_GROUPS.map(m => { const active = activeModes.has(m.key) return ( -
- ))} {frame && ( -
setFrame(null)}> -
e.stopPropagation()}> +
setFrame(null)}> +
e.stopPropagation()}>
{frame.label} - +
diff --git a/client/src/components/Settings/AccountTab.test.tsx b/client/src/components/Settings/AccountTab.test.tsx index 54a0abe2c..11bd84931 100644 --- a/client/src/components/Settings/AccountTab.test.tsx +++ b/client/src/components/Settings/AccountTab.test.tsx @@ -74,7 +74,7 @@ describe('AccountTab', () => { render(<>); await user.click(screen.getByText('Update password')); // Validation fires: first checks currentPassword — "Current password is required" - await screen.findByText(/Current password is required/i); + expect(await screen.findByText(/Current password is required/i)).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-011: password mismatch shows error', async () => { @@ -86,7 +86,7 @@ describe('AccountTab', () => { await user.type(passwordInputs[1], 'NewPassword1!'); await user.type(passwordInputs[2], 'DifferentPass1!'); await user.click(screen.getByText('Update password')); - await screen.findByText('Passwords do not match'); + expect(await screen.findByText('Passwords do not match')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-012: valid password change calls API', async () => { @@ -140,7 +140,7 @@ describe('AccountTab – Profile', () => { seedStore(useAuthStore, { updateProfile: vi.fn().mockResolvedValue(undefined) }); render(<>); await user.click(screen.getByRole('button', { name: /save profile/i })); - await screen.findByText('Profile saved'); + expect(await screen.findByText('Profile saved')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-016: failed save shows error toast with error message', async () => { @@ -148,7 +148,7 @@ describe('AccountTab – Profile', () => { seedStore(useAuthStore, { updateProfile: vi.fn().mockRejectedValue(new Error('Server error')) }); render(<>); await user.click(screen.getByRole('button', { name: /save profile/i })); - await screen.findByText('Server error'); + expect(await screen.findByText('Server error')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-017: Save button shows spinner while saving', async () => { @@ -171,7 +171,7 @@ describe('AccountTab – Password change', () => { await user.type(passwordInputs[1], 'short'); await user.type(passwordInputs[2], 'short'); await user.click(screen.getByText('Update password')); - await screen.findByText(/at least 8 characters/i); + expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-019: password change clears fields on success', async () => { @@ -205,7 +205,7 @@ describe('AccountTab – Password change', () => { await user.type(passwordInputs[1], 'NewPassword1!'); await user.type(passwordInputs[2], 'NewPassword1!'); await user.click(screen.getByText('Update password')); - await screen.findByText('Wrong password'); + expect(await screen.findByText('Wrong password')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-021: password section hidden in OIDC-only mode', async () => { @@ -376,7 +376,7 @@ describe('AccountTab – MFA', () => { const codeInput = screen.getByPlaceholderText('6-digit code'); await user.type(codeInput, '123456'); await user.click(screen.getByRole('button', { name: 'Disable 2FA' })); - await screen.findByText('Two-factor authentication disabled'); + expect(await screen.findByText('Two-factor authentication disabled')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-035: policy warning shown when MFA is required by policy', () => { @@ -577,7 +577,7 @@ describe('AccountTab – Backup codes', () => { await user.click(await screen.findByRole('button', { name: 'Copy codes' })); - await screen.findByText('Error'); + expect(await screen.findByText('Error')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-052: Download TXT builds a blob, names the file and revokes the URL', async () => { @@ -682,7 +682,7 @@ describe('AccountTab – Avatar upload', () => { const input = document.querySelector('input[type="file"]') as HTMLInputElement; await user.upload(input, new File(['avatar'], 'me.png', { type: 'image/png' })); - await screen.findByText('Upload failed'); + expect(await screen.findByText('Upload failed')).toBeInTheDocument(); }); it('FE-COMP-ACCOUNT-058: a change event without a file uploads nothing', () => { @@ -723,7 +723,7 @@ describe('AccountTab – Avatar upload', () => { const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; await user.click(fileInput.parentElement!.querySelectorAll('button')[1]); - await screen.findByText('Upload failed'); + expect(await screen.findByText('Upload failed')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index e9a2488b6..126d296d6 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -13,6 +13,14 @@ import PasskeysSection from './PasskeysSection' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' +// Drops every trailing slash, like the `/\/+$/` replace it stands in for — as a scan, +// because that pattern re-walks the slash run from each start position. +const stripTrailingSlashes = (value: string): string => { + let end = value.length + while (end > 0 && value[end - 1] === '/') end-- + return value.slice(0, end) +} + export default function AccountTab(): React.ReactElement { const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore() const [searchParams] = useSearchParams() @@ -198,7 +206,7 @@ export default function AccountTab(): React.ReactElement { placeholder={t('settings.confirmPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> -
)} - {user?.avatar_url && ( -
- - -
- - ))} @@ -413,7 +413,7 @@ export default function AppearanceSettingsTab(): React.ReactElement {
- - @@ -416,6 +416,7 @@ function IntegrationsMcpSection(props: any) { ))} {client.allowed_scopes.length > 5 && (
- -
-
- @@ -526,7 +527,7 @@ function IntegrationsMcpSection(props: any) { )}

- - @@ -585,14 +586,14 @@ function McpTokenModals(props: any) {
                     {mcpCreatedToken}
                   
-
- @@ -605,17 +606,17 @@ function McpTokenModals(props: any) { {/* Delete MCP Token confirm */} {mcpDeleteId !== null && ( -
{ if (e.target === e.currentTarget) setMcpDeleteId(null) }}>

{t('settings.mcp.deleteTokenTitle')}

{t('settings.mcp.deleteTokenMessage')}

- - @@ -636,7 +637,7 @@ function OAuthClientModals(props: any) { <> {/* Create OAuth Client modal */} {oauthCreateOpen && ( -
{ if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
{!oauthCreatedClient ? ( @@ -670,12 +671,12 @@ function OAuthClientModals(props: any) { autoFocus />
-
- -
) : ( -
{isCurrentOwner && member.role !== 'owner' && ( - )} {canRemove && ( - - {companyHolidaysEnabled && ( - -
@@ -149,10 +160,10 @@ export default function VacayPersons() { /> )}
- -
{PRESET_COLORS.map(c => ( -
-
@@ -526,7 +526,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: { /> )}
-
@@ -607,7 +607,7 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo /> )}
- -
@@ -151,10 +156,10 @@ export default function VacaySharedCalendars() { /> )}
- - - -
)} {targets.map(target => ( -
+ )} v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) + const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0) const pa = nums(a), pb = nums(b) for (let i = 0; i < 3; i++) { const x = pa[i] || 0, y = pb[i] || 0 @@ -399,6 +413,12 @@ export default function MAdminPluginsPanel() { const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) + // A picked version OLDER than the installed one — held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) @@ -427,10 +447,13 @@ export default function MAdminPluginsPanel() { // Index the registry once per fetch: the version map the update badges read, plus the // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible — the newest version THIS TREK can install (computed + // server-side) — not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. const indexRegistry = (items: RegistryItem[]) => { const vers: Record = {} const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest }) + items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) setLatest(vers) setRegById(byId) } @@ -563,7 +586,21 @@ export default function MAdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) } - const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: + // the admin just rolled it back, and the banner nagging them straight back would make + // the rollback fight the UI. The row carries its own "paused" marker + resume instead. + const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) + const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id] + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null + if (latest[p.id] === reg.latest) return null + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) + return range ? { version: reg.latest, range } : null + } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the @@ -651,9 +688,11 @@ export default function MAdminPluginsPanel() { .finally(() => { setBusy(null); refresh() }) } - const runUpdate = (p: PluginRow) => { + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) - adminApi.pluginUpdate(p.id) + adminApi.pluginUpdate(p.id, version) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -685,6 +724,27 @@ export default function MAdminPluginsPanel() { .finally(() => { setRetrusting(false); refresh() }) } + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null) + setVersionPicker({ plugin: p, versions: null, failed: false }) + adminApi.pluginDetail(p.id) + .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) + .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) + } + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand — + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return + const p = versionPicker.plugin + setVersionPicker(null) + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) + else runUpdate(p, version) + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) @@ -850,7 +910,9 @@ export default function MAdminPluginsPanel() {
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - @@ -864,9 +926,11 @@ export default function MAdminPluginsPanel() { ) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => runUpdate(p)} + onResume={() => void resumeUpdates(p)} onReviewBlock={() => setSignatureBlock({ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, code: p.updateBlock!.code, detail: p.updateBlock!.detail, @@ -891,9 +955,28 @@ export default function MAdminPluginsPanel() { onRestart={() => restart(rowMenuPlugin.id)} onErrors={() => openErrors(rowMenuPlugin.id)} onEgress={() => openEgress(rowMenuPlugin.id)} + onChangeVersion={() => openVersionPicker(rowMenuPlugin)} onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} /> )} + {/* Version picker sheet */} + {versionPicker && ( + setVersionPicker(null)} /> + )} + + {/* Downgrade confirm */} + setConfirmDowngrade(null)} + onConfirm={() => { const d = confirmDowngrade!; setConfirmDowngrade(null); runUpdate(d.plugin, d.version) }} + title={t('admin.plugins.downgradeTitle')} + message={t('admin.plugins.downgradeBody', { from: confirmDowngrade?.plugin.version ?? '', to: confirmDowngrade?.version ?? '' })} + confirmLabel={t('admin.plugins.downgradeConfirm')} + cancelLabel={t('common.cancel')} + danger + /> + {/* Filter picker sheets */} setMenu(null)} title={t('admin.plugins.filterType')} value={typeFilter} @@ -956,7 +1039,7 @@ export default function MAdminPluginsPanel() { {egressFor.hosts.map(h => (
{h} -
)} + {/* A newer version this TREK can't run — informational, never a button: the fix + is a TREK upgrade, so the row must not nag or offer a doomed install. */} + {newerIncompatible && ( +
+ + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} +
+ )} + + {/* Held: the admin deliberately installed a non-latest version, so updates are + paused rather than nagged — resuming is one click, right where the pause shows. */} + {p.updateHold && ( +
+ + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} + +
+ )} + {p.status === 'error' && p.last_error ? (
{p.last_error} @@ -1186,7 +1291,7 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( - @@ -1223,9 +1328,9 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, blocked, onToggle, onU } // Row ⋯ action sheet — the desktop portal menu re-expressed as a bottom sheet. -function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUninstall }: { +function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onChangeVersion, onUninstall }: { p: PluginRow; t: T; onClose: () => void - onRestart: () => void; onErrors: () => void; onEgress: () => void; onUninstall: () => void + onRestart: () => void; onErrors: () => void; onEgress: () => void; onChangeVersion: () => void; onUninstall: () => void }) { const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] font-semibold text-m-ink' @@ -1238,6 +1343,10 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins )} + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + + )} {linkable && ( <> @@ -1258,6 +1367,51 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onUnins ) } +/** + * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat + * verdict: the installed version is marked, a compatible one gets a Switch action (the + * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is + * explained and never offered. + */ +function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { + plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null + t: T; locale: string; onPick: (version: string) => void; onClose: () => void +}) { + return ( + + +
+ {failed &&

{t('admin.plugins.detailError')}

} + {!failed && !versions &&

{t('common.loading')}

} + {versions?.length === 0 &&

{t('admin.plugins.noVersions')}

} + {versions?.map(v => { + const current = v.version === plugin.version + return ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {current ? ( + {t('admin.plugins.installed')} + ) : v.compatible ? ( + + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ) + })} +
+
+
+ ) +} + // ── Registry (Discover) ──────────────────────────────────────────────────── function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { @@ -1267,7 +1421,7 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {

{t('admin.plugins.empty')}

-
@@ -1340,7 +1494,7 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f {formatCompactCount(item.downloadCount)} )} - +
@@ -1416,7 +1570,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local

{item.author}{item.latest ? ` · v${item.latest}` : ''}

-
+ + {/* Every published version, installable individually. The compat verdict per + version is SERVER-computed — an incompatible one is explained, never offered. */} + {(detail?.versions?.length ?? 0) > 1 && ( +
+

{t('admin.plugins.versionsTitle')}

+
+ {detail!.versions!.map(v => ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {v.compatible ? ( + !installed && ( + + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ))} +
+
+ )}
@@ -1711,7 +1893,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo : t('admin.plugins.dep.requires', { version: r.constraint })}
- @@ -1748,7 +1930,7 @@ function SecurityInfo({ t }: { t: T }) {

{t('admin.plugins.reviewedMeaning')}

- ) } diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index 2dd33cc3e..180c7046b 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -142,7 +142,18 @@ export function PlaceRow({ assignment, fullPlace, linkedRes, chrome, reorder, dr : place?.address || place?.description || '' return ( -
+ // Stays a div: in editing mode the row carries its own action circles and + // reorder controls, which may not be nested inside a )}
@@ -235,12 +236,13 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) { {[leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')} - { if (blurCodes) setCodeRevealed(v => !v) }} className={`flex-none font-geist text-[0.71875rem] tabular-nums text-m-muted ${codeBlurred ? 'blur-[4px] select-none' : ''}`} > #{leg.confirmation_number} - +
))}
diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index 6ce637da2..6a6313899 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -66,18 +66,24 @@ interface StatBoxProps { /** Small stat box (check-in / times / code): value 700 tabular over a faint Geist label. */ export function StatBox({ value, label, blurred = false, onClick }: StatBoxProps) { - return ( -
+ const box = 'min-w-0 flex-1 rounded-[10px] bg-[color:var(--m-ic)] px-[9px] py-[7px] text-center' + const inner = ( + <>
{value}
{label}
-
+ + ) + // A tappable stat is a real button — value and label give it its name. Without + // a handler it stays a plain box rather than an empty stop in the tab order. + if (!onClick) return
{inner}
+ return ( + ) } diff --git a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx index 2467e987f..28da858a4 100644 --- a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx +++ b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx @@ -38,7 +38,7 @@ interface PlPlaceSearchProps { } /** "48.8566, 2.3522" (also ; or whitespace separated) → direct coordinates. */ -const COORD_RE = /^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/ +const COORD_RE = /^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/ function placeToPick(place: MapsPlace): PlSearchPick { const s = (v: unknown) => (v == null ? undefined : String(v)) diff --git a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx index c71856d00..ff2a2617c 100644 --- a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx @@ -240,10 +240,15 @@ function BookingCard({ res, planner, canEdit, compact }: { {t('files.title')}
+ {/* A span with a button role, not a )} {fileIsVideo ? ( -
e.stopPropagation()}> +
e.stopPropagation()}>
) : ( imgSrc && ( - {file.original_name} e.stopPropagation()} - className="block max-h-[78vh] max-w-[92vw] rounded-lg object-contain" - /> + // The stop sits on a wrapper rather than on the : a picture is + // not something you activate, and this keeps its alt text intact. +
e.stopPropagation()}> + {file.original_name} +
) )} {hasNext && ( diff --git a/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx b/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx index 820b495bf..930180fdf 100644 --- a/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackItemSheet.tsx @@ -87,8 +87,8 @@ export default function MPackItemSheet({ const trimmedName = name.trim() setSaving(true) try { - const qty = Math.max(1, Math.min(999, parseInt(quantity, 10) || 1)) - const weightVal = weight.trim() === '' ? null : Math.max(0, parseInt(weight, 10) || 0) + const qty = Math.max(1, Math.min(999, Number.parseInt(quantity, 10) || 1)) + const weightVal = weight.trim() === '' ? null : Math.max(0, Number.parseInt(weight, 10) || 0) await tripActions.updatePackingItem(tripId, item.id, { name: trimmedName, quantity: qty, diff --git a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx index 65be985a3..aa190c619 100644 --- a/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MPackingListTab.tsx @@ -598,8 +598,8 @@ function PackingCategoryCard({ return (
- {/* A
rather than a real
-
-
- - -
- - - )} {bucketForm.name && ( - @@ -643,12 +643,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
- -
) : (
- ))} {countryDetail.manually_marked && onUnmarkCountry && ( - diff --git a/client/src/pages/DashboardPage.test.tsx b/client/src/pages/DashboardPage.test.tsx index cb838d62a..c81ba427f 100644 --- a/client/src/pages/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { localIsoDate } from '../utils/localDate'; import { render, screen, waitFor, within } from '../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -535,9 +536,9 @@ describe('DashboardPage', () => { describe('FE-PAGE-DASH-023: SpotlightCard shows progress bar for ongoing trip', () => { it('renders progress bar and live badge when trip is currently ongoing', async () => { - const today = new Date().toISOString().split('T')[0]; - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; - const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]; + const today = localIsoDate(); + const yesterday = localIsoDate(new Date(Date.now() - 86400000)); + const nextWeek = localIsoDate(new Date(Date.now() + 7 * 86400000)); const ongoingTrip = buildTrip({ title: 'Current Voyage', diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index dad6a303c..812a49c35 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -52,7 +52,7 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string; y: string } | null { if (!dateStr) return null const date = new Date(dateStr + 'T00:00:00Z') - if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash + if (Number.isNaN(date.getTime())) return null // malformed date — render a dash, never crash const otherYear = date.getUTCFullYear() !== new Date().getUTCFullYear() return { d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }), @@ -67,7 +67,7 @@ function splitDate(dateStr: string | null | undefined, locale: string): { d: str function fullDate(dateStr: string | null | undefined, locale: string): string | null { if (!dateStr) return null const date = new Date(dateStr + 'T00:00:00Z') - if (isNaN(date.getTime())) return null + if (Number.isNaN(date.getTime())) return null const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', timeZone: 'UTC' } if (date.getUTCFullYear() !== new Date().getUTCFullYear()) opts.year = 'numeric' return date.toLocaleDateString(locale, opts) @@ -151,7 +151,7 @@ function DashboardPageDesktop(): React.ReactElement { {loadError && (
{t('dashboard.loadErrorBanner')} - @@ -177,11 +177,11 @@ function DashboardPageDesktop(): React.ReactElement {

{t('dashboard.title')}

- - - + + +
- -
@@ -228,7 +228,7 @@ function DashboardPageDesktop(): React.ReactElement { /> ))} {tripFilter === 'planned' && !isLoading && ( - - - - + + + +
@@ -424,12 +430,12 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch ))}
)} -
{ e.stopPropagation(); onOpen() }}> +
{ e.stopPropagation(); onOpen() }}>
{passCells}
- +
) } @@ -552,17 +558,23 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } return ( -
+
{ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }} + >
{trip.cover_image ? {trip.title} :
}
{statusLabel}
- - - - + + + +

{trip.title}

@@ -585,7 +597,7 @@ function TripCard({ trip, locale, badges, onOpen, onEdit, onCopy, onArchive, onD
-
+
) } @@ -633,10 +645,10 @@ function CurrencyTool(): React.ReactElement { }).catch(() => { /* keep localStorage; retry on next load */ }) }, [isLoaded, updateSetting]) - const currencies = rates ? Object.keys(rates).sort() : CURRENCIES + const currencies = rates ? Object.keys(rates).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) : CURRENCIES const ccyOptions = currencies.map(c => ({ value: c, label: c })) const rate = rates?.[to] ?? null - const converted = rate != null ? (parseFloat(amount.replace(',', '.')) || 0) * rate : null + const converted = rate != null ? (Number.parseFloat(amount.replace(',', '.')) || 0) * rate : null const swap = () => { setFrom(to); setTo(from) } @@ -644,7 +656,7 @@ function CurrencyTool(): React.ReactElement {
{t('dashboard.currency')}
- +
@@ -652,7 +664,7 @@ function CurrencyTool(): React.ReactElement { setAmount(e.target.value)} inputMode="decimal" /> setFrom(String(v))} options={ccyOptions} searchable size="sm" style={{ marginTop: 6 }} />
- +
{t('dashboard.fx.to')}
@@ -739,7 +751,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
{t('dashboard.timezone')}
-
@@ -757,7 +769,7 @@ function TimezoneTool({ locale }: { locale: string }): React.ReactElement {
{offsetLabel(tz)}
{timeIn(tz)}
- +
))} {zones.length === 0 && ( @@ -793,7 +805,9 @@ function UpcomingTool({ items, locale, onOpen }: { const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null const typeClass = RES_TYPE_CLASS[r.type] || 'other' return ( -
onOpen(r.trip_id)}> +
onOpen(r.trip_id)} + role="button" tabIndex={0} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(r.trip_id) } }}>
{dateStr?.d ?? '–'}
{dateStr?.m ?? ''}
{r.title}
diff --git a/client/src/pages/HelpPage.tsx b/client/src/pages/HelpPage.tsx index 12018ebab..1e9d37fc5 100644 --- a/client/src/pages/HelpPage.tsx +++ b/client/src/pages/HelpPage.tsx @@ -72,7 +72,7 @@ export default function HelpPage() { {/* Content */}
{/* Mobile nav toggle */} -
diff --git a/client/src/pages/InAppNotificationsPage.tsx b/client/src/pages/InAppNotificationsPage.tsx index ea66e3886..a34c3a706 100644 --- a/client/src/pages/InAppNotificationsPage.tsx +++ b/client/src/pages/InAppNotificationsPage.tsx @@ -44,7 +44,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement { {/* Bulk actions */}
{unreadCount > 0 && ( - )} {notifications.length > 0 && ( - - - -
- - {canEditJourney && ( -
-
{canEditJourney && ( - + )}
@@ -320,7 +320,7 @@ function JourneyDetailPageDesktop() { { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, ] ).map(v => ( -
{canEditEntries && view === 'timeline' && ( - )} {canEditEntries && view === 'gallery' && ( - )} {!feedEdge.atBottom && ( - - - )} @@ -220,7 +222,7 @@ function JourneyPageDesktop() { ))} {/* Create card */} - - ) } diff --git a/client/src/pages/JourneyPage.wiring.test.tsx b/client/src/pages/JourneyPage.wiring.test.tsx index 14aa14453..cb59435a7 100644 --- a/client/src/pages/JourneyPage.wiring.test.tsx +++ b/client/src/pages/JourneyPage.wiring.test.tsx @@ -1,5 +1,6 @@ // FE-JRN-LISTWIRE-001 to FE-JRN-LISTWIRE-014 import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { localIsoDate } from '../utils/localDate'; import { render, screen, fireEvent } from '../../tests/helpers/render'; import type { Journey } from '../store/journeyStore'; import JourneyPage from './JourneyPage'; @@ -182,7 +183,7 @@ describe('JourneyPage wiring', () => { }); it('FE-JRN-LISTWIRE-014: modal trips render their status and toggle their selection', () => { - const today = new Date().toISOString().split('T')[0]; + const today = localIsoDate(); // local — the status chips classify against the wall clock const availableTrips = [ { id: 1, title: 'Past trip', start_date: '2020-01-01', end_date: '2020-01-05', place_count: 3, cover_image: '/uploads/t.jpg' }, { id: 2, title: 'Running trip', start_date: today, end_date: '2099-01-01', place_count: 1 }, diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index e75a976ec..0f9a4aa2a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -145,19 +145,19 @@ export default function JourneyPublicPage() { perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, !desktopTwoColumn && - !isMobile && - perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + !isMobile && + perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]; // The hook cannot build these: the thumbnail URL needs the share token, which is // the credential for the byte proxy. const photoLayer = token ? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({ - id: p.id, - lat: p.lat, - lng: p.lng, - thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'), - })) + id: p.id, + lat: p.lat, + lng: p.lng, + thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'), + })) : []; // Shared timeline renderer used in both layout modes @@ -219,7 +219,7 @@ export default function JourneyPublicPage() { id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, - mediaType: (p as any).media_type, + mediaType: p.media_type, })); const isActive = activeEntryId === String(entry.id); @@ -241,11 +241,27 @@ export default function JourneyPublicPage() { > {/* Photo area */} {photos.length === 1 && ( -
setLightbox({ photos: lightboxPhotos, index: 0 })} > - +
+ + + {photos[0].media_type === 'video' && ( +
+ + + +
+ )} +
)} -
+ )} - {photos.length === 2 && (
{photos.slice(0, 2).map((p, i) => ( - setLightbox({ photos: lightboxPhotos, index: i })} - /> + > + + + {p.media_type === 'video' && ( +
+ + + +
+ )} + ))}
)} {photos.length >= 3 && (
-
setLightbox({ photos: lightboxPhotos, index: 0 })} > -
+
-
setLightbox({ photos: lightboxPhotos, index: 1 })} > -
-
+
+
- )} + ) + } {/* Content */} -
setViewingEntry(entry)}> + {/* Stays a div: the story renders user markdown, links included, + and a link may not sit inside a
+ ); })} - + ); // Shared gallery renderer const renderGallery = () => (
{allPhotos.map((photo, idx) => ( -
setLightbox({ photos: allPhotos.map((p) => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, - mediaType: (p as any).media_type, + mediaType: p.media_type, })), index: idx, }) @@ -475,18 +527,21 @@ export default function JourneyPublicPage() { > - {(photo as any).media_type === 'video' && ( + {photo.media_type === 'video' && (
)} -
+ ))}
); @@ -496,14 +551,13 @@ export default function JourneyPublicPage() { views.length > 1 && (
{views.map((v) => ( -
- - {selectedDay === day.id && merged.length > 0 && (
@@ -796,6 +804,7 @@ export default function SharedTripPage() { {place.image_url ? ( ) : ( @@ -1022,7 +1031,7 @@ export default function SharedTripPage() { return g; }, {}); const sumIn = (items: any[]) => - items.reduce((s: number, i: any) => s + convert(parseFloat(i.total_price) || 0, curOf(i)), 0); + items.reduce((s: number, i: any) => s + convert(Number.parseFloat(i.total_price) || 0, curOf(i)), 0); const total = sumIn(budget || []); return (
@@ -1099,7 +1108,7 @@ export default function SharedTripPage() { style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }} > {item.total_price - ? `${convert(parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` + ? `${convert(Number.parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` : '—'}
@@ -1183,6 +1192,7 @@ export default function SharedTripPage() { {msg.avatar ? ( ) : ( diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index e0859d5ed..6f7c99375 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -130,7 +130,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p const active = subTab === tab.id const Icon = tab.icon return ( - )} - -
diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index f4a51c4b3..4ab76513e 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -351,13 +351,17 @@ describe('TripPlannerPage — shell', () => { }) describe('TripPlannerPage — plan tab', () => { - it('FE-PAGE-TPW-008: the map receives the filtered markers, the tile url and the panel widths', () => { + it('FE-PAGE-TPW-008: the map receives the filtered markers, the tile url, the panel widths and the selected day', () => { renderPage() expect(props('map').places).toEqual([place]) expect(props('map').tileUrl).toBe('https://tiles/{z}/{x}/{y}.png') expect(props('map').leftWidth).toBe(320) expect(props('map').center).toBeUndefined() + // Without the day the route toggle belongs to, the map draws every automated + // transport in the trip as soon as any day's route is on (#2019). + expect(props('map').days).toEqual([day]) + expect(props('map').selectedDayId).toBe(7) }) it('FE-PAGE-TPW-009: collapsed panels report a zero width to the map', () => { diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index 420d266ed..56e4728c1 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -48,26 +48,27 @@ function VacayPageDesktop(): React.ReactElement {
- -
{selectedYear}
- -
{years.map(y => ( -
setSelectedYear(y)} +
setSelectedYear(y)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedYear(y) } }} className="group relative rounded-[9px] text-center cursor-pointer transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]" style={{ padding: '7px 0', @@ -78,10 +79,11 @@ function VacayPageDesktop(): React.ReactElement { }}> {y} {years.length > 1 && ( - { e.stopPropagation(); setDeleteYear(y); setShowMobileSidebar(false) }} + )}
))} @@ -129,13 +131,13 @@ function VacayPageDesktop(): React.ReactElement {

{t('admin.addons.catalog.vacay.name')}

- - -
@@ -232,11 +234,11 @@ function VacayPageDesktop(): React.ReactElement {
- - diff --git a/client/src/pages/admin/AdminNotificationsPanel.tsx b/client/src/pages/admin/AdminNotificationsPanel.tsx index d9173075f..a62072acd 100644 --- a/client/src/pages/admin/AdminNotificationsPanel.tsx +++ b/client/src/pages/admin/AdminNotificationsPanel.tsx @@ -93,7 +93,7 @@ export default function AdminNotificationsPanel({ t, toast }: { t: (k: string) = const isOn = matrix.preferences[eventType]?.[ch] ?? true return (
-
-
- - - - -
- - - - )} - @@ -239,8 +239,8 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): )}
- - + +
diff --git a/client/src/pages/atlas/AtlasCountrySearch.tsx b/client/src/pages/atlas/AtlasCountrySearch.tsx index 800a50bf4..4c7bd0855 100644 --- a/client/src/pages/atlas/AtlasCountrySearch.tsx +++ b/client/src/pages/atlas/AtlasCountrySearch.tsx @@ -113,7 +113,7 @@ export default function AtlasCountrySearch({ }} /> {search.trim() && ( -