mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
Compare commits
347 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48cd46f818 | |||
| 2efa843306 | |||
| 53eeba3c1b | |||
| c1d55c463c | |||
| 60cbb41d5f | |||
| 2ffe4d6054 | |||
| 1b49995c15 | |||
| 25282ea1c5 | |||
| da679e6266 | |||
| 68ce177901 | |||
| 3841412b22 | |||
| 049fb76965 | |||
| 54b94cf9e3 | |||
| b058b63f6e | |||
| f24827e7ed | |||
| 00d00be1c0 | |||
| accdd5905e | |||
| f4bbb0fe5d | |||
| a6e8c66609 | |||
| b2384a7523 | |||
| 8cddd445ba | |||
| 39d832c772 | |||
| f4380727c7 | |||
| 61880ce7d6 | |||
| c8cb88133f | |||
| 9deedb80ea | |||
| f0e4c9a33d | |||
| 89bae414ff | |||
| c42cf5477b | |||
| 54e5525568 | |||
| 0cc6dcf8aa | |||
| a2585beb13 | |||
| 803a7cb64a | |||
| 9cb74e22f6 | |||
| 8eb936f46f | |||
| 6e8aa3ab26 | |||
| 76f45512d1 | |||
| c2751d9c80 | |||
| f8ce80da32 | |||
| cbc28fad88 | |||
| 15aefa449e | |||
| c2707b7eff | |||
| 6edb4462f9 | |||
| 27ce4df6f5 | |||
| 1a95157b3a | |||
| 8fc788fb37 | |||
| de3b2a6ac7 | |||
| fcee6bd2c5 | |||
| c351a0471c | |||
| 3f6d1e8ed7 | |||
| 46aa0c6b0a | |||
| 77a16d6f26 | |||
| 7b50692372 | |||
| 62d4cdcc18 | |||
| a89ad06ba5 | |||
| 7197ec1c96 | |||
| 3d201e1ced | |||
| f4809a67d3 | |||
| d4677bdeab | |||
| 6845db4cb8 | |||
| 274dbea069 | |||
| d0d3f455a8 | |||
| e3471e2dc8 | |||
| eff4605ef3 | |||
| edf0538260 | |||
| 462a208fdf | |||
| 5e0245046d | |||
| f0e1da2d81 | |||
| 3ab5ddaacc | |||
| 8a28d4dd4a | |||
| 105bba2b95 | |||
| 5b4a7e82ff | |||
| 5678eed55c | |||
| 218d8baf0a | |||
| dad1316172 | |||
| 906eef4349 | |||
| b010305c0d | |||
| 3529b72393 | |||
| dd5b45ffda | |||
| 11b69ae815 | |||
| 79095823e5 | |||
| f2bd116044 | |||
| ba535e5aab | |||
| aa0d62f063 | |||
| 3e8ab22836 | |||
| 2332d45d01 | |||
| 2fd5b6b00a | |||
| f120266f9e | |||
| fdb993de62 | |||
| 84c277993c | |||
| 43a6c30325 | |||
| 1944bdf0a5 | |||
| 57eac32f9f | |||
| a066c94ec5 | |||
| 9307025ed0 | |||
| 66761d57bb | |||
| e0c9a8d1b6 | |||
| a635e31bd4 | |||
| bc6d804af6 | |||
| 259a8c6b4d | |||
| 953720c874 | |||
| aa848cc6b1 | |||
| 3dd6674d3f | |||
| bef11c5222 | |||
| 3a8ae6334c | |||
| bd46089450 | |||
| 76e182f44e | |||
| 51626e8184 | |||
| bc04072dc9 | |||
| bc15036cfb | |||
| 0c78462567 | |||
| 4f1d0aa210 | |||
| 465f67ae05 | |||
| 8b99188fc8 | |||
| ac57321840 | |||
| b10b02c685 | |||
| 359551653f | |||
| f56e7a161e | |||
| 228e6a13a9 | |||
| b58771fd4c | |||
| e8431d6426 | |||
| 41b18f970c | |||
| ff5fa55fad | |||
| af45b0ca74 | |||
| ec281e5974 | |||
| b773faff70 | |||
| dd6ad31ab9 | |||
| fccc55732b | |||
| 491cfccc56 | |||
| 3c735c80a4 | |||
| 4f8605b529 | |||
| 50839f8cc1 | |||
| 8ee2b6ae4c | |||
| 3e37a708c7 | |||
| d8f2dc755d | |||
| 134fcbb424 | |||
| 2c06c9904b | |||
| 67dd43bc8a | |||
| f5ef14a05f | |||
| 7eba81dfd8 | |||
| edb9a55099 | |||
| 74a0825f5a | |||
| a8e4c45576 | |||
| 87c73e50fb | |||
| 1128bcd1c5 | |||
| 7946c2f749 | |||
| 5ef60f5641 | |||
| 9ce8b7ea9e | |||
| a9907e2f10 | |||
| a00cc889ed | |||
| 3f0eb210d8 | |||
| bd6e6312e1 | |||
| 96ab748f34 | |||
| aaf6559d5d | |||
| 5b147af0b6 | |||
| 95d1752a3d | |||
| c9e0fe52d5 | |||
| bf1900c3a0 | |||
| 59b7c35e87 | |||
| 77162aa8d5 | |||
| 29fc586930 | |||
| 7ba7338f5f | |||
| 2f71549413 | |||
| fab3d2bdf8 | |||
| 00cac02665 | |||
| 756e0fb908 | |||
| d30b916f9c | |||
| 88129e4546 | |||
| a46d3fe88a | |||
| 149b87fd47 | |||
| 15beb0ea40 | |||
| f283254bb0 | |||
| 8acfaea3c4 | |||
| a463246c1c | |||
| 2a38e79431 | |||
| 2d2f1e88bd | |||
| 1e2a5e3862 | |||
| 7e3b9fd039 | |||
| 07bb899c50 | |||
| 9543fdc3f2 | |||
| 59a1756529 | |||
| 4611f0ab7a | |||
| 6a3d4b1281 | |||
| 06ecc5135c | |||
| b2a196d1e7 | |||
| f5d6af9c77 | |||
| 9fa0ceaf49 | |||
| 900e4806ad | |||
| 4a88dbcbcf | |||
| 1761f2bcd5 | |||
| 8be594d34c | |||
| 5ac6ce40be | |||
| 2d7889c237 | |||
| cfae0a1f78 | |||
| 31061751b0 | |||
| 559b7624d0 | |||
| 2de9de8c3a | |||
| d609aefe8f | |||
| 1792fc17d0 | |||
| 72935d7e40 | |||
| 569f0ca1a5 | |||
| 5d0d106c70 | |||
| c04e740ff1 | |||
| f1120be3c1 | |||
| d3aad4c41e | |||
| 91801b10c0 | |||
| b2d9b25bb6 | |||
| 44d52ab215 | |||
| 239856f35c | |||
| 65be8db122 | |||
| 41e256bf0f | |||
| 1e1dd9b814 | |||
| c1f7587704 | |||
| 676c82a308 | |||
| 6fca238440 | |||
| 761ec4e66f | |||
| 620f4c6ecd | |||
| 36ac5de84c | |||
| 061259b3d9 | |||
| fc74991a15 | |||
| 49f97af3fb | |||
| 4215db2908 | |||
| 95c173a8a8 | |||
| 282e3c6b13 | |||
| ea9de65550 | |||
| e79a2591a1 | |||
| dfb038d90f | |||
| 56266138c6 | |||
| 11f4c740e1 | |||
| d6eac772aa | |||
| 67163e7224 | |||
| ba7bd1dd91 | |||
| c82a8f899f | |||
| 23bda0e1e1 | |||
| d1a13058a6 | |||
| 25249749ad | |||
| 9a0a6f8c3b | |||
| 7829043a00 | |||
| 7b5c8c1fae | |||
| eebe9a52e1 | |||
| f1fa155899 | |||
| 4f57c1ee13 | |||
| 3d252ac994 | |||
| e11ae6f056 | |||
| 1db55f734c | |||
| b339ef3f04 | |||
| f1eadd670d | |||
| d6955f20a6 | |||
| 6aa7884874 | |||
| 37179cb608 | |||
| 74258bd5d9 | |||
| 552bd61d11 | |||
| 647e972c8b | |||
| 07a4447bbd | |||
| 3902e77991 | |||
| 7cdd61e7fb | |||
| ed5769fc89 | |||
| b0aa7f8d95 | |||
| 48f0f29273 | |||
| 5aa36aaf3d | |||
| 273bac8917 | |||
| 2b7af6267b | |||
| d613c91e16 | |||
| 0991fa538b | |||
| 65eebec53f | |||
| 15c1a5ae06 | |||
| 4c52d16b7e | |||
| c0eb1c6e38 | |||
| fb363f22c8 | |||
| 3caafe0548 | |||
| bac85cb98a | |||
| 87a765b763 | |||
| 715993feaf | |||
| b3d69aa760 | |||
| 0a2fa32fc1 | |||
| 7f2a63e7f7 | |||
| 2a0ceb2687 | |||
| 8e2840b29d | |||
| 0454a383b3 | |||
| 18684f20f3 | |||
| 8913782694 | |||
| 52315234fd | |||
| 2572132117 | |||
| 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 | |||
| 755e9f1ca4 | |||
| b132adf3e3 | |||
| 94914295d1 | |||
| 85f5247a00 | |||
| 8a48131618 | |||
| 0a35ce83a5 | |||
| eaace6ada4 | |||
| e6b4df5213 | |||
| 2d315df8bf | |||
| 04b31ec2e5 | |||
| 8eda1475c3 | |||
| ea0589b21e | |||
| 523443f1d7 | |||
| 8c538db74d | |||
| 11a0780a5b | |||
| f75e9e9af8 | |||
| 3e532aab99 | |||
| 6953ede0e3 | |||
| f81aaa9f4b | |||
| 981cf0a143 | |||
| dc7d1f5e10 | |||
| 6f057ab9f4 | |||
| ce58de29a5 | |||
| c5208967ac | |||
| 53c246e8e5 | |||
| dba4434ef6 | |||
| 55f14c6a70 | |||
| 6d54ac629e | |||
| bafcd456c5 | |||
| 9ed88adf34 | |||
| bc10fba389 | |||
| fa406b708f |
@@ -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.
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
@@ -53,7 +54,7 @@ COPY server/package.json ./server/
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
|
||||
@@ -80,18 +80,14 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -134,6 +134,25 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+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()
|
||||
})
|
||||
|
||||
+9
-6
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -26,7 +26,9 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
@@ -37,6 +39,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": "*",
|
||||
@@ -48,7 +51,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
@@ -78,7 +81,7 @@
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -93,12 +96,12 @@
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sharp": "^0.35.0",
|
||||
"sharp": "^0.35.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-tower-control-icon lucide-tower-control">
|
||||
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
|
||||
<path d="M8 13v9" />
|
||||
<path d="M16 22v-9" />
|
||||
<path d="m9 6 1 7" />
|
||||
<path d="m15 6-1 7" />
|
||||
<path d="M12 6V2" />
|
||||
<path d="M13 2h-2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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',
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+53
-18
@@ -19,11 +19,12 @@ 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'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
@@ -283,7 +284,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,13 +301,14 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
@@ -314,26 +316,49 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_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(() => {})
|
||||
}, [])
|
||||
@@ -341,6 +366,7 @@ export default function App() {
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
useRoadtripPreferencesSync()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
@@ -380,13 +406,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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
|
||||
return assignmentSchema.parse(saved.data.assignment)
|
||||
}
|
||||
@@ -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')
|
||||
@@ -367,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
+297
-17
@@ -1,6 +1,8 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type ApiTokenCreateRequest, type PublicApiScope,
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
@@ -59,6 +62,12 @@ import {
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripDayTrack,
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -214,16 +223,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,7 +343,10 @@ export const authApi = {
|
||||
// and a key of the wrong kind is refused like one that does not exist.
|
||||
apiKeys: {
|
||||
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
// `scopes` narrows what the key may read (#2279). Omitted means everything,
|
||||
// which is what every key minted before scopes existed still does.
|
||||
create: (name: string, scopes?: PublicApiScope[]) =>
|
||||
apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
passkey: {
|
||||
@@ -424,7 +444,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),
|
||||
@@ -453,8 +476,15 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
@@ -635,12 +665,16 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -969,6 +1003,9 @@ export const journeyApi = {
|
||||
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
|
||||
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
|
||||
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
@@ -988,6 +1025,9 @@ export const journeyApi = {
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||
/** Bring every waved-away trip suggestion back. Answers with how many returned. */
|
||||
restoreSuggestions: (id: number): Promise<{ restored: number }> =>
|
||||
apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data),
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
@@ -1022,11 +1062,193 @@ export const memoriesApi = {
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* Place search that still answers with no network.
|
||||
*
|
||||
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
|
||||
* is the one place it is read from, so all ten call sites got the offline path
|
||||
* without any of them learning about it. `source` says 'offline-cache' rather
|
||||
* than pretending to be the index — several screens show it, and a stale answer
|
||||
* that claims to be live is worse than one that says what it is.
|
||||
*
|
||||
* Only network-level failures fall through. A 4xx or 5xx means the server did
|
||||
* answer and the caller has to see it.
|
||||
*/
|
||||
/**
|
||||
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
|
||||
*
|
||||
* Separate because the shape is different: this one is keyed by id rather than
|
||||
* by a query, and a miss has to stay a miss — a place the cache does not hold
|
||||
* must fail the way it did before, not answer with somebody else's record.
|
||||
*/
|
||||
/**
|
||||
* The rejection both search surfaces already know to swallow.
|
||||
*
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
return err
|
||||
}
|
||||
|
||||
async function withCachedPlace(
|
||||
placeId: string,
|
||||
online: () => Promise<{ place: Record<string, unknown> | null }>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
|
||||
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const hit = await getCachedPlace(placeId)
|
||||
return hit ? { place: cachedToPlaceRecord(hit) } : null
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
const cached = await fromCache()
|
||||
if (cached) return cached
|
||||
return online()
|
||||
}
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached) throw err
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
async function withCachedPlaces<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
|
||||
// The keystroke this answers may already be two keystrokes old. Both search
|
||||
// surfaces order their suggestions by aborting the previous request and
|
||||
// discarding the rejection — there is no request counter — so an offline
|
||||
// answer that resolves regardless of the signal can overwrite a newer list.
|
||||
// The two cache reads are also very unequal: a prefix hit comes off the
|
||||
// index, the substring pass walks every cached place of every trip.
|
||||
if (signal?.aborted) throw abortedError()
|
||||
return found
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) return shape(await fromCache())
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached.length) throw err
|
||||
return shape(cached)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins implementing `searchProvider` found for the same query (#2221).
|
||||
*
|
||||
* Its own request beside the core one rather than a branch inside it: the core search
|
||||
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
|
||||
* answers with an empty list for every failure there is — no provider installed, one
|
||||
* that timed out, a 404 on an older server, the network gone — because a search that
|
||||
* breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
/**
|
||||
* How long a plugin index may keep the search list waiting, in milliseconds.
|
||||
*
|
||||
* The host gives a provider two seconds to answer and this gives the round trip a
|
||||
* little more. It is a ceiling on the WAIT, not on the provider: with no search
|
||||
* plugin installed the route answers immediately without reaching any of them, so
|
||||
* the normal case costs one local round trip and nothing else.
|
||||
*
|
||||
* A deadline rather than patience, because the alternative is a search that feels
|
||||
* broken. A list that arrives without an optional index is a smaller answer; a list
|
||||
* that arrives four seconds late is no answer at all.
|
||||
*/
|
||||
const PLUGIN_SEARCH_DEADLINE_MS = 2500
|
||||
|
||||
async function pluginSearchPlaces(
|
||||
query: string,
|
||||
lang?: string,
|
||||
near?: { lat: number; lng: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
|
||||
try {
|
||||
const r = await apiClient.get('/plugin-search', {
|
||||
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
|
||||
signal: stop.signal,
|
||||
})
|
||||
const places = (r.data as { places?: unknown })?.places
|
||||
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
* is the difference between finding the temple the user stands next to and
|
||||
* one 400km away. The route has always accepted it; nothing passed it.
|
||||
*
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*
|
||||
* Plugin indexes are asked at the same time and their hits are appended, so every
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
|
||||
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
|
||||
}),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
withCachedPlaces(
|
||||
input,
|
||||
(places) => ({
|
||||
suggestions: places.map((p) => ({
|
||||
placeId: String(p.osm_id),
|
||||
mainText: String(p.name),
|
||||
secondaryText: String(p.address || ''),
|
||||
})),
|
||||
source: 'offline-cache',
|
||||
}),
|
||||
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
signal,
|
||||
),
|
||||
// Answered from the cache when the network is not there, for the ids the
|
||||
// offline suggestion list hands out. Without it, picking an offline
|
||||
// suggestion failed here and the callers fell back to searching for
|
||||
// "name, address" — which the cache matches on the folded NAME alone, so it
|
||||
// found nothing and the user got an error toast for a place that was sitting
|
||||
// in the cache all along.
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) =>
|
||||
withCachedPlace(placeId, () =>
|
||||
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
|
||||
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1046,10 +1268,60 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
/**
|
||||
* Every place in a box, for the offline cache. One call per trip area, not
|
||||
* per keystroke, so the timeout is generous where the search ones are short.
|
||||
*/
|
||||
area: (
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
apiClient
|
||||
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data as {
|
||||
results: Record<string, unknown>[]
|
||||
truncated: boolean
|
||||
unavailable?: boolean
|
||||
},
|
||||
),
|
||||
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
|
||||
* line produces dozens of them, and one request each would re-route the whole trip once
|
||||
* per point at better than a second apart.
|
||||
*/
|
||||
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
@@ -1065,8 +1337,8 @@ export const budgetApi = {
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
@@ -1162,7 +1434,7 @@ export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number, opts?: { keepStop?: boolean }) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`, opts?.keepStop ? { params: { keepStop: 'true' } } : undefined).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
@@ -1179,13 +1451,19 @@ export const collabApi = {
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
|
||||
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
|
||||
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
|
||||
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
@@ -1219,11 +1497,13 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
// Public transit routing (#1065) — proxied through the server, which picks the
|
||||
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
|
||||
// backend as the languageCode, so station names come back in the user's script.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
|
||||
apiClient.get('/transit/plan', { params }).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 () => {
|
||||
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionImportablesResponse,
|
||||
CollectionFile,
|
||||
CollectionImportRequest,
|
||||
CollectionImportResult,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
@@ -53,9 +56,19 @@ 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 }> =>
|
||||
// Export / import as a file (#2198). The export body IS the file; the import
|
||||
// sends the parsed file back through the same contract.
|
||||
exportFile: (id: number): Promise<CollectionFile> =>
|
||||
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
|
||||
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
|
||||
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
|
||||
// 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),
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
|
||||
* for how long the browser waits.
|
||||
*
|
||||
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
|
||||
* goes into the body or the query string. There is no logic in it to get wrong,
|
||||
* which is exactly why a mistake here is so quiet: a wrong path or a dropped
|
||||
* query parameter does not throw, it hands the layer above an empty answer, and
|
||||
* the panel three levels up simply shows nothing. Every method is therefore
|
||||
* driven through MSW and its verb, path and payload pinned.
|
||||
*
|
||||
* Three things here matter more than the URL bookkeeping:
|
||||
*
|
||||
* - **`offset`.** Recorded points are bare instants, so the reader's own UTC
|
||||
* offset is the one fact the server cannot derive. Both track calls send it,
|
||||
* and they send it negated: `getTimezoneOffset()` counts minutes *behind*
|
||||
* local time, the wire wants minutes *ahead* of UTC. Were it to stop being
|
||||
* sent, or to be sent unsigned, an evening east of UTC would land on the
|
||||
* previous day of the trip and nothing would fail anywhere. The line would
|
||||
* just be drawn on the wrong day, which is the whole reason the parameter
|
||||
* exists. Hence two zones rather than one: a single European fixture cannot
|
||||
* tell a negation from a magnitude.
|
||||
* - **The failure reaches the caller intact.** `useDawarichConnection` and
|
||||
* `useDawarichSuggestions` both translate `err.response.data.code` into the
|
||||
* reader's language. A `.catch()` added anywhere in this file would turn
|
||||
* every upstream failure into a silent success with an undefined body.
|
||||
* - **An abort really cancels.** The track overlay issues a fresh request
|
||||
* whenever the looked-at window changes and aborts the previous one; a call
|
||||
* that resolved anyway would repaint the map with the older answer.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
const BASE = '/api/integrations/dawarich'
|
||||
|
||||
let requestUrl = ''
|
||||
let requestBody: unknown
|
||||
|
||||
/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */
|
||||
function record<T extends JsonBodyType>(data: T) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
requestUrl = request.url
|
||||
const text = await request.text()
|
||||
requestBody = text ? JSON.parse(text) : undefined
|
||||
return HttpResponse.json(data)
|
||||
}
|
||||
}
|
||||
|
||||
function query(): URLSearchParams {
|
||||
return new URL(requestUrl).searchParams
|
||||
}
|
||||
|
||||
const connection: DawarichConnection = {
|
||||
url: 'https://dawarich.example',
|
||||
apiKeyMasked: '••••••••',
|
||||
allowInsecureTls: false,
|
||||
syncEnabled: true,
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
capabilities: null,
|
||||
}
|
||||
|
||||
const suggestion: DawarichSuggestion = {
|
||||
id: 41,
|
||||
sourceVisitId: 'v-41',
|
||||
tripId: 7,
|
||||
tripTitle: 'Koeln',
|
||||
name: 'Cafe Reichard',
|
||||
lat: 50.94,
|
||||
lng: 6.96,
|
||||
startedAt: '2026-09-10T10:15:00+02:00',
|
||||
endedAt: '2026-09-10T12:40:00+02:00',
|
||||
durationMinutes: 145,
|
||||
localDate: '2026-09-10',
|
||||
sourceStatus: 'suggested',
|
||||
confidence: 82,
|
||||
confidenceBand: 'high',
|
||||
state: 'new',
|
||||
target: null,
|
||||
acceptedPlaceId: null,
|
||||
acceptedJournalEntryId: null,
|
||||
acceptedBucketListItemId: null,
|
||||
sourceChanged: false,
|
||||
sourceMissing: false,
|
||||
matchedBucketListItemId: null,
|
||||
matchedBucketListName: null,
|
||||
countryCode: 'DE',
|
||||
firstSeenAt: '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: '2026-09-10T06:00:00Z',
|
||||
}
|
||||
|
||||
const suggestionList: DawarichSuggestionList = {
|
||||
suggestions: [suggestion],
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
}
|
||||
|
||||
const track: DawarichTrack = {
|
||||
days: [{ date: '2026-09-10', segments: [] }],
|
||||
source: 'tracks',
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
pointCount: 0,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
const bucketScan: DawarichBucketScan = {
|
||||
matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }],
|
||||
skippedWithoutCoordinates: 1,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
const atlas: DawarichAtlasSuggestions = {
|
||||
countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }],
|
||||
unresolved: ['Kosovo'],
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestUrl = ''
|
||||
requestBody = undefined
|
||||
// Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the
|
||||
// value on the wire is +120 (positive for a zone ahead of UTC), which is the
|
||||
// sign convention the server's day resolution expects.
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('dawarichApi connection', () => {
|
||||
it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => {
|
||||
server.use(http.get(`${BASE}/settings`, record(connection)))
|
||||
|
||||
const res = await dawarichApi.getSettings()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
expect(res).toEqual(connection)
|
||||
})
|
||||
|
||||
// The three warning fields are why this endpoint does not just answer
|
||||
// `{ success }`: the server writes its sentence in English, the client renders
|
||||
// the code in the reader's language and quotes the resolved address back.
|
||||
// Dropping any of them leaves a private-IP connection looking perfectly healthy.
|
||||
it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => {
|
||||
server.use(
|
||||
http.put(
|
||||
`${BASE}/settings`,
|
||||
record({
|
||||
success: true,
|
||||
warning: 'That host resolves to a private address.',
|
||||
warningCode: 'private_ip',
|
||||
warningIp: '192.168.178.72',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.saveSettings({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
|
||||
expect(requestBody).toEqual({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
expect(res.warningCode).toBe('private_ip')
|
||||
expect(res.warningIp).toBe('192.168.178.72')
|
||||
expect(res.warning).toContain('private address')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => {
|
||||
server.use(http.delete(`${BASE}/settings`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.disconnect()).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
})
|
||||
|
||||
// A probe runs before anything is stored, so the body is deliberately partial:
|
||||
// a url with no key is the shape the connect dialog sends on its first attempt.
|
||||
it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => {
|
||||
server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' })))
|
||||
|
||||
const res = await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
|
||||
expect(requestBody).toEqual({ url: 'https://dawarich.example' })
|
||||
expect(res.connected).toBe(false)
|
||||
expect(res.error).toBe('unauthorized')
|
||||
})
|
||||
|
||||
// `alreadyRunning` is how a second press of Sync Now is told apart from a sync
|
||||
// that genuinely found nothing; both report created/updated/missing as zero.
|
||||
it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => {
|
||||
server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true })))
|
||||
|
||||
const res = await dawarichApi.syncNow()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/sync`)
|
||||
expect(res.alreadyRunning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi suggestions', () => {
|
||||
it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => {
|
||||
server.use(http.get(`${BASE}/suggestions`, record(suggestionList)))
|
||||
|
||||
const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' })
|
||||
expect(query().get('tripId')).toBe('7')
|
||||
expect(query().get('state')).toBe('new')
|
||||
expect(res.suggestions).toHaveLength(1)
|
||||
|
||||
await dawarichApi.listSuggestions()
|
||||
expect(new URL(requestUrl).search).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => {
|
||||
server.use(
|
||||
http.post(
|
||||
`${BASE}/suggestions/:id/accept`,
|
||||
record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`)
|
||||
expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
expect(res.createdPlaceId).toBe(900)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => {
|
||||
server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' })))
|
||||
|
||||
const res = await dawarichApi.setState(41, 'dismissed')
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/state`)
|
||||
expect(requestBody).toEqual({ state: 'dismissed' })
|
||||
expect(res.state).toBe('dismissed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi bucket list', () => {
|
||||
it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan)))
|
||||
|
||||
const res = await dawarichApi.scanBucketList()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/scan`)
|
||||
expect(res.skippedWithoutCoordinates).toBe(1)
|
||||
})
|
||||
|
||||
// `visitedAt` is optional because the default is "whenever the recording says",
|
||||
// which only the server knows. An omitted one must not turn into a null on the
|
||||
// wire, which the server would read as an instruction to store no date at all.
|
||||
it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 })))
|
||||
|
||||
const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z')
|
||||
expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' })
|
||||
expect(res.updated).toBe(2)
|
||||
|
||||
await dawarichApi.confirmBucketVisits([3])
|
||||
expect(requestBody).toEqual({ itemIds: [3] })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => {
|
||||
server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi atlas', () => {
|
||||
it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => {
|
||||
server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas)))
|
||||
|
||||
const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31')
|
||||
|
||||
expect(query().get('from')).toBe('2026-01-01')
|
||||
expect(query().get('to')).toBe('2026-12-31')
|
||||
expect(res.unresolved).toEqual(['Kosovo'])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => {
|
||||
server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 })))
|
||||
|
||||
const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL'])
|
||||
|
||||
expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] })
|
||||
expect(res.marked).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi track overlay', () => {
|
||||
it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' })
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.source).toBe('tracks')
|
||||
})
|
||||
|
||||
// The whole-trip case passes no range at all. The offset still has to be there:
|
||||
// without it the server has to guess a day boundary for every recorded instant.
|
||||
it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
|
||||
expect([...query().keys()]).toEqual(['offset'])
|
||||
expect(query().get('offset')).toBe('120')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.pointCount).toBe(0)
|
||||
})
|
||||
|
||||
// The health handler is here because the axios error interceptor probes
|
||||
// reachability whenever an error carries no response, and a cancellation is
|
||||
// exactly that. Answering the probe keeps the detour short; whichever verdict
|
||||
// it comes back with, the cancellation has to be re-rejected untouched, and
|
||||
// that is what this pins.
|
||||
it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => {
|
||||
server.use(
|
||||
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
|
||||
http.get(`${BASE}/track`, async () => {
|
||||
await delay(50)
|
||||
return HttpResponse.json(track)
|
||||
}),
|
||||
)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal)
|
||||
ctrl.abort()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' })
|
||||
})
|
||||
|
||||
// Nothing in this file catches, on purpose: the connection card and the
|
||||
// suggestions hook both read the upstream reason off `response.data.code` to
|
||||
// say it in the reader's language. A swallowed rejection would reach them as a
|
||||
// successful call carrying an undefined body.
|
||||
it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/suggestions`, () =>
|
||||
HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({
|
||||
response: { status: 502, data: { code: 'unreachable' } },
|
||||
})
|
||||
})
|
||||
|
||||
// Every other test here runs in Berlin, where -120 negated and -120 made
|
||||
// positive look identical. New York separates them: the platform reports +300
|
||||
// and the wire has to carry -300. A `Math.abs` slipped in would leave every
|
||||
// European install working perfectly and put every American one a day out,
|
||||
// which is the kind of bug that gets reported as "sometimes wrong".
|
||||
//
|
||||
// UTC is the second half of the pair: the offset there is falsy, and a guard
|
||||
// that only sends it "when there is one" would drop it exactly for the readers
|
||||
// whose day boundary the server would then have to guess.
|
||||
it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('-300')
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi: how long the browser waits', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
|
||||
// The server gives each request to Dawarich 15 s on its own. At the shared
|
||||
// 8 s the browser gave up first, the server cached the answer anyway, and the
|
||||
// recorded route turned up only after a reload.
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
|
||||
await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
await dawarichApi.syncNow()
|
||||
await dawarichApi.scanBucketList()
|
||||
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
|
||||
|
||||
const configs = [
|
||||
...get.mock.calls.map(call => call[1]),
|
||||
...post.mock.calls.map(call => call[2]),
|
||||
...put.mock.calls.map(call => call[2]),
|
||||
]
|
||||
expect(configs).toHaveLength(6)
|
||||
for (const config of configs) {
|
||||
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
|
||||
}
|
||||
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
|
||||
// The track calls still carry their offset alongside the longer wait.
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
|
||||
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
|
||||
|
||||
// Reading suggestions and confirming countries touch TREK's own database
|
||||
// only; a two-minute wait there would just hide a stuck server.
|
||||
await dawarichApi.listSuggestions()
|
||||
await dawarichApi.acceptAtlasCountries(['DE'])
|
||||
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
|
||||
expect(post.mock.calls[0][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAccept,
|
||||
DawarichAcceptResult,
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSettings,
|
||||
DawarichStatus,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The Dawarich surface, in its own file rather than in the `client.ts` god
|
||||
* module. Types come from the shared contracts, so a server change that the
|
||||
* client has not caught up with is a typecheck failure rather than a runtime
|
||||
* surprise.
|
||||
*/
|
||||
/**
|
||||
* How long the browser waits for a call that makes the server go and ask
|
||||
* Dawarich.
|
||||
*
|
||||
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
|
||||
* these: the server allows each request to Dawarich 15 s by itself, walks pages
|
||||
* of points for a route, and runs six probes one after another to test a
|
||||
* connection. Cut off at 8 s, the browser gave up while the server carried on
|
||||
* and cached the answer, so the recorded route showed "unavailable" and then
|
||||
* appeared on the next reload.
|
||||
*/
|
||||
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* The Atlas asks for a year of visited cities a month at a time, one request
|
||||
* after another, so it gets longer again. Still bounded, and under Node's own
|
||||
* five-minute request limit on the server side.
|
||||
*/
|
||||
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
|
||||
|
||||
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
|
||||
|
||||
export const dawarichApi = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
getSettings: (): Promise<DawarichConnection> =>
|
||||
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
|
||||
saveSettings: (data: DawarichSettings): Promise<{
|
||||
success: boolean
|
||||
/** The server's own sentence, English — the fallback when the code below is unknown here. */
|
||||
warning?: string
|
||||
/** What the warning is about, so the UI can say it in the reader's language. */
|
||||
warningCode?: string
|
||||
/** The address it resolved to, for the private-IP warning. */
|
||||
warningIp?: string
|
||||
}> =>
|
||||
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
|
||||
disconnect: (): Promise<{ success: boolean }> =>
|
||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
||||
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
|
||||
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
||||
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
|
||||
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
|
||||
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
|
||||
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
|
||||
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
|
||||
|
||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||
scanBucketList: (): Promise<DawarichBucketScan> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
|
||||
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
|
||||
.then(r => r.data),
|
||||
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||
|
||||
// ── Track overlay ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Recorded points are bare instants, so the server cannot know which day they
|
||||
// belong to without being told whose day is meant. The browser is the only
|
||||
// party that knows, so it says so — otherwise an evening east of UTC lands on
|
||||
// the wrong day of the trip.
|
||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
}
|
||||
|
||||
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
|
||||
function localOffsetMinutes(): number {
|
||||
return -new Date().getTimezoneOffset()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import apiClient from './client'
|
||||
|
||||
const base = '/school-holiday-catalog'
|
||||
export const schoolHolidayCatalogApi = {
|
||||
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
|
||||
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
|
||||
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
|
||||
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
@@ -27,6 +30,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
'roadtripPreferences:changed',
|
||||
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
|
||||
'roadtripBoundary:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -473,7 +473,7 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
@@ -483,9 +483,9 @@ describe('AddonManager', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -545,7 +545,7 @@ describe('AddonManager', () => {
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
@@ -565,8 +565,8 @@ describe('AddonManager', () => {
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
|
||||
@@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow'
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -59,9 +64,20 @@ interface ProviderOption {
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
// The brand marks are badges, not glyphs: they fill the framed slot instead of
|
||||
// sitting in the middle of it at glyph size, and they grey out when the addon
|
||||
// is off — the lucide glyphs get that from the slot's text colour, an <img>
|
||||
// has to be told.
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
@@ -74,11 +90,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
@@ -211,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} />}
|
||||
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
@@ -307,7 +324,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
|
||||
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,9 @@ export default function AddonTile({
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
@@ -259,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
@@ -273,7 +277,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 +295,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 +316,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 +405,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 +481,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 +550,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 +621,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 +638,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 +698,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 +719,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 +846,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 +968,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 +1014,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 +1452,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 +1563,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 +1584,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 +1619,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 +1649,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 +1719,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 +1741,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">
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function CategoryManager() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -34,7 +35,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = {
|
||||
type Defaults = RoutingDefaults & {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -219,11 +220,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
@@ -321,7 +334,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
@@ -365,6 +380,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{!managed && (
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -490,6 +510,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
const ROUTERS = [
|
||||
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
const value = draft.trim()
|
||||
if (value === saved) { setDraft(null); return }
|
||||
setSaving(true)
|
||||
try { await onSave({ [router.key]: value }) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
const reset = async () => {
|
||||
setSaving(true)
|
||||
try { await onReset(router.key) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
|
||||
onChange={e => setDraft(e.target.value)} onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { within } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
|
||||
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
|
||||
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import type { SchoolHolidayRegionDetail } from '@trek/shared'
|
||||
|
||||
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
|
||||
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
function enterDate(label: string, value: string) {
|
||||
const picker = within(screen.getByRole('group', { name: label }))
|
||||
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
|
||||
const input = picker.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
fireEvent.blur(input)
|
||||
}
|
||||
|
||||
describe('manual school holiday administration', () => {
|
||||
it('creates a country, a district and its named periods', async () => {
|
||||
let created = false
|
||||
const saves: unknown[] = []
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
|
||||
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
|
||||
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
|
||||
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
|
||||
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
|
||||
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
|
||||
enterDate('Start date', '2026-12-20')
|
||||
enterDate('End date', '2027-01-06')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
|
||||
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('opens existing periods and keeps edits after a failed save', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
|
||||
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
|
||||
await screen.findByDisplayValue('Winter break')
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
|
||||
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
|
||||
})
|
||||
|
||||
it('validates names and confirms discarding changes', async () => {
|
||||
const save = vi.fn()
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a period and saves an existing region without changing its revision token', () => {
|
||||
const save = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
|
||||
})
|
||||
|
||||
it('blocks offline edits but still lets the dialog close', () => {
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
|
||||
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
|
||||
}),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps national API calendars selectable alongside manual regions', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
|
||||
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('keeps API regions available if loading the manual catalog fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
|
||||
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('offers USA independently of provider countries and fetches only local regions', async () => {
|
||||
const provider = vi.fn(() => HttpResponse.json([]))
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
|
||||
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
|
||||
render(<Countries />)
|
||||
expect(await screen.findByText('USA')).toBeInTheDocument()
|
||||
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
|
||||
expect(provider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps manual regions usable when a supported country provider fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2026,
|
||||
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
|
||||
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
|
||||
})
|
||||
const entries = useVacayStore.getState().entries
|
||||
await useVacayStore.getState().loadHolidays(2026)
|
||||
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
|
||||
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
|
||||
expect(useVacayStore.getState().entries).toBe(entries)
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export default function SchoolHolidayCatalog() {
|
||||
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
|
||||
}
|
||||
|
||||
function Catalog() {
|
||||
const { t } = useTranslation()
|
||||
const state = useSchoolHolidayCatalog()
|
||||
const [country, setCountry] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
|
||||
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
|
||||
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
|
||||
const disabled = state.busy || state.loading || state.offline
|
||||
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
|
||||
</div>
|
||||
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
|
||||
<fieldset disabled={disabled} className="space-y-4">
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
|
||||
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
|
||||
</div>
|
||||
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
|
||||
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
|
||||
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
|
||||
}}>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
|
||||
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
|
||||
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
|
||||
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
|
||||
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
|
||||
</li>)}</ul>
|
||||
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
|
||||
</>}
|
||||
</fieldset>
|
||||
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
|
||||
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
|
||||
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
|
||||
|
||||
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
|
||||
region: SchoolHolidayRegionDetail
|
||||
busy: boolean
|
||||
offline?: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onSave: (body: SchoolHolidayRegionRequest) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const fieldId = useId()
|
||||
const [name, setName] = useState(region.name)
|
||||
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
|
||||
const [nextKey, setNextKey] = useState(holidays.length)
|
||||
const [discard, setDiscard] = useState(false)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
|
||||
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
|
||||
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
|
||||
|
||||
return <>
|
||||
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
|
||||
setInvalid(!parsed.success)
|
||||
if (parsed.success) onSave(parsed.data)
|
||||
}}>
|
||||
<fieldset disabled={busy || offline} className="space-y-4">
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
|
||||
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
|
||||
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
|
||||
<div className="space-y-1 text-body">
|
||||
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
|
||||
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className="text-body text-danger">{error}</p>}
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-2 border-t border-edge pt-4">
|
||||
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
|
||||
</>
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
|
||||
export function useSchoolHolidayCatalog() {
|
||||
const { t } = useTranslation()
|
||||
const { offline } = useNetworkMode()
|
||||
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
|
||||
const mounted = useRef(true)
|
||||
const pending = useRef(false)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
catalogRepo.requireOnline()
|
||||
const catalog = await catalogRepo.catalog()
|
||||
if (mounted.current) setCatalog(catalog)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (offline) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
catalogRepo.catalog().then(catalog => {
|
||||
if (!cancelled) { setCatalog(catalog); setError('') }
|
||||
}).catch(() => { if (!cancelled) setError(t('common.error')) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [offline, t])
|
||||
|
||||
async function run(action: () => Promise<unknown>, after?: () => void) {
|
||||
if (pending.current) return false
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
catalogRepo.requireOnline()
|
||||
await action()
|
||||
if (mounted.current) after?.()
|
||||
try {
|
||||
await reload()
|
||||
} catch {
|
||||
if (mounted.current) setError(t('schoolCatalog.refreshError'))
|
||||
}
|
||||
return true
|
||||
} catch (failure) {
|
||||
if (mounted.current) {
|
||||
const message = failure instanceof Error ? failure.message : ''
|
||||
const http = failure as { response?: { data?: { error?: string } } }
|
||||
setError(http.response?.data?.error || message || t('common.error'))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
pending.current = false
|
||||
if (mounted.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
catalog, loading, busy, error, offline, editor, setEditor,
|
||||
refresh: () => run(async () => {}),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
|
||||
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
|
||||
openRegion: (id: number) => run(async () => {
|
||||
const region = await catalogRepo.region(id)
|
||||
if (mounted.current) setEditor(region)
|
||||
}),
|
||||
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
|
||||
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
|
||||
() => setEditor(null),
|
||||
),
|
||||
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRef, type RefObject } from 'react'
|
||||
|
||||
export interface GlassGlare {
|
||||
/** Goes on the panel itself — the glare is measured against its box. */
|
||||
panelRef: RefObject<HTMLDivElement | null>
|
||||
/** The soft light under the cursor. */
|
||||
glareRef: RefObject<HTMLDivElement | null>
|
||||
/** The lit stretch of border around it. */
|
||||
borderGlareRef: RefObject<HTMLDivElement | null>
|
||||
onMouseMove: (e: { clientX: number; clientY: number }) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
|
||||
* piece of border nearest to it lighting up.
|
||||
*
|
||||
* Extracted so a second panel beside the first one behaves identically. Two
|
||||
* hand-copied versions of a gradient are two versions that drift, and the whole
|
||||
* point of the effect is that the panels look like one material.
|
||||
*
|
||||
* Written straight to the DOM rather than through state: this runs on every
|
||||
* mousemove, and a re-render per frame would cost more than the effect is worth.
|
||||
*/
|
||||
export function useGlassGlare(dark: boolean): GlassGlare {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
|
||||
glareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
|
||||
const onMouseLeave = (): void => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -188,6 +188,21 @@ describe('readUserNote', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settlementDate', () => {
|
||||
it('prefers settled_at over created_at', () => {
|
||||
expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05')
|
||||
})
|
||||
|
||||
it('falls back to the day it was recorded when settled_at is unset', () => {
|
||||
expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
|
||||
expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
|
||||
})
|
||||
|
||||
it('is empty when neither is set', () => {
|
||||
expect(settlementDate({})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
|
||||
|
||||
describe('calculateTicketShares', () => {
|
||||
@@ -231,3 +246,67 @@ describe('calculateTicketShares', () => {
|
||||
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('paidByUser', () => {
|
||||
it('adds up what one participant fronted and ignores the other payers', () => {
|
||||
const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] }
|
||||
expect(paidByUser(item, 1)).toBe(35)
|
||||
expect(paidByUser(item, 2)).toBe(70)
|
||||
expect(paidByUser(item, 3)).toBe(0)
|
||||
})
|
||||
|
||||
it('is zero for an expense with no payer list', () => {
|
||||
expect(paidByUser({ payers: null }, 1)).toBe(0)
|
||||
expect(paidByUser({}, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalBudgetFor', () => {
|
||||
const finals = [{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50,
|
||||
sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] },
|
||||
}]
|
||||
|
||||
it("returns the server's row for a participant in the ledger", () => {
|
||||
expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0])
|
||||
})
|
||||
|
||||
it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => {
|
||||
expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({
|
||||
user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0,
|
||||
sources: { fronted: [], moved: [], outstanding: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalBudgetSources', () => {
|
||||
const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }]
|
||||
const sources = {
|
||||
fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }],
|
||||
moved: [
|
||||
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 },
|
||||
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 },
|
||||
],
|
||||
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }],
|
||||
}
|
||||
|
||||
it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => {
|
||||
const { fronted } = finalBudgetSources({ sources }, items)
|
||||
// An expense the list does not know yet keeps its row; only the name is missing.
|
||||
expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }])
|
||||
})
|
||||
|
||||
it('keeps the transfers and open flows signed as the server sent them', () => {
|
||||
const { moved, outstanding } = finalBudgetSources({ sources }, items)
|
||||
expect(moved).toEqual([
|
||||
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 },
|
||||
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 },
|
||||
])
|
||||
expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }])
|
||||
})
|
||||
|
||||
it('is empty for a participant with no activity', () => {
|
||||
expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items))
|
||||
.toEqual({ fronted: [], moved: [], outstanding: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
@@ -34,6 +35,67 @@ export function splitCents(amount: number, n: number): number[] {
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar day a settle-up payment counts on for grouping/filtering: its own
|
||||
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
|
||||
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
|
||||
* `created_at` doubled as its date before `settled_at` existed, so this keeps
|
||||
* every pre-existing row grouped exactly where it already was.
|
||||
*/
|
||||
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
|
||||
return (s.settled_at || s.created_at || '').slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one participant fronted for an expense, in the expense's own currency.
|
||||
*
|
||||
* Several payers can share one bill, and the same person can appear only once,
|
||||
* but the reduce covers a row that somehow carries them twice rather than
|
||||
* picking one of the two. The caller converts the result — this file never
|
||||
* touches exchange rates.
|
||||
*/
|
||||
export function paidByUser(
|
||||
item: { payers?: { user_id: number; amount: number }[] | null },
|
||||
userId: number,
|
||||
): number {
|
||||
return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0)
|
||||
}
|
||||
|
||||
/** The figures a final budget is made of, for someone the server left out of the ledger. */
|
||||
export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal {
|
||||
// Absent means they neither fronted anything nor were split into an expense:
|
||||
// the trip has cost them nothing, which is worth a row of its own.
|
||||
return finals.find(f => f.user_id === member.id) || {
|
||||
user_id: member.id, username: member.username, avatar_url: null,
|
||||
expenses: 0, reimbursed: 0, pending: 0, final: 0,
|
||||
sources: { fronted: [], moved: [], outstanding: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows behind one traveler's final budget, ready to print. They arrive as
|
||||
* ids and display cents: the server spreads each of the three figures over its
|
||||
* rows with the same largest-remainder split the figure came from, so every list
|
||||
* adds up to the line it sits under, in whatever currency was asked for and
|
||||
* whether or not the live rates have loaded yet. Only the expense names are
|
||||
* looked up here; who "you" is in a transfer is the shell's call.
|
||||
*/
|
||||
export function finalBudgetSources(
|
||||
row: Pick<BudgetParticipantFinal, 'sources'>,
|
||||
items: { id: number; name: string }[],
|
||||
): {
|
||||
fronted: { item_id: number; name: string; amount: number }[]
|
||||
moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[]
|
||||
outstanding: { from_user_id: number; to_user_id: number; amount: number }[]
|
||||
} {
|
||||
const { fronted, moved, outstanding } = row.sources
|
||||
return {
|
||||
fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })),
|
||||
moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
|
||||
outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum the amounts of the selected payers. */
|
||||
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
||||
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||||
@@ -20,6 +21,9 @@ const tripMembers = [
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
|
||||
const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded'))
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
@@ -295,6 +299,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
|
||||
@@ -587,7 +614,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.type(itemNames[1], 'chocolate cake')
|
||||
await user.type(itemPrices[2], '50')
|
||||
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
|
||||
const bobButtons = bobInForm()
|
||||
await user.click(bobButtons[1])
|
||||
|
||||
await user.type(itemNames[2], 'Milk')
|
||||
@@ -717,7 +744,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
|
||||
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
|
||||
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
|
||||
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string; settled_at?: string | null }
|
||||
|
||||
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
|
||||
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
|
||||
@@ -726,7 +753,7 @@ const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: Member
|
||||
|
||||
function mount(
|
||||
items: BudgetItem[],
|
||||
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {},
|
||||
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {},
|
||||
entries?: string[],
|
||||
) {
|
||||
server.use(
|
||||
@@ -803,8 +830,70 @@ describe('CostsPanel — overview', () => {
|
||||
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
|
||||
|
||||
// Both travellers appear; neither has a signed amount.
|
||||
await screen.findByText('Balances')
|
||||
expect(screen.getAllByText('0,00 €')).toHaveLength(2)
|
||||
const balances = (await screen.findByText('Balances')).parentElement as HTMLElement
|
||||
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => {
|
||||
// Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen
|
||||
// on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each
|
||||
// of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate
|
||||
// is loaded here, so a client converting the dinner itself would print 100 €.
|
||||
const user = userEvent.setup()
|
||||
mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 16 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -16 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }],
|
||||
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61,
|
||||
sources: {
|
||||
fronted: [{ item_id: 101, cents: 9200 }],
|
||||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }],
|
||||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61,
|
||||
sources: {
|
||||
fronted: [{ item_id: 102, cents: 3000 }],
|
||||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }],
|
||||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
|
||||
// The main view stays one figure per person until someone asks for more.
|
||||
expect(within(card).queryByText('Expenses paid')).toBeNull()
|
||||
|
||||
const alice = within(card).getByRole('button', { name: /You/ })
|
||||
await user.click(alice)
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row;
|
||||
// the raw 100 USD never shows up in the card.
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
expect(within(card).getByText('Dinner')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('Taxi')).toBeNull()
|
||||
expect(within(card).getAllByText(/^bob → /)).toHaveLength(2)
|
||||
|
||||
// Opening Bob closes Alice: what he sent back and what he still owes raise his.
|
||||
await user.click(within(card).getByRole('button', { name: /bob/ }))
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(card).getAllByText('+30,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('+15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('+16,00 €')).toHaveLength(2)
|
||||
expect(within(card).getByText('Taxi')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('Dinner')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
|
||||
@@ -957,8 +1046,8 @@ describe('CostsPanel — settle up', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Balances')
|
||||
// Settle up and Balances both hang off the one failed request.
|
||||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
|
||||
// Settle up, Balances and Final budget all hang off the one failed request.
|
||||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3))
|
||||
expect(screen.queryByText("Everyone's square")).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1028,6 +1117,21 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
|
||||
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
|
||||
// must follow settled_at, the same way it already follows expense_date over
|
||||
// an expense's own created_at.
|
||||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
|
||||
pickOption('Sun, Jun 15')
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
|
||||
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
|
||||
|
||||
@@ -1229,6 +1333,62 @@ describe('CostsPanel — payment modal', () => {
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
|
||||
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 9 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
try {
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.settled_at).toBe('2026-08-12')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 7 } })
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.settled_at).toBe('2025-06-10')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => {
|
||||
// Cleared, the server would store NULL and the ledger would quietly move
|
||||
// the payment back to the day it was recorded on.
|
||||
const user = userEvent.setup()
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'Clear date' }))
|
||||
expect(save).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — expense modal', () => {
|
||||
@@ -1266,7 +1426,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
const rows = screen.getAllByPlaceholderText('Item name')
|
||||
await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0])
|
||||
expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument()
|
||||
await user.click(screen.getAllByRole('button', { name: /bob/i })[0])
|
||||
await user.click(bobInForm()[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -1307,7 +1467,8 @@ describe('CostsPanel — expense modal', () => {
|
||||
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
|
||||
|
||||
// Excluding Bob drops his amount; the split no longer matches the total.
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
expect(bobInForm()).toHaveLength(1)
|
||||
await user.click(bobInForm()[0])
|
||||
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,18 +8,21 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
@@ -39,6 +42,10 @@ interface Settlement {
|
||||
// read as the display currency, which is what the server assumes for them too.
|
||||
currency?: string | null
|
||||
created_at?: string
|
||||
// The day the transfer actually happened; editable, unlike created_at (when it
|
||||
// was recorded). Null/absent on rows predating this field — settlementDate()
|
||||
// falls back to created_at for those.
|
||||
settled_at?: string | null
|
||||
from_username?: string
|
||||
to_username?: string
|
||||
}
|
||||
@@ -46,6 +53,9 @@ interface SettlementData {
|
||||
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
||||
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
||||
settlements: Settlement[]
|
||||
// What the trip ends up costing each participant. Computed server-side off the
|
||||
// same ledger as the balances, so the breakdown can't contradict them.
|
||||
finalBudgets: BudgetParticipantFinal[]
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
@@ -84,9 +94,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
// One open final-budget breakdown at a time, for the same reason a single note
|
||||
// is expanded at a time: the card is a sidebar, not a report.
|
||||
const [expandedFinalId, setExpandedFinalId] = useState<number | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
@@ -122,8 +136,15 @@ 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)
|
||||
const myPaidOf = (e: BudgetItem) => convert(paidByUser(e, me), 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 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 +154,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)
|
||||
@@ -172,14 +189,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
if (filter === 'owed') return []
|
||||
let list = settlement?.settlements || []
|
||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
|
||||
if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter)
|
||||
return list
|
||||
}, [settlement, filter, search, catFilter, dayFilter, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const entries: LedgerEntry[] = [
|
||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })),
|
||||
]
|
||||
const labelOf = (date: string) => {
|
||||
if (!date) return t('costs.noDate')
|
||||
@@ -468,6 +485,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* final budget */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
|
||||
{FinalBudgetList()}
|
||||
</div>
|
||||
|
||||
{/* by category */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
@@ -489,6 +512,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{previewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={previewReceipts.receipts}
|
||||
initialIndex={previewReceipts.initialIndex}
|
||||
onClose={() => setPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
@@ -680,6 +711,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* Final budget */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
|
||||
{FinalBudgetList()}
|
||||
</div>
|
||||
|
||||
{/* By category */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
@@ -759,6 +796,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
{(e.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
|
||||
}}
|
||||
title={t('costs.viewReceipt')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -878,6 +941,112 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trip actually costs each traveler — one amount per person, with the
|
||||
* arithmetic behind it a click away.
|
||||
*
|
||||
* The four figures come from the settlement response, not from a second pass
|
||||
* over the expenses here: the server nets them in the same integer cents as
|
||||
* the balances, so `expenses − reimbursed − pending` always lands on the total
|
||||
* printed beside the name. The lists under the breakdown are the rows those
|
||||
* figures came from, which is what makes an unexpected total explainable.
|
||||
*/
|
||||
function FinalBudgetList() {
|
||||
if (settlementError) return loadFailed()
|
||||
const finals = settlement?.finalBudgets || []
|
||||
const rows = people.map(p => finalBudgetFor(finals, p))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{rows.map(r => {
|
||||
const open = expandedFinalId === r.user_id
|
||||
return (
|
||||
<div key={r.user_id}>
|
||||
<button type="button" onClick={() => setExpandedFinalId(open ? null : r.user_id)} aria-expanded={open}
|
||||
className="text-content"
|
||||
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<Avatar id={r.user_id} size={28} />
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{personName(r.user_id)}</span>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(r.final)}</span>
|
||||
<ChevronDown size={14} className="text-content-faint" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{open && FinalBudgetBreakdown({ row: r })}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The three lines behind one traveler's final budget, and the rows they add up from. */
|
||||
function FinalBudgetBreakdown({ row }: { row: BudgetParticipantFinal }) {
|
||||
// Each line is signed by what it does to the final, so the column reads as the
|
||||
// subtraction it is: a reimbursement this traveler *sent* raises their cost and
|
||||
// shows as a plus, which "received: −50" could never say. The rows under a line
|
||||
// carry the same sign, so they visibly add up to it. Their amounts are the
|
||||
// server's display cents, not a conversion of the expense list done here.
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
|
||||
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
|
||||
const detail = (label: string, value: string) => (
|
||||
<div style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1 }}>{label}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
|
||||
// Capped and scrollable: a long trip's expense list would otherwise push the
|
||||
// rest of the sidebar off the screen every time a name is tapped.
|
||||
const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const
|
||||
return (
|
||||
<div className="bg-surface-secondary" style={{ borderRadius: 12, padding: '11px 12px', margin: '2px 0 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{detail(t('costs.finalExpenses'), signed(row.expenses))}
|
||||
{detail(t('costs.finalReimbursed'), signed(-row.reimbursed))}
|
||||
{detail(t('costs.finalPending'), signed(-row.pending))}
|
||||
</div>
|
||||
{fronted.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{moved.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalReimbursed')}</div>
|
||||
<div style={listCls}>
|
||||
{moved.map(r => (
|
||||
<div key={r.settlement_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{outstanding.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalPending')}</div>
|
||||
<div style={listCls}>
|
||||
{outstanding.map((r, i) => (
|
||||
<div key={i} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
// Categories net refunds against spend (#2176): a negative entry lowers its
|
||||
// category's sum, and a category that nets negative keeps its own row —
|
||||
@@ -974,16 +1143,17 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
|
||||
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
|
||||
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
|
||||
const [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const amt = Number.parseFloat(amount) || 0
|
||||
const valid = amt > 0 && fromId !== toId
|
||||
const valid = amt > 0 && fromId !== toId && !!day
|
||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
@@ -1027,6 +1197,10 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.day')}</label>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -1119,6 +1293,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
@@ -1315,12 +1507,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1630,8 +1833,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
handleReceiptFileSelect(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
|
||||
<Plus size={13} /> {t('costs.attachReceipt')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadingReceipt && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
|
||||
{t('common.saving')}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
|
||||
{t('costs.noReceipts')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{receipts.map((r, rIdx) => (
|
||||
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
|
||||
className="text-content hover:underline"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Receipt size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveReceipt(r.id)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingReceiptFiles.map((file, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePendingReceipt(idx)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalPreviewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={modalPreviewReceipts.receipts}
|
||||
initialIndex={modalPreviewReceipts.initialIndex}
|
||||
onClose={() => setModalPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
|
||||
// on top of the expense form.
|
||||
import { vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
|
||||
|
||||
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.jpg',
|
||||
original_name: 'lunch.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 100,
|
||||
url: '/uploads/files/stored.jpg',
|
||||
...over,
|
||||
} as BudgetItemReceipt
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
|
||||
})
|
||||
|
||||
describe('ReceiptPreviewModal', () => {
|
||||
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
|
||||
// The viewer renders through a portal, so it lives on document.body.
|
||||
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
|
||||
authUrl.get.mockRejectedValue(new Error('403'))
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parent = vi.fn()
|
||||
document.addEventListener('keydown', parent)
|
||||
try {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// The expense form registers its own Escape handler on `document`; one
|
||||
// keypress must not close both and throw away the user's edits.
|
||||
expect(parent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
document.removeEventListener('keydown', parent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
|
||||
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
interface ReceiptPreviewModalProps {
|
||||
receipts: BudgetItemReceipt[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
|
||||
const [signedUrl, setSignedUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const current = receipts[index]
|
||||
const isImg = isImage(current?.mime_type)
|
||||
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSignedUrl('')
|
||||
|
||||
getAuthUrl(current.url, 'download')
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
setSignedUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [current?.url])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// The preview opens on top of the expense form, and both the desktop
|
||||
// Modal and the phone MSheet close themselves on Escape from a listener
|
||||
// on `document`. Without this the one keypress closed the form too and
|
||||
// threw away whatever the user had typed into it.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||
}
|
||||
// Capture phase, so this runs before the listeners the parents put on
|
||||
// `document`; stopPropagation on the bubble phase would be too late.
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => window.removeEventListener('keydown', handleKey, true)
|
||||
}, [index, receipts.length, onClose])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleOpenTab = () => {
|
||||
openFileUrl(current.url, current.original_name).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
triggerDownload(current.url, current.original_name)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 950,
|
||||
height: '94vh',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - identical to PdfPreviewModal */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{current.original_name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
display: 'flex',
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip if multiple receipts */}
|
||||
{receipts.length > 1 && (
|
||||
<div
|
||||
className="bg-surface-secondary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.map((r, i) => {
|
||||
const active = i === index
|
||||
const isRImg = isImage(r.mime_type)
|
||||
const Icon = isRImg ? FileImage : FileText
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 11px',
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||
background: active ? 'var(--bg-card)' : 'none',
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
|
||||
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.original_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
opacity: active ? 0.8 : 0.5,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: isImg ? '#0f141c' : 'var(--bg-card)',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.length > 1 && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i - 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{receipts.length > 1 && index < receipts.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPdf ? (
|
||||
<object
|
||||
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
|
||||
title={current.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.downloadPdf')}
|
||||
</button>
|
||||
</p>
|
||||
</object>
|
||||
) : isImg ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<img
|
||||
src={signedUrl}
|
||||
alt={current.original_name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 6,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content-faint" style={{ padding: 48 }}>
|
||||
{loading ? `${t('common.loading')}...` : current.original_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
|
||||
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
|
||||
{current.original_name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
|
||||
// expense, and taking the uploads back out when the expense fails to save.
|
||||
import { vi } from 'vitest'
|
||||
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
|
||||
|
||||
const files = vi.hoisted(() => ({
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
permanentDelete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({ filesApi: files }))
|
||||
|
||||
function file(name: string) {
|
||||
return new File(['x'], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
|
||||
files.delete.mockReset().mockResolvedValue({})
|
||||
files.permanentDelete.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('uploadReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], 42)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBe('42')
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], null)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
|
||||
files.upload
|
||||
.mockResolvedValueOnce({ file: { id: 11 } })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ file: { id: 13 } })
|
||||
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
|
||||
const order: string[] = []
|
||||
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
|
||||
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
|
||||
|
||||
expect(await discardReceipts(3, [9])).toEqual([])
|
||||
expect(order).toEqual(['delete', 'permanent'])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
expect(await discardReceipts(3, [9, 10])).toEqual([9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWithReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockResolvedValue('saved')
|
||||
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
|
||||
expect(save).toHaveBeenCalledWith([5, 6])
|
||||
expect(out.result).toBe('saved')
|
||||
expect(files.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
const save = vi.fn()
|
||||
|
||||
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
|
||||
expect(err.stuckReceiptIds).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Uploading receipts alongside an expense, and undoing it when the expense
|
||||
* itself fails to save.
|
||||
*
|
||||
* One module for both shells. The desktop form and the phone sheet each had
|
||||
* their own copy of this, three near-identical try/catch blocks apiece, which is
|
||||
* six places for the same bug to sit and six copies against the duplication
|
||||
* budget the PR gate measures.
|
||||
*/
|
||||
|
||||
/** Upload each file and return the ids the server handed back, in order. */
|
||||
export async function uploadReceipts(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// Only an existing expense can be named here. A new one has no id yet, so
|
||||
// its receipts are linked by the create call instead.
|
||||
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
|
||||
const res = await filesApi.upload(tripId, fd)
|
||||
if (res?.file?.id) ids.push(res.file.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an upload after the save it belonged to failed.
|
||||
*
|
||||
* Two calls per file, and the order matters: the permanent route only accepts a
|
||||
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
|
||||
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
|
||||
* delete puts it there first. It also drops the receipt link the upload created,
|
||||
* so a failed edit does not leave the file attached to the expense.
|
||||
*
|
||||
* Returns the ids it could not remove. The caller is expected to say so rather
|
||||
* than swallow it: a file left behind is a file the user has to find in the
|
||||
* Files tab and delete by hand.
|
||||
*/
|
||||
export async function discardReceipts(
|
||||
tripId: number | string,
|
||||
ids: readonly number[],
|
||||
): Promise<number[]> {
|
||||
const stuck: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
await filesApi.permanentDelete(tripId, id)
|
||||
} catch {
|
||||
stuck.push(id)
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the pending receipts, run the save, and take the uploads back out
|
||||
* again if it throws.
|
||||
*
|
||||
* The rollback runs for a failure in the save AND for one partway through the
|
||||
* upload loop, which is why the ids are collected outside the try.
|
||||
*/
|
||||
export async function saveWithReceipts<T>(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
save: (uploadedIds: number[]) => Promise<T>,
|
||||
): Promise<{ result: T; stuckIds: number[] }> {
|
||||
const uploaded: number[] = []
|
||||
try {
|
||||
for (const file of files) {
|
||||
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
|
||||
if (id != null) uploaded.push(id)
|
||||
}
|
||||
const result = await save(uploaded)
|
||||
return { result, stuckIds: [] }
|
||||
} catch (err) {
|
||||
const stuck = await discardReceipts(tripId, uploaded)
|
||||
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
@@ -14,7 +14,7 @@ interface CollabChatProps {
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-008
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabLinks from './CollabLinks';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
const buildLink = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
title: 'Ferry timetable',
|
||||
url: 'https://ferries.example/timetable',
|
||||
pinned: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabLinks', () => {
|
||||
it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
|
||||
// The mascot carries the scene class; it is what makes this panel match its siblings.
|
||||
await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
const heading = await screen.findByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent(/links/i);
|
||||
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
|
||||
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-003: renders a link with its title and url', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posted: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/links', async ({ request }) => {
|
||||
posted = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) });
|
||||
}),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
|
||||
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
|
||||
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
|
||||
// collab_edit reserved for the owner, on somebody else's trip.
|
||||
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) });
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
interface CollabLink {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The add form as a modal, like the poll and note panels next to it. Inline it
|
||||
* pushed the list down and gave the panel a second header, which is what made
|
||||
* this tab read as a different app from its four siblings.
|
||||
*/
|
||||
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const valid = title.trim().length > 0 && url.trim().length > 0
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!valid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onCreate({ title: title.trim(), url: url.trim() })
|
||||
onClose()
|
||||
} catch {
|
||||
// add() already said what went wrong; the form stays open with the input in it.
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field: CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
}
|
||||
const label: CSSProperties = {
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
|
||||
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
|
||||
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
|
||||
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
|
||||
</div>
|
||||
<button type="submit" disabled={!valid || busy} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{busy ? '...' : t('collab.links.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
// Both are new objects on every render, so depending on them would reload the
|
||||
// list after each keystroke and drop a link that was just added back out of it.
|
||||
const toastRef = useRef(toast)
|
||||
toastRef.current = toast
|
||||
const tRef = useRef(t)
|
||||
tRef.current = t
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
collabApi.getLinks(tripId)
|
||||
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to load collab links:', err)
|
||||
toastRef.current.error(tRef.current('common.error'))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
// Live sync, the same shape the notes panel uses. Without it the three
|
||||
// collab:link events the server broadcasts would arrive nowhere and a link
|
||||
// somebody else added would only appear on the next reload.
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
||||
// The panel is not remounted on a trip change, so an event still in
|
||||
// flight from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:link:created' && msg.link) {
|
||||
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:link:updated' && msg.link) {
|
||||
setLinks(prev => prev
|
||||
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
}
|
||||
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
||||
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// Every write says something when it fails. Silence left the pin looking
|
||||
// unchanged and the delete looking ignored, with an unhandled rejection in
|
||||
// the console as the only trace.
|
||||
const add = async (data: { title: string; url: string }) => {
|
||||
try {
|
||||
const d = await collabApi.createLink(tripId, data)
|
||||
setLinks(v => [d.link, ...v])
|
||||
} catch (err) {
|
||||
console.error('Failed to add collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (link: CollabLink) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
} catch (err) {
|
||||
console.error('Failed to pin collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
setLinks(v => v.filter(x => x.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<Link2 size={14} color="var(--text-faint)" />
|
||||
{t('collab.tabs.links')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.links.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{links.length === 0 ? (
|
||||
<EmptyState scene="links" title={t('collab.links.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{links.map(link => (
|
||||
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<LinkIcon url={link.url} title={link.title} />
|
||||
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
|
||||
</a>
|
||||
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
|
||||
{canEdit && <>
|
||||
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
it('desktop layout renders all five panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mobile: Links tab shows the translated label, not the i18n key', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The three-or-more branch used to render notes and links as a pair and drop
|
||||
// whichever of the two was on its own, so these walk every shape of it.
|
||||
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
|
||||
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -28,6 +29,7 @@ interface TripMember {
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
links?: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
@@ -39,10 +41,11 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
// Older server/admin configs predate the Links feature; merge defaults so a
|
||||
// missing `links` key does not silently hide the new panel.
|
||||
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey),
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.notes && 'notes', features.links && 'links',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
// One place decides what a panel id renders. It was spelled out at five call
|
||||
// sites, and the layout branch below dropped a panel precisely because one of
|
||||
// those copies did not list it.
|
||||
const renderPanel = (p: string) => (
|
||||
<>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'links' && <CollabLinks tripId={tripId} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
// the old condition needed notes AND links together, so turning
|
||||
// the new Links feature off dropped Notes out of the layout
|
||||
// entirely. Each row renders only when it has something in it.
|
||||
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
||||
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
||||
return <>
|
||||
{written.length > 0 && panelRow(written)}
|
||||
{rest.length > 0 && panelRow(rest)}
|
||||
</>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
|
||||
*
|
||||
* The reason this file exists: the hook used to create object URLs and enqueue
|
||||
* the preview state from inside a `setFiles` updater. React invokes an updater
|
||||
* an extra time in development, so one picked image produced two preview URLs
|
||||
* against one file, the composer strip drew two thumbnails for one attachment,
|
||||
* and removing one of them left a preview for a picture that would not be sent.
|
||||
* `renderHook` under StrictMode is what reproduces it.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { StrictMode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
|
||||
|
||||
let nextUrl = 0
|
||||
const revoked: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
nextUrl = 0
|
||||
revoked.length = 0
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`),
|
||||
revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }),
|
||||
})
|
||||
})
|
||||
|
||||
const img = (name: string, size = 1024) =>
|
||||
new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
|
||||
const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode })
|
||||
|
||||
describe('useChatImages', () => {
|
||||
it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
|
||||
expect(result.current.files).toHaveLength(1)
|
||||
expect(result.current.previews).toHaveLength(1)
|
||||
expect(result.current.previews[0]).toBe('blob:mock/1')
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => {
|
||||
const { result } = strict()
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
|
||||
act(() => { result.current.remove(0) })
|
||||
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(result.current.previews).toEqual([])
|
||||
expect(revoked).toEqual(['blob:mock/1'])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-003: files and previews stay in step across several adds', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
act(() => { result.current.add([img('b.png'), img('c.png')]) })
|
||||
|
||||
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png'])
|
||||
expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3'])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => {
|
||||
const { result } = strict()
|
||||
let ok = true
|
||||
|
||||
act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) })
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(result.current.previews).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) })
|
||||
|
||||
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
|
||||
expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES)
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-006: clear revokes every URL it handed out', () => {
|
||||
const { result } = strict()
|
||||
act(() => { result.current.add([img('a.png'), img('b.png')]) })
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The images pinned to a chat message before it is sent.
|
||||
*
|
||||
* One hook for both shells. The desktop hook and the phone screen each carried
|
||||
* their own copy of the same picking, previewing and clearing, which is two
|
||||
* places for the same leak and a duplication bill on every line.
|
||||
*
|
||||
* A file and the object URL that previews it are one thing, so they are stored
|
||||
* as one thing and the two arrays the callers read are derived from it. Every
|
||||
* mutator computes the next list first and then commits it: nothing is created
|
||||
* or revoked inside a state updater, because React invokes those an extra time
|
||||
* in development and the second run would mint a second URL for the same file.
|
||||
* That is what the strip showed as two thumbnails for one attachment, and
|
||||
* removing one of them left a preview for a picture that was never sent.
|
||||
*
|
||||
* The ref is the list, the state is the render of it. Written together so two
|
||||
* calls in the same tick see each other, which a functional updater would give
|
||||
* for free and a plain array would not.
|
||||
*/
|
||||
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const MAX_CHAT_IMAGES = 4
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
interface PickedImage {
|
||||
file: File
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useChatImages(): ChatImages {
|
||||
const [items, setItems] = useState<PickedImage[]>([])
|
||||
const itemsRef = useRef<PickedImage[]>([])
|
||||
|
||||
const commit = useCallback((next: PickedImage[]) => {
|
||||
itemsRef.current = next
|
||||
setItems(next)
|
||||
}, [])
|
||||
|
||||
// Unmount with pictures still pinned: the URLs outlive the component otherwise.
|
||||
useEffect(() => () => {
|
||||
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
|
||||
itemsRef.current = []
|
||||
}, [])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
const room = MAX_CHAT_IMAGES - itemsRef.current.length
|
||||
const added = valid.slice(0, Math.max(0, room))
|
||||
if (added.length) {
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
|
||||
}
|
||||
return valid.length === all.length
|
||||
}, [commit])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
const gone = itemsRef.current[index]
|
||||
if (!gone) return
|
||||
URL.revokeObjectURL(gone.url)
|
||||
commit(itemsRef.current.filter((_, i) => i !== index))
|
||||
}, [commit])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
|
||||
commit([])
|
||||
}, [commit])
|
||||
|
||||
const files = useMemo(() => items.map(item => item.file), [items])
|
||||
const previews = useMemo(() => items.map(item => item.url), [items])
|
||||
|
||||
return { files, previews, add, remove, clear }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const images = useChatImages()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
@@ -117,23 +121,35 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
if ((!body && !images.files.length) || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
let data
|
||||
if (images.files.length) {
|
||||
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
|
||||
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
|
||||
} else {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
|
||||
data = await collabApi.sendMessage(tripId, payload)
|
||||
}
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -175,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
|
||||
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-015
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -160,4 +160,39 @@ describe('CollectionHero', () => {
|
||||
expect(within(share).queryByText('3')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Export as a file (#2198) ───────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-COLHERO-012: offers Export beside Edit and Share, in that order', async () => {
|
||||
const onExport = vi.fn();
|
||||
renderHero({ canEdit: true, canShare: true, isOwner: true, onExport });
|
||||
|
||||
const actions = ['Edit', 'Export', 'Share'].map(name => screen.getByRole('button', { name }));
|
||||
// Reading order in the DOM is the reading order on screen.
|
||||
const all = [...document.querySelectorAll('.col-hero-actions button')];
|
||||
expect(all).toEqual(actions);
|
||||
|
||||
await userEvent.click(actions[1]);
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
|
||||
renderHero({ onExport: undefined });
|
||||
expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-014: a viewer may export, since exporting reads and does not write', async () => {
|
||||
const onExport = vi.fn();
|
||||
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-015: refuses a second click while the first export is still running', () => {
|
||||
renderHero({ onExport: vi.fn(), exporting: true });
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { Share2, Users, Link2, Pencil } from 'lucide-react'
|
||||
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
|
||||
import type { CollectionMember, CollectionLink } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
@@ -26,14 +26,18 @@ interface CollectionHeroProps {
|
||||
onEdit: () => void
|
||||
shareMemberCount: number
|
||||
onShare: () => void
|
||||
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
|
||||
onExport?: () => void
|
||||
exporting?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* The page header — a colour-washed (or cover-image) glass hero that gives the
|
||||
* active list an identity: an eyebrow with the sharing state + member avatars,
|
||||
* the big list name, an optional description + link chips, and a Share action
|
||||
* top-right. Filtering lives in the toolbar above the places, not here.
|
||||
* the big list name, an optional description + link chips, and the Edit,
|
||||
* Export and Share actions top-right. Filtering lives in the toolbar above the
|
||||
* places, not here.
|
||||
* Modelled on the dashboard hero-trip.
|
||||
*/
|
||||
function linkHost(url: string): string {
|
||||
@@ -42,7 +46,7 @@ function linkHost(url: string): string {
|
||||
|
||||
export default function CollectionHero({
|
||||
eyebrow, title, color, coverImage, description, links,
|
||||
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
|
||||
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, onExport, exporting, t,
|
||||
}: CollectionHeroProps): React.ReactElement {
|
||||
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
|
||||
const showAvatars = accepted.length > 1
|
||||
@@ -94,6 +98,19 @@ export default function CollectionHero({
|
||||
<span className="txt">{t('common.edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
disabled={exporting}
|
||||
aria-label={t('collections.file.export')}
|
||||
title={t('collections.file.exportTitle')}
|
||||
className="col-glass-btn"
|
||||
>
|
||||
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
|
||||
<span className="txt">{t('collections.file.export')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -29,7 +29,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
hoverDisabled
|
||||
// Hover was off here, so a wall of round photos gave no way to tell what any of
|
||||
// them were without clicking. The trip map has said what a marker is all along;
|
||||
// this one adds the rating, because "which of these did I like" is exactly the
|
||||
// question a small picture cannot answer.
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
|
||||
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
/**
|
||||
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
||||
*
|
||||
* The panel is positioned against the FILTER ROW rather than against this
|
||||
* button, so it opens across the full width of the row instead of the half its
|
||||
* own trigger occupies. The two filters sit side by side and only one can be
|
||||
* open at a time, so the wider panel costs nothing and stops list names being
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{page.map(place => (
|
||||
|
||||
@@ -222,35 +222,12 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
<div className="col-detail-body">
|
||||
{/* Meta (view only) */}
|
||||
{!editing && (place.address || navigationTargets.length > 0) && (
|
||||
{!editing && place.address && (
|
||||
<div className="col-detail-meta">
|
||||
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
||||
{/* Same picker as inside a trip (#1455). A saved place is somewhere
|
||||
you intend to go, and until now the only way to get directions
|
||||
was to add it to a trip first. */}
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={navBtnRef}
|
||||
type="button"
|
||||
className="col-detail-nav"
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
>
|
||||
<Navigation size={12} />
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</button>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -258,9 +235,22 @@ export default function CollectionPlaceDetail({
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
||||
{onRate && (
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
{/* Rating and labels on one line, each on its own ground.
|
||||
A bare line of stars read as another row of text, and the labels tried the
|
||||
cover photo first — where a blue chip on a blue sky is simply not there.
|
||||
Side by side they are two badges answering two questions about the place. */}
|
||||
{(onRate || assignedLabels.length > 0) && (
|
||||
<div className="col-detail-badges">
|
||||
{onRate && (
|
||||
<div className="col-detail-rating">
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
{!editing && assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static big" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -334,15 +324,6 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
|
||||
@@ -371,6 +352,32 @@ export default function CollectionPlaceDetail({
|
||||
<>
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
{/* Beside the other two rather than up by the address: getting directions is
|
||||
something you DO with the place, like editing it or copying it into a
|
||||
trip, and the row of things you do belongs in one place. */}
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={navBtnRef}
|
||||
type="button"
|
||||
className="col-detail-btn"
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
>
|
||||
<Navigation size={14} />
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</button>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionFile } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import ImportCollectionModal from './ImportCollectionModal';
|
||||
|
||||
// The modal takes `t` as a prop, so this harness forwards the real English
|
||||
// translator and the assertions read as the strings a person sees.
|
||||
function Harness(props: Omit<React.ComponentProps<typeof ImportCollectionModal>, 't'>) {
|
||||
const { t } = useTranslation();
|
||||
return <ImportCollectionModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const listFile: CollectionFile = {
|
||||
format: 'trek.collection', version: 1, name: 'Lisbon',
|
||||
description: 'Worth it if you have three days',
|
||||
color: '#ef4444',
|
||||
labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#00ff00' }],
|
||||
places: [{ name: 'Time Out Market' }, { name: 'Miradouro' }, { name: 'Pastéis de Belém' }],
|
||||
} as CollectionFile;
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
|
||||
const props = {
|
||||
onImport: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
/** Put a file into the hidden input the way a file picker would. */
|
||||
async function choose(content: string, name = 'list.trekcollection.json') {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File([content], name, { type: 'application/json' });
|
||||
// jsdom's File has no usable text(); the component awaits it.
|
||||
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
|
||||
await userEvent.upload(input, file);
|
||||
}
|
||||
|
||||
const importButton = () => screen.getByRole('button', { name: 'Import' });
|
||||
|
||||
describe('ImportCollectionModal (#2198)', () => {
|
||||
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
|
||||
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
|
||||
expect(importButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-002: shows what the file holds before anything is imported', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Worth it if you have three days')).toBeInTheDocument());
|
||||
expect(screen.getByText('3 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 labels')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
|
||||
expect(importButton()).toBeEnabled();
|
||||
expect(props.onImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-003: leaves the label count out when the file has none', async () => {
|
||||
renderModal();
|
||||
await choose(JSON.stringify({ ...listFile, labels: [] }));
|
||||
await waitFor(() => expect(screen.getByText('3 places')).toBeInTheDocument());
|
||||
expect(screen.queryByText(/labels$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-004: imports the file under its own name', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(importButton()).toBeEnabled());
|
||||
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(props.onImport).toHaveBeenCalledTimes(1));
|
||||
// No name argument: the file's own name was kept.
|
||||
expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-005: imports under a name the person typed instead', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
|
||||
await userEvent.clear(screen.getByDisplayValue('Lisbon'));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Lisbon (from Ana)');
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.anything(), 'Lisbon (from Ana)'));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-006: will not import under an empty name', async () => {
|
||||
renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
|
||||
await userEvent.clear(screen.getByRole('textbox'));
|
||||
|
||||
expect(importButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-007: says what is wrong with a file it cannot use', async () => {
|
||||
renderModal();
|
||||
|
||||
await choose('not json');
|
||||
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
|
||||
|
||||
await choose(JSON.stringify({ format: 'something.else' }));
|
||||
await waitFor(() => expect(screen.getByText('That is not a TREK list file.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-008: takes a good file after a bad one, and forgets the complaint', async () => {
|
||||
renderModal();
|
||||
await choose('not json');
|
||||
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
|
||||
|
||||
await choose(JSON.stringify(listFile));
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
expect(screen.queryByText('That file could not be read.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-009: keeps the dialog open and says why when the import fails', async () => {
|
||||
const props = renderModal({ onImport: vi.fn().mockRejectedValue(new Error('the server said no')) });
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(importButton()).toBeEnabled());
|
||||
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('the server said no')).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-010: closes without importing on Cancel', async () => {
|
||||
const props = renderModal();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(props.onImport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionFile } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { readCollectionFile, type CollectionFileError, COLLECTION_FILE_EXTENSION } from './collectionFile'
|
||||
|
||||
interface ImportCollectionModalProps {
|
||||
onImport: (file: CollectionFile, name?: string) => Promise<void>
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a list file and make a list of it (#2198).
|
||||
*
|
||||
* Two steps on purpose. A file from somebody else is an unknown quantity, so
|
||||
* it is read and shown first — how many places, which labels, what the list is
|
||||
* called — and only then imported. The name is editable in the same breath,
|
||||
* because a file called "Lisbon" from a friend is usually worth calling
|
||||
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
|
||||
* the list editor.
|
||||
*
|
||||
* The file never leaves the browser as a file: it is parsed here, and what
|
||||
* goes to the server is the parsed list, through the same contract the server
|
||||
* validates against.
|
||||
*/
|
||||
export default function ImportCollectionModal({ onImport, onClose, t }: ImportCollectionModalProps): React.ReactElement {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [file, setFile] = useState<CollectionFile | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
|
||||
const [failedMessage, setFailedMessage] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const take = async (chosen: File | undefined) => {
|
||||
if (!chosen) return
|
||||
setError(null)
|
||||
setFailedMessage(null)
|
||||
const result = await readCollectionFile(chosen)
|
||||
setFile(result.file)
|
||||
setError(result.error)
|
||||
if (result.file) setName(result.file.name)
|
||||
}
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
void take(e.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setFailedMessage(null)
|
||||
try {
|
||||
const trimmed = name.trim()
|
||||
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
|
||||
} catch (err) {
|
||||
setError('failed')
|
||||
setFailedMessage(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const errorText = error === 'failed'
|
||||
? (failedMessage ?? t('common.error'))
|
||||
: error === 'too-large' ? t('collections.file.errorTooLarge')
|
||||
: error === 'unreadable' ? t('collections.file.errorUnreadable')
|
||||
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t('collections.file.importTitle')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-[13px] font-medium text-content-muted hover:bg-surface-hover transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || !name.trim()}
|
||||
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('collections.file.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
|
||||
{!file ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
|
||||
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Upload size={22} className="text-content-faint" />
|
||||
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
|
||||
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
|
||||
<FileJson size={15} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
|
||||
<span className="flex items-center gap-3 text-[11.5px] text-content-faint">
|
||||
<span className="inline-flex items-center gap-1"><MapPin size={11} />{t('collections.placeCount', { count: file.places.length })}</span>
|
||||
{file.labels && file.labels.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1"><Tag size={11} />{t('collections.file.labelCount', { count: file.labels.length })}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" onClick={() => inputRef.current?.click()} className="text-[12px] font-medium text-accent shrink-0 hover:underline">
|
||||
{t('collections.file.change')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{file.description && (
|
||||
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorText && (
|
||||
<p className="flex items-start gap-2 text-[12px] text-danger">
|
||||
<AlertCircle size={13} className="shrink-0 mt-0.5" />
|
||||
<span>{errorText}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function renderRail(overrides: Partial<RailProps> = {}) {
|
||||
incomingInvites: [],
|
||||
onSelect: vi.fn(),
|
||||
onNewList: vi.fn(),
|
||||
onImportList: vi.fn(),
|
||||
onAcceptInvite: vi.fn(),
|
||||
onDeclineInvite: vi.fn(),
|
||||
...overrides,
|
||||
@@ -93,7 +94,7 @@ describe('ListsRail', () => {
|
||||
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
|
||||
const { unmount } = render(<Harness {...{
|
||||
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
|
||||
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
|
||||
onSelect: vi.fn(), onNewList: vi.fn(), onImportList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
|
||||
}} />);
|
||||
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
@@ -127,4 +128,18 @@ describe('ListsRail', () => {
|
||||
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-011: offers Import beside New list, and it opens the file dialog (#2198)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderRail();
|
||||
|
||||
const importBtn = screen.getByRole('button', { name: 'Import a list from a file' });
|
||||
await user.click(importBtn);
|
||||
|
||||
expect(props.onImportList).toHaveBeenCalledTimes(1);
|
||||
expect(props.onNewList).not.toHaveBeenCalled();
|
||||
// New list keeps its label; import is the icon beside it.
|
||||
expect(screen.getByRole('button', { name: 'New list' })).toHaveTextContent('New list');
|
||||
expect(importBtn).toHaveTextContent('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Plus, Layers, Users } from 'lucide-react'
|
||||
import { Plus, Layers, Users, Upload } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { ALL_SAVED } from '../../store/collectionStore'
|
||||
@@ -12,6 +12,8 @@ interface ListsRailProps {
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
onSelect: (id: ActiveCollectionId) => void
|
||||
onNewList: () => void
|
||||
/** Read a list file as a new list (#2198). */
|
||||
onImportList: () => void
|
||||
onAcceptInvite: (id: number) => void
|
||||
onDeclineInvite: (id: number) => void
|
||||
t: TranslationFn
|
||||
@@ -30,7 +32,7 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Left rail of the user's lists: a "New list" action, the "All saved" union
|
||||
* Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union
|
||||
* pseudo-list, owned lists (colour dot + count), a shared section, and an
|
||||
* incoming-invites block. Selecting a list makes it active; editing / deleting
|
||||
* happens from the Edit button in the hero of the active list.
|
||||
@@ -38,14 +40,27 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
|
||||
export default function ListsRail(props: ListsRailProps): React.ReactElement {
|
||||
const {
|
||||
ownedLists, sharedLists, activeId, incomingInvites,
|
||||
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
|
||||
onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onNewList} className="col-rail-new">
|
||||
<Plus size={16} /> {t('collections.newList')}
|
||||
</button>
|
||||
<div className="col-rail-newrow">
|
||||
<button type="button" onClick={onNewList} className="col-rail-new">
|
||||
<Plus size={16} /> {t('collections.newList')}
|
||||
</button>
|
||||
{/* Import sits beside New rather than in the hero: a file becomes a NEW
|
||||
list, so it belongs where lists are made, not on the one open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportList}
|
||||
className="col-rail-new col-rail-import"
|
||||
aria-label={t('collections.file.importButton')}
|
||||
title={t('collections.file.importTitle')}
|
||||
>
|
||||
<Upload size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="col-row">
|
||||
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// FE-COLL-FILE-001 to FE-COLL-FILE-012
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
|
||||
import type { CollectionFile } from '@trek/shared'
|
||||
|
||||
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
|
||||
format: 'trek.collection', version: 1, name: 'Lisbon', places: [{ name: 'Time Out Market' }], ...over,
|
||||
} as CollectionFile)
|
||||
|
||||
/** A File whose text() the jsdom build does not provide on its own. */
|
||||
function asFile(content: string, size = content.length): File {
|
||||
return { size, text: () => Promise.resolve(content) } as unknown as File
|
||||
}
|
||||
|
||||
describe('collectionFileName', () => {
|
||||
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
|
||||
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
|
||||
expect(collectionFileName('Wochenende in Rom!')).toBe('wochenende-in-rom' + COLLECTION_FILE_EXTENSION)
|
||||
expect(collectionFileName('東京')).toBe('collection' + COLLECTION_FILE_EXTENSION)
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-002: caps a very long name rather than producing an unusable file name', () => {
|
||||
const name = collectionFileName('a'.repeat(200))
|
||||
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadCollectionFile', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
|
||||
vi.useFakeTimers()
|
||||
const revoke = vi.fn()
|
||||
const created: Blob[] = []
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
|
||||
revokeObjectURL: revoke,
|
||||
})
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
expect(this.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
|
||||
expect(this.href).toContain('blob:')
|
||||
})
|
||||
|
||||
downloadCollectionFile(file())
|
||||
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
expect(created[0].type).toBe('application/json')
|
||||
expect(document.querySelector('a')).toBeNull()
|
||||
expect(revoke).not.toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(revoke).toHaveBeenCalledWith('blob:x')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCollectionFile', () => {
|
||||
it('FE-COLL-FILE-004: reads one of ours', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file())))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.name).toBe('Lisbon')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
|
||||
const text = vi.fn()
|
||||
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
|
||||
const result = await readCollectionFile(huge)
|
||||
expect(result).toEqual({ file: null, error: 'too-large' })
|
||||
expect(text).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
|
||||
expect(await readCollectionFile(asFile('not json at all'))).toEqual({ file: null, error: 'unreadable' })
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
|
||||
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
|
||||
expect(await readCollectionFile(asFile(content)), content).toEqual({ file: null, error: 'not-a-collection' })
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })))
|
||||
expect(result.error).toBe('not-a-collection')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.places).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-010: keeps a place the envelope does not check, for the server to judge', async () => {
|
||||
// The envelope counts places; each one is validated where it is written.
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({
|
||||
places: [{ name: 'Fine' }, { nonsense: true }] as never,
|
||||
}))))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.places).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
|
||||
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))))
|
||||
expect(result.error).toBe('not-a-collection')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.version).toBe(99)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The browser half of list export and import (#2198).
|
||||
*
|
||||
* The file itself is built by the server, which is also where the decision
|
||||
* about what may leave the instance lives (`collection-file.schema.ts`). This
|
||||
* module only hands the JSON to the browser as a download, and reads a chosen
|
||||
* file back into something the import request will accept.
|
||||
*
|
||||
* A file that arrives here is a stranger's JSON and is treated as one: read
|
||||
* locally, never uploaded, parsed rather than evaluated, size-capped before
|
||||
* parsing, and put through the shared contract so a field this build does not
|
||||
* know is dropped instead of travelling on to the server.
|
||||
*/
|
||||
|
||||
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
|
||||
|
||||
/** A file name from a list name: lowercase, ascii-ish, short. */
|
||||
export function collectionFileName(name: string): string {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
|
||||
return `${slug || 'collection'}${COLLECTION_FILE_EXTENSION}`
|
||||
}
|
||||
|
||||
/** Hand the file to the browser as a download. */
|
||||
export function downloadCollectionFile(file: CollectionFile): void {
|
||||
const json = JSON.stringify(file, null, 2)
|
||||
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = collectionFileName(file.name)
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
// Revoked on the next tick: revoking synchronously can cancel the download
|
||||
// in some browsers before it has started reading the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
|
||||
|
||||
/**
|
||||
* Exactly one of the two is set.
|
||||
*
|
||||
* Two nullable fields rather than a discriminated union on an `ok` flag: the
|
||||
* client compiles with `strict: false`, and without `strictNullChecks` a
|
||||
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
|
||||
* failure branch would need a cast.
|
||||
*/
|
||||
export interface ParsedCollectionFile {
|
||||
file: CollectionFile | null
|
||||
error: CollectionFileError | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a chosen file into a list file, or say why it is not one.
|
||||
*
|
||||
* The three failures are kept apart because they are three different things a
|
||||
* person can do something about: a file too big to be one of ours, a file that
|
||||
* is not JSON at all, and a JSON that is not a TREK list.
|
||||
*/
|
||||
export async function readCollectionFile(file: File): Promise<ParsedCollectionFile> {
|
||||
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(await file.text())
|
||||
} catch {
|
||||
return { file: null, error: 'unreadable' }
|
||||
}
|
||||
const parsed = collectionFileSchema.safeParse(raw)
|
||||
return parsed.success
|
||||
? { file: parsed.data, error: null }
|
||||
: { file: null, error: 'not-a-collection' }
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
|
||||
/**
|
||||
* The review step between a detector's guess and a row in somebody's trip.
|
||||
*
|
||||
* This dialog is the only thing standing between Dawarich's visit detector and
|
||||
* real data, and almost all of its behaviour is invisible until it is wrong:
|
||||
* which fields are prefilled, which of them are sent back, and which are left
|
||||
* out because the user did not actually change them. The server takes the body
|
||||
* at face value (an accidental `name` or `date` in it silently overrides the
|
||||
* recording), so "only what was corrected travels" is the contract worth
|
||||
* pinning, and it is the one a refactor breaks first.
|
||||
*
|
||||
* The rest is the shape of the three targets. `place` needs a trip and offers a
|
||||
* day, `journal` needs a journey and asks for a story, `bucket_list` needs a
|
||||
* matched wish and hides the clock entirely because ticking a wish off uses the
|
||||
* recorded timestamp and nothing else. Each of the three can be in a state
|
||||
* where there is nothing to write to (no trips, no journeys, no matched wish),
|
||||
* and in each of those the confirm button has to be inert rather than sending a
|
||||
* body the server will reject with a 400.
|
||||
*
|
||||
* The seeding is keyed on the stay, not on the target, and one live instance
|
||||
* serves all three rails. That makes two things true at once that are easy to
|
||||
* get wrong together: what the user typed survives a switch from the place rail
|
||||
* to the journal rail, and the trip and day they chose under the place rail
|
||||
* must not travel in the journal body they then confirm.
|
||||
*
|
||||
* Both shells are covered: the phone gets an MSheet with its own footer, the
|
||||
* desktop a Modal, and the fields in between are the same element tree. A
|
||||
* regression that dropped one shell would otherwise only show up on a device.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
|
||||
// The one thing the dialog reads about the device. A hoisted flag rather than a
|
||||
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
|
||||
const phone = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
|
||||
|
||||
/**
|
||||
* A stay as the suggestions endpoint hands it over. The offsets are real ones:
|
||||
* `clockOf` slices the local wall clock straight out of the string rather than
|
||||
* converting it, so a fixture in UTC would quietly test a different code path
|
||||
* than the one production sees.
|
||||
*/
|
||||
function stay(over: Partial<DawarichSuggestion> = {}): DawarichSuggestion {
|
||||
return {
|
||||
id: 1,
|
||||
sourceVisitId: '1',
|
||||
tripId: null,
|
||||
tripTitle: null,
|
||||
name: 'Cafe Reichard',
|
||||
lat: 50.94,
|
||||
lng: 6.96,
|
||||
startedAt: '2026-09-10T10:15:00+02:00',
|
||||
endedAt: '2026-09-10T12:40:00+02:00',
|
||||
durationMinutes: 145,
|
||||
localDate: '2026-09-10',
|
||||
sourceStatus: 'suggested',
|
||||
confidence: null,
|
||||
confidenceBand: null,
|
||||
state: 'new',
|
||||
target: null,
|
||||
acceptedPlaceId: null,
|
||||
acceptedJournalEntryId: null,
|
||||
acceptedBucketListItemId: null,
|
||||
sourceChanged: false,
|
||||
sourceMissing: false,
|
||||
matchedBucketListItemId: null,
|
||||
matchedBucketListName: null,
|
||||
countryCode: null,
|
||||
firstSeenAt: '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: '2026-09-10T06:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TRIPS: AcceptTargetOption[] = [
|
||||
{ id: 31, label: 'Rhineland' },
|
||||
{ id: 32, label: 'Alps' },
|
||||
]
|
||||
|
||||
// Distinct labels per trip so "the day list followed the trip" is observable
|
||||
// without reading ids out of the DOM.
|
||||
const DAYS: Record<number, AcceptTargetOption[]> = {
|
||||
31: [
|
||||
{ id: 401, label: 'Day 1', badge: '10 Sept' },
|
||||
{ id: 402, label: 'Day 2', badge: '11 Sept' },
|
||||
],
|
||||
32: [{ id: 501, label: 'Alpine day', badge: '01 Oct' }],
|
||||
}
|
||||
const daysForTrip = (tripId: number): AcceptTargetOption[] => DAYS[tripId] ?? []
|
||||
|
||||
const JOURNALS: AcceptTargetOption[] = [
|
||||
{ id: 71, label: 'Rhine notebook' },
|
||||
{ id: 72, label: 'Alpine notebook' },
|
||||
]
|
||||
|
||||
type Props = React.ComponentProps<typeof DawarichAcceptDialog>
|
||||
|
||||
function open(props: Partial<Props> = {}) {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel }
|
||||
const view = render(<DawarichAcceptDialog {...all} />)
|
||||
return {
|
||||
...view,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
/** Re-render with the same props bar the ones named: the parent's own update path. */
|
||||
update: (next: Partial<Props>) => view.rerender(<DawarichAcceptDialog {...all} {...next} />),
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a CustomSelect by its trigger, then pick the option with this label. */
|
||||
function pick(triggerName: string | RegExp, optionLabel: string): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: triggerName }))
|
||||
fireEvent.click(screen.getByText(optionLabel))
|
||||
}
|
||||
|
||||
/** Type into the date field's keyboard entry, which takes ISO as-is. */
|
||||
function typeDate(iso: string): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter date manually' }))
|
||||
const input = screen.getByLabelText('Enter date manually')
|
||||
fireEvent.change(input, { target: { value: iso } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone.value = false
|
||||
})
|
||||
|
||||
describe('DawarichAcceptDialog', () => {
|
||||
it('FE-DAWARICH-ACCEPT-001: nothing is on screen while no stay is pending', () => {
|
||||
open({ suggestion: null, trips: TRIPS })
|
||||
// The panel portals to document.body, so "empty container" proves nothing:
|
||||
// the absence of the title and the confirm button does.
|
||||
expect(screen.queryByText('Add this stay as a place')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Add place' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-002: every field opens prefilled from the recording', () => {
|
||||
open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
|
||||
// The wall clock out of the offset the recording carries, not UTC.
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
|
||||
expect(screen.getByLabelText('Notes')).toHaveValue('')
|
||||
expect(screen.getByText(/^Recorded /)).toBeInTheDocument()
|
||||
expect(screen.getByText('145 min')).toBeInTheDocument()
|
||||
// No trip of its own, so the first trip the user may write to.
|
||||
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-003: an untouched stay sends only the target, its trip and the recorded clock', () => {
|
||||
const { onConfirm } = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
// No name, no notes, no date: the server falls back to the recording for
|
||||
// each of those, and repeating them would turn every accept into an edit.
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-004: every correction travels, the day included', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
|
||||
typeDate('2026-09-12')
|
||||
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
|
||||
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'place',
|
||||
tripId: 31,
|
||||
dayId: 401,
|
||||
name: 'Café Reichard am Dom',
|
||||
notes: 'Two hours and a lot of cake.',
|
||||
date: '2026-09-12',
|
||||
time: '09:00',
|
||||
endTime: '11:45',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-005: a name or note that says nothing new is left out of the body', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
const untouched = { target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }
|
||||
|
||||
// Emptied entirely: the recording's own name is what should survive.
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
|
||||
|
||||
// Retyped with padding around it: after trimming this is the same name, and
|
||||
// sending it would write a "correction" nobody made.
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Cafe Reichard ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-006: unplanned is an answer, not a missing one', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
// The date rides along as the trigger's badge rather than inside its label.
|
||||
expect(screen.getByRole('button', { name: /Day 1/ })).toHaveTextContent('10 Sept')
|
||||
|
||||
// Back to "not on a day yet": the sentinel option has to clear the choice
|
||||
// rather than be stored as a day id of its own.
|
||||
pick(/Day 1/, 'Not on a day yet')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-007: changing the trip drops the day that belonged to the old one', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 2')
|
||||
pick('Rhineland', 'Alps')
|
||||
|
||||
// Day 2 is a day of the Rhineland trip; keeping it would attach the place to
|
||||
// somebody else's day, which the server refuses with a 400.
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
|
||||
expect(screen.getByText('Alpine day')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Day 2')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-008: the trip falls back from the stay to the first writable one to nothing', () => {
|
||||
// The stay's own trip wins: it is the one whose dates the recording fell into.
|
||||
const own = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
|
||||
own.unmount()
|
||||
|
||||
const first = open({ trips: TRIPS, daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
|
||||
first.unmount()
|
||||
|
||||
// No trip at all: the picker admits it and the confirm is inert, because a
|
||||
// place with no trip is a 400 rather than a partial success.
|
||||
open({ trips: [], daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Choose a trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-009: with no day resolver the day picker offers only "not on a day yet"', () => {
|
||||
// The trip rail passes a resolver; an Atlas-side or MCP-shaped caller need
|
||||
// not, and the picker has to survive that rather than throw.
|
||||
open({ trips: TRIPS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
|
||||
expect(screen.getAllByText('Not on a day yet')).toHaveLength(2)
|
||||
expect(screen.queryByText('Day 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-010: the journal target asks for a story and sends the chosen journey', () => {
|
||||
const { onConfirm } = open({ target: 'journal', journals: JOURNALS })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
// A story gets room to be one; notes on a place do not need five rows.
|
||||
expect(screen.getByLabelText('Your story')).toHaveAttribute('rows', '5')
|
||||
expect(screen.getByText('Add photos to the entry after it is created.')).toBeInTheDocument()
|
||||
// No trip or day rail here: the entry belongs to a journey, not to a day.
|
||||
expect(screen.queryByText('Day')).toBeNull()
|
||||
expect(screen.queryByText('Trip')).toBeNull()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Your story'), { target: { value: 'Cake, then the cathedral.' } })
|
||||
pick('Rhine notebook', 'Alpine notebook')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'journal',
|
||||
journalId: 72,
|
||||
notes: 'Cake, then the cathedral.',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-011: with no journey to write to, the journal confirm is inert', () => {
|
||||
open({ target: 'journal' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-012: ticking a wish off states the rule and sends the matched wish', () => {
|
||||
const { onConfirm } = open({
|
||||
target: 'bucket_list',
|
||||
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Tick off a wish' })).toBeInTheDocument()
|
||||
// Why a stay at a café is being offered against a museum: the rule, spelled out.
|
||||
expect(screen.getByText(/On your wishlist: Museum Ludwig/)).toHaveTextContent('within 250 m and after 20 minutes')
|
||||
// Nothing here is editable but the wish it ticks off: the visit's own
|
||||
// timestamp is what gets stored, so a clock or a note would be theatre.
|
||||
expect(screen.queryByLabelText('Arrived')).toBeNull()
|
||||
expect(screen.queryByLabelText('Left')).toBeNull()
|
||||
expect(screen.queryByLabelText('Notes')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'bucket_list',
|
||||
bucketListItemId: 88,
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-013: a stay that matched no wish cannot be ticked off', () => {
|
||||
open({ target: 'bucket_list', suggestion: stay() })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Tick it off' })).toBeDisabled()
|
||||
expect(screen.queryByText(/On your wishlist/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-014: a write already in flight blocks a second one', () => {
|
||||
// Everything is answered, so only `busy` can be holding the button, which is
|
||||
// the point: double-accepting a stay creates two places out of one recording.
|
||||
open({ trips: TRIPS, daysForTrip, busy: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-015: backing out writes nothing, by button or by Escape', () => {
|
||||
const { onCancel, onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(2)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("FE-DAWARICH-ACCEPT-016: the source's own verdict is shown only while it is unconfirmed", () => {
|
||||
const unconfirmed = open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'suggested' }) })
|
||||
expect(screen.getByText('Detected, unconfirmed')).toBeInTheDocument()
|
||||
unconfirmed.unmount()
|
||||
|
||||
// Confirmed in Dawarich: saying so would be noise, the badge is simply gone.
|
||||
open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'confirmed' }) })
|
||||
expect(screen.queryByText('Detected, unconfirmed')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-017: only the bands Dawarich actually emits are shown, and only "low" warns', () => {
|
||||
const low = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'low', confidence: 20 }) })
|
||||
expect(screen.getByText('Uncertain detection').parentElement?.className).toContain('text-warning')
|
||||
low.unmount()
|
||||
|
||||
const high = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'high', confidence: 90 }) })
|
||||
// A confident detection is a fact, not a warning. Same badge, neutral tone.
|
||||
expect(screen.getByText('Confident detection').parentElement?.className).toContain('text-content-secondary')
|
||||
high.unmount()
|
||||
|
||||
const none = open({ trips: TRIPS, suggestion: stay({ confidenceBand: null }) })
|
||||
expect(screen.queryByText(/detection$/)).toBeNull()
|
||||
none.unmount()
|
||||
|
||||
// A band a later Dawarich invents: nothing at all, rather than a raw key.
|
||||
open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'certain', confidence: 100 }) })
|
||||
expect(screen.queryByText(/dawarich\.confidence/)).toBeNull()
|
||||
expect(screen.queryByText(/detection$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
|
||||
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
|
||||
// answers with an empty string rather than a guess, and an empty string must
|
||||
// not reach the body as `time: ""`.
|
||||
const { onConfirm } = open({
|
||||
trips: TRIPS,
|
||||
daysForTrip,
|
||||
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('')
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
|
||||
const { update } = open({ trips: TRIPS, daysForTrip })
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Edited by hand' } })
|
||||
|
||||
// A fresh object for the same row: a parent re-render, not a new decision.
|
||||
// Re-seeding here would eat what the user typed mid-sentence.
|
||||
update({ suggestion: stay() })
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Edited by hand')
|
||||
|
||||
update({
|
||||
suggestion: stay({
|
||||
id: 2,
|
||||
name: 'Kölner Dom',
|
||||
tripId: 32,
|
||||
localDate: '2026-09-11',
|
||||
startedAt: '2026-09-11T08:00:00+02:00',
|
||||
endedAt: '2026-09-11T09:30:00+02:00',
|
||||
}),
|
||||
})
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Kölner Dom')
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('08:00')
|
||||
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-020: on a phone it is a sheet, and its footer confirms', () => {
|
||||
phone.value = true
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
// The desktop modal's own heading is the tell that the wrong shell rendered.
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Reichard' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'place',
|
||||
tripId: 31,
|
||||
name: 'Reichard',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-021: the phone sheet backs out from its header and its footer, and blocks an impossible write', () => {
|
||||
phone.value = true
|
||||
const { onCancel, onConfirm } = open({ target: 'journal' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
|
||||
// Two ways out of the sheet, both the same door: the header's close circle
|
||||
// and the footer's cancel pill.
|
||||
const exits = screen.getAllByRole('button', { name: 'Cancel' })
|
||||
expect(exits).toHaveLength(2)
|
||||
exits.forEach(button => fireEvent.click(button))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(2)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-022: the name stays editable on the wishlist rail, and a correction travels', () => {
|
||||
// The clock, the date and the notes are all hidden for a wish, which is
|
||||
// exactly why the one field that survives is worth naming: the detector's
|
||||
// guess at a place name is what a tick-off is filed under, and "hide
|
||||
// everything that is not the wish" is the tidy-up that would take it away.
|
||||
const { onConfirm } = open({
|
||||
target: 'bucket_list',
|
||||
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Museum Ludwig' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'bucket_list',
|
||||
bucketListItemId: 88,
|
||||
name: 'Museum Ludwig',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-023: a new stay arrives with no day chosen, whatever the last one had', () => {
|
||||
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 2')
|
||||
|
||||
// Same trip, so the day list does not change and a stale `dayId` would stay
|
||||
// a legal value, which is the dangerous version of this bug: the second
|
||||
// stay is filed silently on the day somebody picked for the first.
|
||||
update({ suggestion: stay({ id: 3, name: 'Kölner Dom', tripId: 31 }) })
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-024: switching rails keeps what was typed and leaks no trip into the journal body', () => {
|
||||
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Cake, then the cathedral.' } })
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
|
||||
// Backing out of "add as a place" and choosing "write a journal entry" on
|
||||
// the same row is one instance and one stay, so nothing is re-seeded.
|
||||
update({ target: 'journal', journals: JOURNALS })
|
||||
expect(screen.getByLabelText('Your story')).toHaveValue('Cake, then the cathedral.')
|
||||
|
||||
// The journal was never chosen, because the seeding runs off the stay and
|
||||
// the stay has not changed. Offering a confirm here would send a body with
|
||||
// no journalId in it.
|
||||
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
|
||||
pick('Choose a journal', 'Rhine notebook')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
|
||||
// Trip 31 and day 401 are still held in state. A journal entry belongs to a
|
||||
// journey, and the server reads the body at face value.
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'journal',
|
||||
journalId: 71,
|
||||
notes: 'Cake, then the cathedral.',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export interface AcceptTargetOption {
|
||||
id: number
|
||||
label: string
|
||||
/** Secondary half of the label — a day's date beside its number. */
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface DawarichAcceptDialogProps {
|
||||
suggestion: DawarichSuggestion | null
|
||||
target: DawarichSuggestionTarget
|
||||
/** Trips the user may write to, for the `place` target. */
|
||||
trips?: AcceptTargetOption[]
|
||||
/** Days of the chosen trip, resolved by the caller. */
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may edit, for the `journal` target. */
|
||||
journals?: AcceptTargetOption[]
|
||||
busy?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (body: DawarichAccept) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The review step: see what was recorded, correct it, then decide where it goes.
|
||||
*
|
||||
* The whole reason the integration is not a one-click import. A visit detector
|
||||
* is right most of the time and confidently wrong the rest, and the difference
|
||||
* between the two is a judgement only the traveller can make — so the values
|
||||
* are prefilled from the recording and every one of them is editable before
|
||||
* anything is written.
|
||||
*
|
||||
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
|
||||
* a correction made here would be lost the next time the detector ran.
|
||||
*
|
||||
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
|
||||
* journal rails open the first two; `bucket_list` is reached through MCP, where
|
||||
* an assistant can accept a stay against a wish. The branch is kept for that and
|
||||
* for the day an Atlas-side entry point wants it — the server has always been
|
||||
* able to do it, and a dialog that could only handle two of three targets would
|
||||
* be the thing that had to be fixed then.
|
||||
*/
|
||||
export default function DawarichAcceptDialog({
|
||||
suggestion,
|
||||
target,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
busy = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const phone = useIsPhone()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [time, setTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [tripId, setTripId] = useState<number | ''>('')
|
||||
const [dayId, setDayId] = useState<number | ''>('')
|
||||
const [journalId, setJournalId] = useState<number | ''>('')
|
||||
// Re-seeded whenever a different suggestion opens the dialog, so the fields
|
||||
// always describe the row the user just pressed rather than the previous one.
|
||||
const [seededFor, setSeededFor] = useState<number | null>(null)
|
||||
|
||||
if (suggestion && seededFor !== suggestion.id) {
|
||||
setSeededFor(suggestion.id)
|
||||
setName(suggestion.name)
|
||||
setNotes('')
|
||||
setDate(suggestion.localDate)
|
||||
setTime(clockOf(suggestion.startedAt))
|
||||
setEndTime(clockOf(suggestion.endedAt))
|
||||
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
|
||||
setDayId('')
|
||||
setJournalId(journals[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const days = useMemo(
|
||||
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
|
||||
[tripId, daysForTrip],
|
||||
)
|
||||
|
||||
if (!suggestion) return null
|
||||
|
||||
const missingTarget =
|
||||
(target === 'place' && typeof tripId !== 'number') ||
|
||||
(target === 'journal' && typeof journalId !== 'number') ||
|
||||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
|
||||
|
||||
const confirm = () => {
|
||||
const body: DawarichAccept = { target }
|
||||
if (target === 'place') {
|
||||
if (typeof tripId === 'number') body.tripId = tripId
|
||||
if (typeof dayId === 'number') body.dayId = dayId
|
||||
}
|
||||
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
|
||||
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
|
||||
body.bucketListItemId = suggestion.matchedBucketListItemId
|
||||
}
|
||||
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
|
||||
if (notes.trim()) body.notes = notes.trim()
|
||||
if (date && date !== suggestion.localDate) body.date = date
|
||||
if (time) body.time = time
|
||||
if (endTime) body.endTime = endTime
|
||||
onConfirm(body)
|
||||
}
|
||||
|
||||
// The same fields in both shells — only the frame around them differs.
|
||||
const fields = (
|
||||
<div className="space-y-4">
|
||||
{/* What was recorded, before anything the user changed. Kept visible so a
|
||||
corrected field can be compared against the source rather than
|
||||
replacing it invisibly. */}
|
||||
{/* What Dawarich recorded, as facts rather than as a sentence with
|
||||
separators in it: a line for when, and a row of the same badges the
|
||||
review list uses so the dialog and the list read as one thing. */}
|
||||
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={20} />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
|
||||
</DawarichBadge>
|
||||
{/* The source's own verdict, shown here rather than on every row of
|
||||
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
|
||||
of a visit, so it says nothing in a list and something here, where
|
||||
somebody is deciding whether to trust this one. */}
|
||||
{suggestion.sourceStatus === 'suggested' && (
|
||||
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
|
||||
)}
|
||||
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
|
||||
<DawarichBadge
|
||||
icon={Gauge}
|
||||
size="sm"
|
||||
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
|
||||
>
|
||||
{t('dawarich.confidence.' + suggestion.confidenceBand)}
|
||||
</DawarichBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why this stay is being offered against a wish at all. The row that
|
||||
led here shows the wish's name and nothing else, which leaves the
|
||||
reader to guess what a star beside a café has to do with a museum —
|
||||
it is the rule, and the rule is short enough to state. */}
|
||||
{target === 'bucket_list' && suggestion.matchedBucketListName && (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-muted">
|
||||
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
|
||||
<span>
|
||||
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
|
||||
{' — '}
|
||||
{t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.name')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-accept-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
|
||||
itself from the OS locale and a native `type="time"` ignores the 12h/24h
|
||||
setting outright (#2067) — and neither follows the colour scheme. The
|
||||
date column is the wide one because a localized date is long and a
|
||||
clock never is. */}
|
||||
{target !== 'bucket_list' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.from')}
|
||||
</span>
|
||||
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.to')}
|
||||
</span>
|
||||
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked and full width, in the order the choice is actually made: the
|
||||
day is what somebody is deciding, the trip is usually already right.
|
||||
Side by side, both pickers were half as wide as their longest option
|
||||
and the menus opened narrower than the text in them. */}
|
||||
{target === 'place' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.day')}
|
||||
</span>
|
||||
{/* Unplanned is a real answer, not a missing one: half the places on
|
||||
a trip sit on no day, and forcing a choice here would invent one. */}
|
||||
<CustomSelect
|
||||
value={dayId === '' ? NO_DAY : dayId}
|
||||
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
|
||||
options={[
|
||||
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
|
||||
// The date rides as the option's badge rather than inside its
|
||||
// label: the day number is what people scan for, and the date
|
||||
// is the confirmation they read second.
|
||||
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.trip')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={tripId}
|
||||
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
|
||||
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
|
||||
placeholder={t('dawarich.accept.pickTrip')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target === 'journal' && (
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.journal')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={journalId}
|
||||
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
|
||||
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
|
||||
placeholder={t('dawarich.accept.pickJournal')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== 'bucket_list' && (
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="dawarich-accept-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={target === 'journal' ? 5 : 3}
|
||||
placeholder={t('dawarich.accept.storyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
{target === 'journal' && (
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const title = t(`dawarich.accept.title.${target}`)
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own footer pill,
|
||||
// and every other form on it uses them. A desktop modal scaled down would be
|
||||
// the one dialog in the app that came from somewhere else.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
// The mark fills the tile edge to edge, rounded like every other
|
||||
// avatar-sized badge in the shell — a small logo floating in a grey
|
||||
// square reads as a placeholder.
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={confirm}
|
||||
submitLabel={t(`dawarich.accept.confirm.${target}`)}
|
||||
submitDisabled={busy || missingTarget}
|
||||
/>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
size="lg"
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={busy || missingTarget}
|
||||
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{t(`dawarich.accept.confirm.${target}`)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fields}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
|
||||
* A band a later version adds is shown as nothing rather than as a raw English
|
||||
* word or a missing translation key.
|
||||
*/
|
||||
const KNOWN_BANDS = ['high', 'medium', 'low']
|
||||
|
||||
/**
|
||||
* "No day" as a real option value.
|
||||
*
|
||||
* CustomSelect uses strict equality on its values and treats the empty string as
|
||||
* "nothing chosen", which would make the placeholder and the deliberate answer
|
||||
* indistinguishable — and unplanned is a deliberate answer here.
|
||||
*/
|
||||
const NO_DAY = '__none__'
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-029
|
||||
/**
|
||||
* The room where Dawarich's two Atlas questions get answered.
|
||||
*
|
||||
* Nothing in here runs on its own, and that is the whole design: the Atlas is a
|
||||
* curated list that carries tombstones precisely so a country somebody removed
|
||||
* stays removed, so every read and every write starts with a person pressing a
|
||||
* button. What is worth pinning is therefore not "does it fetch" but everything
|
||||
* arranged around the fetch.
|
||||
*
|
||||
* - **What is written, and dated when.** A wish is ticked off on the day it was
|
||||
* reached, not on today, and wishes reached on the same day travel in one
|
||||
* request. A regression here either back-dates somebody's list wrongly or
|
||||
* turns one confirmation into twenty requests.
|
||||
* - **The five states each half can be in.** Unasked, in flight, answered,
|
||||
* answered with nothing, answered with caveats. The confirm button is
|
||||
* rendered or absent rather than merely greyed out, so each state has to
|
||||
* decide whether that control exists at all, and the caveats under the list
|
||||
* are only said when there is something to say.
|
||||
* - **Refusing to write blind.** Offline is not a server error: nothing was
|
||||
* sent, and "something went wrong talking to Dawarich" for a train tunnel
|
||||
* sends people hunting for a bug in their own instance.
|
||||
* - **Both shells.** The phone gets an MSheet with its own footer, the desktop
|
||||
* a Modal; the body between them is the same tree, so a regression that
|
||||
* dropped one shell would otherwise only surface on a device.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import { DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
|
||||
|
||||
const repo = {
|
||||
bucketScan: vi.fn(),
|
||||
atlasSuggestions: vi.fn(),
|
||||
}
|
||||
vi.mock('../../repo/dawarichRepo', () => ({
|
||||
dawarichRepo: {
|
||||
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
|
||||
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
|
||||
},
|
||||
DawarichOfflineError: class extends Error {},
|
||||
}))
|
||||
|
||||
const api = {
|
||||
confirmBucketVisits: vi.fn(),
|
||||
acceptAtlasCountries: vi.fn(),
|
||||
}
|
||||
vi.mock('../../api/dawarich', () => ({
|
||||
dawarichApi: {
|
||||
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
|
||||
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// The one thing the dialog reads about the device. A hoisted flag rather than a
|
||||
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
|
||||
const phone = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
|
||||
|
||||
const SCAN = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 11,
|
||||
name: 'Museum Ludwig',
|
||||
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 12,
|
||||
name: 'Kölner Dom',
|
||||
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
|
||||
alreadyVisited: true,
|
||||
},
|
||||
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
|
||||
],
|
||||
skippedWithoutCoordinates: 2,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
const COUNTRIES = {
|
||||
countries: [
|
||||
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
|
||||
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
|
||||
],
|
||||
unresolved: ['Freedonia'],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/**
|
||||
* Three wishes reached on two days. The pair sharing a timestamp is the whole
|
||||
* point of the fixture: the dialog groups by the date it is about to write, and
|
||||
* a regression that lost the grouping would send one request per wish. The
|
||||
* third one sits far enough away to be reported in kilometres rather than
|
||||
* metres, which is the other half of the row's arithmetic.
|
||||
*/
|
||||
const SAME_DAY = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 21,
|
||||
name: 'Van Gogh Museum',
|
||||
match: { at: '2026-08-02T11:00:00Z', minutes: 95, distanceMeters: 30, points: 40 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 22,
|
||||
name: 'Rijksmuseum',
|
||||
match: { at: '2026-08-02T11:00:00Z', minutes: 120, distanceMeters: 55, points: 51 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 23,
|
||||
name: 'Kinderdijk',
|
||||
match: { at: '2026-07-30T09:00:00Z', minutes: 70, distanceMeters: 1400, points: 22 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
],
|
||||
skippedWithoutCoordinates: 0,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading with nothing to apologise for: no unmatched names, one country with
|
||||
* more cities than fit beside a flag and one with none at all.
|
||||
*/
|
||||
const MORE_COUNTRIES = {
|
||||
countries: [
|
||||
{
|
||||
countryCode: 'FR',
|
||||
sourceName: 'France',
|
||||
cities: ['Paris', 'Lyon', 'Nice', 'Nantes'].map(name => ({
|
||||
name,
|
||||
minutes: 120,
|
||||
lastSeenAt: '2026-08-02T10:00:00Z',
|
||||
})),
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{ countryCode: 'BE', sourceName: 'Belgium', cities: [], alreadyVisited: false },
|
||||
],
|
||||
unresolved: [],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/** A promise this test settles by hand, to pin down what is on screen mid-flight. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
|
||||
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone.value = false
|
||||
setForcedOffline(false)
|
||||
toast.success.mockReset()
|
||||
toast.error.mockReset()
|
||||
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
|
||||
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
|
||||
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
|
||||
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
|
||||
})
|
||||
|
||||
// Force-offline is module state, not component state: left on, it would leak
|
||||
// into the next file's writes.
|
||||
afterEach(() => setForcedOffline(false))
|
||||
|
||||
describe('DawarichAtlasDialog', () => {
|
||||
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
|
||||
open()
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
|
||||
open()
|
||||
// Hidden until a result is on screen rather than merely disabled: a greyed
|
||||
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
|
||||
// keeps it out of the accessibility tree too.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
|
||||
expect(row.textContent).toContain('49 m away')
|
||||
expect(row.textContent).toContain('2 h 25 min')
|
||||
// A wish with no match at all is not a row — there is nothing to decide.
|
||||
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// With the date the stay was actually reached on — a wish from last year
|
||||
// ticked off with today's date is a wrong entry in a list people keep.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
// Marked in place rather than rescanned — one upstream request per wish is
|
||||
// not worth spending to tell the reader what they just did.
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
|
||||
// A country Dawarich named but TREK could not code is said out loud, not dropped.
|
||||
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
|
||||
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-010: while the recordings are being read there are placeholders and nothing to press', async () => {
|
||||
const scan = deferred<typeof SCAN>()
|
||||
repo.bucketScan.mockReturnValueOnce(scan.promise)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
expect(screen.getByText('Checking…')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox')).toBeNull()
|
||||
// Neither control exists yet: there is nothing to confirm, and "check again"
|
||||
// before the first answer is a second request for the same thing.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
scan.resolve(SCAN)
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-011: a scan that found nothing says so in a sentence and offers no confirmation', async () => {
|
||||
repo.bucketScan.mockResolvedValue({ ...SCAN, matches: [], skippedWithoutCoordinates: 0 })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Nothing on your wishlist turned up in your recordings.')).toBeInTheDocument(),
|
||||
)
|
||||
// Absent, not greyed out: a dead "Tick off 0" under an empty state only asks
|
||||
// to be understood.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
// The rule stays on screen, because it is the answer to "why did nothing
|
||||
// turn up": driving past a wish does not count as reaching it.
|
||||
expect(screen.getByText(/A wish counts as reached within 250 m and after 20 minutes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-012: the caveats under a scan are only said when there are any', async () => {
|
||||
repo.bucketScan.mockResolvedValue({ ...SCAN, skippedWithoutCoordinates: 0, truncated: true })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Only the first entries were checked. Run it again for the rest.')).toBeInTheDocument(),
|
||||
)
|
||||
// Nothing was skipped this time, so nothing is said about skipping: a
|
||||
// "0 entries have no coordinates" line is noise dressed as information.
|
||||
expect(screen.queryByText(/have no coordinates/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-013: a country shows the first of its cities, one without any shows none', async () => {
|
||||
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'France' })).toBeInTheDocument())
|
||||
const france = screen.getByRole('checkbox', { name: 'France' })
|
||||
// The badge counts all four; the line beside it names three, which is what
|
||||
// fits beside a flag at the width a phone gives this dialog.
|
||||
expect(france.textContent).toContain('4 cities')
|
||||
expect(france.textContent).toContain('Paris, Lyon, Nice')
|
||||
expect(france.textContent).not.toContain('Nantes')
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' }).textContent).not.toContain('cities')
|
||||
// Everything matched, so the dialog does not mention matching at all.
|
||||
expect(screen.queryByText(/could not match these to a country/)).toBeNull()
|
||||
expect(screen.getByText('The last 12 months were looked at.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-014: a reading with nothing new in it says so rather than showing an empty list', async () => {
|
||||
// Germany alone, and the Atlas already has it.
|
||||
repo.atlasSuggestions.mockResolvedValue({ ...COUNTRIES, countries: [COUNTRIES.countries[0]] })
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
|
||||
repo.bucketScan.mockResolvedValue(SAME_DAY)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Rijksmuseum' })).toBeInTheDocument())
|
||||
// Over a kilometre out is said in kilometres: "1400 m away" is a
|
||||
// measurement, "1.4 km away" is how far it was.
|
||||
expect(screen.getByRole('checkbox', { name: 'Kinderdijk' }).textContent).toContain('1.4 km away')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 3/ }))
|
||||
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(2))
|
||||
// Grouped by date, not ordered by it: the row order inside a day is the
|
||||
// sort's business and nothing here depends on it.
|
||||
const calls = api.confirmBucketVisits.mock.calls as Array<[number[], string]>
|
||||
const byDate = new Map(calls.map(([ids, at]) => [at, [...ids].sort((a, b) => a - b)]))
|
||||
expect(byDate.get('2026-08-02T11:00:00Z')).toEqual([21, 22])
|
||||
expect(byDate.get('2026-07-30T09:00:00Z')).toEqual([23])
|
||||
// Both answers counted, not just the last one to come back.
|
||||
expect(toast.success).toHaveBeenCalledWith('2 wishes ticked off')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-016: an unticked wish is left out of the write entirely', async () => {
|
||||
repo.bucketScan.mockResolvedValue(SAME_DAY)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Kinderdijk' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Kinderdijk' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Rijksmuseum' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// One request, for one day, carrying one id. The day whose other wish was
|
||||
// dropped does not travel half-empty, and the other day does not travel at all.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(1))
|
||||
expect(api.confirmBucketVisits).toHaveBeenCalledWith([21], '2026-08-02T11:00:00Z')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-017: a row that was unticked can be put back', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-018: offline, a wish confirmation is refused before anything is sent', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
// Not attempted at all: a confirmation fired into a dead connection hangs to
|
||||
// a timeout and then reports a server error for something no server saw.
|
||||
expect(api.confirmBucketVisits).not.toHaveBeenCalled()
|
||||
// And the selection survives, so pressing it again once there is signal is enough.
|
||||
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-019: offline, a country confirmation is refused before anything is sent', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-020: a write that failed leaves every row exactly as it was', async () => {
|
||||
// A rejection with no `{ error }` envelope at all: a socket that died
|
||||
// mid-request rather than a server that answered.
|
||||
api.confirmBucketVisits.mockRejectedValue(new Error('socket hang up'))
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
// Nothing was marked off locally, so a second attempt still has something to
|
||||
// send, and the Atlas is not asked to re-read for a write that never landed.
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeEnabled()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-021: a refusal carrying no readable reason still gets a sentence', async () => {
|
||||
// An `{ error }` that is not a string is not a message: falling through to
|
||||
// the generic sentence beats printing "[object Object]" at somebody.
|
||||
api.acceptAtlasCountries.mockRejectedValue({ response: { data: { error: { code: 502 } } } })
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-022: a read that never left the device reads as a connection, not a failure', async () => {
|
||||
// The repo refuses offline reads itself, and its refusal has to survive the
|
||||
// trip through failureText as the connection message rather than as
|
||||
// "something went wrong talking to Dawarich", which points at the instance.
|
||||
repo.atlasSuggestions.mockRejectedValue(new DawarichOfflineError())
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')),
|
||||
)
|
||||
// Still unasked, so the button that asks is the one on screen.
|
||||
expect(screen.getByRole('button', { name: 'Look for countries' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-023: the other tab asks the other question, and each keeps its own answer', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Countries/ }))
|
||||
|
||||
// Switching tabs asks nothing on its own: a country reading costs an
|
||||
// upstream request, and nobody asked for it by moving their eyes.
|
||||
expect(repo.atlasSuggestions).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Wishlist/ }))
|
||||
|
||||
// The scan is still there, and was not repeated for the return trip.
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-024: "Check again" re-runs the scan and is inert while it runs', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
const second = deferred<typeof SCAN>()
|
||||
repo.bucketScan.mockReturnValueOnce(second.promise)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
|
||||
|
||||
// A second press while the first is in flight would run the whole scan twice:
|
||||
// one upstream request per wish, for the same answer.
|
||||
expect(screen.getByRole('button', { name: 'Check again' })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(SCAN)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-025: "Check again" on the countries tab re-reads the countries, not the wishes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
|
||||
|
||||
await waitFor(() => expect(repo.atlasSuggestions).toHaveBeenCalledTimes(2))
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
// A year back from the moment it was asked, both times: the window is the
|
||||
// same question asked again, not a widening one.
|
||||
const calls = repo.atlasSuggestions.mock.calls as Array<[string, string]>
|
||||
for (const [from, to] of calls) {
|
||||
expect(Date.parse(to) - Date.parse(from)).toBe(365 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
expect(Date.parse(calls[1][0])).toBeGreaterThanOrEqual(Date.parse(calls[0][0]))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-026: on a phone it is a sheet, and the count and the confirm live in its own footer', async () => {
|
||||
phone.value = true
|
||||
const onClose = vi.fn()
|
||||
open({ onClose })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Dawarich' })).toBeInTheDocument()
|
||||
// The desktop modal puts the title in a heading: its absence is the tell
|
||||
// that the right shell rendered.
|
||||
expect(screen.queryByRole('heading', { name: /Dawarich/ })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-027: a country that was just added stops being offered, and the Atlas is told', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ initialTab: 'countries', onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
// Marked in place rather than re-read: the reading costs an upstream request
|
||||
// and the only thing that changed is what the reader just pressed. The row
|
||||
// leaving the list is what says the write landed.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
|
||||
// The Atlas keeps its own map and its own counts; without this the country
|
||||
// it was just told about only appears there after a reload.
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-028: a request that died without a reason at all still gets a sentence', async () => {
|
||||
// An aborted request rejects with nothing: not an Error, not a response.
|
||||
// Reaching into `.response.data` on that must not be the thing that takes
|
||||
// the dialog down, because a crash here loses the scan the reader is
|
||||
// looking at as well as the message they needed.
|
||||
repo.bucketScan.mockRejectedValue(undefined)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-029: a country can be unticked and put back, and only the ticked ones are written', async () => {
|
||||
// Both offers start ticked, which is the useful default but not the answer
|
||||
// every time: a border crossed on the way to somewhere else is a country
|
||||
// Dawarich saw and the reader does not want in their Atlas. Taking one out
|
||||
// has to actually take it out of the write, and changing one's mind has to
|
||||
// put it back, because the Atlas carries tombstones and an unwanted country
|
||||
// added here is removed by hand afterwards.
|
||||
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Belgium' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /Add 2 countries/ })).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Belgium' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
// Out and back in again: the tick is a toggle, not a one-way door.
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('button', { name: /Add 0 countries/ })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
// Belgium was never sent, so nothing has to be undone in the Atlas after.
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
// And it is still on offer, still unticked, for the trip it does belong to.
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,578 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
|
||||
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Skeleton } from '../shared/Skeleton'
|
||||
import { SlidingTabs } from '../shared/SlidingTabs'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { dawarichApi } from '../../api/dawarich'
|
||||
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { formatDuration } from './dawarichSuggestionModel'
|
||||
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
|
||||
|
||||
type Tab = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Everything Dawarich can answer about the Atlas, in a room of its own.
|
||||
*
|
||||
* It used to be a card wedged under the wishlist, which made two different
|
||||
* questions — "which wishes did I actually reach?" and "which countries was I
|
||||
* in?" — look like one paragraph of small print. Both are offers that need
|
||||
* reading before they are accepted, and reading needs space, so they get a
|
||||
* dialog with a tab each, one selection model and one confirm button.
|
||||
*
|
||||
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
|
||||
* precisely so a country somebody removed stays removed — and a background job
|
||||
* that ticked countries off would be fighting its owner.
|
||||
*/
|
||||
export default function DawarichAtlasDialog({
|
||||
isOpen,
|
||||
initialTab = 'wishes',
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
/** Which question was asked to open it — the panel has a button per tab. */
|
||||
initialTab?: Tab
|
||||
onClose: () => void
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const phone = useIsPhone()
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set())
|
||||
|
||||
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
|
||||
const [loadingCountries, setLoadingCountries] = useState(false)
|
||||
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
|
||||
// One write at a time: the button stays on screen while the request is in
|
||||
// flight, and a second press would send the same confirmation twice.
|
||||
const [writing, setWriting] = useState(false)
|
||||
|
||||
const matches = orderedMatches(scan?.matches ?? [])
|
||||
const openMatches = matches.filter(match => !match.alreadyVisited)
|
||||
const offered = newCountries(countries?.countries ?? [])
|
||||
|
||||
const runScan = async (): Promise<void> => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const result = await dawarichRepo.bucketScan()
|
||||
setScan(result)
|
||||
// Everything with a match and not already ticked starts selected: the
|
||||
// common case is "yes, all of those", and unticking the odd one is less
|
||||
// work than ticking twenty.
|
||||
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCountries = async (): Promise<void> => {
|
||||
setLoadingCountries(true)
|
||||
try {
|
||||
const { from, to } = countryWindow(new Date())
|
||||
const result = await dawarichRepo.atlasSuggestions(from, to)
|
||||
setCountries(result)
|
||||
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setLoadingCountries(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmWishes = async (): Promise<void> => {
|
||||
if (picked.size === 0 || writing) return
|
||||
// Writes are online-only: the reads are gated in the repo, and firing a
|
||||
// confirmation into a dead connection would hang to a timeout and then
|
||||
// report a server error for something the server never saw.
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
// Ticked off on the day each wish was reached, not on today. The dialog is
|
||||
// holding that date for every row it is about to write, so it says it;
|
||||
// grouping by date keeps that to one request per day rather than per wish.
|
||||
const byDate = new Map<string, number[]>()
|
||||
for (const m of matches) {
|
||||
if (!picked.has(m.itemId) || !m.match) continue
|
||||
const bucket = byDate.get(m.match.at)
|
||||
if (bucket) bucket.push(m.itemId)
|
||||
else byDate.set(m.match.at, [m.itemId])
|
||||
}
|
||||
const results = await Promise.all(
|
||||
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
|
||||
)
|
||||
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
|
||||
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
|
||||
// Marked in place rather than rescanned: the scan costs one upstream
|
||||
// request per wish, and the reader already knows what they just ticked.
|
||||
setScan(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
|
||||
)
|
||||
setPicked(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCountries = async (): Promise<void> => {
|
||||
if (pickedCountries.size === 0 || writing) return
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
|
||||
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
|
||||
setCountries(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
countries: prev.countries.map(c =>
|
||||
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
|
||||
),
|
||||
},
|
||||
)
|
||||
setPickedCountries(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||
const next = new Set(set)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
const busy = tab === 'wishes' ? scanning : loadingCountries
|
||||
const loaded = tab === 'wishes' ? scan !== null : countries !== null
|
||||
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
|
||||
// Nothing was found, so there is nothing to confirm — the empty state says so
|
||||
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
|
||||
// be understood.
|
||||
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
|
||||
|
||||
// The same two questions in both shells — only the frame differs.
|
||||
const body = (
|
||||
<div className="space-y-4">
|
||||
<SlidingTabs<Tab>
|
||||
tabs={[
|
||||
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
|
||||
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
size="sm"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{tab === 'wishes' ? (
|
||||
<Section
|
||||
icon={Star}
|
||||
title={t('dawarich.bucket.title')}
|
||||
description={t('dawarich.bucket.description')}
|
||||
action={t('dawarich.bucket.scan')}
|
||||
busyLabel={t('dawarich.bucket.scanning')}
|
||||
busy={scanning}
|
||||
loaded={scan !== null}
|
||||
onRun={() => { void runScan() }}
|
||||
empty={matches.length === 0}
|
||||
emptyLabel={t('dawarich.bucket.noMatches')}
|
||||
notes={[
|
||||
scan && scan.skippedWithoutCoordinates > 0
|
||||
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
|
||||
: null,
|
||||
scan?.truncated ? t('dawarich.bucket.truncated') : null,
|
||||
t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{matches.map(match => (
|
||||
<Row
|
||||
key={match.itemId}
|
||||
name={match.name}
|
||||
checked={picked.has(match.itemId)}
|
||||
disabled={match.alreadyVisited}
|
||||
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
|
||||
leading={
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
|
||||
<Star className="w-4 h-4 text-accent-on" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{match.alreadyVisited ? (
|
||||
<DawarichBadge icon={Check} tone="success" size="sm">
|
||||
{t('dawarich.bucket.alreadyVisited')}
|
||||
</DawarichBadge>
|
||||
) : (
|
||||
<>
|
||||
<DawarichBadge icon={MapPin} size="sm">
|
||||
{formatDistance(match.match!.distanceMeters, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{formatDuration(match.match!.minutes, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={CalendarDays} size="sm">
|
||||
{new Date(match.match!.at).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</DawarichBadge>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Section
|
||||
icon={Globe2}
|
||||
title={t('dawarich.atlas.title')}
|
||||
description={t('dawarich.atlas.description')}
|
||||
action={t('dawarich.atlas.load')}
|
||||
busyLabel={t('dawarich.atlas.loading')}
|
||||
busy={loadingCountries}
|
||||
loaded={countries !== null}
|
||||
onRun={() => { void loadCountries() }}
|
||||
empty={offered.length === 0}
|
||||
emptyLabel={t('dawarich.atlas.empty')}
|
||||
notes={[
|
||||
countries && countries.unresolved.length > 0
|
||||
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
|
||||
: null,
|
||||
countries ? t('dawarich.atlas.window') : null,
|
||||
]}
|
||||
>
|
||||
{offered.map(country => (
|
||||
<Row
|
||||
key={country.countryCode}
|
||||
name={countryLabel(country.countryCode, country.sourceName, locale)}
|
||||
checked={pickedCountries.has(country.countryCode)}
|
||||
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
|
||||
leading={
|
||||
// The Atlas page draws its countries with the same flags, and a
|
||||
// list of country names without them looks like a spreadsheet.
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
|
||||
alt=""
|
||||
className="h-6 w-8 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{country.cities.length > 0 && (
|
||||
<>
|
||||
<DawarichBadge icon={Building2} size="sm">
|
||||
{country.cities.length === 1
|
||||
? t('dawarich.atlas.citiesOne')
|
||||
: t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Rendered or not, rather than `hidden`: the attribute loses to the display
|
||||
// utility on the class list, so the button stayed on screen greyed out.
|
||||
const confirmButton = !loaded || !anything ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
|
||||
disabled={selected === 0 || writing}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
|
||||
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
|
||||
}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{tab === 'wishes'
|
||||
? t('dawarich.bucket.confirm', { count: selected })
|
||||
: t('dawarich.atlas.accept', { count: selected })}
|
||||
</button>
|
||||
)
|
||||
|
||||
const againButton = loaded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
|
||||
disabled={busy}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
|
||||
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{t('dawarich.again')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own pill
|
||||
// buttons, and every other panel on it uses them.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={t('dawarich.title')}
|
||||
subtitle={t('dawarich.atlas.dialogSubtitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded dialog reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<DawarichIcon size={26} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
|
||||
<span className="block truncate text-caption font-normal text-content-muted">
|
||||
{t('dawarich.atlas.dialogSubtitle')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-caption text-content-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One half of the dialog: the pitch before anything has been asked, the wait
|
||||
* while it is being asked, and the answer.
|
||||
*
|
||||
* Both halves behave identically, which is the point — the two questions are
|
||||
* different, the shape of answering them is not.
|
||||
*/
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
busyLabel,
|
||||
busy,
|
||||
loaded,
|
||||
onRun,
|
||||
empty,
|
||||
emptyLabel,
|
||||
notes,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
busyLabel: string
|
||||
busy: boolean
|
||||
loaded: boolean
|
||||
onRun: () => void
|
||||
empty: boolean
|
||||
emptyLabel: string
|
||||
notes: (string | null)[]
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy>
|
||||
<p className="text-caption text-content-muted">{busyLabel}</p>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
|
||||
<Skeleton width={32} height={32} radius={8} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton width="45%" height={13} radius={4} />
|
||||
<Skeleton width="70%" height={11} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<Icon className="w-5 h-5 text-content-secondary" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{title}</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{empty ? (
|
||||
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
)}
|
||||
{notes.filter(Boolean).length > 0 && (
|
||||
<div className="space-y-1 border-t border-edge-faint pt-2.5">
|
||||
{notes.filter(Boolean).map(note => (
|
||||
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
|
||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable answer: what it is on the left, why it is being offered
|
||||
* underneath, and a tick that says whether it will be applied.
|
||||
*
|
||||
* The whole row is the control — a 16px checkbox is a hard target on a phone,
|
||||
* and there is nothing else in the row to click.
|
||||
*/
|
||||
function Row({
|
||||
name,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
leading,
|
||||
children,
|
||||
}: {
|
||||
name: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
leading: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={name}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
||||
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
|
||||
} ${disabled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
|
||||
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
|
||||
</span>
|
||||
{leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-medium text-content">{name}</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell the reader when a question could not be asked.
|
||||
*
|
||||
* Being offline is not an error the server reported — nothing was sent — and
|
||||
* "something went wrong" for a plane journey is the kind of message that sends
|
||||
* somebody looking for a bug in their Dawarich instance.
|
||||
*/
|
||||
function failureText(err: unknown, t: (key: string) => string): string {
|
||||
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
|
||||
return errorText(err) ?? t('dawarich.error.unknown')
|
||||
}
|
||||
|
||||
/** The server's `{ error }` envelope, when there is one. */
|
||||
function errorText(err: unknown): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// FE-DAWARICH-ATLASPANEL-001 to FE-DAWARICH-ATLASPANEL-007
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The panel itself holds almost no logic, and the little it does hold is the
|
||||
* kind that is invisible until it is wrong:
|
||||
*
|
||||
* - **It has to disappear completely when the addon is off.** Not empty, not a
|
||||
* blank pane of glass in the row beside the stats: absent, so an Atlas
|
||||
* without Dawarich looks exactly as it did before the integration landed.
|
||||
* - **It has to ask the question that was pressed.** Two tiles, one dialog, one
|
||||
* `initialTab` between them. Wiring both tiles to the same tab is a bug that
|
||||
* looks like a working panel.
|
||||
* - **It mounts the dialog on open and throws it away on close**, deliberately,
|
||||
* so a second visit starts from a fresh question rather than from a scan
|
||||
* somebody ran an hour ago.
|
||||
* - **It is made of the same glass as the Atlas panel next to it.** The tint
|
||||
* and the cursor glare are hand-written rgba rather than tokens (a solid
|
||||
* surface token would punch an opaque hole in a panel floating over a blurred
|
||||
* map), so nothing but a test stops the two panels drifting apart in the
|
||||
* dark theme.
|
||||
*
|
||||
* The dialog is pinned by its own file and stands in for itself here, so the
|
||||
* panel's decisions are observable without a scan and without mocking Dawarich.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import DawarichAtlasSidePanel from './DawarichAtlasSidePanel'
|
||||
|
||||
vi.mock('./DawarichAtlasDialog', () => ({
|
||||
default: ({
|
||||
initialTab,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
initialTab: string
|
||||
onClose: () => void
|
||||
onChanged?: () => void
|
||||
}) => (
|
||||
<div data-testid="atlas-dialog">
|
||||
<span>asked: {initialTab}</span>
|
||||
<button type="button" onClick={onClose}>
|
||||
dismiss
|
||||
</button>
|
||||
<button type="button" onClick={() => onChanged?.()}>
|
||||
wrote something
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true }
|
||||
|
||||
/** The glass the Atlas hands down, reduced to the one property jsdom can answer for. */
|
||||
const GLASS: React.CSSProperties = { borderRadius: 20 }
|
||||
|
||||
/** Inline rgba comes back from jsdom respaced; nothing here cares about the spaces. */
|
||||
const rgba = (el: HTMLElement): string => el.style.background.replace(/\s/g, '')
|
||||
|
||||
function mount(props: Partial<React.ComponentProps<typeof DawarichAtlasSidePanel>> = {}) {
|
||||
return render(<DawarichAtlasSidePanel style={GLASS} dark {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [ADDON], loaded: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAddonStore.setState({ addons: [], loaded: false })
|
||||
})
|
||||
|
||||
describe('DawarichAtlasSidePanel', () => {
|
||||
it('FE-DAWARICH-ATLASPANEL-001: an Atlas without the addon gets no panel at all', () => {
|
||||
useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] })
|
||||
|
||||
const { container } = mount()
|
||||
|
||||
// Nothing rendered, rather than an empty pane of glass in the row: the
|
||||
// panel is stretched to the height of the stats beside it, so an empty one
|
||||
// would be a visible hole.
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByRole('button', { name: 'Wishlist' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-002: with the addon on it offers two questions and asks neither', () => {
|
||||
const { container } = mount()
|
||||
|
||||
expect(screen.getByText('Dawarich')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Wishlist' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Countries' })).toBeInTheDocument()
|
||||
// A scan costs one upstream request per wish. Nothing is asked until asked.
|
||||
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
|
||||
// And it wears the glass it was handed rather than one of its own.
|
||||
expect(container.firstElementChild).toHaveStyle({ borderRadius: '20px' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-003: the wishlist tile opens the dialog on the wishlist, and closing puts it away', () => {
|
||||
mount()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Wishlist' }))
|
||||
|
||||
expect(screen.getByText('asked: wishes')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dismiss' }))
|
||||
|
||||
// Unmounted, not hidden: the next visit starts from a clean question rather
|
||||
// than from an answer that is an hour old.
|
||||
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-004: the countries tile opens the same dialog on the other question', () => {
|
||||
mount()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
|
||||
|
||||
expect(screen.getByText('asked: countries')).toBeInTheDocument()
|
||||
expect(screen.queryByText('asked: wishes')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-005: what the dialog wrote is reported up to the Atlas', () => {
|
||||
// The Atlas keeps its own counts and its own map. Without this the confirmed
|
||||
// countries only appear after a reload.
|
||||
const onChanged = vi.fn()
|
||||
mount({ onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'wrote something' }))
|
||||
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-006: the tiles are a tint of the light the panel is made of, per theme', () => {
|
||||
const { rerender } = render(<DawarichAtlasSidePanel style={GLASS} dark={false} />)
|
||||
const wishes = screen.getByRole('button', { name: 'Wishlist' })
|
||||
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
|
||||
fireEvent.mouseEnter(wishes)
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.09)')
|
||||
fireEvent.mouseLeave(wishes)
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
|
||||
|
||||
rerender(<DawarichAtlasSidePanel style={GLASS} dark />)
|
||||
|
||||
// Inverted on the dark map, not merely darkened: the tile is light added to
|
||||
// the glass, and a black tint on a dark blurred map is an invisible button.
|
||||
expect(rgba(screen.getByRole('button', { name: 'Wishlist' }))).toBe('rgba(255,255,255,0.07)')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-007: the glass glare follows the cursor and goes out behind it', () => {
|
||||
const { container } = mount()
|
||||
const panel = container.firstElementChild as HTMLElement
|
||||
const [glare, borderGlare] = Array.from(panel.children) as HTMLElement[]
|
||||
|
||||
expect(glare.style.opacity).toBe('0')
|
||||
|
||||
fireEvent.mouseMove(panel, { clientX: 140, clientY: 60 })
|
||||
|
||||
// The pool of light has to sit under the pointer, not merely exist: the
|
||||
// panel's box measures as the origin here, so the cursor's own coordinates
|
||||
// are what should end up in the gradient. An effect that lit up in a fixed
|
||||
// spot would pass an opacity-only assertion and look broken on screen.
|
||||
expect(glare.style.background).toContain('at 140px 60px')
|
||||
expect(glare.style.opacity).toBe('1')
|
||||
expect(borderGlare.style.opacity).toBe('1')
|
||||
|
||||
fireEvent.mouseLeave(panel)
|
||||
|
||||
expect(glare.style.opacity).toBe('0')
|
||||
expect(borderGlare.style.opacity).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Globe2, Star } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGlassGlare } from '../Atlas/useGlassGlare'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
type Question = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The Atlas is a map with a single glass panel floating over it, and everything
|
||||
* it offers lives in that panel. Dawarich answers two questions about the same
|
||||
* map — which wishes were actually reached, and which countries the recordings
|
||||
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
|
||||
* squeezed under the wishlist they read as fine print nobody asked for.
|
||||
*
|
||||
* So it gets a panel of its own, in the same glass and stretched to the same
|
||||
* height, and the two questions are two tiles built like the stat blocks beside
|
||||
* them: a mark, then a small capitalised label. The lists they produce open in a
|
||||
* dialog — they are offers to read and confirm, and a bar over a world map is no
|
||||
* place to read anything.
|
||||
*
|
||||
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
|
||||
* exactly as it did before.
|
||||
*/
|
||||
export default function DawarichAtlasSidePanel({
|
||||
style,
|
||||
dark,
|
||||
onChanged,
|
||||
}: {
|
||||
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
|
||||
style: React.CSSProperties
|
||||
dark: boolean
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const addonEnabled = useAddonStore(state => state.isEnabled)
|
||||
const [asked, setAsked] = useState<Question | null>(null)
|
||||
const glare = useGlassGlare(dark)
|
||||
|
||||
if (!addonEnabled('dawarich')) return null
|
||||
|
||||
// theme-lint-disable — the panel floats on a blurred map: a solid surface
|
||||
// token would punch an opaque hole in the glass, so the tiles are a tint of
|
||||
// the same light the panel is made of.
|
||||
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
|
||||
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
|
||||
|
||||
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsked(id)}
|
||||
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
|
||||
style={{
|
||||
background: tile,
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
gap: 6,
|
||||
minWidth: 74,
|
||||
padding: '10px 12px',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = tile }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
<span
|
||||
className="font-bold uppercase tracking-wide whitespace-nowrap"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={glare.panelRef}
|
||||
onMouseMove={glare.onMouseMove}
|
||||
onMouseLeave={glare.onMouseLeave}
|
||||
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
|
||||
style={style}
|
||||
>
|
||||
{/* Liquid glass glare, and the border glow that follows the cursor —
|
||||
the same two layers the Atlas panel carries. */}
|
||||
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
|
||||
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
|
||||
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
|
||||
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
{/* The long explanation moved into the tooltip: on a panel this size it
|
||||
was three lines of grey that said what the two tiles already say. */}
|
||||
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
|
||||
<span className="relative flex items-center justify-center gap-2.5">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
|
||||
<DawarichIcon size={30} />
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-black tracking-tight text-content"
|
||||
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
|
||||
>
|
||||
{t('dawarich.title')}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="relative flex items-stretch gap-2">
|
||||
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
|
||||
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounted on open so every visit starts from a clean question rather than
|
||||
from a scan somebody ran an hour ago. */}
|
||||
{asked !== null && (
|
||||
<DawarichAtlasDialog
|
||||
isOpen
|
||||
initialTab={asked}
|
||||
onClose={() => setAsked(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// FE-DAWARICH-BADGE-001 to FE-DAWARICH-BADGE-007
|
||||
/**
|
||||
* The one pill the whole Dawarich surface is drawn from, pinned prop by prop.
|
||||
*
|
||||
* It is nine lines of JSX, which is exactly why it is worth a test: the review
|
||||
* list, the accept dialog and the Atlas dialog all render it, so a change here
|
||||
* lands in three places at once and none of their tests would name it as the
|
||||
* culprit.
|
||||
*
|
||||
* What is worth holding still, roughly in the order of what breaks worst:
|
||||
*
|
||||
* - **Icon-only means named.** `children === undefined` is the switch that
|
||||
* turns a badge into a round dot of colour, and it is the same switch that
|
||||
* adds `role="img"` and the accessible name. Lose the pairing and the low
|
||||
* confidence flag, the "source gone" flag and the "source changed" flag all
|
||||
* become unlabelled squares that a screen reader reads as nothing at all.
|
||||
* - **`children === undefined`, not `!children`.** The day header counts its
|
||||
* stays with the array length, so a falsy check would turn a legitimate
|
||||
* count of zero into an unlabelled dot.
|
||||
* - **The tone is the meaning.** Five tones, each a pair of theme tokens; a
|
||||
* warning that quietly maps to the neutral pair still reads as a fact worth
|
||||
* ignoring, which is the opposite of what it is there to say.
|
||||
* - **The type scales with the reader's setting.** Both sizes go through
|
||||
* `--fs-scale-caption`; a raw `fontSize: 11` would look identical in review
|
||||
* and ignore the text-size preference entirely.
|
||||
* - **A badge with something to add gets TREK's tooltip, one without gets
|
||||
* none.** `title` wins over `label` as the hover text, so a pointer user can
|
||||
* be told more than the accessible name says.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
|
||||
type BadgeProps = React.ComponentProps<typeof DawarichBadge>
|
||||
|
||||
// The clock goes in before anything renders, the way Tooltip's own suite does
|
||||
// it: swapping timers out from under a mounted tree leaves whatever it already
|
||||
// scheduled on the real clock. `shouldAdvanceTime` keeps real time moving, so
|
||||
// the locale promise the render helper's provider starts still settles.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Stands in for a lucide icon. All the badge does with one is hand it a
|
||||
* className, so that string is the only thing worth asserting about it.
|
||||
*/
|
||||
function StubIcon({ className }: { className?: string }) {
|
||||
return <svg data-testid="badge-icon" className={className} />
|
||||
}
|
||||
|
||||
function badge(props: BadgeProps) {
|
||||
const view = render(<DawarichBadge {...props} />)
|
||||
return { ...view, el: view.container.firstElementChild as HTMLElement }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the badge says on hover, or null when it has nothing to add and is
|
||||
* therefore not wrapped in a tooltip at all. The tooltip opens on a 250 ms
|
||||
* timer, so the clock has to be moved on by hand for either answer to be true
|
||||
* rather than merely early.
|
||||
*/
|
||||
function hoverText(el: HTMLElement): string | null {
|
||||
fireEvent.mouseEnter(el)
|
||||
// One window covers both steps: the 250 ms open timer, and the effect that
|
||||
// measures the trigger afterwards. Until that effect lands the portal is
|
||||
// `visibility: hidden`, which is invisible to a role query as well as to a
|
||||
// reader, so a half-flushed tooltip would read as no tooltip at all.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
return screen.queryByRole('tooltip')?.textContent ?? null
|
||||
}
|
||||
|
||||
describe('DawarichBadge', () => {
|
||||
it('FE-DAWARICH-BADGE-001: the default pill is neutral, xs, and says nothing on hover', () => {
|
||||
const { el } = badge({ children: '3 stays' })
|
||||
|
||||
expect(el).toHaveTextContent('3 stays')
|
||||
expect(el).toHaveClass('h-[18px]', 'px-1.5', 'bg-surface-secondary', 'text-content-secondary')
|
||||
// A pill that already reads "3 stays" needs no image role and no name of
|
||||
// its own: both would make a screen reader announce the same thing twice.
|
||||
expect(el).not.toHaveAttribute('role')
|
||||
expect(el).not.toHaveAttribute('aria-label')
|
||||
expect(el.style.fontSize).toBe('calc(10px * var(--fs-scale-caption, 1))')
|
||||
expect(el.querySelector('[data-testid="badge-icon"]')).toBeNull()
|
||||
// No title and no label, so there is nothing a tooltip could add.
|
||||
expect(hoverText(el)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-002: sm raises the box, the icon and the type together', () => {
|
||||
const { el } = badge({ icon: StubIcon, size: 'sm', children: '2 h 25 min' })
|
||||
|
||||
expect(el).toHaveClass('h-[20px]', 'px-1.5')
|
||||
expect(el.style.fontSize).toBe('calc(11px * var(--fs-scale-caption, 1))')
|
||||
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-3 h-3 flex-shrink-0')
|
||||
// The text truncates and the glyph does not: a long name must run out of
|
||||
// room before it squeezes the icon out of shape.
|
||||
expect(el.querySelector('.truncate')).toHaveTextContent('2 h 25 min')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-003: an icon-only pill is square, named, and offers its name on hover', () => {
|
||||
const { el } = badge({ icon: StubIcon, tone: 'warning', label: 'Low confidence' })
|
||||
|
||||
expect(el).toHaveAttribute('role', 'img')
|
||||
expect(el).toHaveAttribute('aria-label', 'Low confidence')
|
||||
expect(el).toHaveClass('w-[18px]', 'justify-center', 'bg-warning-soft', 'text-warning')
|
||||
// Side padding would make the circle an oval, and there is no text to pad
|
||||
// away from in the first place.
|
||||
expect(el).not.toHaveClass('px-1.5')
|
||||
expect(el.querySelector('.truncate')).toBeNull()
|
||||
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-2.5 h-2.5 flex-shrink-0')
|
||||
// No title, so the accessible name doubles as the hover text and a pointer
|
||||
// user is told exactly what a screen reader is told.
|
||||
expect(hoverText(el)).toBe('Low confidence')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-004: the square grows with the size, so the dot stays a circle', () => {
|
||||
const { el } = badge({ icon: StubIcon, size: 'sm', label: 'Source gone' })
|
||||
|
||||
expect(el).toHaveClass('h-[20px]', 'w-[20px]', 'justify-center')
|
||||
expect(el).not.toHaveClass('w-[18px]')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-005: title is the hover text, label stays the name', () => {
|
||||
const { el } = badge({
|
||||
icon: StubIcon,
|
||||
tone: 'warning',
|
||||
label: 'Low confidence',
|
||||
title: 'Dawarich is not sure this stay happened',
|
||||
})
|
||||
|
||||
// The two are not interchangeable: the name is what the badge is, the title
|
||||
// is what it has to add, and a screen reader would drown in the long one.
|
||||
expect(el).toHaveAttribute('aria-label', 'Low confidence')
|
||||
expect(hoverText(el)).toBe('Dawarich is not sure this stay happened')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-006: every tone keeps its own pair of theme tokens', () => {
|
||||
const tones: Array<[NonNullable<BadgeProps['tone']>, string[]]> = [
|
||||
['neutral', ['bg-surface-secondary', 'text-content-secondary']],
|
||||
['quiet', ['bg-surface-secondary', 'text-content-faint']],
|
||||
['accent', ['bg-accent-subtle', 'text-accent-on']],
|
||||
['success', ['bg-success-soft', 'text-success']],
|
||||
['warning', ['bg-warning-soft', 'text-warning']],
|
||||
]
|
||||
|
||||
for (const [tone, classes] of tones) {
|
||||
const { el, unmount } = badge({ tone, children: tone })
|
||||
// Tokens rather than colour literals, or the badge stops following the
|
||||
// user's scheme the moment they pick one.
|
||||
expect(el).toHaveClass(...classes)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-007: a zero is a child, not an absent one', () => {
|
||||
// The day header counts its stays with the array length. Icon-only is
|
||||
// `children === undefined` and not a falsy check for exactly this reason: a
|
||||
// count of 0 that turned into an unlabelled dot would be a day that lost
|
||||
// its number.
|
||||
const { el } = badge({ tone: 'quiet', children: 0 })
|
||||
|
||||
expect(el).toHaveTextContent('0')
|
||||
expect(el).not.toHaveAttribute('role')
|
||||
expect(el).toHaveClass('px-1.5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/**
|
||||
* One badge shape for everything Dawarich shows.
|
||||
*
|
||||
* Fixed height and a single radius, so a row of them lines up however many there
|
||||
* are; the tone is the only thing that varies, and it carries the meaning rather
|
||||
* than decorating it. `quiet` is for a fact worth stating and not worth looking
|
||||
* at twice.
|
||||
*
|
||||
* It lives in its own file because the review list, the accept dialog and the
|
||||
* Atlas dialog all use it, and three copies of a pill is how two of them end up
|
||||
* a pixel apart.
|
||||
*/
|
||||
export default function DawarichBadge({
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
size = 'xs',
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
|
||||
/**
|
||||
* `xs` matches the places rail, where the panel sits beside rows of that size;
|
||||
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
|
||||
*/
|
||||
size?: 'xs' | 'sm'
|
||||
/** Hover text. Defaults to `label` when the badge is icon-only. */
|
||||
title?: string
|
||||
/**
|
||||
* The accessible name for an icon-only badge. Passing it instead of children
|
||||
* is what makes a flag a round dot of colour without turning a screen reader
|
||||
* into someone staring at an unlabelled icon.
|
||||
*/
|
||||
label?: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-surface-secondary text-content-secondary',
|
||||
quiet: 'bg-surface-secondary text-content-faint',
|
||||
accent: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
}
|
||||
const metrics =
|
||||
size === 'sm'
|
||||
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
|
||||
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
|
||||
const iconOnly = children === undefined
|
||||
const hint = title ?? label
|
||||
const badge = (
|
||||
<span
|
||||
aria-label={iconOnly ? label : undefined}
|
||||
role={iconOnly ? 'img' : undefined}
|
||||
style={metrics.style}
|
||||
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
|
||||
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
|
||||
} ${tones[tone]}`}
|
||||
>
|
||||
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
|
||||
{!iconOnly && <span className="truncate">{children}</span>}
|
||||
</span>
|
||||
)
|
||||
// TREK's own tooltip rather than the browser's: the native one ignores the
|
||||
// colour scheme, waits a second and a half, and cannot be read on a touch
|
||||
// device at all. A badge with nothing to add gets none.
|
||||
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
// FE-DAWARICH-PANEL-001 to FE-DAWARICH-PANEL-033
|
||||
/**
|
||||
* The review panel both shells share, pinned end to end.
|
||||
*
|
||||
* `useDawarichSuggestions` is mocked rather than the network: the hook is
|
||||
* already covered on its own, and driving the panel through six `status`
|
||||
* values, a busy row and a refused accept via MSW would mean six round trips to
|
||||
* assert one branch each. What is NOT mocked is anything the panel renders
|
||||
* itself, `DawarichAcceptDialog` included, because the wiring between a row's
|
||||
* button and that dialog's confirm is exactly the part that would rot silently.
|
||||
*
|
||||
* What is worth pinning here, roughly in the order of what breaks worst:
|
||||
*
|
||||
* - **The panel renders nothing when there is nothing to review.** A connected,
|
||||
* quiet integration is meant to be invisible; the moment that early return
|
||||
* goes, every trip in the app grows a permanent empty card explaining that
|
||||
* Dawarich found nothing.
|
||||
* - **`accept` is the only path that writes.** The dialog closes and the host
|
||||
* is notified only when the hook says the write landed, so a refused accept
|
||||
* has to leave the dialog standing with the user's corrections still in it.
|
||||
* - **The two accept buttons are conditional on there being somewhere to put
|
||||
* the stay.** The planner has no journey list and the journal has no trip
|
||||
* list; offering the missing one opens an empty picker.
|
||||
* - **A shrinking place count means somebody deleted what an acceptance
|
||||
* created**, which puts the stay back into review on the server. If that
|
||||
* effect stops firing, the row stays hidden until a full reload.
|
||||
* - **The phone shell paints from `--m-*`.** Both shells run this one
|
||||
* component, so every palette fork inside it is real behaviour that no
|
||||
* mobile-screen test covers.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act, within } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import type { Place } from '../../types'
|
||||
import type { DawarichSuggestionsState } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import DawarichSuggestionsPanel from './DawarichSuggestionsPanel'
|
||||
|
||||
// The hook is the panel's whole world: status, rows, the busy row and the three
|
||||
// mutations. Held in a `let` the factory reads lazily, so each test hands the
|
||||
// panel a different world without re-importing the component.
|
||||
let S: DawarichSuggestionsState
|
||||
const seenFilter = vi.fn()
|
||||
vi.mock('../../hooks/useDawarichSuggestions', () => ({
|
||||
useDawarichSuggestions: (filter?: { tripId?: number }) => {
|
||||
seenFilter(filter)
|
||||
return S
|
||||
},
|
||||
}))
|
||||
|
||||
// Both the panel and the accept dialog read this module, and the relative
|
||||
// specifier resolves to the same file from either, so one mock flips the whole
|
||||
// tree between the desktop tokens and the phone's own palette.
|
||||
let phone = false
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone }))
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const accept = vi.fn<(id: number, body: unknown) => Promise<boolean>>()
|
||||
const dismiss = vi.fn<(id: number) => Promise<void>>()
|
||||
const restore = vi.fn<(id: number) => Promise<void>>()
|
||||
const reload = vi.fn()
|
||||
|
||||
function hookState(over: Partial<DawarichSuggestionsState> = {}): DawarichSuggestionsState {
|
||||
return {
|
||||
suggestions: [],
|
||||
status: 'ready',
|
||||
lastSyncAt: null,
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
busyId: null,
|
||||
reload,
|
||||
accept,
|
||||
dismiss,
|
||||
restore,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the one field the panel's place-count effect reads. */
|
||||
function places(count: number): Place[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i + 1 })) as unknown as Place[]
|
||||
}
|
||||
|
||||
const TRIPS = [{ id: 5, label: 'Cologne' }]
|
||||
const JOURNALS = [{ id: 9, label: 'Rhine notes' }]
|
||||
|
||||
function panel(props: Partial<React.ComponentProps<typeof DawarichSuggestionsPanel>> = {}) {
|
||||
return render(<DawarichSuggestionsPanel {...props} />)
|
||||
}
|
||||
|
||||
/** The collapsible header, which is also the only element carrying aria-expanded. */
|
||||
function header(): HTMLElement {
|
||||
return screen.getByRole('button', { name: /From Dawarich/ })
|
||||
}
|
||||
|
||||
/** The row card holding a given stay name, for scoping a query to one row. */
|
||||
function rowOf(container: HTMLElement, name: string): HTMLElement {
|
||||
const row = within(container).getByText(name).closest('.items-stretch')
|
||||
if (!row) throw new Error(`no row found for ${name}`)
|
||||
return row as HTMLElement
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone = false
|
||||
S = hookState()
|
||||
seenFilter.mockReset()
|
||||
accept.mockReset().mockResolvedValue(true)
|
||||
dismiss.mockReset().mockResolvedValue(undefined)
|
||||
restore.mockReset().mockResolvedValue(undefined)
|
||||
reload.mockReset()
|
||||
useTripStore.setState({ places: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState({ places: [] })
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: whether it shows up at all', () => {
|
||||
it('FE-DAWARICH-PANEL-001: an addon nobody switched on renders nothing', () => {
|
||||
S = hookState({ status: 'idle' })
|
||||
const { container } = panel()
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-002: connected and quiet is invisible, not an empty card', () => {
|
||||
// Read successfully, nothing pending and nothing in the history: the panel
|
||||
// stays out of the rail rather than explaining that it found nothing.
|
||||
S = hookState({ status: 'ready', suggestions: [] })
|
||||
const { container } = panel()
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-003: it does show up while it is still reading, before it knows', () => {
|
||||
S = hookState({ status: 'loading', suggestions: [] })
|
||||
panel()
|
||||
// Twice: once under the title, once in the opened body.
|
||||
expect(screen.getAllByText('Reading Dawarich…')).toHaveLength(2)
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-004: a failed read is said in the danger colour, without the stale error under it', () => {
|
||||
S = hookState({ status: 'unavailable', lastSyncError: 'Dawarich rejected the API key.' })
|
||||
const { container } = panel()
|
||||
|
||||
const line = container.querySelector('p.text-danger')
|
||||
expect(line).not.toBeNull()
|
||||
expect(line).toHaveTextContent('Dawarich could not be read.')
|
||||
// The sync error belongs above a list that did load. Beside "could not be
|
||||
// read" it is the same news twice in two different wordings.
|
||||
expect(screen.queryByText('Dawarich rejected the API key.')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-005: not-connected and offline each get their own sentence', () => {
|
||||
S = hookState({ status: 'disconnected' })
|
||||
const { unmount } = panel()
|
||||
expect(screen.getAllByText('Connect Dawarich in Settings to see your stays here.')).toHaveLength(2)
|
||||
unmount()
|
||||
|
||||
S = hookState({ status: 'offline' })
|
||||
panel()
|
||||
expect(screen.getAllByText('The recorded route needs a connection')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-006: freshness reads as a relative time, and says so when there is none', () => {
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
|
||||
lastSyncAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
})
|
||||
const { unmount } = panel()
|
||||
// "checked 2 minutes ago" answers the question; a stamp makes the reader
|
||||
// do the subtraction.
|
||||
expect(header()).toHaveTextContent(/checked 2 minutes ago/)
|
||||
unmount()
|
||||
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })], lastSyncAt: null })
|
||||
panel()
|
||||
expect(header()).toHaveTextContent('Not checked yet')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-007: the trip filter is handed to the hook', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ tripId: 5 })
|
||||
expect(seenFilter).toHaveBeenCalledWith({ tripId: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the list', () => {
|
||||
it('FE-DAWARICH-PANEL-008: stays are grouped by day, newest day first, each day counted', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-10', name: 'Cafe Reichard' }),
|
||||
stay({ id: 3, localDate: '2026-09-11', name: 'Koelner Dom' }),
|
||||
],
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
const headings = Array.from(container.querySelectorAll('.uppercase')).map(node => node.textContent)
|
||||
expect(headings).toEqual(['Fri, Sep 11', 'Thu, Sep 10'])
|
||||
// The badge in the header repeats what the open list adds up to.
|
||||
expect(header()).toHaveTextContent('3')
|
||||
// The times, which are the only thing telling two stays on one day apart.
|
||||
const row = rowOf(container, 'Cafe Reichard')
|
||||
expect(row).toHaveTextContent('10:15')
|
||||
expect(row).toHaveTextContent('12:40')
|
||||
expect(row).toHaveTextContent('2 h 25 min')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-009: collapsed to start, and the header is the way back in', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
panel({ initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
|
||||
fireEvent.click(header())
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-010: bare drops the card and its header, and cannot be collapsed shut', () => {
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })],
|
||||
lastSyncAt: null,
|
||||
})
|
||||
// The phone journey opens this list inside a sheet that already carries a
|
||||
// title, and `initiallyCollapsed` must not fold it away in there.
|
||||
const { container } = panel({ bare: true, initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
// Read the property rather than the attribute: what matters is that the
|
||||
// card is gone, not how React chooses to serialise an empty className.
|
||||
expect(container.querySelector('section')?.className).toBe('')
|
||||
// The freshness line survives the missing header, as a plain paragraph.
|
||||
expect(screen.getByText('Not checked yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-011: a sync error rides above a list that did load', () => {
|
||||
S = hookState({
|
||||
status: 'ready',
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
|
||||
lastSyncError: 'Dawarich asked TREK to slow down. Try again shortly.',
|
||||
})
|
||||
panel({ trips: TRIPS })
|
||||
expect(screen.getByText('Dawarich asked TREK to slow down. Try again shortly.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-012: a row carries the flags worth interrupting for, and gone beats changed', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({
|
||||
id: 1,
|
||||
localDate: '2026-09-10',
|
||||
name: 'Museum Ludwig',
|
||||
confidenceBand: 'low',
|
||||
// Both set on purpose: a stay that vanished upstream may well have
|
||||
// changed before it went, and saying both is two warnings about one
|
||||
// row where only the fatal one matters.
|
||||
sourceChanged: true,
|
||||
sourceMissing: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Unsure' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Gone from Dawarich' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: 'Changed in Dawarich' })).toBeNull()
|
||||
expect(screen.getByText(/no longer exists in Dawarich/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/has changed in Dawarich/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-013: a stay that only changed says exactly that, in full', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', sourceChanged: true })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Changed in Dawarich' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/has changed in Dawarich since you used it/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-014: the trip name shows only where rows can come from more than one trip', () => {
|
||||
const rows = [stay({ id: 1, localDate: '2026-09-10', tripId: 5, tripTitle: 'Cologne' })]
|
||||
S = hookState({ suggestions: rows })
|
||||
const { unmount } = panel({ trips: TRIPS })
|
||||
expect(screen.getByText('Cologne')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Inside one trip's rail every row is from that trip, so the name is noise.
|
||||
S = hookState({ suggestions: rows })
|
||||
panel({ tripId: 5, trips: TRIPS })
|
||||
expect(screen.queryByText('Cologne')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-015: a stay with no trip behind it gets no empty badge', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', tripId: null, tripTitle: null })] })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
// Two badges on the row, the clock and the duration, and nothing else.
|
||||
expect(rowOf(container, 'Cafe Reichard').querySelectorAll('.rounded-full.font-semibold')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: acting on a stay', () => {
|
||||
it('FE-DAWARICH-PANEL-016: an action is offered only where there is somewhere to put the stay', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
const { unmount } = panel({ trips: [], journals: [] })
|
||||
|
||||
// No trips and no journeys: only "not a place I visited" is still an answer.
|
||||
expect(screen.queryByRole('button', { name: 'Add as a place' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Write a journal entry' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Not a place I visited' })).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-017: dismissing hands the row id straight to the hook', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not a place I visited' }))
|
||||
|
||||
expect(dismiss).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-018: the row being written is the row that goes dead, and only that one', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' }),
|
||||
stay({ id: 43, localDate: '2026-09-11', name: 'Koelner Dom' }),
|
||||
],
|
||||
busyId: 42,
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
expect(within(rowOf(container, 'Museum Ludwig')).getByRole('button', { name: 'Add as a place' })).toBeDisabled()
|
||||
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Add as a place' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-019: accepting opens the review step and reports a write that landed', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
const daysForTrip = vi.fn((tripId: number) => [{ id: 70 + tripId, label: 'Day 1', badge: 'Thu, Sep 10' }])
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS, daysForTrip, onAccepted })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
|
||||
|
||||
// The dialog opens on the row that was pressed, for the target that was pressed.
|
||||
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
expect(daysForTrip).toHaveBeenCalledWith(5)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledWith(42, expect.objectContaining({ target: 'place', tripId: 5 }))
|
||||
// Only now: the host refreshes its own list off the back of a write that landed.
|
||||
expect(onAccepted).toHaveBeenCalledWith(expect.objectContaining({ id: 42 }))
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-020: a refused write leaves the dialog standing, with the correction still in it', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
accept.mockResolvedValue(false)
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS, onAccepted })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Corrected name' } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ target: 'journal', journalId: 9, name: 'Corrected name' }),
|
||||
)
|
||||
// Closing here would throw away what the user typed and leave them nothing
|
||||
// to retry with.
|
||||
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Corrected name')
|
||||
expect(onAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-021: a host that wants no callback still gets its dialog closed', async () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('heading', { name: 'Write a journal entry' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-022: cancelling writes nothing', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
expect(accept).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: what was already dealt with', () => {
|
||||
const handledRows = [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', state: 'accepted', target: 'place' }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
|
||||
// Accepted before `target` was recorded: the wording falls back rather than
|
||||
// printing a missing translation key at somebody.
|
||||
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'accepted', target: null }),
|
||||
]
|
||||
|
||||
it('FE-DAWARICH-PANEL-023: the history stays folded away until it is asked for', () => {
|
||||
S = hookState({ suggestions: handledRows })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
expect(screen.getByText('Added as a place')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dismissed')).toBeInTheDocument()
|
||||
expect(screen.getByText('In the journal')).toBeInTheDocument()
|
||||
// A history row carries its own date, which the open list keeps in the day
|
||||
// heading it no longer sits under.
|
||||
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide the ones already dealt with' }))
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-024: only a dismissal can be put back, and it goes through the hook', () => {
|
||||
S = hookState({ suggestions: handledRows })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
// An accepted stay already produced a place or an entry; "put back" there
|
||||
// would mean silently unpicking something the user wrote.
|
||||
expect(within(rowOf(container, 'Museum Ludwig')).queryByRole('button', { name: 'Put back' })).toBeNull()
|
||||
// And no history row offers the accept buttons a second time.
|
||||
expect(within(rowOf(container, 'Koelner Dom')).queryByRole('button', { name: 'Add as a place' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' }))
|
||||
|
||||
expect(restore).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-025: a restore in flight disables the button that started it', () => {
|
||||
S = hookState({ suggestions: handledRows, busyId: 2 })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-026: nothing left open says so instead of showing an empty day', () => {
|
||||
S = hookState({ suggestions: [handledRows[0]] })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByText('Everything recorded here has been dealt with.')).toBeInTheDocument()
|
||||
// And the count badge goes with the last open row rather than showing a zero.
|
||||
expect(container.querySelector('.text-accent-text')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the deleted-place hint', () => {
|
||||
it('FE-DAWARICH-PANEL-027: a shrinking place count reloads, a growing one does not', () => {
|
||||
useTripStore.setState({ places: places(3) })
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ tripId: 5, trips: TRIPS })
|
||||
|
||||
// Mounting is not a shrink.
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact, and the server puts the stay back into review.
|
||||
act(() => { useTripStore.setState({ places: places(2) }) })
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A place appearing is usually this panel's own doing.
|
||||
act(() => { useTripStore.setState({ places: places(6) }) })
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-028: outside a trip the place count is not this panel\'s business', () => {
|
||||
useTripStore.setState({ places: places(3) })
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
// The journal shell mounts this without a trip, and whatever trip happens to
|
||||
// be loaded has nothing to do with the rows it shows.
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
act(() => { useTripStore.setState({ places: places(1) }) })
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the phone shell', () => {
|
||||
it('FE-DAWARICH-PANEL-029: the phone paints from its own palette, header to action pill', () => {
|
||||
phone = true
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', sourceChanged: true }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
|
||||
],
|
||||
lastSyncAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS, journals: JOURNALS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 1 already dealt with' }))
|
||||
|
||||
// A card in the desktop surface tokens glares inside the mobile shell, and
|
||||
// neither token set exists outside its own shell, so every surface here has
|
||||
// to come from the `--m-*` palette.
|
||||
expect(container.querySelector('section')?.className).toContain('var(--m-ic)')
|
||||
expect(header().className).toContain('var(--m-inner)')
|
||||
expect(container.querySelector('.bg-m-card')).not.toBeNull()
|
||||
expect(container.querySelector('.bg-m-act')).not.toBeNull()
|
||||
expect(container.querySelector('.text-m-ink')).not.toBeNull()
|
||||
expect(container.querySelectorAll('.text-m-muted').length).toBeGreaterThan(0)
|
||||
expect(container.querySelector('.text-m-faint')).not.toBeNull()
|
||||
// The actions keep their accessible names, which is what makes dropping the
|
||||
// hover-only tooltip on a touch device cost nothing.
|
||||
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-030: bare on a phone, with nothing left open', () => {
|
||||
phone = true
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10', state: 'accepted', target: 'journal' })],
|
||||
lastSyncAt: null,
|
||||
})
|
||||
const { container } = panel({ bare: true, trips: TRIPS })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
|
||||
expect(screen.getByText('Everything recorded here has been dealt with.').className).toContain('text-m-muted')
|
||||
expect(screen.getByText('Not checked yet').className).toContain('text-m-muted')
|
||||
expect(container.querySelector('section')?.className).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-033: folded shut on a phone, the arrow that says so comes from the phone palette', () => {
|
||||
phone = true
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
// The planner mounts this one collapsed, so on a phone the right-pointing
|
||||
// arrow is the header's ordinary state and not an edge case. It is also the
|
||||
// only part of the header an expanded phone never paints, which is exactly
|
||||
// how a desktop token would survive in there unnoticed.
|
||||
const { container } = panel({ initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
expect(container.querySelector('.lucide-chevron-right.text-m-faint')).not.toBeNull()
|
||||
expect(container.querySelector('.lucide-chevron-down')).toBeNull()
|
||||
expect(container.querySelector('.text-content-faint')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the desktop tooltip', () => {
|
||||
it('FE-DAWARICH-PANEL-031: a row action explains itself on focus, where there is a pointer to do it with', () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
fireEvent.focus(screen.getByRole('button', { name: 'Not a place I visited' }))
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
|
||||
// The icon is the whole button, so without this a square is a mystery to
|
||||
// anybody who has not pressed one before. Reaching it by keyboard is the
|
||||
// one route where there is no hover to discover it with.
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Not a place I visited')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: open and handled in the same list', () => {
|
||||
it('FE-DAWARICH-PANEL-032: the two halves are counted separately and only the open half gets days', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'accepted', target: 'place' }),
|
||||
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'dismissed' }),
|
||||
],
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
// Counting all three would send somebody looking for work that is done.
|
||||
expect(container.querySelector('.bg-accent')).toHaveTextContent('1')
|
||||
expect(screen.getByRole('button', { name: 'Show 2 already dealt with' })).toBeInTheDocument()
|
||||
// And the day rail is built from the open half alone: a heading for a day
|
||||
// whose only stay was dismissed is an empty day with a coloured dot on it.
|
||||
expect(container.querySelectorAll('.uppercase')).toHaveLength(1)
|
||||
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,523 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { relativeTime } from '../../utils/relativeTime'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Badge from './DawarichBadge'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
|
||||
|
||||
/**
|
||||
* The panel sits inside the places rail, and it has to read at the same size as
|
||||
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
|
||||
* line of it a size larger than the trip's own places, which is the one thing a
|
||||
* panel about somebody else's data must not be.
|
||||
*
|
||||
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
|
||||
* kept as calc() against the per-tier multipliers so the appearance text-size
|
||||
* control still reaches them — a bare number would not.
|
||||
*/
|
||||
const TYPE = {
|
||||
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
|
||||
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
|
||||
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
|
||||
} as const
|
||||
|
||||
export interface DawarichSuggestionsPanelProps {
|
||||
/** Only stays that fall inside this trip. */
|
||||
tripId?: number
|
||||
/** Trips the user may write a place into. */
|
||||
trips?: AcceptTargetOption[]
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may add an entry to. */
|
||||
journals?: AcceptTargetOption[]
|
||||
/** Called after something was written, so the host can refresh its own list. */
|
||||
onAccepted?: (suggestion: DawarichSuggestion) => void
|
||||
/** Start collapsed. The planner does; the journal does not. */
|
||||
initiallyCollapsed?: boolean
|
||||
/**
|
||||
* Drop the panel's own card and header.
|
||||
*
|
||||
* For a sheet that already carries both — the phone's journey opens this list
|
||||
* in one, and a second "From Dawarich" under the first is a stutter.
|
||||
*/
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays Dawarich recorded, waiting to be reviewed.
|
||||
*
|
||||
* One component for both shells. The phone and the desktop place it
|
||||
* differently, but the rows are the same rows, and a second copy of them is a
|
||||
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
|
||||
* next door, and the same reason SonarCloud's duplication budget exists.
|
||||
*
|
||||
* **Grouped by day, and coloured like the map.** A day of travel produces four
|
||||
* or five stays; repeating the full date on each of them buries the one thing
|
||||
* that tells them apart, which is the time. The day's dot carries the colour its
|
||||
* line is drawn in on the trip map, so "the purple day" means the same thing in
|
||||
* both places.
|
||||
*
|
||||
* The panel renders **nothing at all** when there is nothing to review. An
|
||||
* integration that is connected and quiet should be invisible, not a permanent
|
||||
* empty card explaining that it found nothing.
|
||||
*/
|
||||
export default function DawarichSuggestionsPanel({
|
||||
tripId,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
onAccepted,
|
||||
initiallyCollapsed = false,
|
||||
bare = false,
|
||||
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichSuggestions({ tripId })
|
||||
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
|
||||
// tokens glares next to it. Same component, the shell's own colours.
|
||||
const phone = useIsPhone()
|
||||
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
|
||||
const [showHandled, setShowHandled] = useState(false)
|
||||
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact: the server puts the stay back into review, and a
|
||||
// shrinking place count is what tells us to go and ask. Only a shrink — a
|
||||
// place being added is usually this panel's own doing.
|
||||
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
|
||||
const lastPlaceCount = useRef(placeCount)
|
||||
const reload = S.reload
|
||||
useEffect(() => {
|
||||
const shrank = placeCount < lastPlaceCount.current
|
||||
lastPlaceCount.current = placeCount
|
||||
if (shrank) reload()
|
||||
}, [placeCount, reload])
|
||||
|
||||
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
|
||||
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
|
||||
const openDays = useMemo(() => groupByDay(open), [open])
|
||||
|
||||
// Nothing to review and nothing to report: stay out of the way entirely.
|
||||
if (S.status === 'idle') return null
|
||||
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
|
||||
|
||||
const confirm = async (body: DawarichAccept) => {
|
||||
if (!pending) return
|
||||
const ok = await S.accept(pending.suggestion.id, body)
|
||||
if (ok) {
|
||||
onAccepted?.(pending.suggestion)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
phone,
|
||||
// Offered only where there is somewhere to put it: the planner has no
|
||||
// journey list, and a button that opens an empty picker is worse than none.
|
||||
allowJournal: journals.length > 0,
|
||||
allowPlace: trips.length > 0,
|
||||
// The trip name is noise when every row is from the same trip.
|
||||
showTrip: tripId === undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
bare
|
||||
? ''
|
||||
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
|
||||
}
|
||||
>
|
||||
{bare ? (
|
||||
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(value => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
|
||||
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded card reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={22} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
{t('dawarich.suggestions.title')}
|
||||
</span>
|
||||
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</span>
|
||||
</span>
|
||||
{open.length > 0 && (
|
||||
<span
|
||||
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
|
||||
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
style={TYPE.micro}
|
||||
>
|
||||
{open.length}
|
||||
</span>
|
||||
)}
|
||||
{collapsed
|
||||
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
|
||||
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(bare || !collapsed) && (
|
||||
<div className="border-t border-edge-secondary">
|
||||
{S.status !== 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{S.lastSyncError && S.status === 'ready' && (
|
||||
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{open.length === 0 && S.status === 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
|
||||
)}
|
||||
|
||||
{openDays.map(day => (
|
||||
<div key={day.date}>
|
||||
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
|
||||
{/* The colour this day's line is drawn in on the map. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: day.color }}
|
||||
/>
|
||||
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{formatDayHeading(day.date, locale)}
|
||||
</span>
|
||||
<span className="flex-1 h-px bg-edge-faint" />
|
||||
<Badge tone="quiet">{day.stays.length}</Badge>
|
||||
</div>
|
||||
{day.stays.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
{...rowProps}
|
||||
onAccept={target => setPending({ suggestion, target })}
|
||||
onDismiss={() => S.dismiss(suggestion.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{handled.length > 0 && (
|
||||
<div className="border-t border-edge-faint">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHandled(value => !value)}
|
||||
className={`w-full px-3.5 py-2 text-left ${
|
||||
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={TYPE.meta}
|
||||
>
|
||||
{showHandled
|
||||
? t('dawarich.suggestions.hideHandled')
|
||||
: t('dawarich.suggestions.showHandled', { count: handled.length })}
|
||||
</button>
|
||||
{showHandled &&
|
||||
handled.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
showTrip={rowProps.showTrip}
|
||||
showDate
|
||||
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DawarichAcceptDialog
|
||||
suggestion={pending?.suggestion ?? null}
|
||||
target={pending?.target ?? 'journal'}
|
||||
trips={trips}
|
||||
daysForTrip={daysForTrip}
|
||||
journals={journals}
|
||||
busy={S.busyId !== null}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirm}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stay: a name, when it was, and how to act on it.
|
||||
*
|
||||
* Two lines and no chip soup. The meta line carries only what varies — how long,
|
||||
* and the two things worth interrupting for: a wish this fulfils, and a
|
||||
* detection the source itself was unsure about. The "detected, unconfirmed"
|
||||
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
|
||||
* unconfirmed is the normal state of a visit, so saying it everywhere said
|
||||
* nothing. Reviewing it here *is* the confirmation.
|
||||
*
|
||||
* The actions fade in on hover on a pointer device and stay visible on touch,
|
||||
* where there is no hover to reveal them.
|
||||
*/
|
||||
function SuggestionRow({
|
||||
suggestion,
|
||||
busy,
|
||||
allowJournal = false,
|
||||
allowPlace = false,
|
||||
showTrip = false,
|
||||
showDate = false,
|
||||
phone = false,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRestore,
|
||||
}: {
|
||||
suggestion: DawarichSuggestion
|
||||
busy: boolean
|
||||
allowJournal?: boolean
|
||||
allowPlace?: boolean
|
||||
showTrip?: boolean
|
||||
showDate?: boolean
|
||||
/** Rendered inside the phone shell, which has its own palette and its own reach. */
|
||||
phone?: boolean
|
||||
onAccept?: (target: DawarichSuggestionTarget) => void
|
||||
onDismiss?: () => void
|
||||
onRestore?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
|
||||
{/* A stay is a card with its actions clipped to the side — the shape an
|
||||
expense already has in Costs. One layout in both shells; only the
|
||||
palette differs, because the phone paints from `--m-*` and the desktop
|
||||
from the semantic tokens, and neither set exists outside its own. */}
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
|
||||
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
{/* The name gets the line, and the one badge that is about the name
|
||||
itself rides with it: "Unsure" qualifies what this place is called,
|
||||
not how long somebody stayed, and two lines below the name it read as
|
||||
one more fact among the times. It never pushes the name out — the
|
||||
name truncates, the flag does not. */}
|
||||
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
<span className="truncate">{suggestion.name}</span>
|
||||
{/* The mark, never the word: the name is what the line is for, and
|
||||
"Unsure" beside it is a caption competing with a title. The tooltip
|
||||
and the accessible name still say it in full. */}
|
||||
{suggestion.confidenceBand === 'low' && (
|
||||
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* One badge row, one badge shape, colour carrying the meaning: neutral
|
||||
for a fact, accent for a wish this fulfils, success for what was
|
||||
already taken, amber for the two things worth interrupting for.
|
||||
Nothing wraps — a row that reflows every time a name is long reads as
|
||||
a mess, so it stays one line and the badges that do not fit are cut
|
||||
off rather than pushed onto a second. */}
|
||||
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
|
||||
never breaks, so a second line of them reads as a second line of
|
||||
badges rather than as a paragraph coming apart. Cutting them off
|
||||
instead would hide the wish a stay fulfils, which is the one thing in
|
||||
the row worth the space. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{/* Ordered by what a reader needs first, because the rail is narrow and
|
||||
the row does not wrap: how long, then what it would fulfil, then the
|
||||
flags. The flags are icon-only — the word costs half the row and the
|
||||
sentence under the badges says it properly anyway. */}
|
||||
{showDate && (
|
||||
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
|
||||
)}
|
||||
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
|
||||
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>
|
||||
|
||||
{suggestion.state === 'accepted' && (
|
||||
<Badge icon={Check} tone="success">
|
||||
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
|
||||
</Badge>
|
||||
)}
|
||||
{suggestion.state === 'dismissed' && (
|
||||
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
|
||||
)}
|
||||
|
||||
{showTrip && suggestion.tripTitle && (
|
||||
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
|
||||
)}
|
||||
|
||||
{suggestion.sourceMissing && (
|
||||
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
|
||||
)}
|
||||
{!suggestion.sourceMissing && suggestion.sourceChanged && (
|
||||
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The long form of the two warnings, under the badges: the badge is the
|
||||
flag, this is what it means, and somebody reading the panel for the
|
||||
first time should not have to hover to find out. */}
|
||||
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
|
||||
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
|
||||
{suggestion.sourceMissing
|
||||
? t('dawarich.suggestions.sourceMissing')
|
||||
: t('dawarich.suggestions.sourceChanged')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always on screen. Hiding them until hover made the only two things a row
|
||||
is for invisible to anyone who had not discovered that hovering did
|
||||
something — and on a touch screen there is no hover at all.
|
||||
|
||||
A column beside the text in the desktop rail, which is narrow and tall;
|
||||
a row on a phone, where there is width to spare and a stack of small
|
||||
squares reads as a scrollbar. Centred either way, against the name and
|
||||
the time it belongs to. */}
|
||||
{onAccept && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
{allowJournal && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asJournal')}
|
||||
icon={<BookOpen className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('journal')}
|
||||
/>
|
||||
)}
|
||||
{allowPlace && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asPlace')}
|
||||
icon={<MapPin className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('place')}
|
||||
/>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.dismiss')}
|
||||
icon={<X className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRestore && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.restore')}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row keeps its actions: a pill clipped to the side of the card, the
|
||||
* same in both shells, painted from whichever palette the shell has.
|
||||
*/
|
||||
const ACTIONS = (phone: boolean): string =>
|
||||
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
|
||||
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
|
||||
|
||||
/**
|
||||
* One action on a row: a real target with a real accessible name.
|
||||
*
|
||||
* A square with a border rather than a bare glyph, because a bare glyph on a
|
||||
* white rail reads as decoration; this reads as a button, which is what it is.
|
||||
* The label is a tooltip and the accessible name — spelling it out beside the
|
||||
* icon would cost the row the width its name needs.
|
||||
*
|
||||
* All of them look the same. Marking one as the primary action would be TREK
|
||||
* deciding for the reader which of "put it on the map" and "write about it" they
|
||||
* meant, and the whole review step exists because that is theirs to decide.
|
||||
*
|
||||
* Hover fills the square with the inverse surface — near-black in the light
|
||||
* scheme, near-white in the dark one — so the button somebody is about to press
|
||||
* is unmistakable without inventing a colour the theme does not have.
|
||||
*/
|
||||
function RowAction({
|
||||
label,
|
||||
icon,
|
||||
disabled,
|
||||
phone = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
phone?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
|
||||
phone
|
||||
? 'text-m-muted active:scale-95'
|
||||
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
// No tooltip on a phone: there is no hover to open it with, and the label is
|
||||
// already the button's accessible name.
|
||||
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data came from and how fresh it is, in one line under the title.
|
||||
*
|
||||
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
|
||||
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
|
||||
* subtraction themselves — on two lines, seconds included.
|
||||
*/
|
||||
function statusLine(
|
||||
state: ReturnType<typeof useDawarichSuggestions>,
|
||||
openCount: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
if (state.status === 'loading') return t('dawarich.suggestions.loading')
|
||||
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
|
||||
if (state.status === 'offline') return t('dawarich.trail.offline')
|
||||
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
|
||||
|
||||
// Just the freshness. The count sits in the badge beside this line, and
|
||||
// gluing the two together with a separator said the same thing twice.
|
||||
return state.lastSyncAt
|
||||
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
|
||||
: t('dawarich.neverSynced')
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-014
|
||||
/**
|
||||
* The Atlas dialog's arithmetic, with no React around it.
|
||||
*
|
||||
* Everything here is small enough to look correct and wrong enough to matter if
|
||||
* it is not. Three of the five carry a real trap:
|
||||
*
|
||||
* 1. `orderedMatches` sorts on two keys with a hand-written comparator that
|
||||
* never returns 0. That is fine for ordering and a hazard for anything else,
|
||||
* so the cases below check that the order is the same whichever way round
|
||||
* the input arrives and that two wishes reached at the same moment both
|
||||
* survive the sort rather than one of them being swapped away.
|
||||
* 2. `countryLabel` runs on whatever `Intl` the device shipped. A WebView
|
||||
* without `Intl.DisplayNames` throws, one with a partial region table
|
||||
* answers `undefined`, and both have to end up as Dawarich's own spelling
|
||||
* rather than as a crash or an empty label beside a flag.
|
||||
* 3. `formatDistance` switches units at exactly a kilometre, which is the one
|
||||
* input a refactor gets wrong.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
import {
|
||||
cityLine,
|
||||
countryLabel,
|
||||
countryWindow,
|
||||
formatDistance,
|
||||
newCountries,
|
||||
orderedMatches,
|
||||
} from './dawarichAtlasModel'
|
||||
|
||||
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
|
||||
return {
|
||||
itemId: over.itemId,
|
||||
name: over.name ?? `Wish ${over.itemId}`,
|
||||
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
|
||||
return {
|
||||
countryCode: over.countryCode,
|
||||
sourceName: over.sourceName ?? over.countryCode,
|
||||
cities: over.cities ?? [],
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('countryWindow', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
|
||||
const now = new Date('2026-09-12T18:00:00Z')
|
||||
const { from, to } = countryWindow(now)
|
||||
expect(to).toBe('2026-09-12T18:00:00.000Z')
|
||||
expect(from).toBe('2025-09-12T18:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderedMatches', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
|
||||
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, alreadyVisited: true }),
|
||||
match({ itemId: 2 }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
|
||||
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
|
||||
orderedMatches(input)
|
||||
expect(input.map(m => m.itemId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-011: the order they arrive in does not change the order they are shown in', () => {
|
||||
// Both arms of both comparisons. A comparator that answers consistently one
|
||||
// way round and not the other sorts whatever the scan happened to return,
|
||||
// which is a list that reshuffles itself between two identical scans.
|
||||
const ticked = match({ itemId: 1, alreadyVisited: true })
|
||||
const openWish = match({ itemId: 2 })
|
||||
expect(orderedMatches([ticked, openWish]).map(m => m.itemId)).toEqual([2, 1])
|
||||
expect(orderedMatches([openWish, ticked]).map(m => m.itemId)).toEqual([2, 1])
|
||||
|
||||
const older = match({ itemId: 3, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
|
||||
const newer = match({ itemId: 4, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
|
||||
expect(orderedMatches([older, newer]).map(m => m.itemId)).toEqual([4, 3])
|
||||
expect(orderedMatches([newer, older]).map(m => m.itemId)).toEqual([4, 3])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-012: two wishes reached in the same moment both survive the sort', () => {
|
||||
// The comparator never answers 0, so equal timestamps are reported as "swap"
|
||||
// in both directions. Ordering them either way is fine; losing one is not.
|
||||
const at = '2026-08-02T11:00:00Z'
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at, minutes: 95, distanceMeters: 30, points: 40 } }),
|
||||
match({ itemId: 2, match: { at, minutes: 120, distanceMeters: 55, points: 51 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId).sort((a, b) => a - b)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('newCountries', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
|
||||
const offered = newCountries([
|
||||
country({ countryCode: 'DE', alreadyVisited: true }),
|
||||
country({ countryCode: 'NL' }),
|
||||
])
|
||||
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryLabel', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
|
||||
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
|
||||
// Not an ISO region: Intl either throws or echoes it back, and neither is a
|
||||
// name a reader would recognise.
|
||||
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-013: a runtime whose Intl names nothing still labels the row', () => {
|
||||
// The other half of the fallback, and the one no desktop browser reproduces:
|
||||
// a WebView that ships Intl.DisplayNames with a partial region table answers
|
||||
// `undefined` instead of throwing, and an empty string beside a flag is a
|
||||
// row nobody can identify.
|
||||
const real = Intl.DisplayNames
|
||||
Object.defineProperty(Intl, 'DisplayNames', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: class {
|
||||
of(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(countryLabel('NL', 'Netherlands', 'en')).toBe('Netherlands')
|
||||
} finally {
|
||||
Object.defineProperty(Intl, 'DisplayNames', { configurable: true, writable: true, value: real })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cityLine', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
|
||||
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
|
||||
name,
|
||||
minutes: 60,
|
||||
lastSeenAt: '2026-09-01T10:00:00Z',
|
||||
}))
|
||||
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
|
||||
expect(cityLine(cities, 1)).toBe('Cologne')
|
||||
expect(cityLine([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
|
||||
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
|
||||
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
|
||||
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-014: exactly a kilometre is already a kilometre', () => {
|
||||
// The switch is `< 1000`, so 1000 belongs to the other unit. Off by one here
|
||||
// reads as "1000 m away", which is the phrasing the rule exists to avoid.
|
||||
expect(formatDistance(1000, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
|
||||
// One decimal, rounded, not truncated: 1049 and 1051 are not the same walk.
|
||||
expect(formatDistance(1049, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
|
||||
expect(formatDistance(1051, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.1"}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
|
||||
* the window arithmetic can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A year back from the given moment.
|
||||
*
|
||||
* Long enough to cover the trips someone would be filling in, short enough that
|
||||
* a self-hosted instance is not asked to aggregate a decade of points for a card
|
||||
* nobody was looking at. Takes `now` rather than reading the clock so the window
|
||||
* is testable.
|
||||
*/
|
||||
export function countryWindow(now: Date): { from: string; to: string } {
|
||||
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
|
||||
return { from: from.toISOString(), to: now.toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The wishes worth showing, best first.
|
||||
*
|
||||
* Only entries the recordings actually found; the ones still open lead, because
|
||||
* they are the ones the reader can act on, and within each group the most recent
|
||||
* visit comes first — a wish reached last week is easier to confirm than one
|
||||
* reached ten months ago.
|
||||
*/
|
||||
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
|
||||
return matches
|
||||
.filter(match => match.match !== null)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
|
||||
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
/** The countries a confirmation would actually change. */
|
||||
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
|
||||
return countries.filter(country => !country.alreadyVisited)
|
||||
}
|
||||
|
||||
/**
|
||||
* A country's name in the reader's language, falling back to Dawarich's own
|
||||
* spelling.
|
||||
*
|
||||
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
|
||||
* not know, so both are caught — the Atlas page's own resolver does the same.
|
||||
*/
|
||||
export function countryLabel(code: string, sourceName: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
|
||||
} catch {
|
||||
return sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first few cities, as one line.
|
||||
*
|
||||
* Three is what fits beside a flag at the width the dialog has on a phone, and
|
||||
* the count badge beside it already says how many there were in total.
|
||||
*/
|
||||
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
|
||||
return cities
|
||||
.slice(0, limit)
|
||||
.map(city => city.name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A distance a person would say out loud: metres up to a kilometre, then one
|
||||
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
|
||||
* far it was.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
return meters < 1000
|
||||
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
|
||||
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import {
|
||||
clockOf,
|
||||
formatDayHeading,
|
||||
formatDayOption,
|
||||
formatDuration,
|
||||
groupByDay,
|
||||
timeRange,
|
||||
} from './dawarichSuggestionModel'
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('groupByDay', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
])
|
||||
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
|
||||
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
])
|
||||
const oldest = days.find(d => d.date === '2026-09-10')!
|
||||
const newest = days.find(d => d.date === '2026-09-11')!
|
||||
expect(oldest.color).not.toBe(newest.color)
|
||||
// Same input in a different order paints the same day the same colour.
|
||||
const again = groupByDay([
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
])
|
||||
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
|
||||
expect(groupByDay([])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-013: a day that already reads in order is left in it', () => {
|
||||
// Three stays rather than two: with two, a comparator that answered the
|
||||
// same thing both ways would still look right half the time.
|
||||
const input = [
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T13:30:00+02:00' }),
|
||||
]
|
||||
|
||||
const days = groupByDay(input)
|
||||
|
||||
expect(days).toHaveLength(1)
|
||||
expect(days[0].stays.map(s => s.id)).toEqual([1, 3, 2])
|
||||
// The list handed in belongs to the store. Sorting it in place would
|
||||
// reorder what the panel is rendering from underneath it.
|
||||
expect(input.map(s => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clockOf / timeRange', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
|
||||
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
|
||||
expect(clockOf('yesterday')).toBe('')
|
||||
expect(clockOf('')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
||||
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
|
||||
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
|
||||
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
|
||||
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayHeading', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
|
||||
// Built from the date parts rather than from a Date at midnight UTC, which
|
||||
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
|
||||
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-014: a date missing its month or its day is shown as it came too', () => {
|
||||
// Each guard on its own, because `Date` would rather guess than refuse:
|
||||
// month 0 rolls the heading back into December of the previous year, and a
|
||||
// missing day silently becomes the first of the month. Either one reads as
|
||||
// a real date, which is worse than showing the string that arrived.
|
||||
expect(formatDayHeading('2026-00-10', 'en-GB')).toBe('2026-00-10')
|
||||
expect(formatDayHeading('2026-09-00', 'en-GB')).toBe('2026-09-00')
|
||||
expect(formatDayHeading('2026-09', 'en-GB')).toBe('2026-09')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayOption', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
|
||||
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
|
||||
expect(option.label).toBe('planner.dayN:{"n":3}')
|
||||
expect(option.badge).toMatch(/10/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
|
||||
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { dayColor } from '../Map/dawarichTrail'
|
||||
|
||||
/**
|
||||
* Pure shaping for the suggestions panel — no React, so the grouping and the
|
||||
* formatting can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
export interface SuggestionDay {
|
||||
/** `YYYY-MM-DD`, the stay's own local date. */
|
||||
date: string
|
||||
/** The colour this day is drawn in on the map, so the panel and the line agree. */
|
||||
color: string
|
||||
stays: DawarichSuggestion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group stays by the local day they happened on, newest day first, and give each
|
||||
* day the colour the map draws it in.
|
||||
*
|
||||
* The grouping is what turns a flat list into something readable: a day of
|
||||
* travel produces four or five stays, and repeating the full date on every row
|
||||
* buries the one thing that distinguishes them — the time.
|
||||
*
|
||||
* The colour index counts days from the OLDEST, matching how the track overlay
|
||||
* numbers them, so "the purple line" and "the purple day" are the same day.
|
||||
*/
|
||||
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
|
||||
const byDate = new Map<string, DawarichSuggestion[]>()
|
||||
for (const stay of suggestions) {
|
||||
const bucket = byDate.get(stay.localDate)
|
||||
if (bucket) bucket.push(stay)
|
||||
else byDate.set(stay.localDate, [stay])
|
||||
}
|
||||
|
||||
const oldestFirst = [...byDate.keys()].sort()
|
||||
return oldestFirst
|
||||
.map((date, index) => ({
|
||||
date,
|
||||
color: dayColor(index),
|
||||
// Within a day, earliest first: that is the order they were lived in.
|
||||
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
|
||||
}))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
export function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
|
||||
*
|
||||
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
|
||||
* next to times often enough that the difference is visible.
|
||||
*/
|
||||
export function timeRange(startedAt: string, endedAt: string): string {
|
||||
const from = clockOf(startedAt)
|
||||
const to = clockOf(endedAt)
|
||||
if (!from) return ''
|
||||
return to ? `${from} – ${to}` : from
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration a person would say out loud.
|
||||
*
|
||||
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
|
||||
* somewhere. The two label keys carry the units so a locale can put them where
|
||||
* it wants them.
|
||||
*/
|
||||
export function formatDuration(
|
||||
minutes: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest === 0
|
||||
? t('dawarich.duration.hours', { hours })
|
||||
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
|
||||
*
|
||||
* Built from the date string rather than from a Date at midnight UTC, so a
|
||||
* reader east or west of UTC does not see yesterday.
|
||||
*/
|
||||
export function formatDayHeading(date: string, locale: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
if (!year || !month || !day) return date
|
||||
return new Date(year, month - 1, day).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* How a trip day reads in the accept dialog's picker.
|
||||
*
|
||||
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
|
||||
* number the planner gives the day and the weekday it fell on, which is exactly
|
||||
* what the day rail shows them — so the picker says the same thing.
|
||||
*/
|
||||
export function formatDayOption(
|
||||
dayNumber: number,
|
||||
date: string | null | undefined,
|
||||
locale: string,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): { label: string; badge?: string } {
|
||||
return {
|
||||
label: t('planner.dayN', { n: dayNumber }),
|
||||
badge: date ? formatDayHeading(date, locale) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* FE-DAWARICH-API-030 to FE-DAWARICH-API-032
|
||||
*
|
||||
* Two lines of code, and both of them are the kind that rot without a sound.
|
||||
*
|
||||
* Accepting a recorded stay into a trip is written entirely server-side, so the
|
||||
* planner learns nothing about the place it produced until something pulls the
|
||||
* trip back in. This is that something, and both trip shells (the desktop
|
||||
* `PlacesSidebar` and the phone `MPlacesBrowser`) call it as a one-liner
|
||||
* precisely so the two copies cannot drift apart. What the tests hold is:
|
||||
*
|
||||
* - **Both refreshes are started, not one.** A stay accepted onto a day creates
|
||||
* an assignment as well as a place, so refreshing only the places leaves the
|
||||
* day rail one entry short until a reload. That is a plausible "simplification"
|
||||
* for someone reading the call site, and nothing else in the app would notice.
|
||||
* - **They run together, not in sequence.** `Promise.all` fires both before
|
||||
* awaiting either; rewritten as two awaits it still works, just twice as
|
||||
* slowly, and the button keeps spinning for both round trips. The order check
|
||||
* below is synchronous, so it distinguishes the two shapes without timing.
|
||||
* - **The id goes through verbatim.** The phone shell hands over a route param,
|
||||
* which is a string; the desktop one hands over a number. Coercing either
|
||||
* direction here would break the other caller.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { refreshTripAfterAccept } from './dawarichTripRefresh'
|
||||
|
||||
const original = {
|
||||
refreshPlaces: useTripStore.getState().refreshPlaces,
|
||||
refreshDays: useTripStore.getState().refreshDays,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState(original)
|
||||
})
|
||||
|
||||
describe('refreshTripAfterAccept', () => {
|
||||
it('FE-DAWARICH-API-030: starts the day refresh without waiting for the place refresh', async () => {
|
||||
const order: string[] = []
|
||||
let releasePlaces!: () => void
|
||||
// A gate rather than a timer: with Promise.all both calls are made before
|
||||
// anything is awaited, so the day refresh has already run by the time control
|
||||
// returns. Rewritten as `await a; await b` it would still be waiting here.
|
||||
const placesGate = new Promise<void>(resolve => {
|
||||
releasePlaces = resolve
|
||||
})
|
||||
|
||||
useTripStore.setState({
|
||||
refreshPlaces: vi.fn(async (_tripId: number | string) => {
|
||||
order.push('places:called')
|
||||
await placesGate
|
||||
order.push('places:settled')
|
||||
}),
|
||||
refreshDays: vi.fn(async (_tripId: number | string) => {
|
||||
order.push('days:called')
|
||||
}),
|
||||
})
|
||||
|
||||
const pending = refreshTripAfterAccept(7)
|
||||
expect(order).toEqual(['places:called', 'days:called'])
|
||||
|
||||
releasePlaces()
|
||||
await pending
|
||||
expect(order).toEqual(['places:called', 'days:called', 'places:settled'])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-031: resolves only once both refreshes have finished', async () => {
|
||||
let releaseDays!: () => void
|
||||
const daysGate = new Promise<void>(resolve => {
|
||||
releaseDays = resolve
|
||||
})
|
||||
let settled = false
|
||||
|
||||
useTripStore.setState({
|
||||
refreshPlaces: vi.fn(async (_tripId: number | string) => {}),
|
||||
refreshDays: vi.fn(async (_tripId: number | string) => {
|
||||
await daysGate
|
||||
}),
|
||||
})
|
||||
|
||||
const pending = refreshTripAfterAccept(7).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
// Drain the microtask queue: the places half is already done, so anything
|
||||
// that only awaited that one would be finished by now.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseDays()
|
||||
await pending
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-032: hands both refreshes the id exactly as the caller gave it', async () => {
|
||||
const refreshPlaces = vi.fn(async (_tripId: number | string) => {})
|
||||
const refreshDays = vi.fn(async (_tripId: number | string) => {})
|
||||
useTripStore.setState({ refreshPlaces, refreshDays })
|
||||
|
||||
// The phone shell reads the trip id off the route, so it is a string there
|
||||
// and a number on the desktop. Both repos accept either; a cast added here
|
||||
// would only break whichever caller was not the one being looked at.
|
||||
await refreshTripAfterAccept('42')
|
||||
expect(refreshPlaces).toHaveBeenCalledWith('42')
|
||||
expect(refreshDays).toHaveBeenCalledWith('42')
|
||||
|
||||
await refreshTripAfterAccept(42)
|
||||
expect(refreshPlaces).toHaveBeenLastCalledWith(42)
|
||||
expect(refreshDays).toHaveBeenLastCalledWith(42)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
/**
|
||||
* Pull the trip back in after a stay was accepted into it.
|
||||
*
|
||||
* The acceptance is written server-side, so the planner knows nothing about the
|
||||
* place it produced until somebody reloads the page — which is exactly what a
|
||||
* person should never have to do after pressing a button. Both trip shells call
|
||||
* this, which is also why it is a plain function rather than a hook: one line at
|
||||
* each call site keeps the desktop and phone copies from drifting.
|
||||
*
|
||||
* Places and days both, because a stay accepted onto a day creates an assignment
|
||||
* as well, and the day rail would otherwise stay one place short.
|
||||
*/
|
||||
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
|
||||
const store = useTripStore.getState()
|
||||
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// FE-JRN-SCRUBBER-001 to FE-JRN-SCRUBBER-005
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
|
||||
const DAYS = [
|
||||
{ date: '2026-03-15', color: '#6366f1' },
|
||||
{ date: '2026-03-16', color: '#f97316' },
|
||||
{ date: '2026-03-17', color: '#14b8a6' },
|
||||
]
|
||||
|
||||
describe('JourneyDayScrubber', () => {
|
||||
it('FE-JRN-SCRUBBER-001: one segment per day', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={() => {}} />)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-002: says which day you are on, by its date', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-16" onPick={() => {}} />)
|
||||
|
||||
// The long date is the label the reader was missing on the cards. "Day 2"
|
||||
// stood beside it for a while and was the same fact told twice.
|
||||
expect(screen.getByText(/16/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^Day \d/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-003: marks the day it is on', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-17" onPick={() => {}} />)
|
||||
|
||||
const marked = screen.getAllByRole('button').filter(b => b.getAttribute('aria-current') === 'true')
|
||||
expect(marked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-004: a tap asks for that day', async () => {
|
||||
const onPick = vi.fn()
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={onPick} />)
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith('2026-03-17')
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-005: a one-day journey has nothing to scrub', () => {
|
||||
// The bar would say only what the card's own date already says, and it would
|
||||
// take a row of the map to do it.
|
||||
const { container } = render(
|
||||
<JourneyDayScrubber days={[DAYS[0]]} activeDate="2026-03-15" onPick={() => {}} />,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyDay } from './journeyCard'
|
||||
|
||||
/**
|
||||
* The day bar above the phone timeline.
|
||||
*
|
||||
* A horizontal carousel of cards tells you what happened but never where you are
|
||||
* in the trip: scrolling for a while gives no sense of having crossed from the
|
||||
* second day into the third, and there is no way to reach day nine except by
|
||||
* swiping past days three to eight (discussion #2299). One segment per day fixes
|
||||
* both — it says how long the journey is, how far in you are, and it takes you
|
||||
* anywhere in one tap or one drag along it.
|
||||
*
|
||||
* The segment colours are the same `DAY_COLORS` the map markers and the card
|
||||
* edges use, so the bar is a legend for the whole screen rather than a fourth
|
||||
* thing to learn.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
days: JourneyDay[]
|
||||
activeDate: string | null
|
||||
onPick: (date: string) => void
|
||||
}
|
||||
|
||||
export default function JourneyDayScrubber({ days, activeDate, onPick }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const dragging = useRef(false)
|
||||
const lastPicked = useRef<string | null>(null)
|
||||
|
||||
// One day is not a journey to scrub through, and the bar would say nothing the
|
||||
// card's own date does not already say.
|
||||
if (days.length < 2) return null
|
||||
|
||||
const long = (date: string) =>
|
||||
new Date(date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
|
||||
const activeIndex = activeDate ? days.findIndex(d => d.date === activeDate) : -1
|
||||
const shown = activeIndex >= 0 ? days[activeIndex] : days[0]
|
||||
|
||||
/**
|
||||
* The day under a given screen x, measured across the bar.
|
||||
*
|
||||
* Measured against the bar even while the finger is on the date above it, so a
|
||||
* drag that starts on the label still lands on the right day.
|
||||
*/
|
||||
const pickAt = (clientX: number) => {
|
||||
const rect = barRef.current?.getBoundingClientRect()
|
||||
if (!rect || rect.width === 0) return
|
||||
const ratio = (clientX - rect.left) / rect.width
|
||||
const index = Math.min(days.length - 1, Math.max(0, Math.floor(ratio * days.length)))
|
||||
const day = days[index]
|
||||
if (!day || day.date === lastPicked.current) return
|
||||
lastPicked.current = day.date
|
||||
onPick(day.date)
|
||||
}
|
||||
|
||||
return (
|
||||
// touch-none, and the events stop here: the whole thing lies on a map that
|
||||
// pans, and a drag along the days must not drag the world with it.
|
||||
<div
|
||||
className="pointer-events-auto touch-none px-4 pb-[6px] select-none"
|
||||
onPointerDown={e => {
|
||||
dragging.current = true
|
||||
lastPicked.current = shown.date
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
e.stopPropagation()
|
||||
pickAt(e.clientX)
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
if (!dragging.current) return
|
||||
e.stopPropagation()
|
||||
pickAt(e.clientX)
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
dragging.current = false
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
||||
}}
|
||||
onPointerCancel={() => { dragging.current = false }}
|
||||
>
|
||||
{/* The date, and the handle: press it and slide sideways to run through the
|
||||
days. "Day 3" stood beside it for a while and was the same fact told
|
||||
twice, since the bar below already shows how far along that is. */}
|
||||
<div className="mb-[5px] flex items-baseline">
|
||||
<span className="cursor-grab rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px] active:cursor-grabbing">
|
||||
{long(shown.date)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Buttons as well as the drag, because a tap is the whole gesture for most
|
||||
people and a button is the only version of this a keyboard can reach.
|
||||
The bar you see is a few pixels tall; the button around it is 20, because
|
||||
a 3px target on a phone is not a target. */}
|
||||
<div ref={barRef} className="flex items-stretch gap-[3px]">
|
||||
{days.map(day => {
|
||||
const on = day.date === shown.date
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onPick(day.date)}
|
||||
aria-label={t('journey.detail.jumpToDay', { date: long(day.date) })}
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className="flex min-w-[6px] flex-1 items-end py-[7px]"
|
||||
>
|
||||
<span
|
||||
className="block w-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
height: on ? 6 : 3,
|
||||
background: day.color,
|
||||
opacity: on ? 1 : 0.42,
|
||||
boxShadow: on ? '0 0 0 1.5px rgba(255,255,255,.75)' : '0 0 0 1px rgba(0,0,0,.18)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -126,10 +126,14 @@ describe('EntryCard', () => {
|
||||
expect(screen.getByText('food')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-009: renders the pros/cons verdict and the story body', () => {
|
||||
it('FE-JRN-CARD-009: renders the story body, and the verdict once the fold is opened', async () => {
|
||||
// The verdict moved behind the fold in discussion #2299 — see FE-JRN-CARD-020.
|
||||
mountCard(buildEntry({ story: 'A wonderful evening', pros_cons: { pros: ['Great food'], cons: ['Crowded'] } }))
|
||||
|
||||
expect(screen.getByText('A wonderful evening')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great food')).toBeInTheDocument()
|
||||
expect(screen.getByText('Crowded')).toBeInTheDocument()
|
||||
})
|
||||
@@ -258,3 +262,62 @@ describe('CheckinCard', () => {
|
||||
expect(container.querySelector('.cursor-pointer')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('the fold (discussion #2299)', () => {
|
||||
it('FE-JRN-CARD-020: the verdict rides behind the story fold', async () => {
|
||||
// A feed of open pro/con tables reads as a spreadsheet. The verdict belongs to
|
||||
// one entry, so it comes out when that entry is opened.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: 'A short line.', pros_cons: { pros: ['Great coffee'], cons: ['Loud'] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Great coffee')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.getByText('Loud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-021: a verdict on an entry with no story stays in the open', () => {
|
||||
// There is no fold to put it behind, and hiding it behind a button that says
|
||||
// "show more story" when there is no story would be a lie.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: null, pros_cons: { pros: ['Great coffee'], cons: [] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Show more')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissing a suggestion', () => {
|
||||
it('FE-JRN-CARD-022: a suggestion offers a way out, and the way out is not the card itself', async () => {
|
||||
const onClick = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} onClick={onClick} onDismiss={onDismiss} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss this suggestion' }))
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
// The card underneath opens the editor; dismissing must not do both.
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-023: a reader who cannot edit is offered nothing to dismiss', () => {
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Dismiss this suggestion' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
@@ -8,6 +8,7 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
|
||||
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
|
||||
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
|
||||
@@ -152,13 +153,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{!photos.length && entry.location_name && !entry.title && (
|
||||
<div className="mb-2" />
|
||||
)}
|
||||
{entry.story && (
|
||||
<ExpandableStory story={entry.story} />
|
||||
)}
|
||||
|
||||
{/* Pros & Cons — "Pros & Cons" style */}
|
||||
{hasProscons && (
|
||||
<VerdictSection pros={prosArr} cons={consArr} />
|
||||
{/* The verdict rides behind the story's fold: it belongs to one entry, and a
|
||||
feed of open pro/con tables is a spreadsheet rather than a journal. An
|
||||
entry with no story keeps it in the open, since there is no fold to
|
||||
put it behind. */}
|
||||
{entry.story ? (
|
||||
<ExpandableStory story={entry.story}>
|
||||
{hasProscons && <VerdictSection pros={prosArr} cons={consArr} />}
|
||||
</ExpandableStory>
|
||||
) : (
|
||||
hasProscons && <VerdictSection pros={prosArr} cons={consArr} />
|
||||
)}
|
||||
|
||||
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
|
||||
@@ -193,7 +197,16 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
/**
|
||||
* A place the linked trip planned, offered as an entry waiting to be written.
|
||||
*
|
||||
* `onDismiss` is the way out of one that will never be written: plans change, and
|
||||
* a journey used to have no answer to a suggestion for a museum the traveller
|
||||
* skipped except hiding every suggestion at once (discussion #2299). The row is
|
||||
* kept server-side so the trip sync does not offer it again, and the journey
|
||||
* settings sheet brings them all back.
|
||||
*/
|
||||
export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntry; onClick?: () => void; onDismiss?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
@@ -220,6 +233,19 @@ export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?
|
||||
<Plus size={12} strokeWidth={2.6} /> {t('journey.detail.addEntry')}
|
||||
</span>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Tooltip label={t('journey.suggestions.dismiss')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onDismiss() }}
|
||||
aria-label={t('journey.suggestions.dismiss')}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
|
||||
style={{ color: 'var(--vg-ink3)' }}
|
||||
>
|
||||
<X size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user