mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515398f8ee | |||
| e4f2eaf336 | |||
| 16fee0800d | |||
| 888cd4ee08 | |||
| 8fe90d1783 | |||
| 4cda3c4b2e | |||
| bc85ec1eea | |||
| 04c22b34af | |||
| 56a23cb809 | |||
| 251378be1d | |||
| 973ce5f8ff | |||
| bbc32310e9 | |||
| ad6e51c574 | |||
| 6da11994d9 | |||
| eef1f16836 | |||
| 4a8d77c59c | |||
| a70c2eb9e0 | |||
| 99b040f684 | |||
| 944e0a0970 | |||
| b9f69b0c76 | |||
| fcefc7e771 | |||
| 6f0a171c74 | |||
| 3e1c06a0ee | |||
| 1aafab83a5 | |||
| c1d0435480 | |||
| 798859517f | |||
| d3fdf78db6 | |||
| 1b71b8633d | |||
| d77f1c1758 | |||
| e09c0688ae | |||
| 0e66ae72ca | |||
| 61bef334d5 |
@@ -1,18 +1,17 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
|
||||
|
||||
- **`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.
|
||||
- **`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.
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
|
||||
|
||||
**`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.
|
||||
|
||||
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
|
||||
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
|
||||
```bash
|
||||
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 lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
|
||||
npm run format # prettier --write all three
|
||||
```
|
||||
|
||||
@@ -29,17 +28,14 @@ Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server)
|
||||
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 (lcov), all four packages incl. shared + plugin-sdk
|
||||
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. 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)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
|
||||
cd client && npm run e2e # Playwright (local only)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
Run a single test file or test:
|
||||
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
```
|
||||
|
||||
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
|
||||
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
|
||||
|
||||
```bash
|
||||
npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server architecture
|
||||
## Server invariants
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
|
||||
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
|
||||
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
|
||||
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
|
||||
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
|
||||
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
|
||||
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
|
||||
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
|
||||
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
|
||||
|
||||
## Client architecture
|
||||
## Client invariants
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
|
||||
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
|
||||
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
|
||||
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- 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/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- **i18n locale files live in `shared/src/i18n/<locale>/`**, 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.
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
|
||||
These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **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 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.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: 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.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
|
||||
- **DI over global mutable module state.** 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 (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **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): ...`). **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.
|
||||
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
|
||||
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
| Condition (new code) | Must be |
|
||||
|---|---|
|
||||
| Coverage | ≥ 80% |
|
||||
| Duplicated lines | ≤ 3% |
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
|
||||
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
|
||||
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.2.0
|
||||
version: 4.2.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.2.0"
|
||||
appVersion: "4.2.1"
|
||||
|
||||
+45
-53
@@ -1,77 +1,69 @@
|
||||
# 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.
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → 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)
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint # eslint . (lint:check in CI)
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (local only; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
```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 `npm run dev` at the repo root to start both.
|
||||
|
||||
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` fails otherwise)
|
||||
|
||||
## 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<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>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.
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
|
||||
The layering is **component → feature hook → store/slice → `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.
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
## Rules for new code
|
||||
|
||||
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:
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends 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/<domain>.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<Page>()` 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 `<html>` styling.
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
|
||||
|
||||
## Other big-picture pieces
|
||||
## 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/<locale>')`** 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/<domain>/` (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` / `<ServerImage>`; 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`.
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop ↔ mobile shells and the SonarQube duplication gate
|
||||
|
||||
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## 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`).
|
||||
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
|
||||
|
||||
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// An iPad is inside the 768-1023px band, where only one side panel is open at a
|
||||
// time so the map keeps a usable width (#2247). The places list is one tap away.
|
||||
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
|
||||
// "Plan" button of its own.
|
||||
await page.locator('button[aria-label="Places"]').click()
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan and left its
|
||||
// tab as the way back.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
|
||||
})
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
|
||||
* with importScripts. mapbox-gl-rtl-text ships exactly that as
|
||||
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
|
||||
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
|
||||
* and stepping across to dist keeps this working wherever npm hoists the package.
|
||||
*
|
||||
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
|
||||
* asset, and the test run needs it because a lazily imported engine module reaches
|
||||
* the same specifier.
|
||||
*
|
||||
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
|
||||
* which a string alias will not match. It is left unanchored on purpose so the
|
||||
* query survives the rewrite and Vite still emits an asset URL.
|
||||
*/
|
||||
export const rtlTextAlias = {
|
||||
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
|
||||
// The old handler wiped every Cache Storage bucket and every registration
|
||||
// here, which left the device with no app shell until a fresh ~22 MB
|
||||
// precache finished: anyone who closed the app or lost signal inside that
|
||||
// window could no longer start the PWA offline at all, and it also threw
|
||||
// away the map tiles and documents the user had downloaded on purpose.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const cachesDelete = vi.fn().mockResolvedValue(true)
|
||||
const unregister = vi.fn().mockResolvedValue(true)
|
||||
const update = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
// It asks the worker to fetch the new build; Workbox swaps the precache
|
||||
// atomically once that install completes.
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
expect(cachesDelete).not.toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
|
||||
// The value arrives over the wire and is written to this device's storage,
|
||||
// so it is validated rather than trusted. An unusable one must also not be
|
||||
// compared against the stored marker, or every launch would see a mismatch
|
||||
// and try to update.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
|
||||
// The marker used to be written after the purge and outside its try, so a
|
||||
// throwing step left it unwritten and the whole destructive path repeated on
|
||||
// every launch, for good.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: a device that has never mirrored the preference must not treat
|
||||
|
||||
+47
-16
@@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
@@ -316,24 +316,46 @@ export default function App() {
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
||||
// from being compared against the stored marker and starting an update on
|
||||
// every launch.
|
||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
// mode, blocked site data, quota) left the caches deleted, the marker
|
||||
// unwritten and the reload unreached, and the purge then repeated on
|
||||
// every single launch, permanently (#2228).
|
||||
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
|
||||
if (storedVersion && storedVersion !== version) {
|
||||
// A newer build is deployed. Ask the service worker to fetch it and
|
||||
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
|
||||
// installs the new precache and only then drops the outdated one, so
|
||||
// there is never a moment without an app shell.
|
||||
//
|
||||
// This used to delete EVERY Cache Storage bucket and unregister EVERY
|
||||
// worker instead. That left the device with no shell and no worker
|
||||
// until a fresh ~22 MB precache finished (minutes on mobile data),
|
||||
// and anyone who closed the app or lost signal in that window was
|
||||
// left with a PWA that could no longer start offline at all. It also
|
||||
// threw away the map tiles and file blobs the user had deliberately
|
||||
// downloaded for offline use (#2228).
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
|
||||
await reg.update()
|
||||
return
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
} catch { /* fall through to a plain reload */ }
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
@@ -380,13 +402,22 @@ export default function App() {
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
// No session on these, so authenticated-only widgets (system notices,
|
||||
// background tasks, save-to-collection) have nothing to do and would only fire
|
||||
// a doomed authenticated request on mount.
|
||||
//
|
||||
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
|
||||
// drifted, this one naming only /public/journey/ while the response
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
|
||||
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
|
||||
return getRegistration
|
||||
}
|
||||
|
||||
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
|
||||
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
|
||||
probeNow.mockResolvedValue('proxy-wall')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
|
||||
// text/html is not proof of a proxy: several of TREK's own routes answer
|
||||
// res.status(401).send('Authentication required'), which Express labels
|
||||
// text/html. Tearing the worker down for one of those costs the user
|
||||
// offline mode for a wall that is not there, so confirm reachability first.
|
||||
probeNow.mockResolvedValue('online')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
|
||||
expect(probeNow).toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
|
||||
installServiceWorker(vi.fn(async () => true))
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
|
||||
@@ -214,16 +214,24 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
// HTML body (a JS redirect page) instead of a 302.
|
||||
//
|
||||
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
|
||||
// routes answer with res.status(401).send('Authentication required'),
|
||||
// which Express labels text/html. Tearing the service worker down for one
|
||||
// of those would cost the user offline mode for a proxy wall that isn't
|
||||
// there, so confirm a reachable proxy first, exactly like the no-response
|
||||
// branch above (#2228).
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
if (await probeNow() === 'proxy-wall') {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +432,10 @@ export const daysApi = {
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
|
||||
// so every hotel booking minted an unlinked duplicate (#2243).
|
||||
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
|
||||
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
|
||||
@@ -59,23 +59,26 @@ describe('collectionsApi', () => {
|
||||
expect(res.collection.name).toBe('Tokyo')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
|
||||
server.use(http.post(BASE, record({ collection })))
|
||||
// Both handlers answer with the BARE collection, the way the controller does —
|
||||
// stubbing a { collection } envelope here is what let the client keep reading
|
||||
// `.collection` off a response that never had one.
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', async () => {
|
||||
server.use(http.post(BASE, record(collection)))
|
||||
|
||||
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
|
||||
|
||||
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
|
||||
expect(res.collection.id).toBe(1)
|
||||
expect(res.id).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
|
||||
server.use(http.patch(`${BASE}/:id`, record({ collection })))
|
||||
server.use(http.patch(`${BASE}/:id`, record(collection)))
|
||||
|
||||
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1`)
|
||||
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
|
||||
expect(res.collection).toEqual(collection)
|
||||
expect(res).toEqual(collection)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
|
||||
|
||||
@@ -53,9 +53,13 @@ export const collectionsApi = {
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
|
||||
// Both answer with the bare collection, not a { collection } envelope — the
|
||||
// controller returns the service's `Collection` straight through, and the e2e
|
||||
// suite pins that shape. Declaring the envelope made createCollection() resolve
|
||||
// undefined for every caller.
|
||||
create: (body: CollectionCreateRequest): Promise<Collection> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
postMultipart(`${base}/${id}/cover`, formData),
|
||||
|
||||
@@ -2459,3 +2459,85 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
expect(save.className).not.toContain('text-white')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
|
||||
* panel's job is to make sure the admin actually SEES the warning — before a registry
|
||||
* install, after a sideload/link that could not be asked first, and on the row for as
|
||||
* long as the plugin keeps running outside the range its author vouched for.
|
||||
*/
|
||||
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
|
||||
)
|
||||
}
|
||||
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
|
||||
it('FE-PLG-BYPASS-001: says so in the header', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
posted = await request.json()
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
|
||||
}))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
|
||||
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
|
||||
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
|
||||
let posted = false
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
server.use(http.post('*/api/admin/plugins/upload', () =>
|
||||
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
|
||||
const { container } = withToast()
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
|
||||
bypassPanel([plugin({
|
||||
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
|
||||
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
|
||||
})], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -64,6 +65,8 @@ interface PluginRow {
|
||||
trekRange?: string | null
|
||||
/** The TREK this server is running — the server does the semver, the client just shows it. */
|
||||
hostVersion?: string
|
||||
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
|
||||
trekRangeBypassed?: TrekRangeBypass | null
|
||||
/** The author's signature was verified and their key pinned at install. False means the
|
||||
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
|
||||
signed?: boolean
|
||||
@@ -273,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
return out
|
||||
}
|
||||
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
|
||||
@@ -291,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
blocked: true,
|
||||
})
|
||||
}
|
||||
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
|
||||
if (bypassed) out.push(bypassed)
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
@@ -310,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
|
||||
* grey button. The plugin is perfectly usable, just not at its newest.
|
||||
*/
|
||||
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
|
||||
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
|
||||
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
|
||||
const title = item.trek
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown')
|
||||
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
|
||||
if (item.latestCompatible) {
|
||||
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
|
||||
}
|
||||
@@ -398,6 +404,8 @@ export default function AdminPluginsPanel() {
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
|
||||
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
|
||||
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
|
||||
const [linkPath, setLinkPath] = useState('')
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -472,9 +480,10 @@ export default function AdminPluginsPanel() {
|
||||
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
|
||||
void usePluginStore.getState().loadPlugins()
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
|
||||
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
|
||||
setRuntimeOn(!!d.enabled)
|
||||
setDevLink(!!d.devLink)
|
||||
setIgnoreTrekRange(!!d.ignoreTrekRange)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
|
||||
@@ -540,6 +549,7 @@ export default function AdminPluginsPanel() {
|
||||
const res = await adminApi.pluginUpload(file)
|
||||
setView('installed')
|
||||
toast.success(t('admin.plugins.uploaded', { name: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -610,7 +620,11 @@ export default function AdminPluginsPanel() {
|
||||
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'))
|
||||
// `warn` is the pre-install confirm for an entry the registry already flagged as
|
||||
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
|
||||
// was only a pre-download filter) is caught by the marker on the response instead.
|
||||
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
|
||||
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), 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
|
||||
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
|
||||
@@ -623,6 +637,7 @@ export default function AdminPluginsPanel() {
|
||||
setView('installed')
|
||||
setLinkPath('')
|
||||
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -682,9 +697,10 @@ export default function AdminPluginsPanel() {
|
||||
if (busy === parent.id) return
|
||||
setBusy(parent.id)
|
||||
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
bypass.notice(depId, r?.trekRangeBypassed)
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
|
||||
@@ -702,9 +718,10 @@ export default function AdminPluginsPanel() {
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
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 }])
|
||||
bypass.notice(p.name, r.trekRangeBypassed)
|
||||
})
|
||||
.catch(e => {
|
||||
const { error, code } = errBody(e)
|
||||
@@ -828,11 +845,19 @@ export default function AdminPluginsPanel() {
|
||||
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +967,7 @@ export default function AdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -988,10 +1013,12 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
@@ -1424,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1535,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string, version?: string) => void
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1555,7 +1583,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// No press-scale on the card: shrinking it mid-click slides the Install
|
||||
@@ -1590,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
@@ -1620,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return createPortal(
|
||||
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title}
|
||||
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
|
||||
<AlertTriangle size={20} className="text-warning" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
{confirm ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.installAnyway')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1645,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
// The detail fetch carries the same compat verdict as the browse list; prefer it once
|
||||
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
|
||||
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
@@ -1667,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
|
||||
|
||||
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
|
||||
|
||||
/**
|
||||
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
|
||||
* shells — the desktop panel and the phone panel render their own markup over this.
|
||||
*/
|
||||
describe('rangeBypass helpers', () => {
|
||||
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
|
||||
expect(bypassChip(null, t)).toBeNull()
|
||||
expect(bypassChip(undefined, t)).toBeNull()
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRangeBypass', () => {
|
||||
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard(undefined, () => { ran++ }))
|
||||
expect(ran).toBe(1)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
expect(ran).toBe(1) // nothing sent until the admin accepts
|
||||
expect(result.current.copy?.confirm).toBe(true)
|
||||
|
||||
act(() => result.current.confirm())
|
||||
expect(ran).toBe(2)
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
|
||||
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
act(() => result.current.dismiss())
|
||||
expect(ran).toBe(0)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.notice('trek-new', null))
|
||||
expect(result.current.warning).toBeNull()
|
||||
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
|
||||
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
|
||||
expect(result.current.copy?.confirm).toBe(false)
|
||||
act(() => result.current.confirm()) // a notice's confirm is just a close
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
|
||||
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
|
||||
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
|
||||
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
const confirm = w.onConfirm !== null
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
|
||||
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
|
||||
* registry install behind the confirm step when the entry was already flagged
|
||||
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
|
||||
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
|
||||
* setter is read inside the mount-time refresh, and only a useState setter is something
|
||||
* the exhaustive-deps rule knows to be stable.
|
||||
*/
|
||||
export function useRangeBypass() {
|
||||
const { t } = useTranslation()
|
||||
const [warning, setWarning] = useState<RangeWarning | null>(null)
|
||||
|
||||
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
|
||||
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
|
||||
}
|
||||
const guard = (warn: RangeWarning | undefined, run: () => void) => {
|
||||
if (warn) setWarning({ ...warn, onConfirm: run })
|
||||
else run()
|
||||
}
|
||||
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
|
||||
const dismiss = () => setWarning(null)
|
||||
|
||||
return {
|
||||
warning,
|
||||
copy: warning ? rangeWarningCopy(warning, t) : null,
|
||||
notice, guard, confirm, dismiss,
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,29 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
|
||||
// tile kept counting them, so it contradicted the balances right beside it.
|
||||
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
// "Your share" is the first of the two bold figures in the Total spend card's
|
||||
// footer; the second is "You paid". 60 from the paid expense only — the 40
|
||||
// nobody has paid for is not a share, though it still counts as spend.
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
|
||||
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
|
||||
expect(figures[0]).toContain('60')
|
||||
expect(figures[0]).not.toContain('100')
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
|
||||
@@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
|
||||
// counting them here left the tile contradicting the balances right beside it.
|
||||
if (isUnfinished(e)) return 0
|
||||
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||
if (!myMember) return 0
|
||||
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||
@@ -133,10 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
|
||||
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5CNT-001 to FE-W5CNT-029
|
||||
// FE-W5CNT-001 to FE-W5CNT-036
|
||||
// Fills in the load/error/attachment/category branches of useCollabNotes and the
|
||||
// view modal that the smoke tests above do not reach.
|
||||
|
||||
@@ -1947,4 +1947,32 @@ describe('CollabNotes details', () => {
|
||||
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
|
||||
expect(ref).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
|
||||
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
// A member without collab_edit never opens the edit modal, so this is the
|
||||
// only place the link is readable for them (#2222).
|
||||
const link = within(full).getByRole('link');
|
||||
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveTextContent('hotel.test');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
expect(within(full).queryAllByRole('link')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
|
||||
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -21,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
|
||||
import { NoteCard } from './CollabNotesCard'
|
||||
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
interface CollabNotesProps {
|
||||
@@ -393,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Label for the link chip: the host is what people recognise, "www." is noise. */
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
function ViewNoteModal(S: NotesState) {
|
||||
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
|
||||
if (!viewingNote) return null
|
||||
// A member without collab_edit only ever gets this modal, so the link has to
|
||||
// be here too (#2222). Allow-listed like the card tile: the field takes any
|
||||
// string, and a javascript: one would run in this origin.
|
||||
const websiteHref = safeExternalHref(viewingNote.website)
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
@@ -447,6 +457,17 @@ function ViewNoteModal(S: NotesState) {
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
|
||||
{websiteHref && (
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
|
||||
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-primary)', textDecoration: 'none',
|
||||
}}>
|
||||
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
|
||||
</a>
|
||||
)}
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-049
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
// takes it away for the window that #2254 fell into.
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
|
||||
@@ -58,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import type { Mock } from 'vitest';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
@@ -693,4 +698,47 @@ describe('JourneyMap', () => {
|
||||
expect(html).toHaveLength(1);
|
||||
expect(html[0]).not.toMatch(/>\d+</);
|
||||
});
|
||||
|
||||
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
|
||||
// effect can draw while the map still has no center or zoom. Clustering
|
||||
// projects to container pixels, which throws in that window and took the whole
|
||||
// public journey page down with it.
|
||||
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
|
||||
const noView = () => { throw new Error('Set map center and zoom first.'); };
|
||||
let hasView = false;
|
||||
const photoHtml = () =>
|
||||
vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
|
||||
|
||||
// The mock hands out one shared map instance, so it can be reached — and
|
||||
// stripped of its view — before the component ever builds one. Typed down to
|
||||
// the three spies this needs: L.map's signature promises a real Leaflet map.
|
||||
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
|
||||
L.map(document.createElement('div'));
|
||||
const map = mockedMap() as MapProbe;
|
||||
vi.mocked(L.map).mockClear();
|
||||
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
|
||||
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
|
||||
|
||||
try {
|
||||
// Rendering must survive the viewless window, and draw nothing in it.
|
||||
expect(() =>
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/late.jpg' }]} />,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(photoHtml()).toHaveLength(0);
|
||||
|
||||
// The deferred fitBounds lands: the listener this effect registered redraws.
|
||||
hasView = true;
|
||||
const redraw = map.on.mock.calls.find(c => c[0] === 'moveend')?.[1] as () => void;
|
||||
expect(redraw).toBeTypeOf('function');
|
||||
act(() => { redraw(); });
|
||||
|
||||
expect(photoHtml()).toHaveLength(1);
|
||||
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
|
||||
} finally {
|
||||
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
|
||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,6 +405,16 @@ function JourneyMap(
|
||||
photoLayerRef.current = null
|
||||
if (!photos?.length) return
|
||||
|
||||
// The initial view (setView/fitBounds) is set on a deferred rAF in the
|
||||
// map-build effect above, so this can run before the map has a center/zoom
|
||||
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
|
||||
// listener below redraws once the deferred view lands.
|
||||
try {
|
||||
map.getCenter()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(map, photos)) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls mapbox-gl in at runtime.
|
||||
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
|
||||
* quietly undoes the split. `npm run check:gl-split` fails on that.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
|
||||
|
||||
export default mapboxgl
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
it('FE-MAP-RTL-001: importing the mapbox engine registers it deferred, with a null callback', async () => {
|
||||
await import('./mapbox');
|
||||
|
||||
expect(mapboxSet).toHaveBeenCalledTimes(1);
|
||||
// Mapbox GL 3 takes (url, callback, deferred). The third argument is what
|
||||
// keeps the payload off the wire until a map paints RTL text; dropping it
|
||||
// would fetch 133 KB for every user on every map.
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
mapboxSet.mockImplementation(() => { throw new Error('already registered'); });
|
||||
|
||||
const engine = await import('./mapbox');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerRtlTextPlugin } from './rtlText'
|
||||
|
||||
describe('registerRtlTextPlugin', () => {
|
||||
it('runs the engine-supplied registration', () => {
|
||||
const register = vi.fn()
|
||||
|
||||
expect(registerRtlTextPlugin(register)).toBe(true)
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Registering twice is how the engines report it: Mapbox throws.
|
||||
it('never lets a refused registration break map creation', () => {
|
||||
const register = vi.fn(() => {
|
||||
throw new Error('setRTLTextPlugin cannot be called multiple times.')
|
||||
})
|
||||
|
||||
expect(() => registerRtlTextPlugin(register)).not.toThrow()
|
||||
expect(registerRtlTextPlugin(register)).toBe(false)
|
||||
})
|
||||
|
||||
// MapLibre reports the same by rejecting, and an unhandled rejection would
|
||||
// reach the console for something the user cannot act on.
|
||||
it('swallows a rejected registration promise', async () => {
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a resolved promise alone', async () => {
|
||||
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
|
||||
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
|
||||
* is drawn differently depending on its neighbours). Without them the engine lays
|
||||
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
|
||||
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
* and both are handled below, so the probe bought nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
|
||||
*
|
||||
* Callers register lazily, so the payload is only fetched once a map actually
|
||||
* paints RTL text and a user who never leaves Latin script downloads nothing.
|
||||
*
|
||||
* @returns whether the registration call completed without throwing.
|
||||
*/
|
||||
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
|
||||
try {
|
||||
const pending = register()
|
||||
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
|
||||
// routes the same through its callback. Neither is worth surfacing: a map
|
||||
// with unshaped labels still renders, and an unhandled rejection would not
|
||||
// give the user anything to act on.
|
||||
if (pending instanceof Promise) pending.catch(() => {})
|
||||
return true
|
||||
} catch {
|
||||
// Already registered, or the engine refused outright. Registering the plugin
|
||||
// must never be able to break map creation.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1388,39 +1388,90 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onDeletePlace).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
// ── Note card edit/delete buttons ─────────────────────────────────────
|
||||
// ── Note card editing (#2249) ─────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note edit button (Pencil in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 0) {
|
||||
await user.click(noteEditBtns[0] as HTMLElement)
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(cardRow(screen.getByText('My note')))
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
||||
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
|
||||
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
|
||||
// made it permanent on tablets. Nothing may float over that column again.
|
||||
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column owns the row's right edge.
|
||||
expect(row.lastElementChild).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(row).toHaveAttribute('tabindex', '0')
|
||||
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
|
||||
expect(row).toHaveAttribute('data-no-press')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note delete button (Trash2 in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 1) {
|
||||
await user.click(noteEditBtns[1] as HTMLElement)
|
||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
// Confirming triggers the actual delete.
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(screen.getByRole('link', { name: 'docs' }))
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [
|
||||
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
|
||||
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
|
||||
] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('Second note'))
|
||||
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
|
||||
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
|
||||
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
|
||||
// Otherwise the edit modal is left open on a note that no longer exists.
|
||||
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
|
||||
})
|
||||
|
||||
// ── Drop on assignment: same-day reorder ─────────────────────────────
|
||||
@@ -2940,6 +2991,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
|
||||
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
|
||||
expect(screen.getByText('A note')).toBeInTheDocument()
|
||||
// A viewer must not reach the edit modal they could never save (#2249).
|
||||
const noteRow = cardRow(screen.getByText('A note'))
|
||||
expect(noteRow).not.toHaveAttribute('role', 'button')
|
||||
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
|
||||
|
||||
@@ -696,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
|
||||
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
@@ -2614,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
]) : undefined}
|
||||
{...(canEditDays ? {
|
||||
role: 'button' as const,
|
||||
// No press-scale: index.css shrinks every [role=button] on
|
||||
// :active, which on a draggable row fights the drag (#2158).
|
||||
'data-no-press': '',
|
||||
tabIndex: 0,
|
||||
// The row is the note's edit affordance — the same gesture the
|
||||
// phone shell already uses (#2249). Links inside the rendered
|
||||
// body and the reorder buttons keep their own click.
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
} : {})}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -2636,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
background: noteSkin.background,
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
@@ -2658,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Stacked in a capsule of their own, set off from the text:
|
||||
a note card is as tall as its body, so the height is free
|
||||
while the width next to the text is not, and a 10px icon
|
||||
in a 14px box was barely a target. */}
|
||||
{/* Floated over the text rather than sharing the row with
|
||||
it: a note is mostly text, and reserving a column for
|
||||
two buttons that are invisible until you hover costs
|
||||
every note a shorter line for the whole time you are
|
||||
not hovering. */}
|
||||
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
@@ -2877,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote}
|
||||
saveNote={saveNote}
|
||||
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -2892,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteNote}
|
||||
onClose={() => setPendingDeleteNote(null)}
|
||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
||||
onConfirm={() => {
|
||||
if (!pendingDeleteNote) return
|
||||
// Close the edit modal behind the confirm; leaving it open would go on
|
||||
// editing a note that no longer exists.
|
||||
cancelNote(pendingDeleteNote.dayId)
|
||||
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
|
||||
}}
|
||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
||||
message={t('dayplan.confirmDeleteNoteBody')}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
|
||||
interface NoteModalUi {
|
||||
mode: 'add' | 'edit'
|
||||
noteId?: number
|
||||
icon: string
|
||||
text: string
|
||||
time: string
|
||||
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
|
||||
noteInputRef: React.RefObject<HTMLInputElement>
|
||||
cancelNote: (dayId: number) => void
|
||||
saveNote: (dayId: number) => void
|
||||
/** Asks the planner to confirm and then delete the note being edited (#2249). */
|
||||
onRequestDelete: (dayId: number, noteId: number) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
|
||||
* right. The preview is the reason the colour picker is worth having — a swatch
|
||||
* row tells you nothing about what a tinted card looks like next to a place.
|
||||
*/
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
|
||||
document.body
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
dayId: string
|
||||
ui: NoteModalUi
|
||||
}) {
|
||||
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{/* Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
away (#2249). The planner still confirms before it deletes. */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{ui.mode === 'edit' && ui.noteId != null && (
|
||||
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
|
||||
className="text-danger"
|
||||
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
|
||||
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
|
||||
|
||||
@@ -254,7 +254,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Hotels link a place through the accommodation record; every other type links
|
||||
// the picked trip place/activity directly on the reservation (#1353).
|
||||
place_id: form.type === 'hotel' ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
// Omitted on an edit: the server replaces the endpoint set whenever the
|
||||
// key is present, and this form never edits endpoints, so sending an
|
||||
// empty list would drop a transit booking's stations (#2216).
|
||||
|
||||
@@ -543,7 +543,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-024
|
||||
// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-028
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -21,16 +21,17 @@ const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const tripsCount = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
@@ -104,6 +105,7 @@ const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsCount.mockResolvedValue(rows.length);
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
@@ -130,6 +132,7 @@ beforeEach(() => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
@@ -138,8 +141,8 @@ beforeEach(() => {
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.syncAll.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
@@ -284,6 +287,7 @@ describe('OfflineTab', () => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -294,7 +298,7 @@ describe('OfflineTab', () => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -304,6 +308,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 1 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -322,6 +327,7 @@ describe('OfflineTab', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'trips', current: 1, total: 4 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<OfflineTab />);
|
||||
|
||||
@@ -380,7 +386,7 @@ describe('OfflineTab', () => {
|
||||
it('FE-COMP-OFFLINETAB-017: Re-sync shows the syncing label while it runs', async () => {
|
||||
const user = userEvent.setup();
|
||||
let release!: () => void;
|
||||
h.syncAll.mockImplementation(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
h.syncAll.mockImplementation(() => new Promise(resolve => { release = () => resolve({ status: 'done', trips: 1 }); }));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
@@ -408,21 +414,69 @@ describe('OfflineTab', () => {
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-019: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
it('FE-COMP-OFFLINETAB-019: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OfflineTab />);
|
||||
|
||||
// Both fixture trips ended in 2025, so the date rule would skip them: the
|
||||
// switch has to read "off" rather than promising storage that never happens.
|
||||
const toggle = await screen.findByRole('button', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([2]);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Tokyo' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tokyo' }));
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
// Turning it off releases the pin, so "off then on" cannot leave it pinned.
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-025: an ongoing trip is stored without needing a pin', async () => {
|
||||
const soon = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10);
|
||||
const ongoing = buildTrip({ id: 7, title: 'Lisbon', start_date: '2025-01-01', end_date: soon });
|
||||
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [ongoing] })));
|
||||
render(<OfflineTab />);
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: 'Lisbon' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Stored offline')).toBeInTheDocument();
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-026: a sync that never ran says so instead of reporting success', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'busy' });
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('A sync is already running. Try again in a moment.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-028: a download that throws is reported instead of leaving the last frame up', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockRejectedValue(new Error('network went away'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('The download could not finish. Check your connection and try again.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-027: an unreadable offline database is reported, not shown as empty', async () => {
|
||||
h.syncMetaToArray.mockRejectedValue(new Error('DatabaseClosedError'));
|
||||
render(<OfflineTab />);
|
||||
|
||||
expect(await screen.findByText(/Could not read this device’s offline storage/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('No trips cached yet. Connect to the internet to sync.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-OFFLINETAB-020: the per-trip section disappears when there are no trips at all', async () => {
|
||||
@@ -478,6 +532,7 @@ describe('OfflineTab', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled());
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
await user.click(screen.getByRole('button', { name: 'Clear cache' }));
|
||||
|
||||
expect(h.clearAll).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -6,33 +6,18 @@
|
||||
* - What to store: a map-tiles toggle plus a per-trip on/off.
|
||||
* - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy.
|
||||
* - Cache stats + clear.
|
||||
*
|
||||
* All of the logic lives in `useOfflineSettings`, shared with the phone twin
|
||||
* `MSettingsOffline`; this file is the desktop markup over it.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
import type { ConflictStrategy } from '../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../db/offlineDb'
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
@@ -44,132 +29,14 @@ function conflictName(m: QueuedMutation): string {
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm(t('settings.offline.clearConfirm'))) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}
|
||||
const {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
||||
runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
@@ -237,9 +104,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: '#10b981' }}>
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 10, color: isOfflineNoticeWarning(notice) ? 'var(--warning)' : 'var(--success)' }}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,7 +141,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
control={
|
||||
<select
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={e => setConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
onChange={e => handleConflictStrategy(e.target.value as ConflictStrategy)}
|
||||
className="border border-edge bg-surface-secondary text-content"
|
||||
style={{ padding: '6px 10px', borderRadius: 8, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
@@ -302,7 +170,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{allTrips.map((trip) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
return (
|
||||
<div key={trip.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
@@ -311,9 +179,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
{on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
{!dateEligible && ` · ${t('settings.offline.storage.tripFinished')}`}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip.id)} label={trip.title} />
|
||||
<ToggleSwitch on={on} onToggle={() => handleToggleTrip(trip)} label={trip.title} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -335,10 +204,10 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button"
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
onClick={() => { if (window.confirm(t('settings.offline.clearConfirm'))) void handleClear() }}
|
||||
disabled={clearing || !canClear}
|
||||
className="border border-edge bg-surface-secondary text-[#ef4444]"
|
||||
style={btnStyle(clearing || rows.length === 0)}
|
||||
style={btnStyle(clearing || !canClear)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
@@ -349,7 +218,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-content-muted" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('settings.offline.empty')}
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Shared state for the offline settings screen (#1135), used by both the desktop
|
||||
* `OfflineTab` and its phone twin `MSettingsOffline`. The two shells render very
|
||||
* different markup over identical logic, so the logic lives here once, the same
|
||||
* arrangement as `useInstanceSettings` / `useRangeBypass`.
|
||||
*
|
||||
* It also carries the honesty fixes from #2228: a sync that never started is
|
||||
* reported as such instead of silently painting a finished progress bar, a
|
||||
* failed read of the offline database surfaces instead of rendering as "nothing
|
||||
* cached", and a trip the user switches on is pinned so the date rule cannot
|
||||
* quietly refuse to store it.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { tripSyncManager, isDateEligible, type PrepareProgress, type SyncOutcome } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
setTripOfflineEnabled, setTripPinned, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
export interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/** How a trip's storage switch should read, and why. */
|
||||
export interface TripStorageState {
|
||||
/** The switch position: is this trip kept on the device? */
|
||||
on: boolean
|
||||
/** The user asked for it explicitly, overriding the date rule. */
|
||||
pinned: boolean
|
||||
/** The date rule would keep it on its own (ongoing, future, or open-ended). */
|
||||
dateEligible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The last thing a download / re-sync run did. `null` while nothing has been
|
||||
* run yet. The screen shows no banner in that state.
|
||||
*/
|
||||
export type OfflineNotice =
|
||||
| { kind: 'stored'; trips: number }
|
||||
| { kind: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
||||
| { kind: 'sync-failed' }
|
||||
| { kind: 'load-failed' }
|
||||
|
||||
/**
|
||||
* The message a notice reads as. Shared so both shells say the same thing and
|
||||
* only the chrome around it differs.
|
||||
*/
|
||||
export function offlineNoticeKey(notice: OfflineNotice): string {
|
||||
if (notice.kind === 'load-failed') return 'settings.offline.notice.loadFailed'
|
||||
if (notice.kind === 'sync-failed') return 'settings.offline.notice.failed'
|
||||
if (notice.kind === 'skipped') {
|
||||
if (notice.reason === 'busy') return 'settings.offline.notice.busy'
|
||||
if (notice.reason === 'offline') return 'settings.offline.notice.offline'
|
||||
return 'settings.offline.notice.signedOut'
|
||||
}
|
||||
return notice.trips > 0 ? 'settings.offline.notice.stored' : 'settings.offline.notice.nothing'
|
||||
}
|
||||
|
||||
/** A notice that reports a problem rather than a result. */
|
||||
export function isOfflineNoticeWarning(notice: OfflineNotice): boolean {
|
||||
return notice.kind !== 'stored' || notice.trips === 0
|
||||
}
|
||||
|
||||
function noticeFor(outcome: SyncOutcome): OfflineNotice {
|
||||
return outcome.status === 'done'
|
||||
? { kind: 'stored', trips: outcome.trips }
|
||||
: { kind: 'skipped', reason: outcome.reason }
|
||||
}
|
||||
|
||||
export function useOfflineSettings() {
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [storedTripCount, setStoredTripCount] = useState(0)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [notice, setNotice] = useState<OfflineNotice | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, storedTrips, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
offlineDb.trips.count(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setStoredTripCount(storedTrips)
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// The per-trip storage toggles are driven by the FULL trip list, not just
|
||||
// the cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} catch (err) {
|
||||
// A Dexie read that rejects (a closed handle, a failed upgrade) used to
|
||||
// leave every list at its initial empty value, so a broken offline
|
||||
// database was indistinguishable from an empty one: the screen read
|
||||
// "No trips cached yet" and gave the user nothing to act on (#2228).
|
||||
console.error('[offline] could not read the offline database:', err)
|
||||
setNotice({ kind: 'load-failed' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const outcome = await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
setNotice(noticeFor(outcome))
|
||||
// A run that never started never reported progress either; clearing it
|
||||
// keeps the bar from painting "finished" over a download that did not
|
||||
// happen.
|
||||
if (outcome.status !== 'done') setProgress(null)
|
||||
} catch (err) {
|
||||
// The trip list this starts from is a plain fetch, so a network blip
|
||||
// rejects the whole run. Reporting that beats leaving the last progress
|
||||
// frame on screen as if the download had completed.
|
||||
console.error('[offline] the download could not finish:', err)
|
||||
setProgress(null)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleToggleForce = useCallback(async () => {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (!isEffectivelyOffline()) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
|
||||
setForced(false)
|
||||
}
|
||||
}, [forced, runPrepare, setForced])
|
||||
|
||||
const handleResync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
/** Wipes the offline database. Each shell asks for confirmation its own way. */
|
||||
const handleClear = useCallback(async () => {
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
setNotice(null)
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const handleToggleTiles = useCallback(async () => {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}, [prefs.cacheTiles])
|
||||
|
||||
/**
|
||||
* How a trip's switch should read. A finished trip is only stored when the
|
||||
* user pinned it, so reporting the raw opt-out flag would show "on" over a
|
||||
* trip the sync deliberately skips, which is what made the whole feature
|
||||
* look broken for anyone whose trips are all in the past (#2228).
|
||||
*/
|
||||
const tripStorageState = useCallback((trip: Trip): TripStorageState => {
|
||||
const dateEligible = isDateEligible(trip)
|
||||
const pinned = prefs.pinnedTripIds.includes(trip.id)
|
||||
const allowed = !prefs.disabledTripIds.includes(trip.id)
|
||||
return { on: allowed && (pinned || dateEligible), pinned, dateEligible }
|
||||
}, [prefs.pinnedTripIds, prefs.disabledTripIds])
|
||||
|
||||
const handleToggleTrip = useCallback(async (trip: Trip) => {
|
||||
const next = !tripStorageState(trip).on
|
||||
setTripOfflineEnabled(trip.id, next)
|
||||
// Switching a finished trip on has to pin it, or the date rule would skip
|
||||
// it on the next sync and evict it a week after it ended.
|
||||
setTripPinned(trip.id, next && !isDateEligible(trip))
|
||||
if (!next) {
|
||||
await clearTripData(trip.id)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
if (isEffectivelyOffline()) {
|
||||
setNotice({ kind: 'skipped', reason: 'offline' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
setNotice(noticeFor(await tripSyncManager.syncAll()))
|
||||
} catch (err) {
|
||||
console.error('[offline] the sync could not finish:', err)
|
||||
setNotice({ kind: 'sync-failed' })
|
||||
}
|
||||
await load()
|
||||
}, [load, tripStorageState])
|
||||
|
||||
const resolveConflict = useCallback(async (id: string, keepMine: boolean) => {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
}, [load])
|
||||
|
||||
const handleConflictStrategy = useCallback((strategy: ConflictStrategy) => {
|
||||
setConflictStrategy(strategy)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, storedTripCount,
|
||||
// Clearable once the database actually holds something, not only once a
|
||||
// sync wrote its bookkeeping row: a download that died between writing the
|
||||
// trip and writing its syncMeta left `rows` empty over a non-empty Dexie,
|
||||
// and the clear button was disabled on exactly the state that needed it.
|
||||
canClear: storedTripCount > 0 || pendingCount > 0,
|
||||
load, runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
}
|
||||
}
|
||||
@@ -534,14 +534,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
|
||||
<div className={panelCls}>
|
||||
|
||||
{/* min-w-0 on both cells: a grid item defaults to min-width:auto, so a
|
||||
long date ("12. Sept. 2026" and the keyboard button beside it) pushed
|
||||
the cell past its track and the two columns overlapped (#2267). */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<label className={labelCls}>
|
||||
<Calendar className="w-3.5 h-3.5" />{t('dashboard.startDate')}
|
||||
</label>
|
||||
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<label className={labelCls}>
|
||||
<Calendar className="w-3.5 h-3.5" />{t('dashboard.endDate')}
|
||||
</label>
|
||||
|
||||
@@ -304,6 +304,11 @@ export function CustomDatePicker({
|
||||
aria-haspopup="dialog"
|
||||
style={{
|
||||
flex: 1,
|
||||
// Without this the trigger never goes below its text width, so the
|
||||
// ellipsis on the label below is unreachable and the button
|
||||
// overflows its column instead. A long locale date ("12. Sept.
|
||||
// 2026") in a two-column grid is exactly that case (#2267).
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { SUPPORTED_LANGUAGES } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import PublicLanguagePicker from './PublicLanguagePicker'
|
||||
|
||||
describe('PublicLanguagePicker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: 'en' } }))
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-001: the trigger keeps the current language as its accessible name', () => {
|
||||
render(<PublicLanguagePicker locale="de-DE" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// `br` and `zh-TW` are whole values, not prefixes: matching the prefix first
|
||||
// labelled Brazilian Portuguese "Language" and zh-TW "简体中文".
|
||||
it('FE-PUBLANG-001b: a hyphenated or non-ISO stored value labels itself correctly', () => {
|
||||
const { unmount } = render(<PublicLanguagePicker locale="br" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Português (Brasil)' })).toBeInTheDocument()
|
||||
unmount()
|
||||
render(<PublicLanguagePicker locale="zh-TW" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: '繁體中文' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-002: an unknown locale falls back to the generic label', () => {
|
||||
render(<PublicLanguagePicker locale="xx" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Language' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-003: closed renders no options at all', () => {
|
||||
render(<PublicLanguagePicker locale="en" open={false} onOpenChange={vi.fn()} />)
|
||||
expect(screen.queryByTestId('public-language-menu')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Deutsch' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-004: open lists every supported language', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = within(screen.getByTestId('public-language-menu'))
|
||||
for (const lang of SUPPORTED_LANGUAGES) {
|
||||
expect(menu.getByRole('button', { name: lang.label })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
// The regression guard for #2248: the journey hero clips its own overflow, so a
|
||||
// menu rendered inside the page tree is amputated at the hero's bottom edge.
|
||||
it('FE-PUBLANG-005: the menu is portalled straight to the document body', () => {
|
||||
const { container } = render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = screen.getByTestId('public-language-menu')
|
||||
expect(menu.parentElement).toBe(document.body)
|
||||
expect(container.contains(menu)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-006: the menu is fixed, capped in height and scrolls its own overflow', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const menu = screen.getByTestId('public-language-menu')
|
||||
expect(menu.style.position).toBe('fixed')
|
||||
expect(menu.style.overflowY).toBe('auto')
|
||||
expect(menu.style.overscrollBehavior).toBe('contain')
|
||||
expect(Number.parseInt(menu.style.maxHeight, 10)).toBeLessThanOrEqual(320)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-007: the trigger toggles through an updater so a controlled parent stays in sync', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open={false} onOpenChange={onOpenChange} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'English' }))
|
||||
const toggle = onOpenChange.mock.calls[0][0] as (v: boolean) => boolean
|
||||
expect(toggle(false)).toBe(true)
|
||||
expect(toggle(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-008: picking a language stores it locally and closes the menu', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deutsch' }))
|
||||
expect(useSettingsStore.getState().settings.language).toBe('de')
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-009: Escape closes the menu', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-010: a pointer press outside closes it, one on the trigger or the menu does not', () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const { container } = render(<PublicLanguagePicker locale="en" open onOpenChange={onOpenChange} />)
|
||||
fireEvent.mouseDown(within(container).getByRole('button', { name: 'English' }))
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: 'Deutsch' }))
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-011: hovering an option paints and clears its background', () => {
|
||||
render(<PublicLanguagePicker locale="en" open onOpenChange={vi.fn()} />)
|
||||
const option = screen.getByRole('button', { name: 'Deutsch' })
|
||||
fireEvent.mouseEnter(option)
|
||||
expect(option.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(option)
|
||||
expect(option.style.background).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-PUBLANG-012: the current language is marked for assistive tech', () => {
|
||||
render(<PublicLanguagePicker locale="de" open onOpenChange={vi.fn()} />)
|
||||
const menu = within(screen.getByTestId('public-language-menu'))
|
||||
expect(menu.getByRole('button', { name: 'Deutsch' })).toHaveAttribute('aria-current', 'true')
|
||||
expect(menu.getByRole('button', { name: 'English' })).toHaveAttribute('aria-current', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SUPPORTED_LANGUAGES } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
/** Wide enough for the longest label ("Português (Brasil)") at the default text size. */
|
||||
const MENU_MIN_WIDTH = 190
|
||||
const MENU_MAX_HEIGHT = 320
|
||||
|
||||
interface PublicLanguagePickerProps {
|
||||
/** Current UI locale, e.g. `de` or `pt-BR`. */
|
||||
locale?: string | null
|
||||
open: boolean
|
||||
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The language pill both anonymous share pages carry in their top-right corner (#2248).
|
||||
*
|
||||
* Each page used to inline the same absolutely-positioned menu. On the journey
|
||||
* page that menu sits inside a hero with `overflow: hidden` — the clip that keeps
|
||||
* the hero's decorative circles from widening the document — so the list of 23
|
||||
* languages was amputated at the hero's bottom edge and most of it could never be
|
||||
* picked. The menu is portalled to `document.body` and positioned against the
|
||||
* trigger now, which no ancestor can clip, and it caps its own height at the room
|
||||
* the viewport actually has: the treatment `CustomSelect` already gets.
|
||||
*/
|
||||
export default function PublicLanguagePicker({ locale, open, onOpenChange }: PublicLanguagePickerProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// matchWidth stays on: the anchored box's `width` is the trigger's own width,
|
||||
// which is what the right-edge alignment below needs.
|
||||
const anchored = useAnchoredPosition(wrapRef, open, { estimatedHeight: MENU_MAX_HEIGHT, offset: 6 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (wrapRef.current?.contains(e.target as Node)) return
|
||||
if (menuRef.current?.contains(e.target as Node)) return
|
||||
onOpenChange(false)
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onOpenChange(false) }
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// The two pages hand in different shapes: the journey page passes the stored
|
||||
// setting (`br`, `zh-TW`), the trip page the resolved locale (`de-DE`). Try the
|
||||
// whole string before its prefix, or `zh-TW` would label itself 简体中文.
|
||||
const raw = locale || 'en'
|
||||
const current = SUPPORTED_LANGUAGES.some(l => l.value === raw) ? raw : raw.split('-')[0]
|
||||
// Pinned by its RIGHT edge, the way `right: 0` did inside the trigger's box. That
|
||||
// also lets the panel size to its content: a reader on the largest text setting
|
||||
// gets a wider menu instead of "Português (Brasil)" wrapping to two lines.
|
||||
const right = anchored
|
||||
? Math.max(8, (typeof window === 'undefined' ? 0 : window.innerWidth) - anchored.left - anchored.width)
|
||||
: 8
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange(v => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === current)?.label || 'Language'}
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-testid="public-language-menu"
|
||||
className="bg-surface-card border border-edge"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...(anchored?.flipped ? { bottom: anchored.bottom } : { top: anchored?.top ?? 0 }),
|
||||
right,
|
||||
minWidth: MENU_MIN_WIDTH,
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
// 23 languages do not fit beside a short viewport; scroll them there
|
||||
// rather than letting the list run past the bottom of the screen.
|
||||
maxHeight: Math.min(MENU_MAX_HEIGHT, anchored?.maxHeight ?? MENU_MAX_HEIGHT),
|
||||
overflowY: 'auto',
|
||||
// Portalled and fixed, so the scroll chain runs to the viewport: without
|
||||
// this a flick past either end scrolls the page behind it instead (#2078).
|
||||
overscrollBehavior: 'contain',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
aria-current={lang.value === current}
|
||||
onClick={() => {
|
||||
// No API call: a share-link visitor has no account to save it to.
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||
onOpenChange(false)
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-hover)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||
className="text-content-secondary"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '7px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: lang.value === current ? 600 : 400,
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
/**
|
||||
* The band where the desktop planner renders but two panels leave the map a
|
||||
* useless strip. Below 768 the phone shell takes over, so the squeeze lives
|
||||
* entirely between there and Tailwind's `lg`: a Pixel 10 Pro Fold unfolded is
|
||||
* ~860 CSS px, where 340 + 300 of panel left the map 200 px (#2247).
|
||||
*/
|
||||
const NARROW_QUERY = '(min-width: 768px) and (max-width: 1023px)'
|
||||
/** However wide a panel was dragged on a big screen, the map keeps this much. */
|
||||
const MIN_MAP = 360
|
||||
|
||||
export function useResizablePanels() {
|
||||
const [leftWidth, setLeftWidth] = useState<number>(() => Number.parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340)
|
||||
@@ -11,6 +20,34 @@ export function useResizablePanels() {
|
||||
const isResizingLeft = useRef(false)
|
||||
const isResizingRight = useRef(false)
|
||||
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(NARROW_QUERY).matches,
|
||||
)
|
||||
// Which panel owns the screen while only one of them fits. The day plan is what
|
||||
// the Plan tab is about, so it starts open and the places list is one tap away;
|
||||
// null is map-only. Deliberately separate from the two collapse flags, which stay
|
||||
// the wide-layout state — folding back to a wide window restores what was there.
|
||||
const [narrowPanel, setNarrowPanel] = useState<'left' | 'right' | null>('left')
|
||||
const [maxPanel, setMaxPanel] = useState(Number.POSITIVE_INFINITY)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_QUERY)
|
||||
const handler = (e: MediaQueryListEvent) => setNarrow(e.matches)
|
||||
setNarrow(mq.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
// Only measured inside the narrow band: that is the only place the clamp can
|
||||
// bite, and a resize listener on every planner mount is not worth the rest.
|
||||
useEffect(() => {
|
||||
if (!narrow) { setMaxPanel(Number.POSITIVE_INFINITY); return }
|
||||
const measure = () => setMaxPanel(Math.max(MIN_SIDEBAR, window.innerWidth - MIN_MAP - 20))
|
||||
measure()
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [narrow])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (isResizingLeft.current) {
|
||||
@@ -41,5 +78,27 @@ export function useResizablePanels() {
|
||||
const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
|
||||
return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight }
|
||||
// What the layout actually shows. The collapse flags stay the caller's intent —
|
||||
// handlePlaceClick reopening "both" must not re-crowd a narrow screen.
|
||||
const leftHidden = narrow ? narrowPanel !== 'left' : leftCollapsed
|
||||
const rightHidden = narrow ? narrowPanel !== 'right' : rightCollapsed
|
||||
|
||||
const toggleLeft = useCallback(() => {
|
||||
if (narrow) setNarrowPanel(p => (p === 'left' ? null : 'left'))
|
||||
else setLeftCollapsed(c => !c)
|
||||
}, [narrow])
|
||||
const toggleRight = useCallback(() => {
|
||||
if (narrow) setNarrowPanel(p => (p === 'right' ? null : 'right'))
|
||||
else setRightCollapsed(c => !c)
|
||||
}, [narrow])
|
||||
|
||||
return {
|
||||
// Clamped for the layout; the stored width is left alone, so a 520 px panel
|
||||
// dragged on a desktop comes back untouched once there is room for it again.
|
||||
leftWidth: Math.min(leftWidth, maxPanel),
|
||||
rightWidth: Math.min(rightWidth, maxPanel),
|
||||
leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrow,
|
||||
startResizeLeft, startResizeRight,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-18
@@ -232,8 +232,9 @@ html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) !
|
||||
html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; }
|
||||
|
||||
/* Reorder buttons: desktop = original style; touch = always visible, larger touch targets.
|
||||
Keyed on pointer as well as width — a tablet is a wide touch device, and drag reorder is
|
||||
off wherever the pointer is coarse (#1432), so these buttons are the only affordance left. */
|
||||
Keyed on pointer as well as width — a tablet is a wide touch device with no hover, so
|
||||
without this the buttons would never appear there. (Drag reorder itself is no longer off
|
||||
on a coarse pointer: #1616 gave tablets the long-press bridge.) */
|
||||
.reorder-buttons {
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
@@ -270,9 +271,6 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.note-edit-buttons {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Packing list rows: on phones, keep the item name readable and move the dense
|
||||
@@ -1290,19 +1288,6 @@ img[alt="TREK"] {
|
||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
||||
|
||||
/* Note card actions: stacked beside the card, and big enough to aim at. */
|
||||
.note-edit-btn {
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.note-edit-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.note-edit-btn-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.13);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Day-plan header action grid (edit / +transport / note / collapse) */
|
||||
.dp-day-actions button {
|
||||
color: var(--text-faint);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import PluginIcon from '../../../components/shared/PluginIcon'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { useInstanceSettings } from '../../../components/Admin/useInstanceSettings'
|
||||
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from '../../../components/Admin/useRangeBypass'
|
||||
import { usePluginStore } from '../../../store/pluginStore'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
@@ -65,6 +66,8 @@ interface PluginRow {
|
||||
trekRange?: string | null
|
||||
/** The TREK this server is running — the server does the semver, the client just shows it. */
|
||||
hostVersion?: string
|
||||
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
|
||||
trekRangeBypassed?: TrekRangeBypass | null
|
||||
/** The author's signature was verified and their key pinned at install. False means the
|
||||
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
|
||||
signed?: boolean
|
||||
@@ -272,7 +275,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
return out
|
||||
}
|
||||
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
|
||||
@@ -290,6 +293,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
blocked: true,
|
||||
})
|
||||
}
|
||||
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
|
||||
if (bypassed) out.push(bypassed)
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
@@ -309,11 +314,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
|
||||
* grey button. The plugin is perfectly usable, just not at its newest.
|
||||
*/
|
||||
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
|
||||
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
|
||||
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
|
||||
const title = item.trek
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown')
|
||||
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
|
||||
if (item.latestCompatible) {
|
||||
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
|
||||
}
|
||||
@@ -404,6 +410,8 @@ export default function MAdminPluginsPanel() {
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
|
||||
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
|
||||
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
|
||||
const [linkPath, setLinkPath] = useState('')
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -478,9 +486,10 @@ export default function MAdminPluginsPanel() {
|
||||
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
|
||||
void usePluginStore.getState().loadPlugins()
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
|
||||
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
|
||||
setRuntimeOn(!!d.enabled)
|
||||
setDevLink(!!d.devLink)
|
||||
setIgnoreTrekRange(!!d.ignoreTrekRange)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
|
||||
@@ -546,6 +555,7 @@ export default function MAdminPluginsPanel() {
|
||||
const res = await adminApi.pluginUpload(file)
|
||||
setView('installed')
|
||||
toast.success(t('admin.plugins.uploaded', { name: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -621,7 +631,11 @@ export default function MAdminPluginsPanel() {
|
||||
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'))
|
||||
// `warn` is the pre-install confirm for an entry the registry already flagged as
|
||||
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
|
||||
// was only a pre-download filter) is caught by the marker on the response instead.
|
||||
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
|
||||
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), 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
|
||||
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
|
||||
@@ -634,6 +648,7 @@ export default function MAdminPluginsPanel() {
|
||||
setView('installed')
|
||||
setLinkPath('')
|
||||
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -693,9 +708,10 @@ export default function MAdminPluginsPanel() {
|
||||
if (busy === parent.id) return
|
||||
setBusy(parent.id)
|
||||
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
bypass.notice(depId, r?.trekRangeBypassed)
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
|
||||
@@ -713,9 +729,10 @@ export default function MAdminPluginsPanel() {
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
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 }])
|
||||
bypass.notice(p.name, r.trekRangeBypassed)
|
||||
})
|
||||
.catch(e => {
|
||||
const { error, code } = errBody(e)
|
||||
@@ -838,11 +855,19 @@ export default function MAdminPluginsPanel() {
|
||||
</h2>
|
||||
<p className="mt-1 font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex flex-none items-center gap-1.5 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--m-st-confirmed)]" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex flex-none items-center gap-1 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-pending)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-pending)]"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={11} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex flex-none items-center gap-1.5 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--m-st-confirmed)]" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -920,7 +945,7 @@ export default function MAdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-[color:var(--m-st-danger)]">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryList items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryList items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -965,10 +990,19 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail sheet */}
|
||||
{detailFor && (
|
||||
<PluginDetailSheet item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailSheet item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{/* TREK_PLUGINS_IGNORE_TREK_RANGE: confirm before a registry install the server would
|
||||
otherwise refuse (onConfirm set), or a plain notice after a path that could not
|
||||
ask first — sideload, dev-link, update, dependency download. */}
|
||||
{bypass.copy && (
|
||||
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
|
||||
title={bypass.copy.title} message={bypass.copy.body}
|
||||
confirmLabel={t('admin.plugins.installAnyway')} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
|
||||
)}
|
||||
|
||||
{/* Row ⋯ action sheet */}
|
||||
{rowMenuPlugin && (
|
||||
<RowActionsSheet p={rowMenuPlugin} t={t} onClose={() => setMenu(null)}
|
||||
@@ -1428,8 +1462,8 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blo
|
||||
{deps.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-medium ${d.blocked ? CHIP_PENDING : CHIP_NEUTRAL}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'} />{d.label}
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-medium ${d.blocked || d.warn ? CHIP_PENDING : CHIP_NEUTRAL}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1573,14 +1607,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string, version?: string) => void
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-m-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1593,7 +1628,7 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="space-y-3">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// Same press-scale opt-out as the desktop Discover card (#2158).
|
||||
@@ -1626,7 +1661,7 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className={`ml-auto ${ACT_PILL}`}>
|
||||
@@ -1656,9 +1691,9 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="rounded bg-[color:var(--m-ic)] px-1.5 py-0.5 font-mono text-[11px]">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1678,7 +1713,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
// The detail fetch carries the same compat verdict as the browse list; prefer it once
|
||||
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
|
||||
const linkClass = 'inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-1.5 text-xs font-medium text-m-muted'
|
||||
const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
|
||||
|
||||
@@ -1702,7 +1737,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-m-faint">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className={`${ACT_PILL} self-end`}>
|
||||
|
||||
@@ -3,35 +3,20 @@
|
||||
* components/Settings/OfflineTab. Same logic (force-offline switch, prepare /
|
||||
* resync, conflict resolver, per-trip storage, cache stats + clear), rebuilt on
|
||||
* the MSet* card system with MToggle switches and an MConfirmSheet for clear.
|
||||
*
|
||||
* The logic itself lives in the shared `useOfflineSettings` hook; only the
|
||||
* markup differs between the two shells.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
|
||||
import { offlineDb, clearAll, clearTripData } from '../../../db/offlineDb'
|
||||
import { tripsApi } from '../../../api/client'
|
||||
import { tripSyncManager, type PrepareProgress } from '../../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../../sync/mutationQueue'
|
||||
import { clearTileCache } from '../../../sync/tilePrefetcher'
|
||||
import { isEffectivelyOffline } from '../../../sync/networkMode'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
|
||||
type ConflictStrategy,
|
||||
} from '../../../sync/offlinePrefs'
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode'
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
|
||||
import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from '../../../components/Settings/useOfflineSettings'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { SyncMeta, QueuedMutation } from '../../../db/offlineDb'
|
||||
import type { Trip } from '../../../types'
|
||||
import type { ConflictStrategy } from '../../../sync/offlinePrefs'
|
||||
import type { QueuedMutation } from '../../../db/offlineDb'
|
||||
import { MSetCard, MSetEyebrow, MSetRow, MSetSegments, MSetButton } from './MSettingsUi'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
function conflictName(m: QueuedMutation): string {
|
||||
const body = (m.body ?? {}) as { name?: unknown }
|
||||
const server = (m.conflictServer ?? {}) as { name?: unknown }
|
||||
@@ -42,132 +27,19 @@ function conflictName(m: QueuedMutation): string {
|
||||
|
||||
export default function MSettingsOffline() {
|
||||
const { t } = useTranslation()
|
||||
const { offline, forced, setForced } = useNetworkMode()
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [allTrips, setAllTrips] = useState<Trip[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [conflicts, setConflicts] = useState<QueuedMutation[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [progress, setProgress] = useState<PrepareProgress | null>(null)
|
||||
const [prefs, setPrefs] = useState(getOfflinePrefs())
|
||||
|
||||
useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending, failed, conflictList] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
mutationQueue.conflicts(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
setConflicts(conflictList)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
|
||||
// Per-trip storage toggles are driven by the FULL trip list, not just the
|
||||
// cached ones, so a trip turned off stays visible and re-enableable.
|
||||
try {
|
||||
const trips = isEffectivelyOffline()
|
||||
? await offlineDb.trips.toArray()
|
||||
: await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray())
|
||||
trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? ''))
|
||||
setAllTrips(trips)
|
||||
} catch {
|
||||
setAllTrips([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const runPrepare = useCallback(async () => {
|
||||
setPreparing(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
await tripSyncManager.prepareForOffline(p => setProgress(p))
|
||||
await load()
|
||||
} finally {
|
||||
setPreparing(false)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
async function handleToggleForce() {
|
||||
if (!forced) {
|
||||
// Turning offline mode on: download everything first (while still online),
|
||||
// then engage so the app has all it needs before the network drops.
|
||||
if (navigator.onLine) await runPrepare()
|
||||
setForced(true)
|
||||
} else {
|
||||
// Back online: lifting the switch flushes the queue + re-syncs.
|
||||
setForced(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
const {
|
||||
offline, forced,
|
||||
rows, allTrips, pendingCount, failedCount, conflicts,
|
||||
syncing, clearing, loading, preparing, progress, notice, prefs, canClear,
|
||||
runPrepare, handleToggleForce, handleResync, handleClear,
|
||||
handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
|
||||
handleConflictStrategy,
|
||||
} = useOfflineSettings()
|
||||
|
||||
async function doClear() {
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
setShowClearConfirm(false)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleTiles() {
|
||||
const next = !prefs.cacheTiles
|
||||
setCacheTiles(next)
|
||||
// Turning tiles off reclaims the bulk tile storage straight away.
|
||||
if (!next) await clearTileCache()
|
||||
}
|
||||
|
||||
async function handleToggleTrip(tripId: number) {
|
||||
const next = !isTripOfflineEnabled(tripId)
|
||||
setTripOfflineEnabled(tripId, next)
|
||||
if (!next) {
|
||||
await clearTripData(tripId)
|
||||
await load()
|
||||
} else if (navigator.onLine) {
|
||||
tripSyncManager.syncAll().then(load).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(id: string, keepMine: boolean) {
|
||||
if (keepMine) await mutationQueue.resolveKeepMine(id)
|
||||
else await mutationQueue.resolveKeepServer(id)
|
||||
await load()
|
||||
await handleClear()
|
||||
setShowClearConfirm(false)
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
@@ -229,9 +101,10 @@ export default function MSettingsOffline() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preparing && progress?.phase === 'done' && (
|
||||
<div className="mt-[10px] flex items-center gap-[6px] font-geist text-[0.6875rem] text-[color:var(--m-st-confirmed)]">
|
||||
<Check size={14} /> {t('settings.offline.prepare.done')}
|
||||
{!preparing && notice && notice.kind !== 'load-failed' && (
|
||||
<div className={`mt-[10px] flex items-center gap-[6px] font-geist text-[0.6875rem] ${isOfflineNoticeWarning(notice) ? 'text-[color:var(--m-st-pending)]' : 'text-[color:var(--m-st-confirmed)]'}`}>
|
||||
{isOfflineNoticeWarning(notice) ? <AlertTriangle size={14} /> : <Check size={14} />}
|
||||
{t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,7 +148,7 @@ export default function MSettingsOffline() {
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.offline.conflicts.strategyTitle')}</MSetEyebrow>
|
||||
<MSetSegments<ConflictStrategy>
|
||||
value={prefs.conflictStrategy}
|
||||
onChange={setConflictStrategy}
|
||||
onChange={handleConflictStrategy}
|
||||
options={strategyOptions}
|
||||
/>
|
||||
</div>
|
||||
@@ -297,14 +170,15 @@ export default function MSettingsOffline() {
|
||||
<div className="mb-1 text-[0.78125rem] font-bold text-m-ink">{t('settings.offline.storage.tripsTitle')}</div>
|
||||
<div>
|
||||
{allTrips.map((trip, i) => {
|
||||
const on = isTripOfflineEnabled(trip.id)
|
||||
const { on, dateEligible } = tripStorageState(trip)
|
||||
const sub = on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')
|
||||
return (
|
||||
<MSetRow
|
||||
key={trip.id}
|
||||
first={i === 0}
|
||||
label={<span className="block truncate">{trip.title}</span>}
|
||||
sub={on ? t('settings.offline.storage.tripOn') : t('settings.offline.storage.tripOff')}
|
||||
trailing={<MToggle checked={on} onChange={() => handleToggleTrip(trip.id)} ariaLabel={trip.title} />}
|
||||
sub={dateEligible ? sub : `${sub} · ${t('settings.offline.storage.tripFinished')}`}
|
||||
trailing={<MToggle checked={on} onChange={() => handleToggleTrip(trip)} ariaLabel={trip.title} />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -323,7 +197,7 @@ export default function MSettingsOffline() {
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<MSetButton variant="danger" disabled={clearing || rows.length === 0} onClick={() => setShowClearConfirm(true)}>
|
||||
<MSetButton variant="danger" disabled={clearing || !canClear} onClick={() => setShowClearConfirm(true)}>
|
||||
<Trash2 size={14} />
|
||||
{t('settings.offline.clear')}
|
||||
</MSetButton>
|
||||
@@ -332,7 +206,9 @@ export default function MSettingsOffline() {
|
||||
{loading ? (
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">{t('settings.offline.loading')}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">{t('settings.offline.empty')}</p>
|
||||
<p className="mt-3 font-geist text-[0.71875rem] text-m-muted">
|
||||
{t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
||||
import { findFocusDayId } from '../../../components/Planner/today'
|
||||
import {
|
||||
ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
|
||||
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
|
||||
Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
@@ -262,6 +262,38 @@ export default function MTripShell({
|
||||
planner.setExpandedDayIds(dayId == null ? null : new Set([dayId]))
|
||||
}
|
||||
|
||||
// The map's day selection: frame the day, draw its route, drop the other days'
|
||||
// pins. Shared by the chip tap and the all-days toggle so the two cannot drift.
|
||||
const selectDayOnMap = (dayId: number) => {
|
||||
planner.handleSelectDay(dayId, false)
|
||||
planner.autoShowRoute()
|
||||
focusMapOnDay(dayId)
|
||||
}
|
||||
|
||||
// The day an "all days" deselect came from, so the toggle can put it back.
|
||||
// A remote day:deleted can retire it while it is parked here, hence the
|
||||
// membership check before it is trusted.
|
||||
const lastDayRef = useRef<number | null>(null)
|
||||
const dayToRestore = (): number | null =>
|
||||
(lastDayRef.current != null && days.some(d => d.id === lastDayRef.current)
|
||||
? lastDayRef.current
|
||||
: findFocusDayId(days) ?? days[0]?.id) ?? null
|
||||
|
||||
// Whole trip ⇄ one day on the map (#2257). Its own control rather than a
|
||||
// second tap on the active chip: that tap already opens the day sheet, the
|
||||
// only route to it that also exists in map view. Never skipFit — changing
|
||||
// what the map is filtered to is exactly when it should reframe.
|
||||
const toggleAllDays = () => {
|
||||
if (planner.selectedDayId != null) {
|
||||
lastDayRef.current = planner.selectedDayId
|
||||
focusMapOnDay(null)
|
||||
planner.handleSelectDay(null, false)
|
||||
return
|
||||
}
|
||||
const back = dayToRestore()
|
||||
if (back != null) selectDayOnMap(back)
|
||||
}
|
||||
|
||||
const toggleView = () => {
|
||||
const next = view === 'plan' ? 'map' : 'plan'
|
||||
setView(next)
|
||||
@@ -276,6 +308,13 @@ export default function MTripShell({
|
||||
// Leaving the map: nothing is filtered while the timeline is up, so the next
|
||||
// visit starts from the whole trip unless a day is picked again.
|
||||
focusMapOnDay(null)
|
||||
// The timeline is single-day and renders empty without one, so an "all days"
|
||||
// deselect on the map must not follow the user back into the plan. Same day
|
||||
// the toggle would restore, so the two ways back agree.
|
||||
if (planner.selectedDayId == null) {
|
||||
const back = dayToRestore()
|
||||
if (back != null) planner.handleSelectDay(back, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,15 +346,10 @@ export default function MTripShell({
|
||||
|
||||
const onDayChipTap = (dayId: number) => {
|
||||
if (dayId === planner.selectedDayId) openSheet('day', { dayId })
|
||||
else {
|
||||
planner.handleSelectDay(dayId, view !== 'map')
|
||||
// In map mode a day tap fits to that day's places, draws its route and drops
|
||||
// the other days' pins, so the day it framed is the day it shows.
|
||||
if (view === 'map') {
|
||||
planner.autoShowRoute()
|
||||
focusMapOnDay(dayId)
|
||||
}
|
||||
}
|
||||
// In map mode a day tap fits to that day's places, draws its route and drops
|
||||
// the other days' pins, so the day it framed is the day it shows.
|
||||
else if (view === 'map') selectDayOnMap(dayId)
|
||||
else planner.handleSelectDay(dayId, true)
|
||||
}
|
||||
|
||||
const packedCount = packingItems.filter(i => i.checked).length
|
||||
@@ -347,7 +381,7 @@ export default function MTripShell({
|
||||
|
||||
{/* ── Day chips (z-25 — covered by non-plan tab overlays, stays mounted) ── */}
|
||||
{days.length > 0 && (
|
||||
<div className="absolute left-4 right-4 z-[25] flex top-[calc(var(--m-safe-top,12px)+50px)]">
|
||||
<div className="absolute left-4 right-4 z-[25] flex gap-[6px] top-[calc(var(--m-safe-top,12px)+50px)]">
|
||||
<div className="flex flex-1 items-center gap-[2px] overflow-x-auto rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
|
||||
{days.map((day, idx) => {
|
||||
const active = day.id === planner.selectedDayId
|
||||
@@ -376,6 +410,26 @@ export default function MTripShell({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Drop the day filter and show the whole trip (#2257). Map only: the
|
||||
plan timeline is single-day, so there is nothing to widen there.
|
||||
Stays mounted while a day is active rather than appearing on
|
||||
selection, which would shove the rail sideways under the thumb. */}
|
||||
{view === 'map' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllDays}
|
||||
aria-pressed={planner.selectedDayId == null}
|
||||
aria-label={t('mobileTrip.allDays')}
|
||||
title={t('mobileTrip.allDays')}
|
||||
className={`flex w-9 flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] backdrop-blur-[24px] backdrop-saturate-[1.7] ${
|
||||
planner.selectedDayId == null
|
||||
? 'bg-m-act text-m-actfg shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]'
|
||||
: 'bg-[color:var(--m-glass)] text-m-ink'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays size={15} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -547,7 +547,8 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
|
||||
{...dragProps(drag)}
|
||||
role={chrome.editing ? 'button' : undefined}
|
||||
tabIndex={chrome.editing ? 0 : undefined}
|
||||
onClick={chrome.editing ? onEdit : undefined}
|
||||
// A link in the rendered body keeps its own tap; the rest of the row edits.
|
||||
onClick={chrome.editing ? (e => { if (!(e.target as HTMLElement).closest('a')) onEdit() }) : undefined}
|
||||
onKeyDown={chrome.editing ? (e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onEdit() } }) : undefined}
|
||||
className={`my-[2px] flex items-center gap-2.5 ${chrome.editing ? 'cursor-pointer' : ''} ${dragClass(drag)}`}
|
||||
>
|
||||
|
||||
@@ -259,7 +259,12 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
assignment_id: (isHotel && !form.accommodation_id) ? null : (assignmentId || null),
|
||||
accommodation_id: isHotel ? (form.accommodation_id || null) : null,
|
||||
place_id: isHotel ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
// Omitted on an edit for the same reason as the desktop dialog: the
|
||||
// server swaps the whole endpoint set when the key is present, and this
|
||||
// sheet never edits endpoints (#2216).
|
||||
|
||||
@@ -3,11 +3,12 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../../../../components/shared/markdownSanitize'
|
||||
import { Check, FileText, Paperclip, Pin, PinOff, Plus, StickyNote, Trash2, X } from 'lucide-react'
|
||||
import { Check, ExternalLink, FileText, Paperclip, Pin, PinOff, Plus, StickyNote, Trash2, X } from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { collabApi } from '../../../../api/client'
|
||||
import { addListener, removeListener } from '../../../../api/websocket'
|
||||
import { openFile } from '../../../../utils/fileDownload'
|
||||
import { safeExternalHref } from '../../../../utils/safeUrl'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
@@ -36,9 +37,15 @@ interface NoteFormSubmitData {
|
||||
content?: string
|
||||
category?: string
|
||||
color: string
|
||||
website: string | null
|
||||
pendingFiles: File[]
|
||||
}
|
||||
|
||||
/** Label for a link chip: the host is what people recognise, "www." is noise. */
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip-tab Collab / Notes. Same architecture as MCollabChat: own state, own
|
||||
* `collabApi` calls, own WebSocket listener — no `tripStore`/`tripActions`
|
||||
@@ -98,6 +105,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
||||
try {
|
||||
const res = (await collabApi.createNote(tripId, {
|
||||
title: data.title, content: data.content, category: data.category, color: data.color,
|
||||
website: data.website,
|
||||
})) as NoteResponse
|
||||
created = res.note
|
||||
} catch {
|
||||
@@ -119,7 +127,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
||||
|
||||
const handleUpdate = useCallback(async (
|
||||
noteId: number,
|
||||
data: { title?: string; content?: string; category?: string; color?: string; pinned?: boolean },
|
||||
data: { title?: string; content?: string; category?: string; color?: string; pinned?: boolean; website?: string | null },
|
||||
pendingFiles: File[] = [],
|
||||
) => {
|
||||
let updated: CollabNoteData | undefined
|
||||
@@ -256,6 +264,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) {
|
||||
if (formTarget && formTarget !== 'new') {
|
||||
await handleUpdate(formTarget.id, {
|
||||
title: data.title, content: data.content, category: data.category, color: data.color,
|
||||
website: data.website,
|
||||
}, data.pendingFiles)
|
||||
} else {
|
||||
await handleCreate(data)
|
||||
@@ -296,6 +305,10 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
t: TripPlanner['t']
|
||||
}) {
|
||||
const initial = (note.username || '?')[0]?.toUpperCase() || '?'
|
||||
// An editor is routed to the form sheet, never to the viewer, so the card is
|
||||
// the only surface where both roles can reach the link (#2222). The field
|
||||
// takes any string, so the href is allow-listed the way the desktop tile is.
|
||||
const websiteHref = safeExternalHref(note.website)
|
||||
|
||||
return (
|
||||
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
@@ -312,6 +325,17 @@ function NoteCardRow({ note, color, canEdit, onTap, onTogglePin, onDelete, t }:
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{websiteHref && (
|
||||
<a
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('collab.notes.website')}
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
|
||||
>
|
||||
<ExternalLink size={12} strokeWidth={2.2} />
|
||||
</a>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -376,6 +400,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
const [category, setCategory] = useState<string | null>(null)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryDraft, setNewCategoryDraft] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [existingFiles, setExistingFiles] = useState<CollabNoteFile[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -388,6 +413,7 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
setCategory(note?.category || null)
|
||||
setAddingCategory(false)
|
||||
setNewCategoryDraft('')
|
||||
setWebsite(note?.website || '')
|
||||
setPendingFiles([])
|
||||
setExistingFiles(note?.attachments || [])
|
||||
}, [open, note])
|
||||
@@ -403,6 +429,9 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
content: content.trim() || undefined,
|
||||
category: category || undefined,
|
||||
color: getCategoryColor(category, colorMap),
|
||||
// Always sent, never undefined: the server only writes the column when
|
||||
// the key is present, so an emptied field has to arrive as null to clear.
|
||||
website: website.trim() || null,
|
||||
pendingFiles,
|
||||
})
|
||||
onClose()
|
||||
@@ -500,6 +529,16 @@ function NoteFormSheet({ open, target, categories, colorMap, canUploadFiles, onC
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('collab.notes.website')}</Eyebrow>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={website}
|
||||
onChange={e => setWebsite(e.target.value)}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
|
||||
{canUploadFiles && (
|
||||
<>
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('collab.notes.attachFiles')}</Eyebrow>
|
||||
@@ -588,6 +627,7 @@ function NoteViewSheet({ open, note, onClose, t }: {
|
||||
useEffect(() => {
|
||||
if (open) setSnapshot(note)
|
||||
}, [open, note])
|
||||
const websiteHref = safeExternalHref(snapshot?.website)
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={snapshot?.title}>
|
||||
@@ -604,6 +644,17 @@ function NoteViewSheet({ open, note, onClose, t }: {
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{snapshot.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{websiteHref && (
|
||||
<a
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-[5px] rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-3 py-[7px] font-geist text-[0.71875rem] font-semibold text-m-ink"
|
||||
>
|
||||
<ExternalLink size={13} strokeWidth={2} className="text-m-muted" />
|
||||
{linkHost(websiteHref)}
|
||||
</a>
|
||||
)}
|
||||
{(snapshot?.attachments.length ?? 0) > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-[6px]">
|
||||
{snapshot?.attachments.map(f => (
|
||||
|
||||
@@ -28,8 +28,8 @@ import MobileEntryView from '../components/Journey/MobileEntryView';
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox';
|
||||
import EmptyState from '../components/shared/EmptyState';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { formatLocationName } from '../utils/formatters';
|
||||
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
|
||||
|
||||
@@ -616,70 +616,7 @@ export default function JourneyPublicPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Language picker */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowLangPicker((v) => !v)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find((l) => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 6,
|
||||
background: 'white',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 50,
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
onClick={() => {
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: lang.value } }));
|
||||
setShowLangPicker(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: '#374151',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PublicLanguagePicker locale={locale} open={showLangPicker} onOpenChange={setShowLangPicker} />
|
||||
|
||||
{/* Logo */}
|
||||
<div
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('JourneyPublicPage wiring', () => {
|
||||
const { hook } = setup({ showLangPicker: true });
|
||||
const german = screen.getByRole('button', { name: 'Deutsch' });
|
||||
fireEvent.mouseEnter(german);
|
||||
expect(german).toHaveStyle({ background: 'rgb(243, 244, 246)' });
|
||||
expect(german).toHaveStyle({ background: 'var(--bg-hover)' });
|
||||
fireEvent.mouseLeave(german);
|
||||
|
||||
fireEvent.click(german);
|
||||
|
||||
@@ -6,6 +6,13 @@ import { server } from '../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../tests/helpers/store';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
// The takeoff overlay renders the animated dot world, which starts three
|
||||
// requestAnimationFrame loops the moment it mounts. Nothing here asserts on the
|
||||
// animation (LoginWorld has its own tests), but under the load of the full suite
|
||||
// those loops pushed the overlay assertions past waitFor's default timeout and
|
||||
// made FE-PAGE-LOGIN-014 fail intermittently in CI while passing in isolation.
|
||||
vi.mock('./login/LoginWorld', () => ({ default: () => null }));
|
||||
|
||||
// LoginPage uses inline styles for labels (no htmlFor/id pairing).
|
||||
// We find inputs by placeholder text.
|
||||
const EMAIL_PLACEHOLDER = 'your@email.com';
|
||||
|
||||
@@ -1317,7 +1317,7 @@ describe('SharedTripPage', () => {
|
||||
const option = screen.getByRole('button', { name: /deutsch/i });
|
||||
|
||||
fireEvent.mouseEnter(option);
|
||||
expect(option.style.background).toBe('rgb(243, 244, 246)');
|
||||
expect(option.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(option);
|
||||
expect(option.style.background).toBe('none');
|
||||
});
|
||||
|
||||
@@ -19,10 +19,10 @@ import { createElement, useEffect, useRef } from 'react';
|
||||
import { renderIconMarkup } from '../utils/iconMarkup';
|
||||
import { MapContainer, Marker, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet';
|
||||
import { getCategoryIcon } from '../components/shared/categoryIcons';
|
||||
import PublicLanguagePicker from '../components/shared/PublicLanguagePicker';
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, attributionForTile } from '../constants/mapDefaults';
|
||||
import VectorBasemap from '../components/Map/VectorBasemap';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { avatarSrc } from '../utils/avatarSrc';
|
||||
import { safeHexColor } from '../utils/safeColor';
|
||||
import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
|
||||
@@ -335,70 +335,7 @@ export default function SharedTripPage() {
|
||||
{t('shared.readOnly')}
|
||||
</div>
|
||||
|
||||
{/* Language picker - top right */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button type="button"
|
||||
onClick={() => setShowLangPicker((v) => !v)}
|
||||
className="bg-[rgba(255,255,255,0.1)] text-[rgba(255,255,255,0.7)]"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find((l) => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div
|
||||
className="bg-white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 6,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 50,
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.value}
|
||||
onClick={() => {
|
||||
// Set language locally without API call (shared page has no auth)
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: lang.value } }));
|
||||
setShowLangPicker(false);
|
||||
}}
|
||||
className="text-[#374151]"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PublicLanguagePicker locale={locale} open={showLangPicker} onOpenChange={setShowLangPicker} />
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
|
||||
|
||||
@@ -38,7 +38,6 @@ import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsAp
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
@@ -246,7 +245,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
|
||||
leftWidth, rightWidth,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
@@ -327,6 +328,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
}
|
||||
if (!trip) return null
|
||||
|
||||
// What each panel actually occupies right now, and where that leaves the
|
||||
// strip of map between them. The panels float 10px inside the map, so the
|
||||
// corridor starts past that margin.
|
||||
const leftPanelPx = leftHidden ? 0 : leftWidth
|
||||
const rightPanelPx = rightHidden ? 0 : rightWidth
|
||||
const mapInsetLeft = leftPanelPx ? leftPanelPx + 10 : 0
|
||||
const mapInsetRight = rightPanelPx ? rightPanelPx + 10 : 0
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
@@ -381,8 +390,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tileUrl={mapTileUrl}
|
||||
fitKey={fitKey}
|
||||
dayOrderMap={dayOrderMap}
|
||||
leftWidth={leftCollapsed ? 0 : leftWidth}
|
||||
rightWidth={rightCollapsed ? 0 : rightWidth}
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
hasInspector={!!selectedPlace}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
@@ -399,7 +408,14 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
// Centred on the corridor the panels leave, not on the viewport: at
|
||||
// 860px the viewport centre sits under the Places panel, where this
|
||||
// cluster covered both collapse tabs and Add Place/Activity (#2247).
|
||||
left: `calc(${mapInsetLeft}px + (100% - ${mapInsetLeft}px - ${mapInsetRight}px) / 2)`,
|
||||
transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8,
|
||||
}}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
@@ -425,30 +441,32 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button type="button" onClick={() => setLeftCollapsed(c => !c)}
|
||||
<button type="button" onClick={toggleLeft}
|
||||
aria-label={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
title={leftHidden ? t('trip.mobilePlan') : t('common.collapse')}
|
||||
style={{
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
|
||||
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
position: leftHidden ? 'fixed' : 'absolute', top: leftHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftHidden ? 10 : undefined, right: leftHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: leftHidden ? 10 : '0 10px 10px 0',
|
||||
background: leftHidden ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: leftHidden ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: leftCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
color: leftHidden ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
onMouseEnter={e => { if (!leftHidden) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!leftHidden) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{leftHidden ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: leftCollapsed ? 0 : leftWidth, height: '100%',
|
||||
width: leftHidden ? 0 : leftWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: leftCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
boxShadow: leftHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: leftCollapsed ? 0 : 1,
|
||||
opacity: leftHidden ? 0 : 1,
|
||||
}}>
|
||||
<DayPlanSidebar
|
||||
isMobile={isMobile}
|
||||
@@ -501,7 +519,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
|
||||
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
{!leftHidden && !narrowPanels && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={startResizeLeft}
|
||||
@@ -514,32 +532,34 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button type="button" onClick={() => setRightCollapsed(c => !c)}
|
||||
<button type="button" onClick={toggleRight}
|
||||
aria-label={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')}
|
||||
title={rightHidden ? t('trip.mobilePlaces') : t('common.collapse')}
|
||||
style={{
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
|
||||
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
position: rightHidden ? 'fixed' : 'absolute', top: rightHidden ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightHidden ? 10 : undefined, left: rightHidden ? undefined : -28, zIndex: -1,
|
||||
width: 36, height: 36, borderRadius: rightHidden ? 10 : '10px 0 0 10px',
|
||||
background: rightHidden ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: rightHidden ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: rightCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
color: rightHidden ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
onMouseEnter={e => { if (!rightHidden) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!rightHidden) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||
{rightHidden ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
width: rightCollapsed ? 0 : rightWidth, height: '100%',
|
||||
width: rightHidden ? 0 : rightWidth, height: '100%',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: rightCollapsed ? 'none' : 'var(--sidebar-shadow)',
|
||||
boxShadow: rightHidden ? 'none' : 'var(--sidebar-shadow)',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
opacity: rightCollapsed ? 0 : 1,
|
||||
opacity: rightHidden ? 0 : 1,
|
||||
}}>
|
||||
{!rightCollapsed && (
|
||||
{!rightHidden && !narrowPanels && (
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={startResizeRight}
|
||||
@@ -614,8 +634,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
weatherPlaceName={weatherPlaceName}
|
||||
onClose={() => { setShowDayDetail(null); handleSelectDay(null) }}
|
||||
onAccommodationChange={loadAccommodations}
|
||||
leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
@@ -659,8 +679,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }}
|
||||
onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }}
|
||||
leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)}
|
||||
rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -165,6 +165,11 @@ function baseState(): HookState {
|
||||
rightCollapsed: false,
|
||||
setLeftCollapsed: vi.fn(),
|
||||
setRightCollapsed: vi.fn(),
|
||||
leftHidden: false,
|
||||
rightHidden: false,
|
||||
toggleLeft: vi.fn(),
|
||||
toggleRight: vi.fn(),
|
||||
narrowPanels: false,
|
||||
startResizeLeft: vi.fn(),
|
||||
startResizeRight: vi.fn(),
|
||||
selectedPlaceId: null,
|
||||
@@ -371,7 +376,7 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-009: collapsed panels report a zero width to the map', () => {
|
||||
renderPage({ leftCollapsed: true, rightCollapsed: true })
|
||||
renderPage({ leftHidden: true, rightHidden: true })
|
||||
|
||||
expect(props('map').leftWidth).toBe(0)
|
||||
expect(props('map').rightWidth).toBe(0)
|
||||
@@ -410,19 +415,42 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
fireEvent.click(leftBtn)
|
||||
expect(updaterOf<boolean>(hookState.setLeftCollapsed)(false)).toBe(true)
|
||||
expect(hookState.toggleLeft).toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseEnter(leftBtn)
|
||||
fireEvent.mouseLeave(leftBtn)
|
||||
|
||||
fireEvent.click(rightBtn)
|
||||
expect(updaterOf<boolean>(hookState.setRightCollapsed)(false)).toBe(true)
|
||||
expect(hookState.toggleRight).toHaveBeenCalled()
|
||||
fireEvent.mouseEnter(rightBtn)
|
||||
fireEvent.mouseLeave(rightBtn)
|
||||
})
|
||||
|
||||
// The tabs are the only way back to a panel on a touch device, and they used to
|
||||
// announce themselves with nothing but a hover colour (#2247).
|
||||
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
|
||||
renderPage({ leftHidden: true, rightHidden: false })
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
|
||||
expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
|
||||
})
|
||||
|
||||
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
|
||||
// floating POI/compass cluster covered both tabs and the Add Place button (#2247).
|
||||
it('FE-PAGE-TPW-013c: the floating map controls centre on the map corridor, not the viewport', () => {
|
||||
const { container } = renderPage({ leftWidth: 340, rightWidth: 300 })
|
||||
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
|
||||
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 310px))')
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-013d: a hidden panel takes no corridor away from the map controls', () => {
|
||||
const { container } = renderPage({ leftWidth: 340, rightWidth: 300, rightHidden: true })
|
||||
const cluster = container.querySelector('div[style*="translateX(-50%)"][style*="z-index: 25"]') as HTMLElement
|
||||
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
|
||||
renderPage({ leftCollapsed: true, rightCollapsed: true })
|
||||
renderPage({ leftHidden: true, rightHidden: true })
|
||||
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
fireEvent.mouseEnter(leftBtn)
|
||||
@@ -433,6 +461,13 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Mouse-only handles (mousedown + document mousemove) are inert on a tablet, and
|
||||
// a 4px invisible strip on the panel edge is a trap for a fat finger (#2247).
|
||||
it('FE-PAGE-TPW-015b: the narrow layout drops the mouse-only resize handles', () => {
|
||||
const { container } = renderPage({ narrowPanels: true })
|
||||
expect(container.querySelectorAll('div[style*="col-resize"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-015: the resize handles start a drag on mouse-down and highlight on hover', () => {
|
||||
const { container } = renderPage()
|
||||
const handles = container.querySelectorAll('div[style*="col-resize"]')
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation } from '../../../tests/helpers/factories'
|
||||
import {
|
||||
addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi,
|
||||
healthApi, airtrailApi, mapsApi, placesApi,
|
||||
healthApi, airtrailApi, mapsApi,
|
||||
} from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
@@ -197,7 +197,6 @@ beforeEach(() => {
|
||||
vi.spyOn(airtrailApi, 'sync').mockResolvedValue({ changed: 0 })
|
||||
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: '', address: '' } as never)
|
||||
vi.spyOn(mapsApi, 'search').mockResolvedValue({ places: [] } as never)
|
||||
vi.spyOn(placesApi, 'create').mockResolvedValue({ id: 321 })
|
||||
vi.mocked(accommodationRepo.list).mockResolvedValue({ accommodations: [] })
|
||||
vi.mocked(getCached).mockReturnValue(undefined)
|
||||
})
|
||||
@@ -1533,7 +1532,7 @@ describe('useTripPlanner — bookings and transports', () => {
|
||||
const payload = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number; venue?: unknown } }
|
||||
expect(payload.create_accommodation.place_id).toBe(4)
|
||||
expect(payload.create_accommodation.venue).toBeUndefined()
|
||||
expect(placesApi.create).not.toHaveBeenCalled()
|
||||
expect(actions.addPlace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-080b: a venue name that only partially matches still links that place', async () => {
|
||||
@@ -1567,9 +1566,77 @@ describe('useTripPlanner — bookings and transports', () => {
|
||||
})
|
||||
|
||||
expect(mapsApi.search).toHaveBeenCalledWith('Ryokan Sakura Gion')
|
||||
expect(placesApi.create).toHaveBeenCalledWith(42, { name: 'Ryokan Sakura', lat: 35.1, lng: 135.7, address: 'Gion' })
|
||||
// Created through the store (addPlace), which unwraps the API's { place }
|
||||
// envelope and pushes the place into `places` — not through placesApi
|
||||
// directly, whose envelope used to be read as the place itself (#2243).
|
||||
expect(actions.addPlace).toHaveBeenCalledWith(42, { name: 'Ryokan Sakura', lat: 35.1, lng: 135.7, address: 'Gion' })
|
||||
const payload = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number; venue?: unknown } }
|
||||
expect(payload.create_accommodation.place_id).toBe(900)
|
||||
expect(payload.create_accommodation.venue).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-081c: the place created for a new hotel is linked, so the next save of the same hotel reuses it (#2243)', async () => {
|
||||
vi.mocked(mapsApi.search).mockResolvedValue({
|
||||
places: [{ lat: 35.1, lng: 135.7, address: 'Kyoto, JP' }],
|
||||
} as never)
|
||||
// The real addPlace puts the created place into the store; mirror that so
|
||||
// the second save sees it the way the planner does after a create.
|
||||
actions.addPlace.mockImplementation(async (_tripId: unknown, data: { name: string }) => {
|
||||
const created = buildPlace({ id: 901, name: data.name, lat: 35.1, lng: 135.7 })
|
||||
useTripStore.setState(s => ({ places: [created, ...s.places] }))
|
||||
return created
|
||||
})
|
||||
seedTrip()
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
const save = () => result.current.handleSaveReservation({
|
||||
title: 'Ryokan Sakura', type: 'hotel',
|
||||
create_accommodation: { venue: { name: 'Ryokan Sakura' } },
|
||||
} as never)
|
||||
await act(async () => { await save() })
|
||||
await act(async () => { await save() })
|
||||
|
||||
expect(actions.addPlace).toHaveBeenCalledTimes(1)
|
||||
const first = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number } }
|
||||
const second = actions.addReservation.mock.calls[1][1] as { create_accommodation: { place_id?: number } }
|
||||
expect(first.create_accommodation.place_id).toBe(901)
|
||||
expect(second.create_accommodation.place_id).toBe(901)
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-081d: offline, no place is minted for a booking that cannot be written', async () => {
|
||||
env.forcedOffline = true
|
||||
seedTrip()
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
await act(async () => {
|
||||
await result.current.handleSaveReservation({
|
||||
title: 'Ryokan Sakura', type: 'hotel',
|
||||
create_accommodation: { venue: { name: 'Ryokan Sakura' } },
|
||||
} as never)
|
||||
})
|
||||
|
||||
// A place created offline gets a negative temp id, and the reservation write
|
||||
// is online-only — linking one is a foreign-key failure on the server.
|
||||
expect(actions.addPlace).not.toHaveBeenCalled()
|
||||
expect(mapsApi.search).not.toHaveBeenCalled()
|
||||
const payload = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number } }
|
||||
expect(payload.create_accommodation.place_id).toBe(321)
|
||||
expect(payload.create_accommodation.place_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-081e: a place still holding an offline temp id is not linked as the accommodation', async () => {
|
||||
actions.addPlace.mockResolvedValue(buildPlace({ id: 902, name: 'Ryokan Sakura' }))
|
||||
seedTrip({ places: [buildPlace({ id: -1758000000000, name: 'Ryokan Sakura' })] })
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
await act(async () => {
|
||||
await result.current.handleSaveReservation({
|
||||
title: 'Ryokan Sakura', type: 'hotel',
|
||||
create_accommodation: { venue: { name: 'Ryokan Sakura' } },
|
||||
} as never)
|
||||
})
|
||||
|
||||
const payload = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number } }
|
||||
expect(payload.create_accommodation.place_id).toBe(902)
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-081b: a geocoded venue without a reviewed address adopts the hit address', async () => {
|
||||
@@ -1587,12 +1654,12 @@ describe('useTripPlanner — bookings and transports', () => {
|
||||
})
|
||||
|
||||
expect(mapsApi.search).toHaveBeenCalledWith('Ryokan Sakura')
|
||||
expect(placesApi.create).toHaveBeenCalledWith(42, { name: 'Ryokan Sakura', lat: 35.1, lng: 135.7, address: 'Kyoto, JP' })
|
||||
expect(actions.addPlace).toHaveBeenCalledWith(42, { name: 'Ryokan Sakura', lat: 35.1, lng: 135.7, address: 'Kyoto, JP' })
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-082: a failed geocode still creates the place, a failed create yields no link', async () => {
|
||||
vi.mocked(mapsApi.search).mockRejectedValue(new Error('overpass down'))
|
||||
vi.mocked(placesApi.create).mockRejectedValue(new Error('quota'))
|
||||
actions.addPlace.mockRejectedValue(new Error('quota'))
|
||||
seedTrip()
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
@@ -1603,7 +1670,7 @@ describe('useTripPlanner — bookings and transports', () => {
|
||||
} as never)
|
||||
})
|
||||
|
||||
expect(placesApi.create).toHaveBeenCalled()
|
||||
expect(actions.addPlace).toHaveBeenCalled()
|
||||
const payload = actions.addReservation.mock.calls[0][1] as { create_accommodation: { place_id?: number } }
|
||||
expect(payload.create_accommodation.place_id).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
|
||||
import { useTranslation, translateApiError } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
@@ -194,7 +194,11 @@ export function useTripPlanner() {
|
||||
if (activeTab === 'finanzplan') tripActions.loadBudgetItems?.(tripId)
|
||||
if (activeTab === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId)
|
||||
}, [tripId])
|
||||
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
||||
const {
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrow: narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
} = useResizablePanels()
|
||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
|
||||
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
||||
@@ -916,8 +920,18 @@ export function useTripPlanner() {
|
||||
if (n) {
|
||||
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
|
||||
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
||||
if (existing) return existing.id
|
||||
// Only a server-side id may be linked. A negative id is an offline temp id
|
||||
// (mutationQueue.nextTempId): the reservation write is online-only, the queue
|
||||
// rewrites temp ids in a URL but never inside another entity's body, and
|
||||
// day_accommodations.place_id carries a foreign key — so a temp id here is a
|
||||
// rolled-back insert and a 500 instead of a saved booking.
|
||||
if (existing && existing.id > 0) return existing.id
|
||||
}
|
||||
// Offline the booking itself cannot be written (reservations are online-only),
|
||||
// so minting a place here would only leave an orphan behind on the next flush
|
||||
// — and its temp id could never be linked anyway. Link nothing, and skip the
|
||||
// geocode round-trip too; the retry online matches this venue by name.
|
||||
if (isEffectivelyOffline()) return null
|
||||
let lat: number | null = null
|
||||
let lng: number | null = null
|
||||
let address: string | null = venue.address ?? null
|
||||
@@ -933,8 +947,14 @@ export function useTripPlanner() {
|
||||
}
|
||||
} catch { /* geocode failure is non-fatal — create the place without coords */ }
|
||||
try {
|
||||
const place = await placesApi.create(tripId, { name: name || address || 'Accommodation', lat, lng, address } as never)
|
||||
return (place as { id?: number })?.id ?? null
|
||||
// Through the store, not placesApi directly: the API answers { place },
|
||||
// and reading .id off that wrapper linked nothing — every save of the
|
||||
// hotel then minted another orphan place, because the store never
|
||||
// learned about the previous one and the name match above could not
|
||||
// find it. addPlace unwraps the response and puts the place into
|
||||
// `places`, so the next save reuses it.
|
||||
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
|
||||
return place && place.id > 0 ? place.id : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
@@ -1065,7 +1085,9 @@ export function useTripPlanner() {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight,
|
||||
leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed,
|
||||
leftHidden, rightHidden, toggleLeft, toggleRight, narrowPanels,
|
||||
startResizeLeft, startResizeRight,
|
||||
selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment,
|
||||
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
|
||||
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace,
|
||||
|
||||
@@ -294,12 +294,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Genuinely offline — keep the persisted session so the PWA serves cached
|
||||
// data without a scary error. This is the offline-first happy path.
|
||||
set({ isLoading: false })
|
||||
// ...but the session still has to be marked live. onAuthSuccess is the
|
||||
// only caller of setAuthed(true), and every repair path (the mutation
|
||||
// queue's flush, syncAll, prepareForOffline) is gated on it. Skipping
|
||||
// it here left a session that launched without network unable to sync
|
||||
// for the rest of its life, even after the signal came back: the offline
|
||||
// settings buttons spun and returned instantly having done nothing, and
|
||||
// queued edits never uploaded (#2228). It also points the offline DB at
|
||||
// this user's scoped database, so cached data is read from the right
|
||||
// one rather than the anonymous fallback.
|
||||
const cachedUser = get().user
|
||||
if (cachedUser && get().isAuthenticated) await onAuthSuccess(cachedUser.id)
|
||||
} else {
|
||||
// Server erroring (5xx) or unreachable while we're online: keep the session
|
||||
// (don't eject the user over a transient outage), but flag it so the UI can
|
||||
// say "couldn't reach the server" instead of showing a blank, error-free
|
||||
// page that looks like the user's trips were lost. #1283
|
||||
set({ isLoading: false, authCheckFailed: true })
|
||||
// Same reasoning as the offline branch: the session is kept, so it must
|
||||
// be marked live or the sync layer stays dead until the next full login.
|
||||
const cachedUser = get().user
|
||||
if (cachedUser && get().isAuthenticated) await onAuthSuccess(cachedUser.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,8 +69,10 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(collectionsApi.list).mockResolvedValue(listResponse())
|
||||
vi.mocked(collectionsApi.get).mockResolvedValue(detail())
|
||||
vi.mocked(collectionsApi.create).mockResolvedValue({ collection: listA })
|
||||
vi.mocked(collectionsApi.update).mockResolvedValue({ collection: listA })
|
||||
// The bare collection is what POST /addons/collections actually answers with;
|
||||
// mocking a { collection } envelope here is what hid the bug this pins.
|
||||
vi.mocked(collectionsApi.create).mockResolvedValue(listA)
|
||||
vi.mocked(collectionsApi.update).mockResolvedValue(listA)
|
||||
vi.mocked(collectionsApi.uploadCover).mockResolvedValue(listA)
|
||||
vi.mocked(collectionsApi.remove).mockResolvedValue({ success: true })
|
||||
vi.mocked(collectionsApi.reorder).mockResolvedValue({ success: true })
|
||||
@@ -289,11 +291,20 @@ describe('collectionStore — list CRUD', () => {
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-014: createCollection() returns null when the response carries no list', async () => {
|
||||
vi.mocked(collectionsApi.create).mockResolvedValue({} as unknown as { collection: Collection })
|
||||
vi.mocked(collectionsApi.create).mockResolvedValue(undefined as unknown as Collection)
|
||||
|
||||
expect(await store().createCollection({ name: 'Tokyo' })).toBeNull()
|
||||
})
|
||||
|
||||
// The API declared a { collection } envelope the server never sends, so the id
|
||||
// never reached the caller: a cover picked while creating a list was dropped
|
||||
// and the new list never opened.
|
||||
it('FE-STORE-COLLECTION-014b: createCollection() hands back the id the server sent', async () => {
|
||||
const created = await store().createCollection({ name: 'Tokyo' })
|
||||
|
||||
expect(created?.id).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-STORE-COLLECTION-015: updateCollection() reloads the detail when the list is active', async () => {
|
||||
useCollectionStore.setState({ activeId: 1 })
|
||||
|
||||
|
||||
@@ -210,9 +210,12 @@ export const useCollectionStore = create<CollectionState>((set, get) => ({
|
||||
},
|
||||
|
||||
createCollection: async (payload) => {
|
||||
const data = await collectionsApi.create(payload)
|
||||
// POST answers with the collection itself. Reading `.collection` off it
|
||||
// returned undefined every time, so the caller never got the new id: a cover
|
||||
// picked while creating a list was dropped and the list never opened.
|
||||
const created = await collectionsApi.create(payload)
|
||||
await get().loadAll()
|
||||
return data.collection ?? null
|
||||
return created ?? null
|
||||
},
|
||||
|
||||
updateCollection: async (id, updates) => {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
* world map"). See tripSyncManager / clearTileCache.
|
||||
* disabledTripIds — trips the user explicitly excluded from offline storage.
|
||||
* Everything else that is date-eligible is cached.
|
||||
* pinnedTripIds : trips the user explicitly asked for that the date rule
|
||||
* would otherwise skip or evict (a finished trip). Without
|
||||
* this there was no way to say yes: the list was opt-out
|
||||
* only, so ticking a past trip changed nothing and the
|
||||
* download quietly stored none of them (#2228).
|
||||
* conflictStrategy — what to do when an offline edit collides with a newer
|
||||
* server change: 'ask' surfaces a per-conflict picker,
|
||||
* 'mine'/'server' resolve automatically.
|
||||
@@ -22,6 +27,7 @@ export type ConflictStrategy = 'ask' | 'mine' | 'server'
|
||||
export interface OfflinePrefs {
|
||||
cacheTiles: boolean
|
||||
disabledTripIds: number[]
|
||||
pinnedTripIds: number[]
|
||||
conflictStrategy: ConflictStrategy
|
||||
}
|
||||
|
||||
@@ -30,6 +36,7 @@ const STORAGE_KEY = 'trek_offline_prefs'
|
||||
const DEFAULTS: OfflinePrefs = {
|
||||
cacheTiles: true,
|
||||
disabledTripIds: [],
|
||||
pinnedTripIds: [],
|
||||
conflictStrategy: 'ask',
|
||||
}
|
||||
|
||||
@@ -44,6 +51,7 @@ function read(): OfflinePrefs {
|
||||
return {
|
||||
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
|
||||
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
|
||||
pinnedTripIds: Array.isArray(parsed.pinnedTripIds) ? parsed.pinnedTripIds.filter(n => typeof n === 'number') : [],
|
||||
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
|
||||
}
|
||||
} catch {
|
||||
@@ -59,7 +67,7 @@ function write(next: OfflinePrefs): void {
|
||||
|
||||
/** Current snapshot (a copy — callers must not mutate it in place). */
|
||||
export function getOfflinePrefs(): OfflinePrefs {
|
||||
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] }
|
||||
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds], pinnedTripIds: [..._prefs.pinnedTripIds] }
|
||||
}
|
||||
|
||||
export function setCacheTiles(on: boolean): void {
|
||||
@@ -77,6 +85,14 @@ export function isTripOfflineEnabled(tripId: number): boolean {
|
||||
return !_prefs.disabledTripIds.includes(tripId)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user explicitly asked to keep this trip, overriding the date
|
||||
* rule that otherwise skips and evicts finished trips (tripSyncManager).
|
||||
*/
|
||||
export function isTripPinned(tripId: number): boolean {
|
||||
return _prefs.pinnedTripIds.includes(tripId)
|
||||
}
|
||||
|
||||
/** Turn offline storage for a single trip on or off. */
|
||||
export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
||||
const has = _prefs.disabledTripIds.includes(tripId)
|
||||
@@ -88,6 +104,20 @@ export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
||||
write({ ..._prefs, disabledTripIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a trip the date rule would not keep on its own, or release it again.
|
||||
* Turning a trip off releases the pin too, so "off then on" cannot leave a
|
||||
* finished trip silently pinned.
|
||||
*/
|
||||
export function setTripPinned(tripId: number, on: boolean): void {
|
||||
const has = _prefs.pinnedTripIds.includes(tripId)
|
||||
if (on === has) return
|
||||
const pinnedTripIds = on
|
||||
? [..._prefs.pinnedTripIds, tripId]
|
||||
: _prefs.pinnedTripIds.filter(id => id !== tripId)
|
||||
write({ ..._prefs, pinnedTripIds })
|
||||
}
|
||||
|
||||
/** Subscribe to preference changes. Returns an unsubscribe function. */
|
||||
export function onOfflinePrefsChange(fn: () => void): () => void {
|
||||
listeners.add(fn)
|
||||
|
||||
@@ -6,13 +6,48 @@
|
||||
*
|
||||
* Best-effort and idempotent: returns whether persistence is (now) granted.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Last known answer, so callers that have to size a download against it do not
|
||||
* each re-ask. `null` until the request at app init has resolved.
|
||||
*
|
||||
* The answer matters: a browser that refuses persistence evicts this origin's
|
||||
* WHOLE bucket under pressure (the Workbox precache with it), and since sw.js
|
||||
* is byte-identical between releases, Workbox never re-installs and never
|
||||
* refills what was evicted. The PWA then cannot start offline at all, and
|
||||
* reinstalling does not help because it does not clear or restore site data
|
||||
* (#2228).
|
||||
*/
|
||||
let _persisted: boolean | null = null
|
||||
|
||||
export async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
|
||||
if (typeof navigator === 'undefined' || !navigator.storage?.persist) {
|
||||
_persisted = false
|
||||
return false
|
||||
}
|
||||
// Already persisted? Avoid re-prompting where the API distinguishes.
|
||||
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
|
||||
return await navigator.storage.persist()
|
||||
if (navigator.storage.persisted && (await navigator.storage.persisted())) {
|
||||
_persisted = true
|
||||
return true
|
||||
}
|
||||
_persisted = await navigator.storage.persist()
|
||||
return _persisted
|
||||
} catch {
|
||||
_persisted = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this origin's storage is exempt from eviction. Treats "not asked yet"
|
||||
* as not granted, so a caller sizing a download errs on the small side.
|
||||
*/
|
||||
export function isStoragePersisted(): boolean {
|
||||
return _persisted === true
|
||||
}
|
||||
|
||||
/** Test helper: forget the cached answer. */
|
||||
export function _resetPersistentStorage(): void {
|
||||
_persisted = null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import type { Place } from '../types'
|
||||
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
import { isAuthed } from './authGate'
|
||||
import { isStoragePersisted } from './persistentStorage'
|
||||
import { isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
|
||||
import { OFM_POSITRON } from '../constants/mapDefaults'
|
||||
import { clearVectorCache, prefetchVectorForPlaces } from './glPrefetcher'
|
||||
@@ -54,6 +55,13 @@ export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
|
||||
*/
|
||||
export const TILE_CONCURRENCY = 6
|
||||
|
||||
/**
|
||||
* Deepest zoom prefetched when the browser refused persistent storage. Keeps the
|
||||
* padded quota cost of one trip in the low hundreds of megabytes rather than the
|
||||
* tens of gigabytes a full z16 run bills (#2228).
|
||||
*/
|
||||
export const UNPERSISTED_MAX_ZOOM = 12
|
||||
|
||||
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
|
||||
const TILE_CACHE = 'map-tiles'
|
||||
|
||||
@@ -430,7 +438,16 @@ export async function prefetchTilesForTrip(
|
||||
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||
// these tiles under the inflated quota, we request persistent storage at app
|
||||
// init instead (sync/persistentStorage.ts).
|
||||
const fetched = await prefetchTiles(bbox, template, 0, 16, cartoKey)
|
||||
//
|
||||
// When that request was refused, the padding stops being an accounting detail:
|
||||
// a full run bills this origin tens of gigabytes it is not exempt from, and
|
||||
// the browser answers by evicting the whole bucket, including the precached
|
||||
// app shell, which then never comes back on its own. So cap the depth instead.
|
||||
// The shallow zooms are the ones the trip map actually opens at and cost a few
|
||||
// dozen tiles (#2180); the deep ones are the volume, and they are what the
|
||||
// user loses rather than the ability to start the app at all (#2228).
|
||||
const maxZoom = isStoragePersisted() ? 16 : UNPERSISTED_MAX_ZOOM
|
||||
const fetched = await prefetchTiles(bbox, template, 0, maxZoom, cartoKey)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
|
||||
@@ -77,18 +77,18 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('tripSyncManager.prepareForOffline — guards', () => {
|
||||
it('FE-SYNC-PREP-001: returns 0 and hits no endpoint when logged out', async () => {
|
||||
it('FE-SYNC-PREP-001: reports why it stopped and hits no endpoint when logged out', async () => {
|
||||
setAuthed(false)
|
||||
let called = false
|
||||
server.use(http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }) }))
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(0)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'signed-out' })
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-SYNC-PREP-002: returns 0 when the browser is offline', async () => {
|
||||
it('FE-SYNC-PREP-002: reports that it stopped because the browser is offline', async () => {
|
||||
setOnline(false)
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(0)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'offline' })
|
||||
})
|
||||
|
||||
it('FE-SYNC-PREP-003: a second concurrent run is refused by the syncing flag', async () => {
|
||||
@@ -99,7 +99,11 @@ describe('tripSyncManager.prepareForOffline — guards', () => {
|
||||
tripSyncManager.prepareForOffline(),
|
||||
tripSyncManager.prepareForOffline(),
|
||||
])
|
||||
expect([first, second].sort()).toEqual([0, 1])
|
||||
// The loser says so rather than reporting a successful run of nothing: the
|
||||
// settings screen paints that answer instead of a finished progress bar
|
||||
// (#2228).
|
||||
expect(first).toEqual({ status: 'done', trips: 1 })
|
||||
expect(second).toEqual({ status: 'skipped', reason: 'busy' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,7 +116,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
const progress: PrepareProgress[] = []
|
||||
const count = await tripSyncManager.prepareForOffline(p => progress.push(p))
|
||||
|
||||
expect(count).toBe(1)
|
||||
expect(count).toEqual({ status: 'done', trips: 1 })
|
||||
expect(progress.map(p => p.phase)).toEqual(['trips', 'files', 'tiles', 'done'])
|
||||
expect(progress[0]).toMatchObject({ current: 1, total: 1, label: 'Munich' })
|
||||
expect(progress[3]).toMatchObject({ phase: 'done', current: 1, total: 1 })
|
||||
@@ -174,7 +178,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
http.get('/api/categories', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(1)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 })
|
||||
expect(await offlineDb.tags.count()).toBe(0)
|
||||
})
|
||||
|
||||
@@ -183,7 +187,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
const broken = buildTrip({ id: 608, end_date: dateOffset(4) })
|
||||
serveTrips([broken, good], { 607: bundleFor(good) })
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(2)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 2 })
|
||||
expect(await offlineDb.trips.get(607)).toBeDefined()
|
||||
expect(await offlineDb.trips.get(608)).toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
@@ -196,7 +200,7 @@ describe('tripSyncManager.prepareForOffline — full run', () => {
|
||||
setTripOfflineEnabled(610, false)
|
||||
serveTrips([kept, excluded], { 609: bundleFor(kept), 610: bundleFor(excluded) })
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toBe(1)
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 })
|
||||
expect(await offlineDb.trips.get(609)).toBeDefined()
|
||||
expect(await offlineDb.trips.get(610)).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs'
|
||||
import { getOfflinePrefs, isTripOfflineEnabled, isTripPinned } from './offlinePrefs'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -74,13 +74,24 @@ function whenIdle(fn: () => void | Promise<void>): void {
|
||||
else setTimeout(run, 2_000)
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
/**
|
||||
* Would the date rule keep this trip on its own? Exported so the offline
|
||||
* settings screen can show the honest state of a trip's switch: a finished trip
|
||||
* is not stored unless the user pins it, and a switch that reads "on" while
|
||||
* nothing is stored is what made "Download for offline use" look broken (#2228).
|
||||
*/
|
||||
export function isDateEligible(trip: Trip): boolean {
|
||||
if (!trip.end_date) return true // no end date → cache forever
|
||||
return trip.end_date >= todayStr() // ongoing or future
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
return isTripPinned(trip.id) || isDateEligible(trip)
|
||||
}
|
||||
|
||||
function isStale(trip: Trip): boolean {
|
||||
if (!trip.end_date) return false
|
||||
if (isTripPinned(trip.id)) return false // the user asked for it, keep it
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 7)
|
||||
return trip.end_date < ymd(cutoff)
|
||||
@@ -185,14 +196,32 @@ async function reconcileTrips(trips: Trip[]): Promise<Trip[]> {
|
||||
return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a sync did nothing. The settings screen shows these: a run that never
|
||||
* started used to be indistinguishable from a run that stored everything, so
|
||||
* "Download for offline use" reported success while the cache stayed at zero
|
||||
* and the user had no way to tell (#2228).
|
||||
*/
|
||||
export type SyncOutcome =
|
||||
| { status: 'done'; trips: number }
|
||||
| { status: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
|
||||
|
||||
function skipReason(): SyncOutcome | null {
|
||||
if (_syncing) return { status: 'skipped', reason: 'busy' }
|
||||
if (!navigator.onLine) return { status: 'skipped', reason: 'offline' }
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
return null
|
||||
}
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale and user-disabled trips. Caches file blobs + map tiles in the
|
||||
* background. No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
async syncAll(): Promise<SyncOutcome> {
|
||||
const skipped = skipReason()
|
||||
if (skipped) return skipped
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
@@ -201,7 +230,7 @@ export const tripSyncManager = {
|
||||
for (const trip of toSync) {
|
||||
// The gate is re-read per trip: a logout halfway through must not keep
|
||||
// writing the old account's rows into the (now anonymous) offline DB.
|
||||
if (!isAuthed()) return
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
@@ -220,7 +249,7 @@ export const tripSyncManager = {
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(trip.id, files).catch(console.error)
|
||||
}
|
||||
@@ -238,6 +267,7 @@ export const tripSyncManager = {
|
||||
}
|
||||
})
|
||||
}
|
||||
return { status: 'done', trips: toSync.length }
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
@@ -250,10 +280,11 @@ export const tripSyncManager = {
|
||||
* front instead of deferring them to idle time, reports progress, and forces
|
||||
* the tile prefetch to run even for a bbox we believe is already cached.
|
||||
*
|
||||
* Returns the number of trips prepared.
|
||||
* Returns how many trips were prepared, or why the run never started.
|
||||
*/
|
||||
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<number> {
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return 0
|
||||
async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise<SyncOutcome> {
|
||||
const skipped = skipReason()
|
||||
if (skipped) return skipped
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
@@ -263,7 +294,7 @@ export const tripSyncManager = {
|
||||
// 1) Trip bundles (structured data).
|
||||
let i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title })
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
@@ -281,7 +312,7 @@ export const tripSyncManager = {
|
||||
// 2) File blobs — awaited so "prepared" really means downloaded.
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'files', current: ++i, total, label: trip.title })
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
await cacheFilesForTrip(trip.id, files).catch(console.error)
|
||||
@@ -293,7 +324,7 @@ export const tripSyncManager = {
|
||||
const cartoKey = useSettingsStore.getState().settings.carto_api_key || undefined
|
||||
i = 0
|
||||
for (const trip of toSync) {
|
||||
if (!isAuthed()) return 0
|
||||
if (!isAuthed()) return { status: 'skipped', reason: 'signed-out' }
|
||||
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
await prefetchTilesForTrip(trip.id, places, tileUrl, true, cartoKey).catch(console.error)
|
||||
@@ -301,7 +332,7 @@ export const tripSyncManager = {
|
||||
}
|
||||
|
||||
onProgress?.({ phase: 'done', current: total, total })
|
||||
return total
|
||||
return { status: 'done', trips: total }
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
|
||||
@@ -223,4 +223,43 @@ describe('computeMapViewport', () => {
|
||||
expect(x).toBeLessThanOrEqual(VIEW.width)
|
||||
}
|
||||
})
|
||||
|
||||
// Two 300px planner panels on an 860px foldable left the framing window 140px
|
||||
// wide, and the camera then opened on half of East Asia instead of the trip
|
||||
// (#2247). The layout fix stops it happening; this stops it mattering.
|
||||
it('never frames the camera for less than a third of the box, however wide the padding', () => {
|
||||
const sane = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 380, right: 340 },
|
||||
})!
|
||||
const absurd = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 480, right: 480 },
|
||||
})!
|
||||
// 1000 - 960 = 40px of window would zoom ~2.5 levels further out than the
|
||||
// third the clamp guarantees.
|
||||
const floored = computeMapViewport([PARIS, LYON], {
|
||||
width: VIEW.width / 3,
|
||||
height: VIEW.height,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
})!
|
||||
expect(absurd.zoom).toBe(floored.zoom)
|
||||
expect(absurd.zoom).toBeGreaterThan(sane.zoom - 3)
|
||||
})
|
||||
|
||||
it('leaves a sane padding untouched', () => {
|
||||
const withPadding = computeMapViewport([PARIS, LYON], {
|
||||
...VIEW,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: { left: 200, right: 100 },
|
||||
})!
|
||||
const equivalent = computeMapViewport([PARIS, LYON], {
|
||||
width: VIEW.width - 300,
|
||||
height: VIEW.height,
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
})!
|
||||
expect(withPadding.zoom).toBe(equivalent.zoom)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,8 +215,14 @@ export function computeMapViewport(
|
||||
const wrapsWorld = options.wrapsWorld ?? isGl
|
||||
|
||||
const padding: ViewportPadding = { ...NO_PADDING, ...options.padding }
|
||||
const width = Math.max(1, (options.width ?? defaultWidth()) - padding.left - padding.right)
|
||||
const height = Math.max(1, (options.height ?? defaultHeight()) - padding.top - padding.bottom)
|
||||
const rawWidth = options.width ?? defaultWidth()
|
||||
const rawHeight = options.height ?? defaultHeight()
|
||||
// Padding wider than the box leaves a sliver, and the camera is then framed for
|
||||
// THAT — a continent instead of the trip. Two 300px planner panels on an 860px
|
||||
// foldable did exactly this (#2247). Never surrender more than two thirds; the
|
||||
// centre offset still uses the real padding, so the framing stays where it was.
|
||||
const width = Math.max(1, rawWidth / 3, rawWidth - padding.left - padding.right)
|
||||
const height = Math.max(1, rawHeight / 3, rawHeight - padding.top - padding.bottom)
|
||||
|
||||
const lats = pts.map(p => p[0])
|
||||
const south = Math.min(...lats)
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { useResizablePanels } from '../../../src/hooks/useResizablePanels';
|
||||
|
||||
/**
|
||||
* The suite's jsdom stub answers `matches: false` to everything, so the hook is
|
||||
* in its wide layout unless a test says otherwise. `narrow()` puts it in the
|
||||
* 768-1023 band the foldable lands in (#2247).
|
||||
*/
|
||||
function setViewport(width: number, narrow: boolean) {
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: width });
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes('min-width: 768px') ? narrow : false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
describe('useResizablePanels', () => {
|
||||
const realMatchMedia = window.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = realMatchMedia;
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-001: default leftWidth is 340 when localStorage is empty', () => {
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(340);
|
||||
@@ -165,4 +189,85 @@ describe('useResizablePanels', () => {
|
||||
expect(result.current.setLeftCollapsed).toBeTypeOf('function');
|
||||
expect(result.current.setRightCollapsed).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// ── The narrow band, 768-1023px (#2247) ──────────────────────────────────
|
||||
|
||||
it('FE-HOOK-PANELS-015: a wide layout shows whatever the collapse flags say', () => {
|
||||
setViewport(1440, false);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.narrow).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-016: the narrow band opens the day plan and hides the places list', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.narrow).toBe(true);
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-017: opening one panel in the narrow band closes the other', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
|
||||
act(() => { result.current.toggleLeft(); });
|
||||
expect(result.current.leftHidden).toBe(false);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-018: tapping the open panel in the narrow band leaves the map alone', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleLeft(); });
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
expect(result.current.rightHidden).toBe(true);
|
||||
});
|
||||
|
||||
// A marker tap calls both setters with false; in the narrow band that used to
|
||||
// re-crowd the screen the user had just cleared.
|
||||
it('FE-HOOK-PANELS-019: reopening both panels does not re-crowd the narrow layout', () => {
|
||||
setViewport(860, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
|
||||
act(() => { result.current.toggleRight(); });
|
||||
act(() => { result.current.setLeftCollapsed(false); result.current.setRightCollapsed(false); });
|
||||
expect(result.current.rightHidden).toBe(false);
|
||||
expect(result.current.leftHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-020: a panel dragged wide on a desktop is clamped so the map keeps 360px', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(800, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
// 800 - 360 map - 20 margins = 420
|
||||
expect(result.current.leftWidth).toBe(420);
|
||||
// The stored value is untouched, so a wide window gets its 520 back.
|
||||
expect(localStorage.getItem('sidebarLeftWidth')).toBe('520');
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-021: the clamp never squeezes a panel below the drag minimum', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(500, true);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(200);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PANELS-022: a wide layout leaves the stored widths alone', () => {
|
||||
localStorage.setItem('sidebarLeftWidth', '520');
|
||||
setViewport(1100, false);
|
||||
const { result } = renderHook(() => useResizablePanels());
|
||||
expect(result.current.leftWidth).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1724,3 +1724,78 @@ describe('MAdminPluginsPanel — instance settings', () => {
|
||||
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE on the phone shell — the same three surfaces as the
|
||||
* desktop panel: the header pill, the confirm-before-install sheet, and the notice after a
|
||||
* sideload that could not ask first, plus the persistent warning chip on the row.
|
||||
*/
|
||||
describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-001: says so in the header', async () => {
|
||||
mockPanel([plugin()], [], { ignoreTrekRange: true });
|
||||
render(<MAdminPluginsPanel />);
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-002: offers "Install anyway" and asks before installing', async () => {
|
||||
let body: unknown = null;
|
||||
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } });
|
||||
}));
|
||||
render(<MAdminPluginsPanel />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||
|
||||
const btn = await screen.findByRole('button', { name: 'Install anyway' });
|
||||
expect(btn).toBeEnabled();
|
||||
fireEvent.click(btn);
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
||||
expect(within(sheet).getByText(/no guarantee/i)).toBeInTheDocument();
|
||||
expect(body).toBeNull();
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Install anyway' }));
|
||||
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-003: cancelling the warning installs nothing', async () => {
|
||||
let posted = false;
|
||||
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
|
||||
render(<MAdminPluginsPanel />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Install anyway' }));
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Outside its supported TREK versions' })).not.toBeInTheDocument());
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
|
||||
mockPanel([plugin()], [], { ignoreTrekRange: true });
|
||||
server.use(http.post('*/api/admin/plugins/upload', () =>
|
||||
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })));
|
||||
const { container } = render(<MAdminPluginsPanel />);
|
||||
await screen.findByText('Gotify');
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip', { type: 'application/zip' })] } });
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Installed outside its supported TREK versions' });
|
||||
expect(within(sheet).getByText(/no guarantee/i)).toBeInTheDocument();
|
||||
expect(within(sheet).getByText(/trek-new/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
|
||||
mockPanel([plugin({
|
||||
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
|
||||
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
|
||||
})], [], { ignoreTrekRange: true });
|
||||
render(<MAdminPluginsPanel />);
|
||||
expect(await screen.findByText('Outside its TREK range (>=3.2.0 <4.0.0) — version checks off')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,16 +21,17 @@ const h = vi.hoisted(() => {
|
||||
const syncMetaToArray = vi.fn();
|
||||
const tripsGet = vi.fn();
|
||||
const tripsToArray = vi.fn();
|
||||
const tripsCount = vi.fn();
|
||||
const placeCount = vi.fn();
|
||||
const fileCount = vi.fn();
|
||||
const counted = (fn: (id: number) => Promise<number>) => ({
|
||||
where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }),
|
||||
});
|
||||
return {
|
||||
syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount,
|
||||
syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount,
|
||||
fakeDb: {
|
||||
syncMeta: { toArray: syncMetaToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray },
|
||||
trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount },
|
||||
places: counted(placeCount),
|
||||
tripFiles: counted(fileCount),
|
||||
},
|
||||
@@ -104,6 +105,7 @@ const conflict = (over: Partial<QueuedMutation> = {}): QueuedMutation => ({
|
||||
/** Seed the Dexie cache with the given (trip, meta) pairs. */
|
||||
function cache(rows: { trip: Trip; meta?: Partial<SyncMeta>; places?: number; files?: number }[]): void {
|
||||
h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta)));
|
||||
h.tripsCount.mockResolvedValue(rows.length);
|
||||
h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip);
|
||||
h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0);
|
||||
h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0);
|
||||
@@ -126,6 +128,7 @@ beforeEach(() => {
|
||||
h.syncMetaToArray.mockResolvedValue([]);
|
||||
h.tripsGet.mockResolvedValue(undefined);
|
||||
h.tripsToArray.mockResolvedValue([]);
|
||||
h.tripsCount.mockResolvedValue(0);
|
||||
h.placeCount.mockResolvedValue(0);
|
||||
h.fileCount.mockResolvedValue(0);
|
||||
h.pendingCount.mockResolvedValue(0);
|
||||
@@ -134,8 +137,8 @@ beforeEach(() => {
|
||||
h.clearAll.mockResolvedValue(undefined);
|
||||
h.clearTripData.mockResolvedValue(undefined);
|
||||
h.clearTileCache.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue(undefined);
|
||||
h.syncAll.mockResolvedValue(undefined);
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.syncAll.mockResolvedValue({ status: 'done', trips: 1 });
|
||||
h.resolveKeepMine.mockResolvedValue(undefined);
|
||||
h.resolveKeepServer.mockResolvedValue(undefined);
|
||||
|
||||
@@ -256,6 +259,7 @@ describe('MSettingsOffline', () => {
|
||||
cb({ phase: 'files', current: 1, total: 4, label: 'Paris' });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
cb({ phase: 'done', current: 4, total: 4 });
|
||||
return { status: 'done', trips: 4 };
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
@@ -266,7 +270,7 @@ describe('MSettingsOffline', () => {
|
||||
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
|
||||
|
||||
await act(async () => { release(); });
|
||||
expect(await screen.findByText('Ready for offline use')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -276,6 +280,7 @@ describe('MSettingsOffline', () => {
|
||||
h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => {
|
||||
cb({ phase: 'tiles', current: 0, total: 0 });
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { status: 'done', trips: 1 };
|
||||
});
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
@@ -349,21 +354,36 @@ describe('MSettingsOffline', () => {
|
||||
expect(h.clearTileCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-015: switching a trip off evicts it, switching it back on re-syncs', async () => {
|
||||
it('FE-MOB-OFFLINE-015: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
// Both fixture trips ended in 2025, so the date rule would skip them and the
|
||||
// switch has to say so rather than promising storage that never happens.
|
||||
const toggle = await screen.findByRole('switch', { name: 'Tokyo' });
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getAllByText('Stored offline')).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument());
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2);
|
||||
|
||||
await user.click(toggle);
|
||||
await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1));
|
||||
expect(h.clearTripData).toHaveBeenCalledTimes(1);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([2]);
|
||||
await waitFor(() => expect(screen.getByRole('switch', { name: 'Tokyo' })).toHaveAttribute('aria-checked', 'true'));
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Tokyo' }));
|
||||
expect(h.clearTripData).toHaveBeenCalledWith(2);
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-022: a sync that never ran says so instead of reporting success', async () => {
|
||||
const user = userEvent.setup();
|
||||
h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'offline' });
|
||||
render(<MSettingsOffline />);
|
||||
|
||||
await screen.findByText('No trips cached yet. Connect to the internet to sync.');
|
||||
await user.click(screen.getByRole('button', { name: 'Download for offline use' }));
|
||||
|
||||
expect(await screen.findByText('No connection. Connect to store trips for offline use.')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-OFFLINE-016: the per-trip section disappears when there are no trips at all', async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resetAllStores } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CNOTE-001 to FE-MOB-CNOTE-026
|
||||
// FE-MOB-CNOTE-001 to FE-MOB-CNOTE-033
|
||||
|
||||
// react-markdown ships as ESM-only chunks that jsdom chokes on; the note cards
|
||||
// only care that the raw text reaches the renderer.
|
||||
@@ -251,7 +251,7 @@ describe('MCollabNotes', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Insurance', content: 'call the hotline', category: 'Docs', color: '#ef4444' },
|
||||
{ title: 'Insurance', content: 'call the hotline', category: 'Docs', color: '#ef4444', website: null },
|
||||
]))
|
||||
expect(await screen.findByText('Insurance')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
@@ -270,7 +270,7 @@ describe('MCollabNotes', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', category: 'Transport', color: '#6366f1' },
|
||||
{ title: 'Ferry', category: 'Transport', color: '#6366f1', website: null },
|
||||
]))
|
||||
})
|
||||
|
||||
@@ -292,7 +292,7 @@ describe('MCollabNotes', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', category: 'Food', color: '#6366f1' },
|
||||
{ title: 'Ferry', category: 'Food', color: '#6366f1', website: null },
|
||||
]))
|
||||
})
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('MCollabNotes', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(updatedBodies).toEqual([
|
||||
{ id: '1', body: { title: 'Visa (done)', content: 'apply early', category: 'Docs', color: '#ef4444' } },
|
||||
{ id: '1', body: { title: 'Visa (done)', content: 'apply early', category: 'Docs', color: '#ef4444', website: null } },
|
||||
]))
|
||||
expect(await screen.findByText('Visa (done)')).toBeInTheDocument()
|
||||
})
|
||||
@@ -515,4 +515,96 @@ describe('MCollabNotes', () => {
|
||||
unmount()
|
||||
expect(removeListener).toHaveBeenCalledWith(handler)
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-027: creates a note with the trimmed website', async () => {
|
||||
serveNotes([])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: ' https://ferry.test/booking ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', color: '#6366f1', website: 'https://ferry.test/booking' },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-028: a blank website is posted as null instead of an empty string', async () => {
|
||||
serveNotes([])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /collab\.notes\.new/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), { target: { value: 'Ferry' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
|
||||
await waitFor(() => expect(createdBodies).toEqual([
|
||||
{ title: 'Ferry', color: '#6366f1', website: null },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-029: editing prefills the stored website and saves it back unchanged', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', website: 'https://visa.test' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
expect(screen.getByPlaceholderText('collab.notes.websitePlaceholder')).toHaveValue('https://visa.test')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(updatedBodies).toEqual([
|
||||
{ id: '1', body: { title: 'Visa', color: '#6366f1', website: 'https://visa.test' } },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-030: clearing the website sends an explicit null so the server drops it', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', website: 'https://visa.test' })])
|
||||
serveWrites()
|
||||
await renderNotes()
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.notes.save' }))
|
||||
|
||||
await waitFor(() => expect(updatedBodies).toEqual([
|
||||
{ id: '1', body: { title: 'Visa', color: '#6366f1', website: null } },
|
||||
]))
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-031: the viewer sheet links a bare host out to a new tab', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', website: 'www.visa.test' })])
|
||||
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
||||
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
const link = within(screen.getByRole('dialog', { name: 'Visa' })).getByRole('link')
|
||||
expect(link).toHaveAttribute('href', 'https://www.visa.test')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
expect(link).toHaveTextContent('visa.test')
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-032: a javascript: website is never turned into a link', async () => {
|
||||
serveNotes([note(1, { title: 'Visa', website: 'javascript:alert(1)' })])
|
||||
await renderNotes(buildPlanner({ can: (() => false) as TripPlanner['can'] }))
|
||||
|
||||
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
||||
fireEvent.click(screen.getByText('Visa'))
|
||||
expect(screen.getByRole('dialog', { name: 'Visa' })).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-CNOTE-033: only a card with a website carries the link affordance', async () => {
|
||||
serveNotes([
|
||||
note(1, { title: 'Visa', website: 'https://visa.test' }),
|
||||
note(2, { title: 'Tent' }),
|
||||
])
|
||||
await renderNotes()
|
||||
|
||||
const links = screen.getAllByRole('link', { name: 'collab.notes.website' })
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveAttribute('href', 'https://visa.test')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -519,4 +519,15 @@ describe('NoteRow', () => {
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('reorder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLROW-041: a link in the note body opens the link instead of the sheet', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(<NoteRow {...base} note={note({ time: 'See [the map](https://example.com)' })} chrome={chrome(true)} onEdit={onEdit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'the map' }))
|
||||
expect(onEdit).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('Buy museum tickets'))
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('MReservationSheet', () => {
|
||||
reservation_time: null, reservation_end_time: null,
|
||||
location: '', confirmation_number: '', notes: '', url: '',
|
||||
assignment_id: null, accommodation_id: null, place_id: null,
|
||||
metadata: null, endpoints: [], needs_review: false,
|
||||
metadata: {}, endpoints: [], needs_review: false,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -447,7 +447,7 @@ describe('MReservationSheet', () => {
|
||||
setup(makePlanner({
|
||||
editingReservation: {
|
||||
id: 57, title: 'Museum', type: undefined, status: undefined,
|
||||
reservation_time: null, reservation_end_time: null, metadata: null,
|
||||
reservation_time: null, reservation_end_time: null, metadata: {},
|
||||
} as unknown as Reservation,
|
||||
}))
|
||||
expect(titleField()).toHaveValue('Museum')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
|
||||
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-046
|
||||
// FE-MOB-SHELL-001 to FE-MOB-SHELL-051
|
||||
|
||||
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
|
||||
|
||||
@@ -504,6 +504,75 @@ describe('MTripShell', () => {
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', inline: 'center', block: 'nearest' })
|
||||
})
|
||||
})
|
||||
|
||||
// #2257 — the map filters down to one day, and until now nothing took that
|
||||
// filter back off: the chip rail can only swap one day for another, and the
|
||||
// second tap on the active chip is the day sheet. Its own control, map only.
|
||||
describe('the all-days toggle (#2257)', () => {
|
||||
const allDays = () => screen.getByRole('button', { name: 'mobileTrip.allDays' })
|
||||
const enterMap = () => fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.mapView' }))
|
||||
|
||||
it('FE-MOB-SHELL-047: stays off the plan view, where nothing is filtered anyway', () => {
|
||||
renderShell()
|
||||
expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument()
|
||||
enterMap()
|
||||
expect(allDays()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-048: drops the day and its pin filter so the whole trip comes back', () => {
|
||||
const { planner } = renderShell()
|
||||
enterMap()
|
||||
vi.mocked(planner.setExpandedDayIds).mockClear()
|
||||
|
||||
fireEvent.click(allDays())
|
||||
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(null, false)
|
||||
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
|
||||
// The day sheet belongs to the chip, not to this button.
|
||||
expect(shellApi.sheet).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-049: a second press goes back to the day it came from', () => {
|
||||
const { planner, rerenderShell } = renderShell()
|
||||
enterMap()
|
||||
fireEvent.click(allDays())
|
||||
|
||||
// The planner is a fixture, so the commit is replayed by hand.
|
||||
;(planner as { selectedDayId: number | null }).selectedDayId = null
|
||||
rerenderShell()
|
||||
vi.mocked(planner.setExpandedDayIds).mockClear()
|
||||
vi.mocked(planner.autoShowRoute).mockClear()
|
||||
|
||||
fireEvent.click(allDays())
|
||||
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(11, false)
|
||||
expect(planner.setExpandedDayIds).toHaveBeenCalledWith(new Set([11]))
|
||||
expect(planner.autoShowRoute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-050: leaving the map puts a day back, the timeline has no all-days state', () => {
|
||||
const { planner, rerenderShell } = renderShell()
|
||||
enterMap()
|
||||
fireEvent.click(allDays())
|
||||
;(planner as { selectedDayId: number | null }).selectedDayId = null
|
||||
rerenderShell()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.listView' }))
|
||||
|
||||
expect(planner.handleSelectDay).toHaveBeenLastCalledWith(11, true)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHELL-051: the active chip still opens the day sheet on the map', () => {
|
||||
const { planner } = renderShell()
|
||||
enterMap()
|
||||
vi.mocked(planner.handleSelectDay).mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sat 2' }))
|
||||
|
||||
expect(shellApi.sheet).toEqual({ id: 'day', payload: { dayId: 11 } })
|
||||
expect(planner.handleSelectDay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildShell fixture', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSystemNoticeStore } from '../../../src/store/systemNoticeStore';
|
||||
import { authApi } from '../../../src/api/client';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildUser } from '../../helpers/factories';
|
||||
import { isAuthed, setAuthed } from '../../../src/sync/authGate';
|
||||
|
||||
// The websocket module is already mocked globally in tests/setup.ts
|
||||
import { connect, disconnect } from '../../../src/api/websocket';
|
||||
@@ -21,12 +22,13 @@ vi.mock('../../../src/sync/syncTriggers', () => syncTriggers);
|
||||
|
||||
// The user-scoped offline DB is real (fake-indexeddb); only the reopen step is
|
||||
// made failable so the "auth still succeeds when the DB won't open" path runs.
|
||||
const dbControl = vi.hoisted(() => ({ reopenFails: false }));
|
||||
const dbControl = vi.hoisted(() => ({ reopenFails: false, reopenedFor: null as number | string | null }));
|
||||
vi.mock('../../../src/db/offlineDb', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/db/offlineDb')>();
|
||||
return {
|
||||
...actual,
|
||||
reopenForUser: async (userId: number | string) => {
|
||||
dbControl.reopenedFor = userId;
|
||||
if (dbControl.reopenFails) throw new Error('offline db locked');
|
||||
return actual.reopenForUser(userId);
|
||||
},
|
||||
@@ -37,6 +39,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
dbControl.reopenFails = false;
|
||||
dbControl.reopenedFor = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -318,6 +321,54 @@ describe('authStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-021: a session kept without a reachable server is still live (#2228)', () => {
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
});
|
||||
|
||||
it('marks a session that launched offline as authenticated for the sync layer', async () => {
|
||||
// onAuthSuccess is the only caller of setAuthed(true), and the mutation
|
||||
// queue's flush, syncAll and prepareForOffline are all gated on it. The
|
||||
// offline branch used to keep the session but skip it, so a PWA that
|
||||
// launched without network could never sync again for the rest of its
|
||||
// life: the settings buttons spun and returned having done nothing.
|
||||
setAuthed(false);
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.error()));
|
||||
useAuthStore.setState({ user: buildUser({ id: 42 }), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||
expect(isAuthed()).toBe(true);
|
||||
// …and it points the offline DB at this user's scoped database, so reads
|
||||
// come from the right one rather than the anonymous fallback.
|
||||
expect(dbControl.reopenedFor).toBe(42);
|
||||
});
|
||||
|
||||
it('does the same when the server is erroring rather than absent', async () => {
|
||||
setAuthed(false);
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
useAuthStore.setState({ user: buildUser({ id: 7 }), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().authCheckFailed).toBe(true);
|
||||
expect(isAuthed()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the gate closed when the session was rejected', async () => {
|
||||
setAuthed(false);
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ error: 'nope' }, { status: 401 })));
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
await useAuthStore.getState().loadUser();
|
||||
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(isAuthed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-015: updateMapsKey', () => {
|
||||
it('updates user maps_api_key', async () => {
|
||||
server.use(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* offlinePrefs unit tests — device-local "what to store offline" + conflict strategy.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
getOfflinePrefs, setCacheTiles, setConflictStrategy,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs,
|
||||
isTripOfflineEnabled, setTripOfflineEnabled, isTripPinned, setTripPinned,
|
||||
onOfflinePrefsChange, _resetOfflinePrefs,
|
||||
} from '../../../src/sync/offlinePrefs'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -43,6 +44,34 @@ describe('offlinePrefs', () => {
|
||||
expect(getOfflinePrefs().disabledTripIds.filter(id => id === 3)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('#2228: a trip can be pinned so the date rule cannot skip or evict it', () => {
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
setTripPinned(5, true)
|
||||
expect(isTripPinned(5)).toBe(true)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([5])
|
||||
})
|
||||
|
||||
it('#2228: pinning is idempotent and releasing removes the pin', () => {
|
||||
setTripPinned(5, true)
|
||||
setTripPinned(5, true)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([5])
|
||||
setTripPinned(5, false)
|
||||
expect(isTripPinned(5)).toBe(false)
|
||||
expect(getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
})
|
||||
|
||||
it('#2228: prefs written before pinning existed still read back cleanly', async () => {
|
||||
// An installed device carries the old shape in localStorage. The module reads
|
||||
// it once at import, so re-import it to exercise that path: a missing
|
||||
// pinnedTripIds must default to "nothing pinned", never undefined.
|
||||
localStorage.setItem('trek_offline_prefs', JSON.stringify({ cacheTiles: false, disabledTripIds: [3], conflictStrategy: 'mine' }))
|
||||
vi.resetModules()
|
||||
const fresh = await import('../../../src/sync/offlinePrefs')
|
||||
expect(fresh.getOfflinePrefs().pinnedTripIds).toEqual([])
|
||||
expect(fresh.getOfflinePrefs().disabledTripIds).toEqual([3])
|
||||
expect(fresh.isTripPinned(3)).toBe(false)
|
||||
})
|
||||
|
||||
it('sets the conflict strategy', () => {
|
||||
setConflictStrategy('mine')
|
||||
expect(getOfflinePrefs().conflictStrategy).toBe('mine')
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
countTiles,
|
||||
prefetchTiles,
|
||||
prefetchTilesForTrip,
|
||||
UNPERSISTED_MAX_ZOOM,
|
||||
clearTileCache,
|
||||
MAX_TILES,
|
||||
TILE_CONCURRENCY,
|
||||
type TileBbox,
|
||||
} from '../../../src/sync/tilePrefetcher';
|
||||
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
|
||||
import { requestPersistentStorage, _resetPersistentStorage } from '../../../src/sync/persistentStorage';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
@@ -348,6 +350,30 @@ describe('prefetchTilesForTrip', () => {
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('#2228: caps the depth when the browser refused persistent storage', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
const places = [buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 })];
|
||||
|
||||
// Opaque tiles are billed at ~7 MB each. Without an eviction exemption a
|
||||
// full-depth run bills this origin tens of gigabytes, and the browser
|
||||
// answers by evicting the whole bucket, the precached app shell included,
|
||||
// which Workbox then never refills because sw.js has not changed.
|
||||
_resetPersistentStorage();
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
const zooms = vi.mocked(fetch).mock.calls.map(c => Number(new URL(String(c[0])).pathname.split('/')[1]));
|
||||
expect(Math.max(...zooms)).toBe(UNPERSISTED_MAX_ZOOM);
|
||||
|
||||
vi.mocked(fetch).mockClear();
|
||||
Object.defineProperty(navigator, 'storage', {
|
||||
configurable: true,
|
||||
value: { persist: async () => true, persisted: async () => true },
|
||||
});
|
||||
await requestPersistentStorage();
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png', true);
|
||||
const deepZooms = vi.mocked(fetch).mock.calls.map(c => Number(new URL(String(c[0])).pathname.split('/')[1]));
|
||||
expect(Math.max(...deepZooms)).toBe(16);
|
||||
});
|
||||
|
||||
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { setTripPinned, _resetOfflinePrefs } from '../../../src/sync/offlinePrefs';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
@@ -47,6 +48,7 @@ function makeBundle(tripId: number) {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
_resetOfflinePrefs();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
@@ -77,6 +79,28 @@ describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — offline guard', () => {
|
||||
it('#2228: reports why a run did not start instead of looking like a success', async () => {
|
||||
setAuthed(false);
|
||||
expect(await tripSyncManager.syncAll()).toEqual({ status: 'skipped', reason: 'signed-out' });
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'signed-out' });
|
||||
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
|
||||
expect(await tripSyncManager.syncAll()).toEqual({ status: 'skipped', reason: 'offline' });
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'skipped', reason: 'offline' });
|
||||
});
|
||||
|
||||
it('#2228: reports how many trips a completed run stored', async () => {
|
||||
const tripId = 104;
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(3) });
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...makeBundle(tripId), trip })),
|
||||
);
|
||||
|
||||
expect(await tripSyncManager.prepareForOffline()).toEqual({ status: 'done', trips: 1 });
|
||||
});
|
||||
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
@@ -147,6 +171,27 @@ describe('tripSyncManager.syncAll — trip filtering', () => {
|
||||
expect(bundleCalled).toBe(false);
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#2228: caches a past trip once the user pins it', async () => {
|
||||
const tripId = 103;
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(-40) });
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
// Without the pin the date rule refuses it, which is what made the per-trip
|
||||
// switch look like it did nothing for anyone whose trips are all finished.
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
|
||||
setTripPinned(tripId, true);
|
||||
tripSyncManager._resetSyncing();
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── stale eviction ─────────────────────────────────────────────────────────────
|
||||
@@ -167,6 +212,21 @@ describe('tripSyncManager.syncAll — stale eviction', () => {
|
||||
expect(await offlineDb.trips.get(staleId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('#2228: does not evict a pinned trip, however long ago it ended', async () => {
|
||||
const pinnedId = 202;
|
||||
const trip = buildTrip({ id: pinnedId, end_date: dateOffset(-400) });
|
||||
await upsertTrip(trip);
|
||||
setTripPinned(pinnedId, true);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${pinnedId}/bundle`, () => HttpResponse.json({ ...makeBundle(pinnedId), trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(pinnedId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT evict trips that ended exactly 6 days ago', async () => {
|
||||
const recentId = 201;
|
||||
const bundle = makeBundle(recentId);
|
||||
|
||||
+10
-2
@@ -2,6 +2,7 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
|
||||
// `npm run build:analyze` writes dist/stats.html — a treemap of what actually ended
|
||||
// up in each chunk. The plain build only reports chunk sizes, which tells you a chunk
|
||||
@@ -46,8 +47,14 @@ export default defineConfig(({ mode }) => ({
|
||||
// Every route chunk is precached alongside the shell, deliberately: for an
|
||||
// offline-first travel planner a route the user never opened before losing
|
||||
// signal still has to work. The trade is that splitting buys first paint and
|
||||
// not install size — 107 entries / 17,795 KiB before any of it, 220 /
|
||||
// 17,855 KiB now.
|
||||
// not install size: 107 entries / 17,795 KiB before any of it, 463 /
|
||||
// 23,292 KiB now (measured, not estimated).
|
||||
//
|
||||
// Keep this figure honest. #2228 traced PWA boot failures to the browser
|
||||
// evicting this origin's whole bucket, precached shell included, and this
|
||||
// comment is the only record of what the install actually costs. Anything
|
||||
// matching the globs below is fetched at service-worker install by every
|
||||
// user, whether or not they ever reach the code.
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
// build:analyze drops a treemap next to the app; it must never end up in a
|
||||
// precache manifest if someone ships that build by accident.
|
||||
@@ -227,6 +234,7 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
}),
|
||||
].filter(Boolean),
|
||||
resolve: { alias: [rtlTextAlias] },
|
||||
build: {
|
||||
// Pin the output level instead of inheriting whatever the current Vite default
|
||||
// is, so a toolchain bump can't silently change which browsers still work.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { rtlTextAlias } from './rtlTextAlias.js';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
alias: [rtlTextAlias],
|
||||
root: '.',
|
||||
globals: true,
|
||||
environment: './tests/environment/jsdom-native-abort.ts',
|
||||
|
||||
@@ -49,6 +49,8 @@ services:
|
||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
|
||||
# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000)
|
||||
# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off)
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
Generated
+12
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
@@ -35,6 +35,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
@@ -4699,6 +4700,12 @@
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-rtl-text": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.4.0.tgz",
|
||||
"integrity": "sha512-WNSgEFxXd2a15Eh2GwZNWBiV7Z8leq8JEGcoHpfrgaBDmFQMW4a1MO5Ol9BNv6APqTbHZ8uq7L8H5q2KCVt1kg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
@@ -21742,7 +21749,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -22110,7 +22117,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
|
||||
+26
-27
@@ -1,59 +1,58 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this package is
|
||||
|
||||
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives inside the TREK monorepo but is **not one of the root npm workspaces**: it has its own `package-lock.json`, is versioned and published to npm independently, and must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`, not the repo root. The repo root's CLAUDE.md covers the main app; its conventions (target `dev` branch, conventional commits) still apply.
|
||||
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives in the TREK monorepo but is **not a root npm workspace**: own `package-lock.json`, versioned and published to npm independently, and it must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`. The root `CLAUDE.md` conventions (target `dev`, conventional commits, no attribution trailers) still apply.
|
||||
|
||||
It ships three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`) and `create-trek-plugin` (`src/cli/create.ts`).
|
||||
Three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`, one file per command under `src/cli/`) and `create-trek-plugin` (`src/cli/create.ts`). `README.md` here is the author guide; the `trek-plugin-dev` skill is the long-form authoring reference.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # vitest run
|
||||
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm test # vitest run
|
||||
npm run test:coverage # what CI runs (feeds the Sonar new-code coverage gate)
|
||||
npx vitest run test/sdk.test.ts # single test file
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
node scripts/gen-lucide-icon-names.mjs # regenerate src/lucide-icon-names.ts (uses repo-root lucide-react)
|
||||
```
|
||||
|
||||
Node >= 18. The package is `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` scope marker there — without it Node parses the CJS output as ESM).
|
||||
Node >= 18, `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` marker there — without it Node parses the CJS output as ESM). There is no lint script here.
|
||||
|
||||
**Release**: a pushed `plugin-sdk-v*` tag runs `npm ci && npm publish` via the root `publish-plugin-sdk.yml`; `prepublishOnly` builds and tests first. The published version is whatever `package.json` says — bump it in the same PR as the SDK change. Never push the tag yourself.
|
||||
|
||||
## The load-bearing principle: mirrored host code + parity tests
|
||||
|
||||
The SDK's core promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
|
||||
The SDK's promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
|
||||
|
||||
Because the package ships standalone, it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original source directly. These tests skip outside the monorepo, but in this checkout they run — keep the pairs in sync when touching either side:
|
||||
Because the package ships standalone it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original directly. Those tests skip outside the monorepo but run in this checkout — keep the pairs in sync when touching either side:
|
||||
|
||||
| SDK copy | Source of truth | Parity test |
|
||||
|---|---|---|
|
||||
| `src/permissions.ts` (`HOOK_PERMISSION`) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate |
|
||||
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/manifest.ts` (validation rules, `KNOWN_ADDONS`) | server plugin loader / `server/src/addons.ts` | — kept in sync by hand |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; set `TREK_PLUGINS_REPO` to point at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format is frozen against the reader |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range, `KNOWN_ADDONS`) | `server/src/nest/plugins/install/` + `server/src/addons.ts` | kept in sync by hand |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format frozen against the reader |
|
||||
|
||||
`src/permissions.ts` also documents a subtlety worth preserving: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud (an ungranted `run(def).hook(...)` throws).
|
||||
**Direction: single-source the contract.** The hand-kept copies are the residual risk, and the parity tests' skip-outside-the-monorepo guards mean the published SDK's own CI can green-light drift in them — keep moving copies into the generated/parity-tested set, never away from it. Changing an egress rule, manifest rule or registry gate means touching **every** copy in one change; parity gates fail closed; version/compat checks fail **closed** on unknown host versions.
|
||||
|
||||
**Direction: single-source the contract.** The protocol core — permissions, RPC methods, hooks, the event catalog — is already single-sourced: `src/generated/host-facts.ts` is **machine-written** by `server/scripts/gen-plugin-facts.ts` from `server/src/nest/plugins/protocol/envelope.ts` (never hand-edit it; the server's `check:plugin-facts` CI gate catches drift). The remaining hand-kept copies (egress-policy, manifest rules, registry gates, the ZIP writer) are the residual risk, and the `skipIf(!inMonorepo)` escape hatch means the published SDK's own CI can green-light drift in those — so keep moving copies into the generated/parity-tested set, never away from it. Until then: changing an egress rule, manifest rule, or registry gate means touching **every** copy in one change; parity gates fail closed, never leniently; and version/compat checks fail **closed** on unknown host versions rather than assuming compatibility.
|
||||
A subtlety documented in `src/permissions.ts`: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's isolated plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (e.g. Playwright for `shot` is intentionally the author's devDependency, and the ZIP writer is in-tree instead of a dependency).
|
||||
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` + driver enforcing the same permission model against configured fixtures.
|
||||
- **`src/cli/`** — one file per command, dispatched by `trek-plugin.ts`; `menu.ts` is the no-command guided menu. Interactive TTY sessions get prompts; non-TTY (CI, pipes) stays flag-driven — machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
|
||||
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so `packPluginDir` can validate before zipping without going async) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`). Printing lives in `report.ts`: `status` renders the checklist and never exits non-zero; `validate` is the same checks with an exit code.
|
||||
- **`src/ui/kit.ts`** — the design kit (`TREK_UI_CSS`, `TREK_THEME_JS`) as plain strings, inlined into a plugin's `client/index.html` where the `<!-- trek:ui -->` marker sits (`dev`/`pack` expand it; the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
|
||||
- **`src/lucide-icon-names.ts`** — generated snapshot (see command above); an unknown icon is a validate **warning**, never an error.
|
||||
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin (README with per-permission lines, ≥400 prose chars, screenshot); the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
|
||||
- **`examples/`** — complete example plugins (`trip-doctor`, `koffi`).
|
||||
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (Playwright for `shot` is the author's devDependency; the ZIP writer is in-tree).
|
||||
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` enforcing the same permission model against fixtures.
|
||||
- **`src/cli/`** — interactive TTY sessions get prompts; non-TTY stays flag-driven. Machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
|
||||
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so packing can validate before zipping) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`).
|
||||
- **`src/ui/kit.ts`** — the design kit as plain strings, inlined where the `<!-- trek:ui -->` marker sits in a plugin's `client/index.html` (the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
|
||||
- **`src/lucide-icon-names.ts`** — generated snapshot; an unknown icon is a validate **warning**, never an error.
|
||||
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin; the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
|
||||
|
||||
## Behavioral contracts to not break
|
||||
|
||||
- `status` never exits non-zero; `validate` is the enforcing form.
|
||||
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged/released (releases are immutable in practice).
|
||||
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged or released.
|
||||
- Signed → unsigned is refused forever; unsigned → signed is always allowed.
|
||||
- Update notices (update-notifier) print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
|
||||
- Update notices print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
|
||||
|
||||
@@ -52,6 +52,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
|
||||
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
|
||||
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
|
||||
|
||||
# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected)
|
||||
|
||||
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
|
||||
|
||||
# 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 get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
|
||||
|
||||
# Initial admin account — ONLY applied on the first boot, when the database has no
|
||||
|
||||
+44
-78
@@ -1,108 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/server`** workspace. See the repo-root `CLAUDE.md` for the monorepo-wide picture (shared contracts, client, conventions). This file covers server internals only.
|
||||
Scope: the **`@trek/server`** workspace. See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy. This file holds the server's commands, invariants and pitfalls; the code and `src/nest/README.md` hold the inventory.
|
||||
|
||||
## Commands (run from `server/`)
|
||||
|
||||
```bash
|
||||
npm run dev # scripts/dev.mjs: tsc -w then node --watch dist/index.js (server on :3001)
|
||||
npm run build # scripts/build.mjs — see "emit-anyway" note below
|
||||
npm run typecheck # tsc --noEmit (the real type gate; build does NOT fail on type errors)
|
||||
npm run build # scripts/build.mjs — emits even on type errors (see below)
|
||||
npm run typecheck # tsc --noEmit — the real type gate
|
||||
npm run typecheck:tests # tsc over tests/ — CI runs this too; vitest green does NOT mean typed mocks compile
|
||||
npm run lint # eslint --fix on src/apps/libs/test
|
||||
npm run lint:check # eslint, no fix
|
||||
npm run test # vitest run (all of tests/**)
|
||||
npm run test:unit # tests/unit
|
||||
npm run test:integration # tests/integration
|
||||
npm run test:ws # tests/websocket
|
||||
npm run test:e2e # tests/e2e (boots Nest modules against a temp in-memory SQLite)
|
||||
npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/** (floor ≥80%)
|
||||
npm run lint # eslint --fix
|
||||
npm run lint:check # eslint, no fix (CI)
|
||||
npm run test # vitest run; also test:unit / test:integration / test:ws / test:e2e
|
||||
npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/**
|
||||
npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sdk/ + shared/
|
||||
npm run check:plugin-facts # CI gate — fails if the generated copies drifted from envelope.ts
|
||||
npm run check:plugin-facts # CI gate — fails if the generated copies drifted
|
||||
node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`.
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/nest/weather.controller.test.ts
|
||||
npx vitest run -t "returns 401 without cookie"
|
||||
npx vitest watch tests/unit/nest/auth-guard.test.ts
|
||||
```
|
||||
**Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). `npm run build` succeeding does **not** mean the code typechecks — run `npm run typecheck`.
|
||||
|
||||
**Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). So `npm run build` succeeding does **not** mean the code typechecks — always run `npm run typecheck` to gate types.
|
||||
## Architecture invariants
|
||||
|
||||
## Architecture
|
||||
Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`controller` + `service` + `module`, registered in `src/nest/app.module.ts`); business logic lives in the injectable `.service.ts`, pure halves in `.helpers.ts`, SQL in a `.repository.ts` where one exists. There is no legacy service layer — ESLint errors on any `services/` import. **`weather/` is the reference module shape; `collections.controller.ts` is the reference for body validation and enumeration-safe access checks.**
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11, taking the final five `*.bridge.ts` files with it (**zero bridges remain**). Legacy `src/services/` is **deleted** — `eslint.config.mjs` errors on any import of it — and every domain is a DI module under `src/nest/<domain>/`. `src/nest/README.md` is the per-module blueprint and records the migration's design decisions. What still runs pre-init is path-prefixed plumbing only: global middleware, /uploads static+guarded routes, the DI-built discovery metadata middleware, production statics, api-docs, and the named body-parser wrappers. The websocket seam is gone: `/ws` is a Nest gateway (`src/nest/realtime/`), and `src/websocket.ts` is a re-export stub. The scheduler seam is gone: every cron is a domain provider on `@nestjs/schedule` (see `src/nest/scheduling/` below).
|
||||
- **`src/bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. Everything on the raw Express instance (global middleware, uploads, discovery metadata, statics, the named `jsonParser`/`urlencodedParser` wrappers) is registered **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and nothing after init is reachable. The wrappers exempt `/mcp` so the MCP SDK reads the raw stream — **never `@Body()` a `/mcp` route**. Don't add routes to that pre-init shell; it bypasses every global guard, interceptor and filter.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
|
||||
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
|
||||
- **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator** — it bypasses that gate.
|
||||
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
|
||||
- **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly.
|
||||
- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on; `database.ts` runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
|
||||
- **`src/index.ts`** — process entry: creates upload/data dirs, builds the app via `bootstrap.ts`, wires the WebSocket server onto the same HTTP server, prints the startup banner. Default port **3001**.
|
||||
- **`src/bootstrap.ts` — `buildApp()`** — the single shared app builder for both prod and the test harness. **Registration order is load-bearing**: `applyGlobalMiddleware` → `applyPlatformUploads` → the discovery metadata middleware → `applyPlatformStatic` → the named body-parser wrappers all run on the raw Express instance **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. **`/mcp` bodies are raw by design**: the parser wrappers (`jsonParser`/`urlencodedParser` — the names suppress Nest's own init-time parsers) exempt `/mcp` so the MCP SDK reads the untouched stream. Never `@Body()` a `/mcp` route.
|
||||
- **Domain modules** live in `src/nest/<domain>/` as `controller` + `service` + `module`, registered in `src/nest/app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract is in `shared/src/<domain>/`.
|
||||
- **`src/services/` no longer exists** — deleted 2026-08-10 after the last fold; ESLint walls off any new import. Business logic lives in each domain's injectable `.service.ts`, with pure halves split into `.helpers.ts` plain modules and, in newer folds, SQL into a repository (`trek_photos` pattern).
|
||||
- **`src/db/`** — `better-sqlite3` (WAL, FK on). `database.ts` boots and runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
- **`src/websocket.ts`** — JWT-authed `ws` server on `/ws`, room-per-trip, heartbeat, per-connection rate limiting. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
|
||||
- **`src/mcp/`** — process-wide MCP state (scopes, sessionManager, tuning knobs, rate-limit map, `nest-mcp-policy.ts` boot gate). The HTTP transport is `src/nest/mcp-transport/` (a `@Public` controller + service); the tools/resources/prompts live in decorator-driven `<domain>.mcp.ts` files under `src/nest/` — `src/mcp/tools.ts` is registry-attach-only. Every tool invocation writes an `mcp.tool_call` audit row (user, OAuth client, tool name, ip) via the nest-mcp `onInvoke` seam.
|
||||
- **`src/nest-mcp/`** — the decorator/registry layer itself (`@McpController`/`@Tool`/`@Resource`/`@Prompt`, `McpRegistry`, `McpModule`, result helpers) that the `<domain>.mcp.ts` files build on. Formerly the standalone `@trek/nest-mcp` workspace, folded in 2026-08-22; see its `README.md` for the API and invariants (self-contained apart from a type-only `../mcp/scopes` import; no scope semantics of its own). Tests: `tests/unit/nest-mcp/`.
|
||||
- **`src/nest/scheduling/`** — `CronRegistrarService`, the one way TREK schedules a cron (`@nestjs/schedule` + the `cron` package). Every job is a `*.job.ts` provider in its owning domain, registered from `onApplicationBootstrap`; the registrar refuses to schedule under `NODE_ENV=test` (the shared `buildApp()` harness must never tick — `tests/integration/scheduler-gate.test.ts` pins it) and stops everything on `nestApp.close()`. Never use a `@Cron` decorator — decorator jobs bypass the gate.
|
||||
- **`src/nest/plugins/`** — sandboxed plugin runtime in three layers: `supervisor/` + `runtime/plugin-host-entry.ts` + `protocol/envelope.ts` (forked child per plugin, IPC envelopes, rate/RSS policing — **intentionally not Nest**, it's the security boundary); `host/rpc-host.ts` (enforcement: a method is registered at spawn only if the plugin holds the unlocking permission — registration = authorization); `host/plugin-host-deps.factory.ts` (dependency wiring — an `@Injectable()` factory injecting the domain services; the RPC surface is decorator-driven since 2026-08-09, with `PluginsModule` split into four and per-domain coverage gates) + `host/plugin-host-state.ts` (process-wide data-DB/budget maps, module-level on purpose). Install-time gates in `install/` (manifest, safe-extract, Ed25519 signature verify, egress). ⚠️ The protocol tables — permissions, RPC methods, hooks, and the core-event catalog — live in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts` for the event families) and are **generated** into `plugin-sdk/src/generated/host-facts.ts` and `shared/src/plugin-permissions.ts` by `npm run gen:plugin-facts`; `check:plugin-facts` is the CI gate, so run it after touching any of them and commit the regenerated files. `runtime/egress-policy.ts` is still a hand-kept **byte-identical** mirror into `plugin-sdk/`, guarded by parity tests there — keep both sides in sync when touching either.
|
||||
## Rules for new code
|
||||
|
||||
## Direction (post-migration guardrails, from the 2026 audit)
|
||||
|
||||
**Nest is the destination — and as of 2026-08-10 the code is there.** These rules now guard against regression rather than describe a migration in flight:
|
||||
|
||||
- **Follow the Nest philosophy, don't work against it.** New surfaces are real Nest modules: thin controller (HTTP shaping, guards, status codes) → injectable provider (business logic, DI-injected deps). The migration to this shape is complete — never resurrect a plain function-module service, and don't grow god services: when a domain outgrows one class, split it (the trips domain became `trips`/`trip-members`/`trip-membership`/`trip-invite`/`trip-read-model`/`calendar`; auth split into `auth` + profile + `tokens`).
|
||||
- **Inject, don't reach for module globals.** No new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Write new code so it can move to DI (`DatabaseModule`, injectable realtime/config/scheduler) without rework. New modules must be importable without side effects (no disk/DB access at module evaluation).
|
||||
- **Don't add routes to the pre-`app.init()` Express shell** — it bypasses every global Nest guard/interceptor/filter. New routes go through Nest so cross-cutting controls actually apply.
|
||||
- **Transactions are not optional.** Any multi-statement write goes in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT` via `db.exec`. Bind values with `?`; identifiers only via a literal allow-list.
|
||||
- **Thin controller → injectable provider.** Never resurrect a plain function-module service; don't grow god services — split a domain into several modules when it outgrows one class (trips and auth already are).
|
||||
- **Inject, don't reach for module globals.** No new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Modules must be importable without side effects.
|
||||
- **Transactions are not optional.** Multi-statement writes go in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT`. Bind values with `?`; identifiers only via a literal allow-list.
|
||||
- **Migrations are append-only.** Identity is positional (array index == `schema_version`) — never reorder, insert mid-list, or delete; no env interpolation in SQL.
|
||||
- **Every write endpoint validates.** `@Body()` must go through the Zod pipe with a schema from `@trek/shared` — no `Record<string, unknown>` + ad-hoc checks. Validating the caller's permission isn't enough: verify every referenced id (place/day/user) exists and belongs to the same trip.
|
||||
- **Every outbound `fetch` gets a timeout (`AbortSignal`), a response-size cap, and boundary validation** — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never silently fall back to another user's API key.
|
||||
- **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs must refuse in production; never select a less-safe code path based on the *absence* of a file. GET handlers must not write to the DB.
|
||||
- **Don't fork the canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate) — reuse it, never reimplement. MCP tools are adapters over the same services/`canAccessTrip`/`checkPermission`; a validation or permission change on a REST route must land in the parallel MCP tool in the same change.
|
||||
- **Money rules**: integer-cent arithmetic; largest-remainder splits (`budgetService.splitEqualShares` is the reference); FX frozen at entry; explicit flags, never sentinel values (`rate === 1`, `scopes: null`); custom splits must reconcile (`Σ balances = 0`) before persisting.
|
||||
- **Reference controllers**: `collections.controller.ts` (Zod pipe on every body + enumeration-safe owner checks) alongside `weather/` for module shape.
|
||||
- **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record<string, unknown>` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip.
|
||||
- **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key.
|
||||
- **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write.
|
||||
- **Don't fork canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate) — reuse it.
|
||||
- **Money**: integer cents; largest-remainder splits (`BudgetService.splitEqualShares` is the reference); FX frozen at entry; explicit flags, never sentinel values; custom splits must reconcile to zero before persisting.
|
||||
|
||||
## Configuration (app-config)
|
||||
|
||||
**Never read `process.env` directly in `src/**`** — ESLint errors on it. All env
|
||||
access goes through `src/app-config/`:
|
||||
**Never read `process.env` in `src/**`** — ESLint errors on it. All env access goes through `src/app-config/`:
|
||||
|
||||
- **Nest DI classes**: inject a boot-stable namespace token
|
||||
(`@Inject(mcpConfig.KEY) private mcp: ConfigType<typeof mcpConfig>` from
|
||||
`src/nest/app-config/`) for values frozen per app build, or use
|
||||
`RuntimeEnvService` / `readEnv()` for runtime-toggled values (DEMO_MODE,
|
||||
NODE_ENV, OIDC_*, …) that ~60 tests mutate mid-lifetime.
|
||||
- **Everything else** (services, mcp, websocket, scheduler, db, middleware):
|
||||
`readEnv()` from `src/app-config` — live, uncached, per call. Modules that
|
||||
froze env in module-top consts keep that timing (read `readEnv()` at module
|
||||
top, marked "frozen on purpose").
|
||||
- **Validation is fail-fast at boot only** (`boot-validate.ts`, imported by
|
||||
`index.ts` right after dotenv): present-but-malformed values abort startup;
|
||||
unset/blank always defaults. Never wire validation into `buildApp()` or
|
||||
`ConfigModule.forRoot` — and never enable `cache: true` or `load`-snapshot a
|
||||
runtime-toggled value (breaks the env-mutating tests).
|
||||
- **Booleans are unified**: every switch accepts true/1/on/yes vs false/0/off/no
|
||||
(any casing) via `parseBool`. Everything else pins its exact legacy coercion —
|
||||
parity quirks are documented in `src/app-config/README.md`, including the
|
||||
exemption list for the ESLint ban (plugin child process, dynamic keys,
|
||||
`config.ts` key material, scripts, tests).
|
||||
- Nest classes inject a boot-stable namespace token from `src/nest/app-config/` for values frozen per app build, or use `RuntimeEnvService` / `readEnv()` for runtime-toggled values (DEMO_MODE, NODE_ENV, OIDC_*, …) that tests mutate mid-lifetime.
|
||||
- Everything else calls `readEnv()` — live, uncached, per call.
|
||||
- Validation is fail-fast at boot only (`boot-validate.ts`, imported by `index.ts` right after dotenv): malformed values abort startup; unset/blank defaults. Never wire validation into `buildApp()` or `ConfigModule.forRoot`, and never `cache`/snapshot a runtime-toggled value (it breaks the env-mutating tests).
|
||||
- Booleans go through `parseBool` (true/1/on/yes vs false/0/off/no). Everything else pins its exact legacy coercion — quirks and the ESLint exemption list are in `src/app-config/README.md`.
|
||||
|
||||
## Cross-cutting Nest pieces
|
||||
## Cross-cutting pieces
|
||||
|
||||
- **`src/nest/common/`**: `trek-exception.filter.ts` (global error envelope, `APP_FILTER`), `zod-validation.pipe.ts` (validates against `@trek/shared` schemas), `idempotency.interceptor.ts` (global `APP_INTERCEPTOR` replaying `X-Idempotency-Key` on mutations).
|
||||
- **Auth is default-deny**: `src/nest/auth/global-auth.guard.ts` + `mfa-policy.guard.ts` run as `APP_GUARD`s; routes opt out with `@Public()`/`@OptionalAuth()` (`public.decorator.ts`), and `src/nest/common/validate-route-guards.ts` refuses at boot any public route not on `PUBLIC_ROUTE_ALLOW_LIST`. The global guard stands down for routes that declare their own guard chain (still resolving `req.user` for MFA), so per-controller `@UseGuards(JwtAuthGuard, ...)` stacks keep working. Other guards: `admin.guard.ts`, `addons/addon.guard.ts` (`@RequireAddon`), `permissions/trip-access.guard.ts` (`@RequirePermission` + `@Trip()` — the standard trip-scoped shape), `permissions/trip-owner.guard.ts`. Auth is cookie-based (`trek_session`); `@CurrentUser()` decorator. **Never gate a multipart upload with a guard** — guards run before the parser, so the client sees ECONNRESET instead of your 403; check in the handler (PROFILE-015).
|
||||
- **`src/middleware/`** — only `globalMiddleware.ts` remains (helmet/CSP, CORS, HSTS, forced-HTTPS, logging, cookie-parser), applied in `bootstrap`. The old `mfaPolicy`/`tripAccess`/`idempotency`/`validate` middlewares are deleted — their jobs moved into the guards above and the Nest pipe/interceptor layer.
|
||||
- **`src/nest/common/`**: the global exception filter (error envelope), the Zod validation pipe, the idempotency interceptor (`X-Idempotency-Key` replay), and `validate-route-guards.ts` (boot-time ratchet refusing any public route not on its allow-list).
|
||||
- **Auth is default-deny**: the global auth guard and the MFA policy guard run as `APP_GUARD`s, followed by the managed-install guard (`managed.guard.ts`), which refuses the routes a centrally administered install withholds from its own admin — it runs last so strangers still get a 401, never a route-revealing 403. Routes opt out with `@Public()`/`@OptionalAuth()`. The global guard stands down for routes declaring their own guard chain, so `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission` stacks work. Session is a cookie (`trek_session`); `@CurrentUser()` reads the user. **Never gate a multipart upload with a guard** — guards run before the parser and the client sees ECONNRESET instead of your 403; check in the handler.
|
||||
- **`src/middleware/globalMiddleware.ts`** is the only Express middleware left (helmet/CSP, CORS, HSTS, forced-HTTPS, logging, cookies).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/` is split into `unit/` (mirrors `src/`, incl. `unit/nest/<domain>.controller.test.ts` and `auth-guard.test.ts`), `integration/`, `e2e/` (one `<domain>.e2e.test.ts` per module, booting the real `JwtAuthGuard` against a temp DB via `tests/e2e/harness.ts` — `createTempDb`/`seedUser`/`sessionCookie`), and `websocket/`. Shared helpers in `tests/helpers/`, fixtures in `tests/fixtures/`, global setup in `tests/setup.ts`.
|
||||
`tests/` is split into `unit/` (mirrors `src/`), `integration/`, `e2e/` (one `<domain>.e2e.test.ts` per module, booting the real guards against a temp DB via `tests/e2e/harness.ts`) and `websocket/`; helpers in `tests/helpers/`, fixtures in `tests/fixtures/`.
|
||||
|
||||
- **vitest uses the SWC plugin** (`vitest.config.ts`), not esbuild, because Nest's type-based DI needs emitted decorator metadata — esbuild drops it. Keep that config when touching test tooling. Pool is `forks` (isolated DBs per worker).
|
||||
- **Coverage gate is a per-domain ratchet over `src/nest/**`** (statements/branches/functions/lines; ≥80% floor, most domains pinned higher — see `vitest.config.ts`); legacy code is intentionally ungated.
|
||||
- The config also aliases `@modelcontextprotocol/sdk/*` to CJS dist files because the SDK's exports map uses unresolvable extension-less wildcards.
|
||||
- **vitest uses the SWC plugin**, not esbuild, because Nest's DI needs emitted decorator metadata. Keep that in `vitest.config.ts`. Pool is `forks` (isolated DB per worker). The config also aliases `@modelcontextprotocol/sdk/*` to its CJS dist because the SDK's exports map is unresolvable.
|
||||
- **Coverage is a per-domain ratchet over `src/nest/**`** (≥80% floor, most domains pinned higher). Regenerate the block with `scripts/coverage-thresholds.mjs` when coverage rises; never lower a threshold to land a change.
|
||||
- CI also runs SonarCloud on the PR's new lines — see the root `CLAUDE.md`.
|
||||
|
||||
## Parity discipline (when adding/changing routes)
|
||||
## Parity discipline (when adding or changing routes)
|
||||
|
||||
Routes must be **byte-identical** for the client: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings. Nest defaults POST to **201**; add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (e.g. `/reorder`, `/in-app/all`) **before** `:id` param routes. Trip-scoped handlers verify trip access (404) and permission (403) per handler.
|
||||
Routes are **byte-identical** for the client: same URL, method, query/body, status, `Set-Cookie` and JSON body, including bespoke error strings reproduced in the controller. Nest defaults POST to **201** — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes. Trip-scoped handlers verify trip access (404) and permission (403).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "4.2.0",
|
||||
"version": "4.2.1",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
|
||||
@@ -78,10 +78,11 @@ below.)
|
||||
**Runtime-toggled** (read live on every access via `readEnv()` /
|
||||
`RuntimeEnvService`; tests mutate these mid-lifetime):
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check).
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
|
||||
LLM_TIMEOUT_MS.
|
||||
|
||||
## Exemptions — raw `process.env` stays
|
||||
|
||||
|
||||
@@ -200,6 +200,12 @@ export function derivePlugins(raw: RawEnv) {
|
||||
/** Kill-switch is default-on: only an explicit falsy value disables (plugins/kill-switch.ts). */
|
||||
enabled: parseBool(raw.TREK_PLUGINS_ENABLED) !== false,
|
||||
devLink: parseBool(raw.TREK_PLUGINS_DEV_LINK) === true,
|
||||
/**
|
||||
* Range bypass is default-OFF: only an explicit truthy value turns the TREK-version
|
||||
* gates into warnings (plugins/install/host-compat.ts). Never inferred from any
|
||||
* other switch — an admin has to ask for "install it anyway" by name.
|
||||
*/
|
||||
ignoreTrekRange: parseBool(raw.TREK_PLUGINS_IGNORE_TREK_RANGE) === true,
|
||||
dir: raw.TREK_PLUGINS_DIR,
|
||||
dataDir: raw.TREK_PLUGINS_DATA_DIR,
|
||||
/** Permission jail is default-on: only an explicit falsy value turns it off. */
|
||||
@@ -235,6 +241,14 @@ export function deriveIntegrations(raw: RawEnv) {
|
||||
overpassUrl: raw.OVERPASS_URL,
|
||||
overpassTimeoutMs: positiveNumberOr(raw.OVERPASS_TIMEOUT_MS, 12000),
|
||||
kitineraryExtractorPath: raw.KITINERARY_EXTRACTOR_PATH,
|
||||
/**
|
||||
* One ceiling for a model call, replacing the three per-client constants
|
||||
* that used to disagree. The default is deliberately generous: heavier
|
||||
* parsing work should fit without a code change.
|
||||
* Floored to a whole number — it reaches undici's headersTimeout, which
|
||||
* rejects a fractional value.
|
||||
*/
|
||||
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
|
||||
// Windows spells it Path; every other platform PATH. Split here so callers
|
||||
// get a list and never re-implement the delimiter.
|
||||
searchPath: (raw.PATH || raw.Path || '')
|
||||
|
||||
@@ -112,6 +112,11 @@ export const envSchema = z.object({
|
||||
// non-http(s) entries today — left unvalidated to keep that behavior.
|
||||
OVERPASS_URL: anyString,
|
||||
OVERPASS_TIMEOUT_MS: positiveNumber,
|
||||
// Whole milliseconds, and inside setTimeout's 32-bit range. A fractional value
|
||||
// would floor to 0 and abort every model call on the next tick; anything past
|
||||
// 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at
|
||||
// boot rather than degrading into a timeout of zero.
|
||||
LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'),
|
||||
KITINERARY_EXTRACTOR_PATH: anyString,
|
||||
// The OS search path. Not configuration anybody sets for TREK — it is here so
|
||||
// the kitinerary probe can resolve its binary to an absolute path itself
|
||||
@@ -150,6 +155,7 @@ export const envSchema = z.object({
|
||||
TREK_API_DOCS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_DEV_LINK: boolStr,
|
||||
TREK_PLUGINS_IGNORE_TREK_RANGE: boolStr,
|
||||
TREK_PLUGINS_DIR: anyString,
|
||||
TREK_PLUGINS_DATA_DIR: anyString,
|
||||
TREK_PLUGIN_PERMISSIONS: boolStr,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AdminService } from './admin.service';
|
||||
import { TokenService } from '../tokens/token.service';
|
||||
import { RegistrationInvitesService } from '../auth/registration-invites.service';
|
||||
import { OauthService } from '../oauth/oauth.service';
|
||||
import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service';
|
||||
import {
|
||||
AdminUserCreateDto,
|
||||
AdminUserUpdateDto,
|
||||
@@ -68,6 +69,7 @@ export class AdminController {
|
||||
private readonly tokens: TokenService,
|
||||
private readonly invites: RegistrationInvitesService,
|
||||
private readonly oauth: OauthService,
|
||||
private readonly kitinerary: KitineraryExtractorService,
|
||||
) {}
|
||||
|
||||
// ── Users ──
|
||||
@@ -149,6 +151,15 @@ export class AdminController {
|
||||
@Get('version-check')
|
||||
async versionCheck() { return this.admin.checkVersion(); }
|
||||
|
||||
/**
|
||||
* Versions of the bundled tools an operator cannot see from the UI. Today the
|
||||
* only one is the KItinerary extractor, whose age decides which booking
|
||||
* providers parse at all — and a stale one is indistinguishable from an
|
||||
* unsupported provider without this (#2261).
|
||||
*/
|
||||
@Get('system-info')
|
||||
systemInfo() { return { kitinerary: this.kitinerary.describe() }; }
|
||||
|
||||
// ── Invites ──
|
||||
@Get('invites')
|
||||
listInvites() { return { invites: this.invites.listInvites() }; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TokensModule } from '../tokens/tokens.module';
|
||||
import { OauthModule } from '../oauth/oauth.module';
|
||||
import { KitineraryExtractorModule } from '../booking-import/kitinerary-extractor.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { VersionCheckJob } from './version-check.job';
|
||||
@@ -24,7 +25,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule],
|
||||
imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService, VersionCheckJob, DemoResetJob],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join, extname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { logDebug } from '../audit/audit-log.logger';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { KiReservation } from './kitinerary.types';
|
||||
@@ -15,15 +16,26 @@ const execFileAsync = promisify(execFile);
|
||||
const BINARY_NAME = 'kitinerary-extractor';
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const MAX_BUFFER = 5 * 1024 * 1024;
|
||||
/** Ceiling on the raw stderr dump under LOG_LEVEL=debug. */
|
||||
const DEBUG_STDERR_LINES = 200;
|
||||
|
||||
@Injectable()
|
||||
export class KitineraryExtractorService implements OnModuleInit {
|
||||
private binaryPath: string | null = null;
|
||||
/** Frozen for the process: the binary cannot change under a running container,
|
||||
* so it is read once at boot and never probed again. */
|
||||
private binaryVersion: string | null = null;
|
||||
/** The configured path, kept even when it did not resolve — a typo there is the
|
||||
* one failure that otherwise looks exactly like "not installed". */
|
||||
private configuredPath: string | null = null;
|
||||
|
||||
onModuleInit() {
|
||||
this.configuredPath = readEnv().integrations.kitineraryExtractorPath || null;
|
||||
this.binaryPath = this.findBinary();
|
||||
if (this.binaryPath) {
|
||||
console.log(`[KItinerary] extractor found at: ${this.binaryPath}`);
|
||||
this.binaryVersion = this.probeVersion(this.binaryPath);
|
||||
const version = this.binaryVersion ? ` (${this.binaryVersion})` : '';
|
||||
console.log(`[KItinerary] extractor found at: ${this.binaryPath}${version}`);
|
||||
} else {
|
||||
console.info('[KItinerary] extractor not found — booking import feature disabled');
|
||||
}
|
||||
@@ -33,6 +45,34 @@ export class KitineraryExtractorService implements OnModuleInit {
|
||||
return this.binaryPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the operator needs to tell three failure modes apart, which look
|
||||
* identical from the UI today: nothing imported and no reason given (#2261).
|
||||
* A stale extractor is missing years of vendor scripts and returns an empty
|
||||
* list; a mistyped KITINERARY_EXTRACTOR_PATH disables the feature entirely;
|
||||
* and a genuinely unsupported provider also returns nothing. `version: null`
|
||||
* with `available: true` means the binary ran but did not answer `--version`.
|
||||
*/
|
||||
describe(): { available: boolean; path: string | null; version: string | null; configuredPath: string | null } {
|
||||
return {
|
||||
available: this.binaryPath !== null,
|
||||
path: this.binaryPath,
|
||||
version: this.binaryVersion,
|
||||
configuredPath: this.configuredPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** `kitinerary-extractor 6.3.3` → `6.3.3`. Never throws: a binary that cannot
|
||||
* answer --version can still extract, so this must not disable the feature. */
|
||||
private probeVersion(path: string): string | null {
|
||||
try {
|
||||
const out = execFileSync(path, ['--version'], { stdio: 'pipe', timeout: 3000 }).toString();
|
||||
return out.trim().split(/\s+/).pop() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async extract(buffer: Buffer, fileName: string): Promise<KiReservation[]> {
|
||||
if (!this.binaryPath) {
|
||||
throw new Error('kitinerary-extractor is not available on this system');
|
||||
@@ -50,12 +90,21 @@ export class KitineraryExtractorService implements OnModuleInit {
|
||||
});
|
||||
|
||||
if (stderr?.trim()) {
|
||||
// Filter expected noise: currency-symbol ambiguity warnings and vendor
|
||||
// extractor script errors are normal (every matching script is tried;
|
||||
// most won't match the current document).
|
||||
const unexpected = stderr
|
||||
.split('\n')
|
||||
.filter(l => l.trim())
|
||||
const lines = stderr.split('\n').filter(l => l.trim());
|
||||
|
||||
// LOG_LEVEL=debug passes the raw stderr through. The lines the filter
|
||||
// below drops are the only signal that a vendor extractor script is
|
||||
// missing or failing in the installed KItinerary build — without them an
|
||||
// outdated extractor and a genuinely unsupported provider look identical
|
||||
// from outside: nothing imported, no reason given (#2261). Capped so one
|
||||
// pathological document cannot flood the log file, and opt-in because the
|
||||
// raw lines can carry document fragments.
|
||||
for (const l of lines.slice(0, DEBUG_STDERR_LINES)) logDebug(`[KItinerary] ${fileName}: ${l}`);
|
||||
|
||||
// At the default level, filter expected noise: currency-symbol ambiguity
|
||||
// warnings and vendor extractor script errors are normal (every matching
|
||||
// script is tried; most won't match the current document).
|
||||
const unexpected = lines
|
||||
.filter(l => !l.includes('Ambig') && !l.includes('JS ERROR') && !l.includes('Invalid result type from script'));
|
||||
if (unexpected.length) {
|
||||
console.warn(`[KItinerary] stderr for "${fileName}":`, unexpected.join('\n'));
|
||||
|
||||
@@ -546,10 +546,42 @@ export class BudgetService {
|
||||
}
|
||||
|
||||
deleteBudgetItem(id: string | number, tripId: string | number): boolean {
|
||||
const item = this.db.get('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
const item = this.db.get<{ id: number; reservation_id: number | null }>(
|
||||
'SELECT id, reservation_id FROM budget_items WHERE id = ? AND trip_id = ?', id, tripId,
|
||||
);
|
||||
if (!item) return false;
|
||||
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
|
||||
return true;
|
||||
return this.db.transaction(() => {
|
||||
this.db.run('DELETE FROM budget_items WHERE id = ?', id);
|
||||
// The booking keeps a copy of this expense's total in its metadata, and
|
||||
// the reservation update path preserves that copy across edits. With the
|
||||
// expense gone there is nothing left to mirror, so drop it here rather
|
||||
// than leave a price on the card that no longer has anything behind it.
|
||||
if (item.reservation_id) this.clearReservationPrice(tripId, item.reservation_id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart to syncReservationPrice: take the mirrored total back off
|
||||
* the booking. Non-fatal, exactly like the writer.
|
||||
*/
|
||||
private clearReservationPrice(tripId: string | number, reservationId: number): void {
|
||||
try {
|
||||
const reservation = this.db.get<{ id: number; metadata: string | null }>(
|
||||
'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?',
|
||||
reservationId, tripId,
|
||||
);
|
||||
if (!reservation?.metadata) return;
|
||||
const meta = JSON.parse(reservation.metadata);
|
||||
if (!meta || typeof meta !== 'object' || meta.price === undefined) return;
|
||||
delete meta.price;
|
||||
delete meta.priceCurrency;
|
||||
this.db.run('UPDATE reservations SET metadata = ? WHERE id = ?', JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = this.db.get('SELECT * FROM reservations WHERE id = ?', reservation.id);
|
||||
this.realtime.broadcast(String(tripId), 'reservation:updated', { reservation: updatedRes }, undefined);
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to clear the mirrored price from the reservation:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -765,6 +797,24 @@ export class BudgetService {
|
||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||
|
||||
// An expense nobody has paid stays out of the ledger (#2225), which is what
|
||||
// both cost panels already promise by flagging the row "Unfinished" (the
|
||||
// hint beside it reads: total only, not settled yet) and counting it into
|
||||
// the Outstanding card instead (CostsPanel/costsModel.isUnfinished). The
|
||||
// panels additionally require a non-zero total to paint that label; the
|
||||
// ledger deliberately does not, or PUT /budget/:id/payers with an empty
|
||||
// array (which zeroes total_price and does not re-apply it) would leave a
|
||||
// custom split debiting its members against no credit at all.
|
||||
// Charging its split members regardless, as #1382/#2176/#1543 left in
|
||||
// place below, debits a share with no credit behind it: Σ(balances) drifts
|
||||
// off zero by the unpaid total, and the greedy simplifier at the end of this
|
||||
// method has no memory of which expense made which debt, so it pairs the
|
||||
// inflated debtor with a creditor from some unrelated expense and offers a
|
||||
// payment between two people who never shared a bill. Booking every flow it
|
||||
// offered then left someone short: "everyone square" beside a balance that
|
||||
// is not zero.
|
||||
if (!payers.some(p => p.amount !== 0)) continue;
|
||||
|
||||
// Payers are credited what they actually paid (converted to trip currency with
|
||||
// the item's stored exchange rate). A negative payer — the recipient of a
|
||||
// refund (#2176) — is debited by the same arithmetic: their credit is negative.
|
||||
@@ -781,14 +831,12 @@ export class BudgetService {
|
||||
// total_price: the two are the same figure (the write path derives the total
|
||||
// from the payer sum), but converting the total separately would round to a
|
||||
// different cent on a foreign-currency expense and leave the item off by one.
|
||||
// With nobody down as a payer there is nothing to divide, so the recorded total
|
||||
// stands in and an unpaid bill keeps reading as money owed. Negative credits
|
||||
// (a refund, #2176) divide the same way — only exactly zero falls back.
|
||||
// Negative credits (a refund, #2176) divide the same way. Nothing falls back
|
||||
// to total_price any more: an item with no payer behind it never reaches
|
||||
// here since #2225. An item whose payers net to exactly zero still does, and
|
||||
// divides zero, which is what it is worth.
|
||||
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||
const splitCents = creditCents !== 0
|
||||
? creditCents
|
||||
: toTripCents(item.total_price, item.currency, item.exchange_rate);
|
||||
const equalShares = !hasCustomSplit ? this.splitEqualShares(splitCents, members, item.id) : {};
|
||||
const equalShares = !hasCustomSplit ? this.splitEqualShares(creditCents, members, item.id) : {};
|
||||
for (const m of members) {
|
||||
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||
? toTripCents(m.amount, item.currency, item.exchange_rate)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { toReservationList } from '../lenient-json';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
const MAX_TOKENS = 8192;
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
const TOOL_NAME = 'emit_reservations';
|
||||
@@ -51,7 +51,7 @@ export class AnthropicClient implements LlmExtractionClient {
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), readEnv().integrations.llmTimeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
// baseUrl is user-configurable — guard it against pointing at the cloud
|
||||
|
||||
@@ -2,12 +2,34 @@ import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.in
|
||||
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
|
||||
import { parseLenientJson, toReservationList } from '../lenient-json';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
import { readEnv } from '../../../app-config';
|
||||
|
||||
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
|
||||
// take a few minutes on a longer document before the first token.
|
||||
const TIMEOUT_MS = 300_000;
|
||||
const MAX_TOKENS = 4096;
|
||||
|
||||
/** What one attempt differs in. Each field is switched on by a 400 that asked for it. */
|
||||
interface RequestShape {
|
||||
tokenParam: 'max_tokens' | 'max_completion_tokens';
|
||||
jsonObject: boolean;
|
||||
omitTemperature: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this 400 body say the model refuses an explicit `temperature`?
|
||||
* OpenAI: "Unsupported value: 'temperature' does not support 0 with this model.
|
||||
* Only the default (1) value is supported."; Azure: "Unsupported parameter:
|
||||
* 'temperature' is not supported with this model."
|
||||
*
|
||||
* The bare word is not enough. Dropping temperature costs the deterministic
|
||||
* sampling every small local model depends on, and an error body can mention the
|
||||
* word while complaining about something else. When the phrasing is unfamiliar
|
||||
* nothing is lost — the request still falls through to the json_object retry,
|
||||
* exactly as it does today.
|
||||
*/
|
||||
function rejectsTemperature(detail: string): boolean {
|
||||
return /temperature/i.test(detail)
|
||||
&& /unsupported|not supported|does not support|only the default/i.test(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat-completions client. Covers both the "openai" cloud
|
||||
* provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio),
|
||||
@@ -22,7 +44,15 @@ const MAX_TOKENS = 4096;
|
||||
*
|
||||
* Structured output is requested as `json_schema` first; servers that only
|
||||
* support `json_object` (DeepSeek, Mistral, some vLLM/llama.cpp) reject that
|
||||
* with a 400, so the request is retried once in `json_object` mode.
|
||||
* with a 400, so the request is retried once in `json_object` mode. Two further
|
||||
* 400s are answered the same way: `max_tokens` becomes `max_completion_tokens`
|
||||
* (#1760), and "temperature is not supported" drops the parameter (#2262).
|
||||
*
|
||||
* Those retries are a loop over what the server actually said, not a fixed
|
||||
* chain. A reasoning model rejects `max_tokens` AND `temperature`, the API names
|
||||
* only one parameter per response, and it may name either first — a chain of
|
||||
* one-shot ifs survives only one of the two orders. Each remedy applies at most
|
||||
* once, so this adds at most three extra requests.
|
||||
*/
|
||||
export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
@@ -50,13 +80,16 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
// local server (Ollama/vLLM/llama.cpp), but newer OpenAI models reject it
|
||||
// with a 400 and demand `max_completion_tokens`. Start with the broadly
|
||||
// supported spelling and swap on that specific rejection (#1760).
|
||||
const buildBody = (tokenParam: 'max_tokens' | 'max_completion_tokens', jsonObject: boolean) => {
|
||||
const buildBody = (shape: RequestShape) => {
|
||||
const baseBody = {
|
||||
model: input.model,
|
||||
[tokenParam]: MAX_TOKENS,
|
||||
[shape.tokenParam]: MAX_TOKENS,
|
||||
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
|
||||
// small models (NuExtract) drop fields or return empty. Pin to 0.
|
||||
temperature: 0,
|
||||
// small models (NuExtract) drop fields or return empty. Pin to 0, and only
|
||||
// leave it out once a server has explicitly rejected the parameter (#2262).
|
||||
// The first attempt always carries it, and that is the only one a local
|
||||
// server ever sees.
|
||||
...(shape.omitTemperature ? {} : { temperature: 0 }),
|
||||
// NuExtract wants the template (in the user turn) to be the only instruction
|
||||
// — a system prompt or a json_schema grammar derails it.
|
||||
messages: nuextract
|
||||
@@ -69,31 +102,43 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
if (nuextract) return baseBody;
|
||||
return {
|
||||
...baseBody,
|
||||
response_format: jsonObject
|
||||
response_format: shape.jsonObject
|
||||
? { type: 'json_object' as const }
|
||||
: { type: 'json_schema' as const, json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false } },
|
||||
};
|
||||
};
|
||||
|
||||
let tokenParam: 'max_tokens' | 'max_completion_tokens' = 'max_tokens';
|
||||
let res = await this.send(url, buildBody(tokenParam, false), input.apiKey);
|
||||
const shape: RequestShape = { tokenParam: 'max_tokens', jsonObject: false, omitTemperature: false };
|
||||
const tried = { tokenParam: false, temperature: false, jsonObject: false };
|
||||
|
||||
let res = await this.send(url, buildBody(shape), input.apiKey);
|
||||
let detail = res.ok ? '' : await res.text().catch(() => '');
|
||||
|
||||
// Newer OpenAI models 400 on `max_tokens` — retry the whole request (schema
|
||||
// and all) with `max_completion_tokens` before giving up.
|
||||
if (!res.ok && res.status === 400 && detail.includes('max_completion_tokens')) {
|
||||
tokenParam = 'max_completion_tokens';
|
||||
res = await this.send(url, buildBody(tokenParam, false), input.apiKey);
|
||||
detail = res.ok ? '' : await res.text().catch(() => '');
|
||||
}
|
||||
|
||||
// Servers that only support `json_object` (DeepSeek, Mistral, some
|
||||
// vLLM/llama.cpp) reject `json_schema` with a 400 — retry once in
|
||||
// `json_object` mode (keeping whichever token param stuck). The system
|
||||
// prompt already dictates the exact output shape (and mentions JSON, which
|
||||
// json_object mode requires).
|
||||
if (!res.ok && res.status === 400 && !nuextract) {
|
||||
res = await this.send(url, buildBody(tokenParam, true), input.apiKey);
|
||||
// A 400 is the server naming the parameter it dislikes. Apply every remedy it
|
||||
// names, and only when it names none fall back to json_object — the unchanged
|
||||
// behaviour for servers that reject `json_schema` with an unspecific 400. The
|
||||
// system prompt already dictates the exact output shape and mentions JSON,
|
||||
// which json_object mode requires.
|
||||
while (!res.ok && res.status === 400) {
|
||||
let named = false;
|
||||
if (!tried.tokenParam && detail.includes('max_completion_tokens')) {
|
||||
shape.tokenParam = 'max_completion_tokens';
|
||||
tried.tokenParam = true;
|
||||
named = true;
|
||||
}
|
||||
if (!tried.temperature && rejectsTemperature(detail)) {
|
||||
shape.omitTemperature = true;
|
||||
tried.temperature = true;
|
||||
named = true;
|
||||
}
|
||||
if (!named) {
|
||||
// NuExtract sends no response_format at all, so it has nothing to fall
|
||||
// back to and the 400 is final.
|
||||
if (tried.jsonObject || nuextract) break;
|
||||
shape.jsonObject = true;
|
||||
tried.jsonObject = true;
|
||||
}
|
||||
res = await this.send(url, buildBody(shape), input.apiKey);
|
||||
detail = res.ok ? '' : await res.text().catch(() => '');
|
||||
}
|
||||
|
||||
@@ -110,7 +155,7 @@ export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
|
||||
private async send(url: string, body: unknown, apiKey?: string): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), readEnv().integrations.llmTimeoutMs);
|
||||
try {
|
||||
// baseUrl is user-configurable — guard it against pointing at the cloud
|
||||
// metadata endpoint, while still allowing a local/LAN Ollama.
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
|
||||
import { parseLenientJson } from '../lenient-json';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
|
||||
const TIMEOUT_MS = 300_000;
|
||||
import { readEnv } from '../../../app-config';
|
||||
|
||||
export interface EnforcedExtractInput {
|
||||
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
|
||||
@@ -65,7 +64,7 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), readEnv().integrations.llmTimeoutMs);
|
||||
let res: Response;
|
||||
try {
|
||||
// baseUrl is user-configurable — guard it against the cloud-metadata range,
|
||||
|
||||
@@ -76,6 +76,9 @@ function logKeyFailure(label: string, status: number, userId: number, source: Ap
|
||||
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
|
||||
}
|
||||
|
||||
/** Ceiling for one Google Places call. Generous — the photo download is the slow one. */
|
||||
const GOOGLE_FETCH_TIMEOUT_MS = 20000;
|
||||
|
||||
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
const endpoint = placesEndpoint(rawEndpoint);
|
||||
googleApiCallCount++;
|
||||
@@ -83,6 +86,11 @@ function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Pr
|
||||
const referer = readEnv().app.appUrl ? getAppUrl() : undefined;
|
||||
return fetch(endpoint, {
|
||||
...init,
|
||||
// A default ceiling here rather than at each of the nine call sites, none of
|
||||
// which passed one: a hung upstream held the request handler open for as
|
||||
// long as it liked. A caller that needs longer still wins, it only has to
|
||||
// say so.
|
||||
signal: init?.signal ?? AbortSignal.timeout(GOOGLE_FETCH_TIMEOUT_MS),
|
||||
headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record<string, string>) ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { registerChannel } from '../channel-registry';
|
||||
import type { ChannelMessage, ExternalChannel } from '../notification-events';
|
||||
import type { MailerService } from '../mailer/mailer.service';
|
||||
import { resolveAdminNtfyUrl, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
|
||||
import { resolveAdminNtfyUrl, resolveNtfyToken, resolveNtfyUrl, type NtfyService } from '../transports/ntfy.service';
|
||||
import type { WebhookService } from '../transports/webhook.service';
|
||||
|
||||
// The three built-in external channels, wrapping the transports that were free
|
||||
@@ -92,7 +92,10 @@ export function buildBuiltinChannels({ mailer, webhook, ntfy }: BuiltinChannelDe
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, userCfg);
|
||||
if (!url) return false;
|
||||
return ntfy.sendNtfy(url, userCfg?.token ?? adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
// Not `?? adminCfg.token`: the user picks their own ntfy_server, so that
|
||||
// handed the operator's decrypted token to whatever host they named, on
|
||||
// every ordinary send and with no test route involved (GHSA-7pqc-fj3c-9346).
|
||||
return ntfy.sendNtfy(url, resolveNtfyToken(adminCfg, userCfg), { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||
},
|
||||
async sendGlobal(msg: ChannelMessage) {
|
||||
const adminCfg = ntfy.getAdminNtfyConfig();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { ChannelTestResult, UnreadCountResult } from '@trek/shared';
|
||||
import type { User } from '../../types';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { resolveNtfyToken } from './transports/ntfy.service';
|
||||
import {
|
||||
PreferencesUpdateDto,
|
||||
TestSmtpDto,
|
||||
@@ -102,9 +103,14 @@ export class NotificationsController {
|
||||
const resolvedTopic = topic || userCfg?.topic || undefined;
|
||||
const resolvedServer = server || userCfg?.server || adminCfg.server || undefined;
|
||||
// Reuse the saved token when the request sends null, empty, or the masked placeholder.
|
||||
// Not `?? adminCfg.token`: the caller picks `server`, so that handed the
|
||||
// operator's decrypted token to any host an authenticated user named
|
||||
// (GHSA-7pqc-fj3c-9346). Same rule as the live send path, and target-based
|
||||
// rather than role-based on purpose — an admin-only gate here would take a
|
||||
// working button away from every user with their own ntfy config.
|
||||
const resolvedToken = (token && token !== MASKED)
|
||||
? token
|
||||
: (userCfg?.token ?? adminCfg.token ?? null);
|
||||
: resolveNtfyToken(adminCfg, userCfg, resolvedServer ?? null);
|
||||
|
||||
if (!resolvedTopic) {
|
||||
throw new HttpException({ error: 'No ntfy topic configured' }, 400);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { logDebug, logError, logInfo } from '../../audit/audit-log.logger';
|
||||
import { decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../../../utils/ssrfGuard';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard';
|
||||
import type { NotifEventType } from '../notification-events';
|
||||
|
||||
export interface NtfyConfig {
|
||||
@@ -44,6 +44,46 @@ export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null)
|
||||
return `${base}/${encodeURIComponent(topic)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an ntfy target is the operator's own server.
|
||||
*
|
||||
* The admin token is the operator's credential for the operator's server, so it
|
||||
* may only travel there. Anyone can save their own `ntfy_server`, and without
|
||||
* this the decrypted admin token went out as `Authorization: Bearer` to whatever
|
||||
* host they named — on the test route and on every ordinary send
|
||||
* (GHSA-7pqc-fj3c-9346). The scheme is part of the comparison: an http:// twin
|
||||
* of an https:// server would put the token on the wire in the clear.
|
||||
* Both sides empty means ntfy.sh against ntfy.sh, which is still a match.
|
||||
*/
|
||||
export function isOperatorNtfyServer(server: string | null | undefined, adminServer: string | null): boolean {
|
||||
const normalize = (value: string | null | undefined): string => {
|
||||
const raw = (value || 'https://ntfy.sh').replace(/(?<!\/)\/+$/, '');
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, '')}`.toLowerCase();
|
||||
} catch {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
};
|
||||
return normalize(server) === normalize(adminServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to send with a per-user ntfy request. The user's own token always
|
||||
* wins; the operator's is only reachable when the request is actually going to
|
||||
* the operator's server. Shared by the test route and the live send path so the
|
||||
* two cannot drift — closing only one of them closes nothing.
|
||||
*/
|
||||
export function resolveNtfyToken(
|
||||
adminCfg: NtfyConfig,
|
||||
userCfg: NtfyConfig | null,
|
||||
serverOverride?: string | null,
|
||||
): string | null {
|
||||
if (userCfg?.token) return userCfg.token;
|
||||
const target = serverOverride ?? userCfg?.server ?? adminCfg.server;
|
||||
return isOperatorNtfyServer(target, adminCfg.server) ? adminCfg.token : null;
|
||||
}
|
||||
|
||||
/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */
|
||||
export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null {
|
||||
if (!adminCfg.topic) return null;
|
||||
@@ -106,12 +146,6 @@ export class NtfyService {
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
if (!ssrf.allowed) {
|
||||
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${ssrf.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = NTFY_EVENT_META[payload.event as NotifEventType] ?? NTFY_DEFAULT_META;
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
@@ -124,13 +158,17 @@ export class NtfyService {
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
// Every hop re-checked and re-pinned. Checking only the first URL was not
|
||||
// enough: Node skips the pinned lookup for an IP-literal host, so a 307 to
|
||||
// 127.0.0.1 or 169.254.169.254 connected straight through
|
||||
// (GHSA-8mw6-xphx-886m). No dispatcher or redirect here — safeFetchFollow
|
||||
// sets both per hop and would overwrite them.
|
||||
const res = await safeFetchFollow(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
dispatcher: createPinnedDispatcher(ssrf.resolvedIp!),
|
||||
} as RequestInit);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
@@ -142,6 +180,13 @@ export class NtfyService {
|
||||
logDebug(`Ntfy url=${url} priority=${meta.priority} tags=${meta.tags.join(',')}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// The guard used to run before the try, with its own line and its own
|
||||
// `return false`. safeFetchFollow throws instead, so the same line and the
|
||||
// same result are reproduced here rather than changing what callers see.
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
logError(`Ntfy blocked by SSRF guard event=${payload.event} url=${url} reason=${err.message}`);
|
||||
return false;
|
||||
}
|
||||
logError(`Ntfy failed event=${payload.event}: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user