Compare commits

...

106 Commits

Author SHA1 Message Date
jubnl 76c8c349b1 Fold nest mcp (#2017)
* refactor(server): fold @trek/nest-mcp into server/src/nest-mcp

* docs(server): point nest-mcp references at server/src/nest-mcp

* remove tracked md file
2026-08-22 12:55:24 +02:00
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00
Maurice Böe 942e1932c9 fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking'
the reservation type uses everywhere else. The shared trip page prints the
stored category verbatim, so a share link would have shown "parkings" in every
language.

The category also had no relationship to the booking type of the same name:
RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense
to Transport, so the new bucket sat empty next to the costs that belong in it
while a manually typed "parking" resolved into it. Both paths agree now.
2026-08-21 14:28:53 +02:00
MikeDabrowski 999236f944 feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
  ("Parking"), alongside the existing set (Accommodation, Food & drink,
  Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
  Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
  lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
  parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
2026-08-21 14:28:53 +02:00
Maurice 875f87b885 fix(planner): open the booking a place is linked to
The place inspector summarises the booking attached to the day assignment and
then went nowhere: the strip is a bare div with no handler, no role and no
tabIndex, and PlaceInspector was never handed an editor callback in the first
place. Reached from a map marker, that strip is the only route a restaurant or
tour booking has — only transports with two positioned endpoints get map pins of
their own — so its fields and its attachments were unreachable.

The identical lookup in the day sidebar has always carried a pencil that picks
the editor by type. The inspector now takes the same onEditTransport /
onEditReservation pair and does the same: a ferry, a flight, a train opens the
transport form, everything else the reservation modal. That split matters —
ReservationModal has no transport entry in its type list, so a ferry sent
through it would have its type re-picked on save.

Each handler arrives as undefined when the user lacks the matching right, which
is what keeps the strip a read-only summary instead of turning it into a button
that does nothing. Gating inside the handler would have re-shipped the reported
symptom with a label claiming otherwise.

The phone place sheet never rendered the strip at all, so the same booking was
just as unreachable there, only invisibly. It shows it now and opens the same
two editors, closing itself first — it hangs off the place selection rather than
the sheet stack, so closeSheet() would leave the editor stacked on top of it.

Closes #2012
2026-08-21 14:19:28 +02:00
Maurice a23a81f35b fix(studio): the spread import points inward too
Missed in the sweep: "Import spread" opens a JSON file picker and wore the
outgoing arrow like the rest of them.

Refs #2006
2026-08-21 13:27:27 +02:00
Maurice 331954c1b0 feat(mobile): drag to reorder the day plan, and reach the day sheet from any day
Reordering a day on the phone meant two 15px arrows, while the same list on the
web takes a drag. It takes one here too now, via the long-press bridge tablets
already use (#1616): it watches only draggable rows inside an opted-in
container, waits out a press before claiming the gesture — so a swipe stays a
scroll — and replays real drag events, which is why the rows only needed the
receiving half. Armed in edit mode and on coarse pointers alone, so go-mode taps
and map pans are untouched and the polyfill retired in #1432 stays retired. The
arrows stay: they are the accessible path.

Both paths go through one mover, so the chronology guard and the undo entry are
the same either way.

The card header was also the only route into the day sheet, and it rendered only
when the day happened to have an accommodation or a weather reading — with the
accommodation chip as the sole tap target. A day with neither had no way in.
A day pill now leads that row unconditionally, the weather reading is a target
too, and the active day chip carries a chevron so the second tap that opens the
sheet is visible in map view as well.

Closes #1997
Closes #2004
2026-08-21 13:27:27 +02:00
Maurice 2d6c8c5157 fix(mobile): open the stay when you tap the stay
Tapping the accommodation in the day sheet opened the place it is pinned to, so
check-in, check-out and the confirmation code had no way in at all. The sheet
that edits them has taken an accId since the mobile rewrite; nothing ever passed
one.

The row is a real button now and opens that editor, with the place kept one tap
to its right for anyone who wants it. Viewers without day_edit keep the place as
their target, since the editor is no use to them. The linked hotel booking below
opens too — the day's booking list filters hotels out, so that strip was the only
place it showed and it did nothing.

Closes #2001
2026-08-21 13:27:27 +02:00
Maurice da92581a16 fix(planner): let a route choice survive the map, and keep new items on their day
Two things the mobile planner forgot.

The day sheet's route toggle only ever lived in component state, and entering
the map — or tapping a day while in it — forced it back on. Turning the route
off therefore did nothing you could see and had reset itself by the time you
looked again. The choice is now remembered per trip; the map still switches it
on by default, but only while the user has never said otherwise.

Adding a place or a booking from inside a day did not connect it to that day.
The place dropped into the unplanned pool, which on mobile is a different screen
— it looks like the save was lost — and the booking opened with no date at all.
Both entry points now carry the day they were opened from, including a
long-press on the mobile map, where the chip rail names a day at all times. The
desktop map has no such context and passes nothing, so its pool behaviour stands.

While here: the import button on the bookings and transports headers only
appeared when kitinerary was installed, though the server also accepts an import
when the LLM parser is configured (#2007).

Closes #2003
Closes #1998
2026-08-21 13:27:27 +02:00
Maurice 8b88986ca8 fix(bookings): make the reservation import findable on the desktop panel
The import is not missing from the desktop — it sits at the far right of the
bookings toolbar, next to the accent "Manual Booking", above an empty page with
nothing but the mascot on it. On a fresh trip that is exactly where nobody
looks, which is how it reads as absent.

The empty state now offers the ways in that apply: add manually, import from a
file, and AirTrail where it is connected. EmptyState grows an optional action
slot for it, so every other caller is untouched.

Closes #2007
2026-08-21 13:27:27 +02:00
Maurice 9094f05604 fix(planner): close the accommodation loop when no times are recorded
With "optimize route from accommodation" on, the drawn route still started at
the first activity on any arrival day and stopped at the last one on any
departure day. The two bookend rules ask whether you can be proven to have been
at the hotel already — but they read a missing check-in time as proof against,
and the hotel picker leaves that field blank by default. So the loop hung open
at one end on every boundary day, and at both ends for a stay booked on a single
day.

With no time recorded there is no bar to clear, so the leg is drawn. Where a
time *is* set the existing rules are untouched: a stop before check-in still
draws nothing (#1465), and a transport arrival is still not a hotel departure
(#1321).

Closes #2009
2026-08-21 13:27:27 +02:00
Maurice 5e78069223 fix(journey): saving a suggestion turns it into an entry
A trip visit shows up in the journey as a suggestion, and opening one puts you
in the entry editor. Pressing Save there only promoted it when a story, a photo
or a linked photo had been added — a title, a mood, the weather or a set of tags
all left it a suggestion, and so did saving it untouched. From the outside that
looks like the save did nothing.

An explicit Save is the user saying this is their entry now. handleSave only
ever runs from the Save button, so there is no autosave to misread as intent.

Closes #2008
2026-08-21 13:27:27 +02:00
Maurice df139a2d96 fix(ui): point import controls inward and give Google Maps its own mark
Every import button in the client wore an outgoing arrow, so the control that
pulls a booking or a place list in looked like it was about to send something
out. The places-import sheet managed to contradict itself inside one screen:
Download in the header, Upload on the row below it, Upload again on the file
step. Imports now take Download, or FileDown where the button opens a file
picker; genuine uploads and the desktop drop zones keep Upload.

The mobile places-import entry point wore an ellipsis, which promises a menu.

The "open in Google Maps" buttons drew the four-segment Google *search* G,
flattened to one colour — a glyph that reads as "run a search". They get a map
pin instead, factored into one component so the desktop and mobile copies stop
drifting apart.

Closes #2005
Closes #2006
2026-08-21 13:27:27 +02:00
Maurice ec3017f9cc fix(ui): keep dropdown panels anchored to their trigger
The three fixed-position popovers computed top/left inline while rendering,
which reads the trigger's rect exactly once. Scroll a sheet afterwards and the
trigger walks off while the panel stays nailed to the viewport; on iOS the
keyboard slides up over a panel that was placed before it existed.

useAnchoredPosition measures in a layout effect and re-measures on scroll
(capture phase, so the sheets' own scroll containers count), on resize and on
visualViewport changes. It also reports how much room is actually left, so a
list opened next to the keyboard shrinks instead of hiding underneath it.

CustomDateTimePicker clamps a fixed 268x360 calendar horizontally, which no
generic box can express, so it takes the bare re-measure signal instead.

Closes #1999
Closes #2000
2026-08-21 13:27:27 +02:00
Maurice e753603ec8 test(db): re-point the trailing-migration guard at the new last slot
The guard proves that a migration appended to the end still runs on an
install that is one version behind, by undoing what the last one did and
replaying it. Its own comment asks whoever appends the next migration to
re-point the undo, which is what this does: the trailing slot now clears the
GB rows out of place_regions rather than creating journey_books.

The Spanish row alongside it is there to catch the opposite mistake — a
migration that clears more than its own country.
2026-08-20 18:40:26 +02:00
Maurice 6a5f067663 fix(atlas): give Great Britain its counties and boroughs back
Zooming into England showed nothing below "England" itself, so boroughs
marked as visited had no polygon to light up. It stopped working in 3.1.0,
when the runtime Natural Earth fetch was replaced by the bundled geoBoundaries
assets: Natural Earth carried 232 GB regions including the London boroughs,
and geoBoundaries calls the four constituent countries ADM1. The builder asks
every country for ADM1, so GB shipped as four features.

GB now ships at ADM2 — 216 counties and unitary authorities, which is the
granularity every other country gets from its ADM1 and the granularity that
was there before. That is a deliberate one-country list: ADM2 everywhere
would multiply the bundle for no gain, since ADM1 already is the level people
think in for most countries.

Two things had to be said to data that already exists. Regions marked before
3.1.0 hold the old Natural Earth codes, and are reconciled by name within the
country, the way the 3.1.1 step already does for everywhere else; a row that
still cannot be matched is left alone rather than destroyed. And place_regions
is full of GB-ENG rows that were correct under the old bundle — nothing
re-derives a row that is already cached, so without clearing them every
English place would keep reporting "England" forever.

The bundle guard that would have caught this in 3.1.0 is now in place: four
features for a whole country is not a region layer.

Closes #1974
2026-08-20 18:40:26 +02:00
Maurice 0ce82b811b fix(atlas): count only the flights a person is actually on
4.0 lets a booking name the people it is for (#1517), and the travel figures
never read that. They scoped reservations by trip membership, so on a shared
trip everyone was credited with everyone's flights: two people meeting in New
York, one from Rome and one from Mexico City, each got both departures, and
each had "visited" the other's country.

The rule now asks whether the reservation belongs to the person asking, in
one fragment used by all three queries that derive figures from reservations
— flown distance, the passport card's countries, and Atlas's own endpoint
scan — so there is one answer to that question rather than three.

Its second half is the important one: a booking with nobody named on it
counts for everyone on the trip. Every reservation made before 4.0 looks
exactly like that, and a plain join would have taken the flown distance of
every upgrading install to zero. With no assignments anywhere the numbers are
identical to before.

Where someone HAS assigned travelers, their figures will go down after this,
because they were counting journeys they did not make. That is the point, but
it is worth a release note.

Places are untouched: they have no per-traveler concept, and this is only
about numbers derived from bookings.

Closes #1966
2026-08-20 18:40:21 +02:00
Maurice 9e13791239 fix(booking-import): geocode transport endpoints on the path clients use
An imported train, bus or rental car showed its pick-up and drop-off in the
review step and then had them gone from the saved booking: no pins, no route,
no error. reservation_endpoints.lat and lng are NOT NULL, so saveEndpoints
drops an endpoint without coordinates, and kitinerary and the LLM name
stations, stops, terminals and rental desks without geo-locating them. Only
airports arrive with coordinates, from the mapper's own table.

The lookup that was meant to fill them in existed — in confirm(), which no
client calls. Both the desktop planner and mobile go preview, review form,
ordinary save, so it never ran.

It runs in preview() now, through a helper confirm() shares, on the throttled
background lane whose own docstring names booking-import as its intended
caller. The query ladder matches the venue lookup beside it: the endpoint plus
the booking's location first, then the address, then the bare name — name-only
is exactly what fails for a desk label like "Curbside Pickup Counter 7", so it
goes last. Answers are cached per preview, since a multi-leg train repeats its
stations and the lane allows about one request a second.

An endpoint that still cannot be placed stays on the item rather than being
filtered, so the review form can show and edit it, and the preview says which
name it could not locate. That warning needed the widget to change too: it
rendered warnings only when a job found nothing at all, which is precisely
the case this is not.

Reported with the full chain, file by file.

Closes #1969
2026-08-20 18:22:29 +02:00
Maurice f574e75dbe fix(pdf): print a day in the order the plan shows it
The export ordered each day by itself, on day_plan_position — one global
number per booking, seeded automatically from the place count of whichever
day happened to render first. On any other day of a span it therefore pointed
at the wrong slot, and where it was still null the export fell back to the
order the API returned rows in, which is reservation_time ASC with nulls
first.

That is why the same trip printed correctly some of the time and swapped two
bookings the rest of it, and why the reporter's workaround worked: adding a
place and removing it again reseeded the position from the day they were
looking at.

The plan has never used that column. It picks transports per day, gives each
one the time that day shows, inserts them among the places and re-sorts the
whole day by the clock — and none of that was reachable from here, because
this file had its own copy of the ordering with subtly different rules.

So the copy is gone and the export goes through utils/dayMerge, the same
functions the plan uses. Two differences fall out of that, both of them the
plan's existing behaviour: a booking attached to a place is no longer printed
twice, and a multi-leg journey prints one row per leg at each leg's own time
rather than one row carrying every leg as a subtitle. The per-segment booking
code #1943 added is read back from the booking, since the leg object does not
carry it.

Closes #1978
2026-08-20 18:08:06 +02:00
Maurice f76ee87336 fix(nav): stop the tab pill from running under the logo and the user menu
The centred tab menu was absolutely positioned, so it had no relationship to
what sat beside it. Its width grows with every enabled addon and every page
plugin, and once it outgrew the free space in the middle of the bar it simply
ran underneath the logo on one side and the bell and user menu on the other.

The only adaptation was a hard-coded 1024px breakpoint that drops the tab
labels — a constant chosen when two or three addons was the whole story, and
one that cannot know a plugin has added anything.

So the bar is three columns in the flow now: the brand block and the action
cluster carry equal weight either side, and the pill sits between them. That
keeps it centred on the bar as before, and makes overlap something that
cannot happen rather than something a breakpoint has to predict. With
min-w-0 the pill gives way and scrolls when even it runs out of room,
instead of pushing the actions off the edge.

Measuring the pill against the free space and folding the overflow into a
menu was the other option. It buys a tidier look at a narrow width and costs
a ResizeObserver on every desktop page, a cache to keep the measurement from
oscillating against the mode it chose, and a new string in 23 locales. Not
for a fix to overlapping.

Closes #1983
2026-08-20 18:08:00 +02:00
Maurice bb0d65bb19 fix(map): read the last fitBounds call without Array.at
The client's tsconfig targets a lib without Array.prototype.at, so the two
new assertions typechecked locally against the test runner but not against
tsc. Index arithmetic instead.
2026-08-20 18:07:54 +02:00
Maurice 7aead0b713 fix(map): stop the day fit from framing everything and then drifting off it
Selecting a day compensated for the day panel twice: the fit reserves the
panel's height in its bottom padding, and then 300ms later the camera was
nudged again with panBy([0, 150]). Both came from the same change, and
together they moved the view 150px further than intended.

Wide days never showed it. A day that runs north to south does: the fit is
height-bound there, so the northernmost stop sits exactly on the top padding
line, and the nudge then pushes it off the canvas. What the user sees is the
map framing the whole day correctly and then sliding upwards, leaving the
first marker outside the view.

The padding alone is right, and it is what MapViewGL has always done with the
same numbers, so this also brings the two renderers back into agreement.

Closes #1982
2026-08-20 18:07:54 +02:00
Maurice d0cc4e2f00 fix(planner): give the day's optimize button room by dropping its label
Every button in the day's route-tools row hands the day somewhere: show the
route, open it in Google Maps, open it in CoMaps, reorder it. Two were
already icon-only. Optimize carried a label on flex: 1 with padding '6px 0',
which is no horizontal padding at all — survivable until CoMaps added a fifth
button, at which point the text sat hard against both edges of its own
button.

Icon-only now, matching the two beside it. The label stays as the accessible
name and the tooltip, so nothing about reaching the action changes, and the
row has room for whatever gets added to it next.

Closes #1981
2026-08-20 17:51:33 +02:00
Maurice a5e468829c fix(llm-parse): stop discarding an extraction Anthropic sent as a string
With the Anthropic provider, an import sometimes reported that no
reservations were found even though the document held one and the model had
extracted it. Nothing was logged, because from the code's point of view
nothing failed: the value simply was not an array, and the check that only
accepted arrays returned an empty list. What reached the user was
indistinguishable from a document with no booking in it.

The tool declares `reservations` as an array and the call forces the tool, so
it usually is one. Sometimes the model serialises the same content as a
JSON-encoded string instead — either a stringified array, or stringified and
wrapped in `{ reservations: ... }` again. Because that is a serialisation
choice made per call, the same file imported fine on one attempt and came
back empty on the next.

Normalising is now one function next to the lenient JSON parser, and the
OpenAI-compatible client uses it too — it had grown its own version of the
object-unwrap half, and two copies of this rule would drift.

It unwraps a string once and no further. A value that is still not a list
after that is not a list, and reaching deeper would start turning prose into
bookings.

Reported with the exact file and lines, and with both observed shapes.

Closes #1968
2026-08-20 17:51:29 +02:00
Maurice 9522e4ce4a fix(budget): keep an uneven split from corrupting the expense total
163.21 across two people is 81.61 and 81.60. Both are exact to the cent — the
client splits properly — but the server discarded the total it was sent and
re-derived it by adding those two as doubles, which lands on
163.20999999999998. That is the number that went into the row.

It looked like a display quirk because everything routed through Intl came
out right; only the places that print a number as it stands showed it. The
stored value was wrong all along, which is why reopening the expense showed
it back, and why the price stamped onto a linked booking carried it too.

The domain already had this rule and followed it everywhere else — the
settlement maths converts to whole cents before it adds anything. The payer
sum was the one arithmetic still done in euros, in both the create and the
update path, so it now goes through the same rule.

The client side is what makes rows that are already stored wrong stop
looking wrong: the expense form and the mobile cost sheet seed their total
from a cent-clean value, and the three panels that print a booking's price
verbatim tidy it when it parses as a number. Free text an import wrote there
passes through untouched rather than becoming NaN.

Closes #1964
2026-08-20 17:44:21 +02:00
Maurice 68bf5316ad fix(planner): stop an empty reservation event from breaking a trip for good
reservation:created and reservation:updated come in two shapes. Normally they
carry the reservation; the accommodation cascade sends them empty, as a
"something changed, go and look" ping that the event contract allows for.

The applier read the id off both. With no reservations loaded yet the
existence check never ran its callback, so the cast slipped through and the
store became [undefined]. From there every render that walked the list threw
"Cannot read properties of undefined (reading 'endpoints')" into the route
error boundary, and since the store is rebuilt from the same events, the trip
stayed unreachable in that browser. The reporter could only get out of it by
clearing localStorage.

Both appliers now decline an event with nothing on it, and the Dexie writer
does too rather than leaning on a swallowed catch. The predicate that walks
the list is total as well: it guarded the property but not the element, and
it is handed straight to Array.filter over a list it does not own.

Declining the empty ping would have left a hotel booked by one member
invisible to the others until they reloaded, so the ping is honoured the way
day:reordered already honours its own — fetch the authoritative list.

The server keeps sending what it sends. Making the cascade carry the full
reservation would mean moving the REST, MCP and RPC paths together with
their pinned tests, and would still leave the client fragile against the
next emitter that does this.

Closes #1979
2026-08-20 17:37:54 +02:00
Maurice 8f171453d3 fix(packing): keep a private item off the other members' screens over MCP
A restricted packing item, changed through an MCP tool, went to every socket
in the trip room. Ticking something off your own list through an assistant
pushed that row to every other member.

The REST and RPC surfaces have scoped these events since #858 — both go
through emitToViewers, which delivers to the owner and the recipients and
nobody else. The MCP tools called safeBroadcast, which took three arguments
and had no way to say who may see the thing that changed.

So safeBroadcast learned that fourth argument, and the packing tools pass
viewersOf(item), which is the same answer the REST route asks for. A shared
item still answers null and still goes to the room in a three-argument call,
so nothing about it changes — including what the hundred-odd broadcast mocks
in the suite record.

The client stops trusting the wire on this too. It used to `put` whatever
arrived, and the offline read hands back every cached row for the trip with
no privacy filter, so a single stray event lodged another member's item in
that browser permanently. It now drops a restricted item that is neither
mine nor shared with me — deleting rather than skipping, so a list that was
already leaked into heals itself the next time an event about that item
comes past.

Closes #1976
2026-08-20 17:30:01 +02:00
Maurice eae8480728 feat(studio): follow-ups from using Studio on a real journey (#1984)
* feat(studio): widen the document, and stop a save from losing it

The schema gains what the elements coming after it need: an icon element, a
badge that can be restyled rather than only placed, a date and a pair of
coordinates on a text binding, and the map's route, pin and road settings.

Widening it turned up two ways a book could fail to come back.

The mood badge writes its code into the document, and the schema said that
code was exactly two characters, from a first draft where it was a country.
The real codes are words, so every save was rejected and the read that
followed parsed to an empty document: seventeen spreads in, nothing out.
Widened to what the badge actually writes, and normalizeBookDocument now
drops the elements it cannot read rather than the whole book, so one bad
element costs a picture frame instead of a year of work.

The other was the body limit. The parser allowed 100kb for everything, and a
book with a routed map measures past that, so the save came back 413 and the
editor sat on "not saved" with no way forward. Book writes get their own
parser at 8mb; everything else keeps the limit it had.

* feat(studio): a date, a pair of coordinates and an icon library

Three things that had to be drawn by hand before.

A journal entry knows when it happened and where, and neither could be put
on a page: you read the date off the entry and typed it into a text box,
where it then stayed while the entry moved. Both are chips now, next to the
title and the story, and both follow their entry the way the others do.
The formatting lives in one place so the chip, the template and the resolver
cannot drift by a character — coordinates in degrees or decimals, the date
in the reader's language.

The icons are the whole Lucide set, searchable, placed as a real element
with its own colour and stroke rather than as a character in a font. The
stroke is deliberately not called strokeWidth: three separate places rescale
a field of that name by page width, and an icon whose line thins as the book
grows is not what anyone asked for.

And the badges finally have their settings. A mood or weather badge could be
placed and then only moved: the icon's colour was fixed, the icon could not
be turned off, and there was no way to keep the icon and drop the words. All
three are switches now, with the colour following the badge unless you say
otherwise.

* fix(studio): let a placed entry field follow its journal entry again

The inspector says a bound field follows the journal entry and that editing
it here breaks the link. The first half was not true: nothing ever re-read
the entry, so renaming a title in Journey left the old words on the page for
good, and the same went for every other bound field.

The resolver runs where the journey is loaded and re-reads the fields that
still point at an entry. What it will not do is as important as what it
does. A field somebody has typed over stays typed over, an entry that is
gone leaves its element alone rather than blanking it, and an entry that
answers with nothing never wipes what is already on the page.

Formatted fields — the date, the coordinates — are only re-read when the
format itself changed, which is what keeps this from quietly rewriting
books that already exist: an entry whose date was placed as a heading and
then set in capitals keeps its capitals. Checked against the real
database before it shipped, where the first version overwrote three of them.

* feat(studio): start a new book empty, with six layouts to choose from

A new journey opened onto a book that had already been laid out, which is
the wrong first move: it decides the shape of somebody's book before they
have seen it, and undoing it means deleting pages one at a time. A new book
starts empty now, and the auto layout runs when it is asked to.

The layouts themselves went from two to six, so choosing one is a real
choice: what was there covered a photo-heavy spread and a text-heavy one and
nothing in between.

* fix(studio): hyphenate the export the way the editor does, and set the bleed

Two things the press sees and the editor did not.

A paragraph broke in different places in the PDF than on screen. The print
document is written from scratch and never carried a language, so the
browser hyphenated the editor's German and the export's nothing-in-
particular. It takes the page's own language now. The wait for the sheet to
be ready also covers the images inside SVG, with a ceiling, so a map that is
slow to draw does not print half-finished.

And the bleed and the safe margin are typeable. Both were baked into every
page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with
no control anywhere. That is fine until a printer asks for 5 and 10, at
which point the book cannot be made here at all: the export carries the wrong
bleed and the crop marks sit in the wrong place, which nobody finds until
the guillotine has run.

* feat(studio): rebuild the inspector

The panel on the right had grown by accretion: every element type had added
its own controls in whatever shape suited it that day, so the same idea —
a colour, a size, a toggle — looked different depending on what you had
selected, and long panels were a wall of unlabelled fields.

It is one set of controls now, used by every element: foldable sections, a
line that holds a label and its control, radio choices, checkboxes, the
app's own toggle, and a number field that holds a draft so typing "500" is
not read as "5", clamped and written back under the cursor. Duplicated
controls were merged rather than deleted; nothing an element could do before
it cannot do now.

The head carries the element's identity and its actions, so what you have
selected is readable without counting fields, and the sections have room to
breathe: what was there was correct and unreadable.

* feat(studio): a route map worth printing

The map in a book was a straight grey line between grey dots on a tile that
was chosen for a screen. At print size those tiles are read at roughly four
times the resolution they were fetched for, which is why it looked soft next
to everything around it.

The imagery comes from sources that hold up on paper: NASA's shaded relief
and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that
matches the printer rather than the screen. Tiles are fetched at twice the
density where the source offers it, and the seam between them scales with
the tile instead of sitting at a fixed fraction of a millimetre, which under
editor zoom fell below one device pixel and showed as white threads.

The route is drawn rather than plotted. A leg that was never recorded bows,
by an amount that grows with its length and turns away from the pole, and it
dashes, because an inferred line should not claim to be a track — a recorded
track never bows. Both strokes are laid down in two passes, casing then core,
so the line stays legible over dark sea and bright desert alike. Legs can
follow roads instead, routed leg by leg with the geometry thinned before it
is stored, which is the setting a road trip wants and the one a flight
cannot have.

Stops carry their own first photograph, cut to a circle by a stencil that
exists — the first one was written into a defs block that only appears on a
map clipped to a coastline, so on an ordinary map it clipped nothing and the
photographs sat square on the page. A stop with no photograph of its own is
numbered rather than handed one from elsewhere in the journey: a marker at
one place showing a picture taken at another is a caption that lies.

Whatever the frame, the whole route is inside it. The window takes the
frame's aspect, so the fit and the imagery agree instead of one containing
what the other covers, and a map dragged narrow no longer cuts off its end.
Each linked trip can carry its own map, and a map notices a photograph added
to a stop afterwards rather than having to be placed again.

* i18n(journey): the new Studio strings, in all 23 locales

* test(journey): pin the coverage the map work brought with it

The journey domain measures higher than its floor after this round, so the
floor follows it up. Only journey moved; the generator's suggestion would
have taken memories' functions down a point, and a ratchet that can fall is
not a ratchet.

* test(studio): cover the thinning that keeps a road route saveable

The one path in roadRoute nothing exercised, and the one that matters most:
a router answers a long drive with thousands of points at a precision no
press can resolve, and all of it goes into the document. Keeping a leg whole
is what pushed a real book past the body limit.

Both ends have to survive so the line still meets its stops, and the spacing
has to stay even, because cutting a run out of the middle would straighten
exactly the bends somebody wanted to see.
2026-08-20 15:43:27 +02:00
Maurice 736599a89b feat(journey): TREK Studio — a photo book designer for Journey (#1973)
* fix(photos): serve local photos root-relative so a dotted install path works

res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest
ExpressAdapter and answers a spurious 404 even though the file is there — the
same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74
already work around with an explicit { root } + basename. The photo resolver
never got that treatment, so every thumbnail and original 404s on any install
whose uploads path contains a dot segment (~/.local/share/trek and friends).
The public journey asset route had it too, which took shared journeys with it.

The guards above each call already confine the path to the uploads tree, so the
root form is no weaker than what it replaces.

* feat(shared): add the book document contract for TREK Studio

Millimetres throughout, not pixels: a pixel means nothing to a print shop and
changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and
72pt with a fixed ratio, so the editor can render the document at a scale and the
print renderer at 1:1 and land on the same page.

Paint order is the array order rather than a zIndex field that can disagree with
itself, text elements may stay bound to the journal entry they came from, and a
spread can park content a layout has no room for instead of dropping it.

* feat(journey): open TREK Studio over the journey

Studio is a child route of /journey/:id and portals itself above it, so the
journey stays mounted and shows through the panel's 16px margin — it reads as
something opened on top of the journey rather than a place you navigated to. The
URL exists for reload, the back button and shared links; the user never sees it.

Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95
resets button font, colour, background and padding at a specificity a bare .st-x
loses to, so an unscoped rule silently does not apply to a button.

Escape clears the selection and only leaves on a second press. In a dialog
Escape means go away; in an editor that reflex would cost you the page.

* feat(journey): build, edit and arrange the book in Studio

The auto mode turns a journey into a book once and then gets out of the way: what
it produces is an ordinary document, every element as editable as one placed by
hand. Templates work the same way — applying one pours the spread's existing
photos and text into the new arrangement and parks whatever will not fit, so
trying a text-only layout does not cost you the pictures.

Moving and resizing are hand-rolled on pointer events. The drag-and-drop
libraries in this space move items between lists and have no notion of a free
frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot
control and no position until the drop. It is used for exactly the one shape it
suits — dragging a photo out of the content panel onto a frame.

Selection chrome carries two rings, dark inside light. A single line vanishes
against a photograph of its own value, and over a picture that is bright on one
side and dark on the other no measurement of the background can be right.

The sheet renders in DOM rather than on a canvas so the same component tree can
be what the print renderer runs — one renderer, nothing to drift against. Edit
affordances live in a layer above it and can never reach the page.

* i18n(journey): studio strings across all 23 locales

The empty-frame prompt breaks per language rather than copying the English line
break onto everything — forced onto Japanese or Arabic it strands a fragment on
the second line.

* refactor(journey): keep SpreadView to components only

FONT_STACKS and photoSrc move to their own module: a file that exports both
components and constants breaks Fast Refresh, and this is a file you edit
constantly while designing a page. Types come from the journey store instead of
any, and the count beside the content tabs is a badge rather than competing with
the label for the same weight.

* feat(journey): more shapes, decoration and a content search in Studio

Six shapes now instead of three — rounded rectangle, triangle and an outline-only
box join the rest, plus a quotation mark and an outlined circle under decoration.
A triangle is a clip path rather than the old CSS border trick: that hack cannot
take a fill gradient or a stroke and its size does not follow the frame the way
everything else does. A stroke sits inside the frame, or the drawn shape would be
wider than the box the editor snapped and the print would not match it.

The content panel gets a search over photos and entries. A photo matches on its
own caption and on the entry it hangs from — most photos carry no words at all,
so matching captions alone would look broken on exactly the journeys that need
it. Plain filtering over what is already in memory; a journey holds tens or
hundreds of items, not thousands.

Dates in the panel and in the book now follow the app's language through the
house formatDate rather than the browser's locale, and the entry's date and place
are two badges instead of one dot-joined string the eye has to parse.

* @
feat(journey): a shape library, picture frames and photo grids for Studio

Studio had six shapes, two frames and no grids. This is the library that
makes it usable for a book rather than for a diagram.

Shapes are SVG paths now, not clip-paths. That was forced rather than
chosen: a clipped div cannot take a stroke — the clip cuts the border in
half lengthwise — and a heart is not a box with a radius. Rectangles and
ellipses keep their old div rendering, since a box genuinely is better as
a box and every document written so far contains those two.

Two decisions in shapes.ts are worth stating.

The path *points* are scaled, not the coordinate system. Fitting a path
with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm
outline on a frame twice as wide as it is tall comes out 0.7mm on the
sides and 0.35mm top and bottom. Printed next to a straight rule at
300dpi, you see it. Hence the deliberately boring path subset: absolute
M/L/C/Q/Z, numbers alternating x and y, no arcs.

And every shape fills its 0..100 box exactly, enforced by normalise() at
module load rather than asked for in a comment. The editor draws its
selection outline, its resize handles and every snap target from the
element frame, so a shape occupying 86% of its own frame cannot be
aligned against anything you can see. It costs a regularity nobody can
spot — a hexagon in a square frame still reads as a hexagon.

Frames are the same list: a picture frame is a shape with a photograph
inside it, and keeping two would mean a heart that exists as one but not
the other. Photos gain a mask and a decoration, kept independent, plus
the Polaroid, film and tape treatments that are not expressible as either.

Grids take their gutter out of the cells rather than adding it between
them, so a grid asked to fill the safe area fills it instead of
overhanging by one gutter.
@

* @
feat(journey): derive what a journey adds up to

GET /api/journeys/:id/stats — distance, days, steps, photos, places, the
furthest point, the countries in visit order, and the route itself. Read
only and derived: nothing here is stored, it is what the trips added to
the journey come to.

The route comes from the journey entries where they carry coordinates,
and falls back to the places on the linked trips where they do not —
which is the common case for a journey assembled from trips nobody has
written up yet. The two are never mixed: an entry written about a place
that is also on the trip would be two stops at one location, and the
distance would count that leg twice.

Countries read place_regions first, which Atlas already fills, and fall
through to getCountryFromCoords for the rest. That import is a plain
function from a module built without DI or Nest edges, so it costs the
journey domain no coupling to Atlas.

Distance is great-circle metres, not driving distance: there is no
routing engine here, consecutive stops are not necessarily joined by a
road, and a book that prints a road distance it never measured is worse
than one that says "as the crow flies". Metres because kilometres and
miles are a display choice.

A hotel across three nights is one place with three day assignments, so
the place query aggregates back down to one row at its earliest day —
otherwise the route would visit the hotel three times.

Two test fixes came out of writing this. journey.controller.test.ts still
asserted an absolute path from res.sendFile, which the photo fix in this
branch had already changed to the basename-plus-root pair; and
linkTripToJourney in the factories wrote to linked_at, a column that does
not exist — it had no callers, so it had never once run.
@

* @
feat(journey): travel elements — the route, the figures, the countries

A photo book of a trip is not an album: the trip carries facts, and those
facts make pages photographs cannot. Four new elements — a vector route
map, a figures panel, a country page and a set of small marks — plus the
mood, weather and pros/cons an entry already records.

Every one of them carries its own values, resolved when the element is
placed and written into the document. The print renderer is headless
Chromium loading a page; one that needs an authenticated fetch to know
what it says will print blank the day a token expires. It also makes a
book a record of a trip as it was, rather than something that quietly
rewrites last year distance when a stop is added. travelRefresh compares
what is stored against the journey and offers an update, so bringing an
element up to date is a choice.

The map is vector, from the coordinates, over silhouettes simplified
offline from the admin-0 boundaries the server already bundles for Atlas
— no licence question, nothing to fetch at render time, sharp at any
print size. It paints no background: a map is geography sitting on the
paper, not a window cut through it, so it can be laid over a photograph.

Flags are drawn rather than typed. A regional-indicator pair is the
obvious answer and works everywhere except Chrome on Windows, which has
no flag glyphs and renders the two letters instead — "DE" in a printed
book is not a flag. Thirty-three flags are exact constructions; anything
with an emblem or unequal bands is deliberately absent and falls back to
the country outline, because a wrong flag is worse than no flag.

Panel tiles render the real element small, through the page own
renderer. These differ from each other by what they say rather than by
their shape: four map styles are four maps, and no icon can tell you
which is which.
@

* @
feat(journey): manage spreads from the pages rail

Add, insert between two spreads, duplicate, reorder and delete. None of
it existed — a book was whatever the auto layout produced.

The cover and the back cover are fixed points: a book has exactly one of
each, they are single pages, and no sequence of clicks should be able to
produce a book with two covers or none. Every operation asks one
predicate whether a spread is inner, rather than each caller testing role
for itself, and a spread inserted after the back cover lands before it.

Duplicating re-ids every element it copies. Two elements sharing an id
would make selection ambiguous and every lookup that follows it wrong,
delete included.

Also: a locked element can be selected again. Locking used to switch its
hit target off entirely, which was a dead end — it could not be picked,
so the inspector never showed it, so the unlock button was unreachable
and the only way back was undo. Locked now means what it says: it cannot
be moved, and its resize handles are hidden so the editor does not offer
a drag it will refuse.
@

* @
feat(journey): let the auto layout use the travel elements

The book now opens on a summary spread — the route drawn on the left, the
figures on the right — and, for a journey that crossed a border, a country
page after it. Both are omitted rather than left empty when there is
nothing to draw: a spread holding a blank frame over the words "0 KM" is
worse than a shorter book, and every page in a printed one costs money.

Only the figures the journey actually has make it onto the page. A zero
is not a fact worth a tile, it is a hole in a composition.

Each entry gains a short accent rule above its heading — one drawn
element, and the cheapest thing here that makes a spread read as designed
rather than as text placed on a page. An entry with no photographs, which
is the one spread with a blank left page, sets its date as a numeral
there: a gap in the book becomes a pause in it.

The back cover closed with `${places} Orte` — German, hardcoded, in a
book that follows the app language everywhere else. It is a figures
element now, so it takes its labels from the same translations as the
rest of Studio.

The layout waits for the figures rather than racing them. Laying out
first would give a journey a summary spread or not depending on which
promise resolved first, and Studio already shows its loading state until
the document exists, so the wait costs nothing.
@

* @
feat(journey): the Studio wordmark replaces the book-name field

The field asked for a second name for something already named after the
journey — a box whose only real use was being left alone. The bar says
what this is instead: the lockup, inline so it takes the theme ink
through currentColor rather than needing a light-on-dark copy, and a beta
mark beside it.

Along with the strings for everything in this branch, across all 23
locales, and the two keys the name field used, removed rather than left
as orphans nobody can tell from live ones.

journey.studio.frames was "Picture frames" — too long for a tab sharing a
row with two others, and untranslated in 21 of the 23 locales, which
showed the English through. It is one word now, in every language.
@

* @
feat(journey): a free trim size, page numbers, and auto layout as a button

Three from the list the PR left open.

**Custom trim.** The five presets are what vendors actually bind, which
covers almost everyone — "almost" being the problem. A local printer with
a 24x18 press, or a book meant for a particular shelf, could not be made
here at all. Two fields under the format list, and typing in them *is*
picking custom: a book that is 210 by 240 is not square-21 with a typo,
so the preset follows rather than needing to be chosen first. Switching
to custom keeps the page it is on — it adjusts the format you have.

**Page numbers.** Set on the document and drawn by the renderer, which is
the only place that knows what number a page is. Stamping them with
pdf-lib afterwards would print numbers the editor never showed, and a book
you cannot proofread on screen is not much of an editor. They are also the
one thing on a page that is not an element: an element holding "14" would
still say 14 after its spread was moved. `startAt` exists because the
first inner spread is rarely page 1 once bound, and outer/inner is a real
distinction on a spread — getting it backwards puts both numbers in the
fold.

The controls live in the inspector empty state, which was one line of
hint text. With nothing selected, that panel is where the book own
settings belong.

**Auto layout as a button.** It ran once, on open, which made it the one
thing in Studio you could not ask for; a page that came out wrong had to
be rebuilt by hand. Now it does one spread or the whole book, both as
ordinary commits — the first thing anyone does with a button like this is
press it to see, and the second is want their page back. A single spread
keeps its id so the rail does not scroll, and refuses on anything not
built from an entry rather than replacing it with a blank.
@

* @
feat(journey): cover layouts, and five more for the spreads

The cover had none. The panel said covers are designed by hand, which was
true and unhelpful — it is the page people care most about and the one
they had no starting point for.

A cover is a different problem from a spread and gets its own set rather
than a filter over the existing one: it is a single page, the type is the
subject rather than a caption to a photograph, and a spread layout applied
to it lands half its frames past the edge. Five of them, from full bleed
to type-only, and the panel serves the right set for whichever spread is
open. The cards scale as one page there, and drop the fold line, since
there is no fold.

Five more spread layouts as well — a picture with a narrow column of
story, nine up, a mosaic around one large frame, two pictures with the
words between them, and four staggered so a row of squares stops reading
as a contact sheet.

Applying a layout also stopped mangling a single text. The old rule took
the longest text as the story and looked for a heading among the rest,
which reads a three-text spread correctly and gets a one-text cover
exactly wrong: the 30pt title *is* the longest text, so it was the story,
so a cover layout with a heading slot and no body slot had nowhere to put
it and parked the title. Size decides the heading now; length only sorts
what is left.
@

* @
feat(journey): seven self-hosted book faces, and a picker that shows them

The serif slot was Georgia. Georgia is a system font — on most desktops,
on no Linux container — so the print renderer would have substituted
something else and a book proofread in one typeface would have printed in
another. The existing Journey PDF export has exactly this bug today,
asking for Inter while only Poppins is bundled.

Every family is self-hosted through @fontsource now, all OFL, all loaded
with Studio own chunk so a face cannot arrive after the text has been
laid out and reflow the line endings someone was setting. Two sans, three
serifs from bookish to high-contrast, two display faces for a cover.
Every stack ends in a generic rather than in another named font nobody
shipped.

The three original ids are kept by name, so documents written so far
still parse — they point at bundled faces rather than at whatever the
machine happened to have.

The picker sets each family in itself. A row of chips reading
"Poppins / Lora / Bebas" in the interface font tells you their names and
nothing about the only thing anyone is choosing between.

Weights a family does not ship are disabled rather than offered: asking
for bold on a single-weight display face gets a synthesised one, which
prints as a smeared regular. Picking such a family moves the weight to
the nearest it really has.
@

* @
feat(journey): real map imagery, from the providers TREK already has

The map element drew country outlines and nothing else. That is still the
default and still the right one — nothing fetched, nothing licensed,
sharp at any print size. But an outline of Iceland cannot show which road
you took, and some books want that.

So two more sources, both reading the tile template and Mapbox token an
admin already configured for the planner and Atlas. A second place to
paste a token is a second place for it to be wrong, and a source with
nothing behind it is not offered at all — showing "Mapbox" to someone
without a token produces a grey rectangle and no explanation anywhere.

Both costs are stated rather than hidden: tiles are fetched when the page
renders, and they are raster, cut for one zoom, so printing much larger
than they were fetched goes soft.

Attribution is neither optional nor a setting. The licence requires
credit and a book is a published work — nobody can add a footnote after
it is bound. The credit is matched on the host, so an instance running its
own OSM mirror still credits OpenStreetMap, and an unrecognised source
gets none rather than an invented one. It is written into the document
when the element is placed, so the renderer cannot lose it.

The route is projected onto the tile grid when there is one. The vector
map fits itself to country outlines, and a line placed by that fit sits
next to the road rather than on it — the sort of error that only shows up
once it is printed. Outlines are dropped under imagery, where they would
be a second coastline over the one in the picture.
@

* @
fix(journey): make the Studio type-check again

The map element used tileView and projectOntoTiles without importing them,
useMapSources declared a stricter frame type than any real frame satisfies,
and the mood icon read a colour through an `in` check that types as unknown.
The page fixtures in the studio tests predate page numbers, so they build a
BookPageSetup that the contract no longer accepts — now parsed from the
schema, which cannot drift from it.
@

* @
feat(journey): the book is saved

Studio laid a book out on every open and kept it in the tab. Close the tab
and the afternoon went with it, and the auto layout was not a starting point
but the only point.

The document now lives in journey_books, one row per journey, and autosaves:
quiet for a moment after the last change, or at the latest fifteen seconds
in, so a drag across a spread is one request rather than four hundred. A
stored book wins over a fresh layout on open, and closing the editor writes
whatever is still queued.

Everyone on a journey can edit its book, so the write carries the version it
was made against and the update matches on it in a single statement. A save
that has been overtaken is refused with the current record in the body,
which turns "your work vanished" into a choice between two versions. The
server broadcasts journey:book:saved to the other editors — the version, not
the document — and a client with nothing outstanding takes the new one. A
client with unsaved edits deliberately takes nothing and conflicts on its
next save instead.
@

* @
feat(journey): export the book

The one thing Studio could not do. The button was there and disabled.

A book now goes out as sheets: every inner spread cut down the middle into
leaves in reading order, bleed carried out past the trim on all four sides,
crop marks outside that saying where to cut. The cut is a window rather than
a re-layout, so a photograph crossing the gutter still meets itself once both
halves are printed. Spreads stay whole in the other mode, which is what you
send someone to read.

The browser makes the PDF, from the editor components at 1:1. Considered and
rejected: headless Chromium on the server, and a PDF library drawing the
pages a second time. TREK is self-hosted, and neither Chromium in the image
nor a second renderer to proofread against is worth carrying when the browser
already produces exactly what the editor shows — it is also how the other
exports here work. What the browser will not write is PDF metadata and the
TrimBox entries; the marks carry that instead, as they have since long before
the format existed.

Two things had to give. `SpreadView` clipped at the trim, which is right on
screen and leaves a white hairline down every edge of a bound book, so print
mode lets the page run out and the sheet does the clipping. And the print
document is its own document: it carries the app stylesheets across so the
seven bundled faces are the ones that print, and waits for images and fonts
before it offers to save.
@

* @
fix(journey): make the Studio popovers cover what they cover

The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when
the user has transparency on. That reads as depth over the workbench, where
the rails and the bar use it, and as a mess over the inspector, where this
menu opens: two layers of text through each other, neither readable.

The export dialog was worse — it named --panel and --card, which are not
tokens here, so its background resolved to nothing at all and the whole thing
was see-through.

Both are opaque now. The other glass surfaces are left alone: they float over
the workbench, and that is what the material is for.

The print preview also fits the pane. A spread sheet is over 400 mm across,
so at 1:1 the book ran off the side and had to be read by scrolling sideways.
Screen only — the print stays at full size.
@

* fix(journey): a cover keeps its own size, and a trim size can be typed

Three things the export and the page setup got wrong.

Spread mode made every sheet as wide as the widest so that one @page rule
would cover them all. That put a cover — one page — in the middle of a
spread-sized sheet with white either side, which is not a cover. Each sheet
is now sized to what is on it, and the print CSS carries a named page rule
for the single-page ones.

The trim fields clamped on every keystroke, so typing 500 meant the field saw
5, clamped it to the minimum and wrote that back under the cursor: the number
could not be typed at all, only nudged with the arrows. The field now keeps
what is being typed and tells the document about values it can use. The
arrows are unchanged, and leaving the field still clamps — someone who typed
5 and clicked away meant the smallest book.

And the autosave could loop. The save effect depended on the whole store
object, which is new on every render, so after a conflict — where the local
document is by definition not the saved one — it was a save attempt per
render: a 409, a re-render, another attempt. It depends on the callbacks now,
and a conflict holds the autosave until somebody answers it.

* feat(journey): page numbers pick their own colour

A book has dark pages and light ones, often on the same spread, so one folio
colour is wrong on half of them: the warm grey that reads on paper vanishes
into a full-bleed night photograph, and white vanishes the moment the page is
white. The way out used to be setting it per spread, which the document has
no place for.

The renderer asks what is under the number instead. It knows every element
and where it sits, so it walks them from the top down and takes the first one
that covers the spot: a shape's fill is arithmetic, a photograph gets white
with a hairline of shadow — unknowable without decoding it, and dark more
often than not once printed — and bare page takes the page colour.

A heuristic, deliberately: the alternative is sampling pixels from an image
that may not have loaded, in a renderer that also has to run for print.
Anyone it gets wrong can still pick a colour, and picking one turns it off.

* feat(journey): see who else is in the book, and where they are

Two people editing the same document without seeing each other is how you get
two people editing the same spread. So: a badge in the bar saying who has the
book open, and an arrow with a name on it for each of them.

The coordinates are millimetres on the spread, not pixels on a screen — the
two of you are at different zoom levels on different monitors, and a pixel
means nothing to the other one. They carry a spread index too, so a pointer
on page 40 is not drawn on page 2.

Ephemeral throughout. Nothing is written down, the room is the authorisation
for the pointer path, and a socket that dies takes its arrow with it rather
than leaving it on everyone else's page. Presence is keyed by socket because
two tabs are two pointers; the badge folds them back into one person, because
two tabs are not two people.

The rate limit needed room. Pointers move about ten times a second, three
times what the per-socket limit allows, and that limit is what stops a client
flooding the process — so it is now two: an outer ceiling every frame counts
against before parsing, and the original tight limit on everything a pointer
is not. Malformed JSON still counts against the tight one, which is what the
guard was for.

Throttled to ten frames a second going out and interpolated coming in: sixty
would be five times the traffic for something the eye cannot use, and drawing
ten as they land reads as a stutter rather than a hand.

* fix(journey): keep the zoom bar on screen at every zoom level

Zoomed far enough in, the bar slid off the bottom of the window — and it is
the only way back out, so the editor was stuck at 300%.

The workbench sat in a grid track sized `auto`, which grows to whatever is in
it; `overflow: auto` on the child does not stop that, because a grid item's
minimum size is its content unless told otherwise. So at 300% the box grew to
2400px inside a 660px window and took its absolutely positioned bottom edge
with it. The track is bound now, and the workbench scrolls instead of growing.

The bar also has a z-index, for the same reason it has to stay in view: it
must never be the thing something else covers.

* feat(journey): Studio replaces the old book export

The journey PDF and Studio were two ways to print the same book that disagreed
about what it looks like — and only one of them can be edited. The old export
goes: the two buttons on the desktop already sat next to the Studio one, and
the renderer they called is deleted rather than left as a second answer.

Nothing takes its place on the phone. The book is a Studio document now and
Studio is a desktop editor; a phone-sized PDF of something laid out for print
was the old export's answer to a question nobody was asking.

* feat(journey): lay the book out the way a travel book is laid out

The auto layout built every spread the same way: a picture on the left, words
on the right, and nothing else. It came out looking like a template because it
was one — and on a journey with little in it, like a stack of blank paper.

Five layouts now, chosen by what an entry has and alternating as the book goes
on. A place page for a stop nobody wrote about, with the country pale behind
the name and the coordinates under it. A story page with a small map of the
stop and the picture bleeding off three edges. A panorama across the fold. A
gallery with the hero on one page and a ruled strip on the other. And every
fourth picture spread goes full bleed with the words set over it, because a
book of loud pages has no loud pages.

The marks are what actually make these pages: a flag beside the country, a
date numeral, a chip saying which day this is, a rule along the foot with a
dot showing how far in you are. Each is trivial and together they are the
difference between a page that was designed and one that was filled in. All of
them are elements the panels can make, so what comes out can be taken apart.

The summary spread gained a heading and the furthest point drawn as two dots
and a line — that figure means nothing until it is a distance between two
names. The countries page went dark, because the printed one is: pale outlines
on near-black is the single most recognisable page in these books.

Two things the old layout got wrong are fixed on the way. Entries with nothing
on them share one page instead of taking a spread each, in the order they
happened rather than gathered at the end. And an entry whose photographs have
not arrived gets the frames they will go in, empty, rather than blank paper —
the layout is finished and the pictures are the only thing missing, which is
the right way round.

* feat(journey): build the spreads out of areas, and set the elements in ink

Two things, both about what a page is made of.

The layouts were photographs with captions. A printed travel book is built out
of areas: a page laid solid in ink with pale type on it, a band down the outer
edge, a picture that overruns the panel it sits against, a box ruled around a
block of text. Two more layouts do that now — one with a whole page in ink and
a second photograph crossing the fold onto it, one with an edge band and the
words in a ruled box, pictures at two sizes and one overlapping the hero. Six
layouts on a rotation, so the book stops reading as a template.

None of it needed a new feature. The document paints in array order, so a panel
pushed first is a panel everything else lands on; that is all layering is, and
it needed using rather than adding.

And the travel elements are ink by default rather than the app's orange. A book
is printed and a printed page is ink on paper; a colour that arrives without
being chosen turns every element into the same accent whether the book wants
one or not. Picking one is a click. The dark map is the exception — the route
goes pale there, because ink on a near-black country is a line nobody can see,
and only the default is overridden: a colour somebody chose stays theirs.

* fix(journey): three things the editor got wrong

An empty photo frame rendered on its own, ignoring frameStyle entirely — so a
Polaroid placeholder was a plain dashed rectangle until a photograph landed in
it and the chin appeared from nowhere. The two branches are folded together
now: the surround is drawn once and only what goes inside it differs, so a
frame shows what it will look like before it is filled.

Adding a page to a book with no inner pages yet put it in front of the cover,
where it could not be moved back from either — a move only ever swaps with
another inner spread, and the cover is not one. An empty book reports its last
inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now.

And snapping only knew the outer safe lines. A spread draws that dashed rule
inset from each of its four vertical edges, so dragging inwards caught the fold
and nothing else — the line you could see just before it was not a target. All
four are, along with the centre of each page and the quarters within it: on a
spread the halfway mark is the fold, so a picture centred on its own page had
nothing to find.

* feat(journey): take the auto layout's spreads from templates drawn by hand

The layouts written as code are what a function can reason its way to: a
picture here, words there, margins that add up. What they are not is designed —
a panel bled off one edge, a shape running off a corner, a strip of pictures at
two sizes with the coordinates set beneath them. Those decisions come from
looking at a page, and the editor is where looking at a page happens.

So a spread built in Studio can now be the layout. `extract-templates.cjs`
reads a journey's book and writes its spreads out as data, with every
measurement divided by the page it was drawn on — a template built on a 210mm
square lays out on an A4 landscape book without being redrawn. `applyTemplate`
does the other half: it fills what the entry can answer for and copies
everything else as drawn.

That division is the whole contract. Text carrying a binding takes the title or
the story; empty frames take the photographs in order; the day, coordinate,
date and country marks take what the stop knows. Panels, rules and decorative
shapes are the design and are left exactly as they were put.

A template is one *for an entry* when it uses one — which has to come out of
the drawing rather than a label somebody remembers to set. A summary spread of
figures and country outlines reads perfectly well on its own, and dropping a
day's photographs into it would be nonsense.

Two templates ship with this, drawn by the person who will use them. The
built-in layouts stay as the fallback for entries no template suits.

* feat(journey): turn elements freely, and pass a spread on as a file

Rotation by the corner, the way it works everywhere else: a handle sits
diagonally outside each of the four corners of the selection, and dragging one
turns the element by the change in angle rather than to the angle under the
pointer, so it can be picked up anywhere without jumping. Held to 15 degree
steps unless shift is down. Four handles rather than one because a single
handle off the top right sits under the inspector for anything in the right
page's upper half.

And a spread can now leave the book it was made in. The zoom bar downloads the
one on screen as a file; Import, beside Add spread in the pages rail, brings one
back. Measurements travel as fractions of the page, so a spread drawn on a 210mm
square lays out correctly in an A4 landscape book, the same way the auto
layout's templates do.

What deliberately does not travel:

  - Photographs. The file carries the design, and the frames arrive empty.
  - The map source. A static map freezes the URL it was rendered from, and on a
    Mapbox instance that URL has the access token in it, so a file meant to be
    posted in a forum would carry a credential. Coming the other way, a tile
    template from a stranger is a URL every reader's browser would fetch tile by
    tile: an IP address handed to whoever wrote the file.

An imported file is treated as what it is, somebody else's JSON. It is read in
the tab that chose it and never uploaded, parsed and never evaluated, and every
element goes through the document contract, which drops unknown fields and
range-checks the rest. Colours in particular are #rrggbb or nothing. The size of
the file and the number of elements are both capped, because the damage a
hostile file can do here is not code execution, it is handing the editor more
than the server will accept and leaving a document that will not save.

Also fixes the rotation handle being drawn but not grabbable: the chrome layer
and the selection box both pass pointers through, and a handle has to opt back
in with pointer-events.

* docs(wiki): add two Studio screenshots for the addon page

The editor with a stats spread open, and the frames panel. Both are what the
Journey addon page will want once Studio ships, and both are wanted now to show
the feature off while it is still in review.

* test(realtime): cover the book room's refusals and its dead sockets

The realtime coverage ratchet caught what the book room work left behind: the
three handlers each have an early return for a message that is missing its
journey id or comes from a socket with no user, and none of them was exercised.
Every one of those is a silent failure by construction. A handler that did
something with a missing journey id would not throw, it would put a socket
somewhere it does not belong, and nobody would notice until two strangers were
looking at the same page.

The other half is sockets that are in a room but cannot be used: a connection
that dropped before its close handler ran, or one put in a room without ever
being registered. The peer list has to walk past both rather than showing a
ghost, and the broadcast has to skip them rather than throwing on a path that
runs ten times a second per editor.

Both files are back to full branch coverage, so the domain's pins hold as they
are rather than being lowered to fit.

* feat(journey): make the route map a map of the journey

Four things were wrong with it, and they compounded.

**It showed the wrong place.** The view was fitted to the country outlines as
well as the stops, so a trip that stayed inside Berlin was drawn as the whole of
Germany with two dots in the middle of it. It fits what the journey did now, and
the outlines run off the edge of the frame the way a coastline does on any real
map. Fitting to the country is still there as a deliberate choice, for the page
every travel book has, and how much air to leave around either is a setting
rather than a constant.

**It drew a line nobody travelled.** Stops joined in order is a claim about the
journey that is almost always false: it crosses mountains, water and borders the
trip went around. TREK already stores routed geometry per place, from the
planner and from imported GPX, so the map now follows the road where the trip
knows it and falls back to joining the stops where it does not. Segments stay
separate, so a trip with a flight in the middle does not grow a road across the
sea. The geometry is thinned when it is frozen into the element: a printed line
gains nothing from a point every fifteen metres, and a book carrying every GPS
fix of a three-week trip is a document nobody can save.

**The tiled and static sources drew nothing at all.** The tile grid was
computed, the route was projected onto it, and then no picture was ever
rendered — the map came out as a frame with a line across it. Three separate
faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted
so every tile URL named a host that does not resolve, and the grid was cut from
the tiles the route happened to sit on rather than from what the frame shows, so
it stopped short of the edge. The frame decides the grid now, and it covers by
construction. Tiles overlap by a tenth of a millimetre, which closes the hairline
of white paper that rounding left between them.

Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static
Images API refuses it outright, so an instance configured for the planner's map
was configured with a style this cannot use. It falls back to the classic
equivalent, and a URL frozen into a document before that existed is repaired on
the way out rather than left broken for good.

**A map was always a rectangle.** It can be cut to the outline of the countries
now, so the picture *is* the country: no frame, no box, the page showing through
around the coastline. That is the version worth laying next to a photograph, and
the one thing a rectangle can never be. The tile grid and the vector fit are
projected from one extent so the picture and the shape it is cut to line up —
two fits meant the country was full of the wrong towns.

Also here: the credit line comes off the map itself. It is still resolved and
still stored, for the colophon, which is where a book has always credited its
sources.

* feat(journey): split the country list, and trim the map cards

The country list and the country grid were compositions somebody else made:
they decided the order, the spacing and the type size, and the only way to
change any of it was to not use them. They are gone, and what is left is the
same entry — the name over its silhouette, set the same way — as an element
placed one country at a time. Three down a page is the list, with the spacing
yours; one at 80mm is a chapter opener, which the list could never be. The name
is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather
than a feature. The compact mark, outline beside the name at the size of a line
of type, stays for the places that want a mark rather than a heading.

The map section offers the four vector styles and nothing else. Tiles, Mapbox
and the cut-out are one click away in the inspector, where the rest of the map's
settings already live, and four cards that are the same map four times were
crowding out everything under them.

A map is fitted to the country by default now, which is the picture a travel
book usually wants. Fitting to the stops is still there and still matters the
moment the trip is smaller than the country it happened in.

Also: no navigation fallback from the dev service worker. In production it is
right — offline, a deep link has to be answered by the cached shell. In
development it answers a reload with the index.html it cached earlier, whose
script tags point at module URLs from before the last restart, so the browser
re-runs the previous version of the app while the editor shows the new one.
Every explanation for that is wrong until someone thinks of the service worker.

* fix(journey): drop what the trimmed map section left behind

Three bindings that only fed the cards removed in the commit before: the map
sources hook, the country codes the cut-out card needed, and the frame the
preview sized itself from. Lint had them as warnings rather than errors, so
they would have ridden along quietly.
2026-08-19 23:36:42 +02:00
Maurice 7164f4fb11 fix(photos): drop the dead initialiser eslint flagged in the exif reader 2026-08-18 22:24:29 +02:00
Maurice 503a03792e test(photos): cover the local EXIF path so the memories ratchet holds
The capture backfill landed in src/nest/memories/ with its local branch
untested, which took the domain under its coverage floor (functions 96.71 vs 97,
branches 79.67 vs 81). Raising the tests rather than lowering the gate: the path
guard against climbing out of the uploads tree, a missing path, an unreadable
file, the CreateDate fallback, empty EXIF, a throwing row lookup, and schedule().
Plus the Immich album mapping with and without coordinates.

One note left in the test file for the next person: a mock that *rejects* makes
vitest fail the run even when the code catches it, because the mock's recorded
result is a second reference to that promise with nothing attached. A bare
mockReset() is enough to trip it, so the beforeEach sets a benign default.
2026-08-18 22:24:29 +02:00
Maurice 2c333cb643 perf(journey): render the gallery in pages instead of all at once
A long trip's gallery is hundreds of tiles and every one of them was in the DOM
from the first paint, which is what made scrolling it a chore (#1614). It now
renders 60 and grows as the sentinel comes into view — the same mechanism the
provider picker already uses, rather than a second one invented for this.

Deliberately not pagination over the wire: the gallery arrives inside the one
getJourneyFull payload that the client, the MCP tool, the MCP resource and the
PDF export all read, so cutting it there would change the answer for four
consumers at once and would silently shorten the book. The reported symptom was
the scrolling, and that is client-side.

The reported 500-photo limit is not in the code — no query limit, no upload cap,
no client slice — so nothing was raised.
2026-08-18 22:24:29 +02:00
Maurice 06b7f3e690 feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path
only ever asked for daily figures, so it answered with a rainy morning and a
bright afternoon averaged into one number (#1614).

With a time in hand it now asks for the hourly series as well and reads that
hour, falling back to the daily figures when the hour has no reading — an
archive gap must not turn into no answer at all. An unparseable time is treated
as no time rather than as midnight, which would have been a confident wrong
answer. The cache key carries the hour, so the two do not shadow each other.

Nothing changes for callers that pass no time, which is every existing one.
2026-08-18 22:24:29 +02:00
Maurice 97f86dcf46 feat(journey): let quick capture take a note, not just a photo
The ask was "photos or quick notes" while travelling, and the capture sheet only
took photos (#1614) — so a moment worth catching without a picture had no way in
short of opening the full editor, which is exactly what capture mode exists to
avoid.

Two rows rather than the full sheet's three: this is a note, not the story. The
story is written later, when the entry is finished.
2026-08-18 22:24:29 +02:00
Maurice 6e4fda897f refactor(journey): stop pulling trip photos into journeys
Photos live in journeys (#1614). Linking a trip used to copy its trip_photos
rows into the journey gallery, which is how a photo one member had chosen not
to share could reach a journey at all — PR #1967 filtered that at the door, and
this removes the door.

Nothing is lost by it: the trip-photo surface lost its UI in 3.1.0, so nothing
writes to trip_photos on an install newer than that, and the client calls none
of its routes. The table and those routes stay for one more release rather than
being dropped in an append-only migration; the read paths keep working for
installs that still hold rows.
2026-08-18 22:24:29 +02:00
Maurice 56b4509b03 feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their
coordinates are drawn as thumbnails on the Journey map, in the shape the report
asked for: a picture rather than an abstract pin, and a count when several
collapse into one.

Clustering is a screen-space grid rather than a library. The Journey maps have
never had clustering, and the one the planner uses hangs off react-leaflet while
this map drives Leaflet directly; bucketing by rounded pixel position is a few
lines, is deterministic, and does the job — photos of one place collapse into one
thumbnail with a count, and pulling the map apart separates them again. The layer
sits below the entry pins, because the itinerary is the point of the map and the
photos are context.

Its own effect, so photos arriving do not tear down and rebuild the map the way
the entry effect does, and redrawn on zoom and pan because the buckets are in
screen space.

The thumbnail URL is built in the page rather than the hook: it needs the share
token, which is the credential the byte proxy checks.

Also re-points the trailing-migration guard at the new last slot, which is what
its own comment asks of anyone appending one.
2026-08-18 22:24:29 +02:00
Maurice 11686be264 feat(journey): let a shared journey read newest-first
A journey shared while the trip is still running is read like a blog: the family
following along wants the latest entry at the top, not to scroll past three weeks
to find it (#1614). The owner sets it per share link, because it is a property of
how the link is meant to be read rather than of the journey, and the reader can
flip it either way.

Off by default, so an already-published link does not reorder itself under its
readers.

Only the reading order changes. Day colours and stop numbers stay chronological,
so the timeline still matches the map when the feed is upside down — which is why
the day index is looked up rather than taken from the render loop. Reuses
memories.newest/oldest, already translated in all 23 locales.
2026-08-18 22:24:29 +02:00
Maurice e809808572 fix(journey): give the public map one numbering instead of two
The marker showed the stop's position within its day; the day heading showed the
day number. Same colour, different meaning, and on mobile the heading is not
rendered at all, so the number had no key — which is exactly what the report
described: the same digits over and over in different colours (#1962).

Both now count stops across the whole journey, so every number appears once, and
the timeline carries the same number next to each geocoded entry. The day colour
still groups the day. The badge sits outside the photo-count gate on purpose: an
entry with a single photo puts its title and location in the image overlay, but
the stop number has to be readable either way.
2026-08-18 22:24:29 +02:00
Maurice 97220b72f6 feat(journey): carry capture metadata into the payload, and keep it behind share_map
The gallery rows now hand the client taken_at, lat and lng, which is what the map
and a capture-ordered gallery will read.

The coordinates needed a projection before they could ship. A photo's capture
location is a place the owner never typed and may not expect to publish, so it
follows share_map — the same switch the entry coordinates already follow. Without
that, turning the map off and leaving the gallery on would have handed out exactly
the locations the owner had just withheld. The capture time is not a location and
stays either way; a blog-style view sorts by it.
2026-08-18 22:24:29 +02:00
Maurice d2cf694882 feat(photos): read the EXIF of an uploaded photo
Closes the third source of capture metadata (#1614). Provider photos are asked
for, uploaded files carry their own answer — and until now nobody read it, so
the most common case of all, a photo taken on the trip and uploaded straight
into the journey, could never appear on the map.

exifr rather than a heavier image library: pure JS, no native build, so no
musl-pin dance in the Alpine image. Only four tags are picked, so a large file
is not fully parsed. The resolved path is checked against the uploads root
before it is opened.

One thing worth knowing before someone files a bug about it: the client
converts HEIC before upload and that conversion drops GPS, so an iPhone photo
arrives here already stripped. Nothing to read is the expected outcome more
often than not, which is why the empty case is silent.
2026-08-18 22:24:29 +02:00
Maurice 359c3afc21 feat(photos): ask the provider where and when, and stop the album path dropping it
Second half of the capture-metadata foundation (#1614).

The album paths were the asymmetry: Immich's search has always carried
exifInfo.latitude/longitude, but the album mapping quietly left them out, and
the Synology album listing never asked for the gps block at all. So a photo
picked out of an album could never be placed on a map, and the distance sort
that PR #1632 added silently had nothing to sort by. Both now carry the pair.

For the add itself the client only sends an asset id. Widening that contract
would have meant trusting the client for something the provider can be asked
for directly, and would have left the MCP path and every already-imported photo
without it — so PhotoCaptureBackfillService asks afterwards instead, detached,
skipping rows that already know both so an album import is not one provider
call per photo. A slow or unreachable provider cannot hold up the add, and a
photo without capture metadata stays the normal case.
2026-08-18 22:24:29 +02:00
Maurice 6185cf96b6 feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
2026-08-18 22:24:29 +02:00
Maurice 3e4b36ac71 fix(weather): give the Open-Meteo calls a deadline
All six outbound calls ran without an AbortSignal, so a hung connection held
the caller until the socket gave up on its own. Every other outbound client in
the codebase carries a timeout; this one is the reference domain and did not.
2026-08-18 20:17:57 +02:00
Maurice 85794a5a59 fix(journey): keep the picker's date headings chronological, and name any provider
Since the provider picker started sorting by distance to the entry, the date
groups were built from the already-sorted list, so first-seen order put the day
holding the nearest photo at the top and the headings stopped reading in order.
Grouping now sorts its own output; the order within a day is untouched, so the
distance sort still does its job where it belongs.

The picker also called every non-Immich provider "Synology Photos". It now
falls back to the provider's own id, the way the gallery view already did.
2026-08-18 20:17:57 +02:00
Maurice edb487fbef feat(journey): let the desktop editor set the entry time, and reach the camera
The time was already stored, sent to the server and shown in the timeline, on
the map, in the PDF and on the public share — the desktop editor just never
rendered an input for it. So a draft carried the wall-clock time it was created
at, it showed up everywhere, and there was no way to correct it (#1614). The
column holds HH:MM:SS and a time input wants HH:MM, so both the initial value
and the dirty check now trim.

The camera gets its own hidden input rather than a capture attribute on the
existing picker: putting it there would take the photo library away on a phone,
which is the more common way in. The phone sheet already works this way.
2026-08-18 20:17:57 +02:00
Maurice 630e8efca1 fix(journey): stop unshared trip photos leaking into journeys, and gate the link
Two things found while reading the domain for discussion #1614.

A photo a trip member left unshared is denied on the trip path — memories-access
requires tp.shared = 1 — but linking that trip to a journey copied every row of
trip_photos into the gallery regardless. From there it was readable by every
journey contributor, and with an open gallery by anonymous visitors of the share
link. journey_photos.shared cannot gate that after the fact: a direct upload
writes 0 into the same column, so filtering on it would empty most galleries.
The fix is at the door instead — only shared rows are copied in.

addTripToJourney checked that the caller could reach the trip but never that
they could reach the journey, so any logged-in user could link a trip of theirs
into a stranger's journey and seed skeleton entries and photos there. The MCP
tool has always checked this; only the REST route was open. Now the service
checks it, which covers both and the internal call from createJourney.

Neither path had a test. They do now.
2026-08-18 20:17:57 +02:00
Maurice b267771772 fix(mobile): let the map show one day again
Collapsing a day card has hidden that day's places from the map since #216, and
it was the only way to reduce a busy map to one day. The phone shell does not
render DayPlanSidebar, so when it replaced the desktop page on phones that
control disappeared with it: expandedDayIds stayed null and every day's pins sat
on top of each other with nothing to do about it (#1962).

A day chip in map view now drives the same declutter the desktop chevron drives,
rather than introducing a second filter. Entering the map focuses the selected
day, tapping another chip moves the focus with it, and leaving the map clears it
so the timeline is never looking at a filtered state. Unplanned places stay
visible, exactly as the collapse path has always left them.
2026-08-18 17:40:56 +02:00
Maurice a3d1fc7808 feat(share): number the day's stops on the map and connect them in order
The public share page drew every geocoded place of the trip as an identical pin
with no sequence and no line, so an itinerary could not be read off the map at
all (#1962). It does filter to a day, but the only control for that sat below a
300px map, which is why the reporter never found it on a phone.

Markers of the selected day now carry their position in that day's order, with
the same contract the planner uses: a stop the day visits twice shows both, e.g.
"1 · 3". A dashed line connects them in order. It is a straight connector on
purpose, not a road route — drawing a real one would mean sending the itinerary
to a third-party routing service for every anonymous visitor of a shared link,
with no way for the trip's owner to opt out. A day picker now sits directly
above the map, driving the same state as the day cards below, so the two can
never disagree.

Two things nobody reported, both found while in here. Outside a day selection
every marker fell back to the placeholder indigo, because the payload nests the
category on a day's assignments but sends it flat on the trip-wide pool and only
the nested shape was read. And the map refit itself whenever the page
re-rendered for an unrelated reason — the language picker, a late FX response —
because the effect keyed on the array identity of a list the page rebuilds every
render, throwing away the viewer's pan and zoom. It keys on the coordinates now.
2026-08-18 17:40:56 +02:00
Maurice f4ee06e931 fix(share): stop the day header collapsing the title into a one-word column
The day card header on /shared/ is a single nowrap flex row, and its title block
carried `flex: 1`. That is a flex base size of 0, so the moment an accommodation
chip pushes the row past the available width the block freezes at its min-content
width while the chip and the place count stay at full content width — the ~37px
column with one word, or one CJK character, per line from #1955.

It is content-driven, not viewport-driven, which is why it also showed up on a
1149px-wide desktop window: one long hotel name, or a day carrying both a
check-out and a check-in, is enough. A breakpoint would have been the wrong fix.

The title block now shrinks proportionally and truncates, the chip can shrink and
truncates too, and the place count is pinned so it never wraps or gets pushed out
of the card. The chip also moves off its hard-coded 9px onto the 10.5px the day
sidebar uses in the app, with an icon to match — at 9px it was barely readable.
2026-08-18 17:40:56 +02:00
Maurice 28765e220b fix(places): keep the Google feature id, and give list imports the free address lookup
Two halves of #1954, both about a place arriving through a list coming out
thinner than the same place pasted as a single link.

The feature id was never dropped on purpose. googleFtidFromMapsUrl only looked
at the `?ftid=` query parameter, while a share link that resolveGoogleMapsUrl
has followed to its final hop carries the id in the `data=!…!1s0x…:0x…` path
blob — the same run the coordinates are already parsed out of two lines further
down. So every pasted maps.app.goo.gl link lost it, and the Maps button opened
a name search rather than the entry the link pointed at. The parser now reads
the blob too, but only on a /place/ URL: a /maps/dir/ route carries one !1s per
waypoint and the first is the origin, which would attach the wrong place.

List imports never had a reverse lookup at all. The only pass behind them is
the Google enrichment, which returns immediately without a Maps key, so an
instance without one imported a list and left every address empty forever.
They now get the same free Nominatim lookup the single-link path has always
had: detached after the import answers, on the throttled background lane so it
never queues in front of someone's search, through COALESCE so it can only fill
an empty column, and each row pushed over the websocket so the sidebar fills in
without a reload. Capped at 250 places — past that it stops being a backfill
and starts being bulk geocoding.

Not included: an osm_id for imported places. Single links do not produce one
either, and one derived from a zoom-18 reverse lookup would name the building
or the street rather than the POI.
2026-08-18 17:40:56 +02:00
Maurice c9278c02ce chore(deps): lift the four packages the Scout gate flags as high
The security scan on the release PR has been red on six HIGH CVEs, all of
them transitive production dependencies of the server workspace, which is
what ends up in the runtime image via `npm ci --workspace=server --omit=dev`:

  brace-expansion  2.1.1 -> 2.1.4   CVE-2026-13149 / -69152 / -14257, ReDoS
                                     reached through archiver -> glob -> minimatch
  ip-address      10.2.0 -> 10.5.0  CVE-2026-69192, improper input validation
                                     reached through the MCP SDK -> express-rate-limit
  fast-uri         3.1.4 -> 3.1.5   CVE-2026-18446, reached through the MCP SDK -> ajv
  js-yaml          4.3.0 -> gone    GHSA-5p4m-2wfm-xmqj

Every one of them resolves inside a range the manifests already declare, so
this is a lockfile change and nothing else: no new dependency, no override,
no package.json edit.

js-yaml was the awkward one. @nestjs/swagger pins it exactly rather than by
range, and 11.4.5 pins the affected 4.3.0. 11.4.7 pins 5.3.0 instead, and
server already asks for ^11.4.5, so the affected copy leaves the production
tree without forcing anything. The root js-yaml 4.3.0 stays for eslint, which
never reaches the image.

Walking the production graph over the lockfile gives the same 482 packages
before and after, with only those four differing. Server suite is green
(409 files, 7805 tests), as are typecheck, typecheck:tests and client lint;
/api/docs-yaml still renders through swagger's new js-yaml.

Still open and deliberately not in here, both MEDIUM and below the gate's
critical/high filter: dompurify (via isomorphic-dompurify, and #1694 reverted
a bump of it once already) and hono (via the MCP SDK).
2026-08-18 16:11:03 +02:00
34ezz623-create 3011bf50b4 refactor(i18n): improve Turkish translations for vacay module
Replaces the machine-translated strings in the Vacay domain. The visible ones:
'Doygunluk' (saturation) and 'Güneş' (the star) were the Saturday and Sunday
column headers, 'Sol' (left, as in direction) stood for the remaining days,
'Efsane' (myth) for the legend, and 'Sigortala' (insure) read "fuse" as an
electrical fuse. Buttons move from the infinitive to the imperative the rest
of the locale already uses.

Rebased onto dev and resolved as a union: the branch carried the file as it
stood on 25 July, which would have dropped the 35 keys dev has gained since
from #1074, #1631, #1633 and #1637. Those keep their existing translations;
only the 47 strings this change actually rewords differ.

vacay.selectRegion keeps '(zorunlu)'. en says '(required)' and the Add button
stays disabled until a region is picked, so '(isteğe bağlı)' would have sent
Turkish users looking for a button that never enables.
2026-08-18 13:58:58 +02:00
Maurice 408e39b9d3 docs(wiki): list the two transport-mode tools in the MCP catalog
Every other tool on those two controllers has a row; these were the only
ones missing.
2026-08-18 13:56:44 +02:00
Maurice 02f7320c90 fix(mcp): name the route profile keys the router actually accepts
Both tool descriptions offered "transit" as an example. RouteCalculator only
knows driving, walking and cycling plus the plugin:<id>/<profile> form, and
maps anything else to driving when it draws the route, so an agent that set
"transit" got a success and a car route with a car icon. Name the real keys
and say plainly what happens to anything else.

The incoming-leg test used "transit" too. It now uses walking, and the
outgoing test seeds the incoming column first, so its "must not touch
incoming" assertion proves something the factory did not already give it.
2026-08-18 13:56:44 +02:00
Ida Rogalska e375f9e002 feat(mcp): add set_day_default_transport_mode tool
Wrap DaysService.setDefaultTransportMode behind an MCP tool, mirroring the
days/:id/transport REST route. Adapter only; per-segment leg modes still
override the day default.
2026-08-18 13:56:44 +02:00
Ida Rogalska 03f3c5ae36 feat(mcp): add set_leg_transport_mode tool
Wrap the existing per-leg transport setters (outgoing + incoming) behind an
MCP tool so leg modes no longer need a raw DB write. Adapter only, no new
business logic; mirrors the assignments/:id/transport REST route.
2026-08-18 13:56:44 +02:00
michael-bohr 55aef8d55c fix(tests): generate trip days in UTC so a DST shift can't drop or repeat one
The test trip factory parsed date-only ISO strings as UTC midnight but stepped
the cursor with setDate(), which advances local wall-clock time. Across a DST
transition that step is 23h or 25h, so the instant drifts off midnight and
toISOString() reports the wrong day — a day is dropped in autumn and repeated
in spring.

TRIP-SVC-015 already builds a trip over 2025-11-01..2025-11-03, which straddles
the US fall-back, and fails on any machine in a DST-observing zone:

  AssertionError: expected [...] to have a length of 5 but got 4

CI runs UTC, where the loop happens to be correct, so this is invisible there
and only bites contributors running the suite locally.

Full server suite: 396 files, 7550 tests, green.
2026-08-18 13:37:06 +02:00
Maurice 412de9df4d docs(readme): serve the tour as animated webp instead of a 91 MB gif
Same 1110 frames, same 1100x618, same behaviour in the page: it is still an img,
so it starts on its own, loops forever and has no player chrome. GitHub strips
autoplay and loop off a video tag, so a video was never an option here anyway.

91.0 MB to 18.7 MB, and it looks better rather than worse. The gif was capped at
256 colours, which is visible as banding in every gradient the tour passes over;
the webp has none. Encoded at quality 60.

Both files sit in the same release, so the gif stays reachable for anything that
still points at it.

The alt text dropped its duration claim. It said a 60-second tour, and the clip
runs 111 seconds.
2026-08-17 19:49:12 +02:00
Maurice e62e76713f docs(readme): repoint the tour gif, and tidy the top of the page
The 60-second tour has been a broken image since the org move. It points at
liketrek/TREK-media, and that repository does not exist; the release asset still
sits in mauriceboe/trek-media, which is where the src goes now. On the rendered
page the gif reports naturalWidth 0, so what a visitor sees under the badges is
an empty gap.

Around it, three things that were only layout:

The screenshot rows move below the feature list. Five full-bleed images ran back
to back before the reader got a single instruction, and the tour gif and the rows
show the same screens anyway. Motion first, then the summary graphic, then the
detail, then the stills.

The second horizontal rule goes with them, so one rule closes the header block
and the rest of the file separates sections the way it already did, with a break
and a heading.

The jump links had a separator dangling off both ends of the row.

The subtitle image says "your trip. your plan." while its alt text said "Your
trips. Your plan. Your server.", so anyone on a screen reader got a different
tagline than everyone else. The alt now reads what the image reads.

The tile SVGs gain width and height attributes. They only ever render through an
img that sets width, but without an intrinsic size of their own they fall back to
300px wide anywhere else.

The badge block stays as it is. Read on the rendered page rather than in the
markup, the two badge weights are a hierarchy rather than a clash.
2026-08-17 19:49:12 +02:00
Maurice 1f92d47452 Remove extra line in README.md
Removed an empty line before the subtitle picture.
2026-08-17 19:49:12 +02:00
Maurice 299c97ac9f docs(readme): correct the feature list against the code
Every line under "See all features" was checked against source, not against the
docs. What was wrong:

- 20 languages. There are 23; ca, sv and vi were missing from the list
- 27 scopes in 13 groups. There are 29 in 14
- 150+ tools. There are 199, and 4 prompts rather than the 3 named
- GeoJSON import. No parser exists; places.service.ts takes gpx, kml and kmz
- default coordinates under Units. No setting by that name exists anywhere
- day check-ins under Collab. The fourth sub-feature is a What's Next list of
  upcoming activities; nothing checks anyone in or out of a day
- Apple as an OIDC provider. The discovery check requires a userinfo_endpoint,
  which Apple does not publish, and the callback is GET only. Also only one
  provider can be configured at a time
- role-based access. trip_members has no role column; access comes from an
  admin-set matrix over 16 trip actions
- one-time invite links. A trip link is one reusable rotating token with an
  optional expiry; the use limit belongs to the admin signup invites
- iOS-style bag distribution. It is a fill bar against an optional limit, and
  bag tracking is an instance switch that ships off
- fullscreen with a splash screen. The manifest says standalone, and there are
  no splash assets; only Android derives one from the icon
- the service worker caching the API. /api is NetworkOnly on purpose, because
  Workbox keys by URL and cannot vary on the session cookie
- Atlas streak tracking. It counts consecutive years travelled, not countries
- approved school holiday overlays. They come from openholidaysapi.org and are
  capped to 16 European countries
- 50 MB per file. Video goes to 500 MB

Missing outright: the plugin system, which the file did not mention once, sandbox
and SDK included. Also Collections and AI Parsing as addons, public share links,
guest members, the ICS feed, Transitous, Overpass, OSRM route profiles, GPX
export, place enrichment, the audit log, multi-leg flights, and the offline
machinery past "caches tiles".

The Addons box duplicated Costs and Packing, which are the same switches as the
core lines above it. It is gone, and the default state sits on the line instead,
which is what a self-hoster actually needs to know.
2026-08-17 19:49:12 +02:00
Maurice 1f3567d928 docs(readme): free-standing cards, and drop the compound media query
Opera serves the stacked phone art to a full-screen desktop window whenever the
colour scheme is dark, and Chrome does not. The one unusual thing in the markup
was the first source, which combined two conditions:

    (prefers-color-scheme: dark) and (max-width: 700px)

GitHub only documents prefers-color-scheme in a README picture, so that source is
gone. No arrangement of single-condition sources can express "narrow and dark", so
width comes first and the colour scheme second: a phone in dark mode now gets the
light stacked art. Unreadable text is broken, a light card on a dark page is not.
grid-mobile-dark.svg goes with the source that referenced it.

The three fields also stop being compartments in one slab. They are three cards
now, each with its own wash, border and lift.

Type is a grade larger throughout because the old sizes were computed against a
1012px column. Measured on the rendered page, GitHub's README column is 838px, so
the label was landing at 14px and the caption at 13px.
2026-08-17 19:49:12 +02:00
Maurice 5440e76223 docs(readme): redraw the feature graphic, move the phone row up
The nine-tile grid gave every tile its own pastel, a two-colour headline, a ghost
icon behind it and the same eyebrow, headline, triplet rhythm, nine times without
a break. Three fields now, one glass panel each, one icon, and the detail stays in
the block underneath where it already lived.

Monochrome, and shipped as a light and dark pair through the picture element the
way the logo above it already is, so it stops being a bright slab on a dark page.
The ink is the app's own neutral accent (#111827 and #E4E4E7) instead of an
invented brand colour, and the panels follow the surface recipe from
client/src/styles/dashboard.css: transluent gradient, hairline border, highlight
on the top edge, soft lift. The backdrop blur cannot come along, an img-embedded
SVG sees nothing behind itself, so the wash it would have blurred is baked in.

Poppins is drawn as outlines for the same reason: that SVG may not load an
external font, so a font-family would have fallen back to whatever the reader
happens to have installed.

Also puts the phone row between the two desktop rows rather than after them.
2026-08-17 19:49:12 +02:00
Maurice 824834c8fe docs(readme): swap the animated logo for the static mark
The header opened on a 162-frame GIF, 878 KB across the light and dark pair for
an animation that runs before anyone has read a line. docs/ now carries the same
mark as the SVG the app itself ships, so the README header and the navbar show
one logo rather than two renderings of it, and the picture element keeps
switching it per colour scheme.

The GIFs stay in docs/ unreferenced, in case the animation is wanted elsewhere.
2026-08-17 19:49:12 +02:00
Maurice 92533afea3 docs(readme): replace the screenshot grid with the release showcase rows
The eight tiles were one screen each at 49% width, so the README opened on a
mosaic of unrelated crops. The showcase renders say the same thing in three
full-width rows: atlas, dashboard and the planner map, then collections, the
journal and costs, then the mobile lineup.

They ship as WebP. The same three frames as PNG are 14.3 MB, which every clone
carries forever and every README view downloads. At quality 92 the full 4292px
render is 1.1 MB for all three and a 1:1 crop is indistinguishable from the
source. .gitattributes learns the extension, since png, jpg and gif were listed
there already.

Nothing but the README pointed at the eight files, so they go with it.
2026-08-17 19:49:12 +02:00
Maurice 84d9fbff3c fix(scripts): rotate the instance-wide provider keys too
#1939 moved the Google Places and Unsplash keys into encrypted app_settings rows,
but the rotation script walks a hardcoded list of app_settings keys and the two
new ones were not on it. Rotating ENCRYPTION_KEY would have left them encrypted
under the old key, which reads back as null and silently drops the install to
OpenStreetMap with nothing in the log to say why.

users.unsplash_api_key was missing from the users column list for as long as the
column has existed. It matters now that the resolver reads that column as the
per-user fallback, so it goes in at the same time.

The script cannot import the canonical list, since it deliberately runs without
config.ts and without the container, so the copy is pinned by a parity test that
reads the script and fails when a name is missing from the app_settings loop.

Wiki: the rotation page listed the Maps key as per-user and did not mention the
app_settings rows an operator now has to carry across.
2026-08-17 18:33:43 +02:00
Maurice 1e166ab0d9 test(server): raise the coverage floor for the four domains this branch covered
Measured values with a point of slack, from scripts/coverage-thresholds.mjs:
atlas, audit, settings and unsplash all gained cases here.

Only those four. The script prints a block for every domain, and taking it whole
would have lowered src/nest/auth lines from 96 to 95 and tightened domains this
branch never touched, which would fail the open PRs for no reason.

unsplash is the one that needed the work rather than just recording it: dropping
the "any admin's key" fallback removed covered branches, so the parse-failure
ternary that was already uncovered started dominating the ratio and the gate
failed at 80.85% against a floor of 82%. Covered it instead of lowering the floor,
which put branches at 89.36%.
2026-08-17 14:48:23 +02:00
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00
Maurice d156404b84 perf(atlas): bound the region layer to what is actually on screen
Interaction degraded the longer the map was used past zoom 5 and a reload fixed
it, which the report traced accurately: admin-1 geometry was fetched for every
country whose bounds crossed the viewport, the cache was never evicted, and the
whole accumulated set was torn down and rebuilt as one layer on every addition.
Panning past Canada or Russia added their geometry to every later rebuild for the
rest of the session.

Only the countries in view are rendered now, a signature over that set skips the
rebuild entirely while panning inside the same countries, and the cache holds a
dozen countries, never evicting one that is on screen or still loading.

One cause was not in the report and is probably the bigger one: a fresh renderer
was constructed on every rebuild. Leaflet registers a renderer as its own map
layer and removing the GeoJSON layer does not remove it, so the pane collected an
orphaned element per rebuild that kept updating on every pan for the rest of the
session, and at the country layer each orphan is a canvas of four times the
viewport area. Both renderers are created once per map now.

The region layer stays on SVG on purpose. Leaflet's stylesheet only makes SVG
paths mouse-transparent, so a canvas container would swallow the clicks meant for
the country layer below zoom 5.

Closes #1950
2026-08-17 14:48:23 +02:00
Maurice 54693474f1 fix(atlas): stop counting a connection airport as a country visited
The role IN ('from','to') filter from #1486 is correct and still in place, but it
cannot see the case this issue actually reports. Joining AirTrail legs into one
booking with a layover stop is opt-in, so legs imported separately store the hub
as the legitimate `to` of one booking and the `from` of the next, which no role
filter can tell apart from two real arrivals.

Pair them where the countries are counted instead: an arrival and a departure at
the same airport, in different bookings of the same trip, within 24 h of each
other are a connection, and neither endpoint counts as reached. 24 h is the same
window the importer already uses to chain same-PNR legs. Doing it on the read side
means the databases that already hold these flights heal on the next request.

Only flights pair, and never cancelled ones, so a rental car picked up at the
airport still counts its country (#1366). A booking that returns to where the
chain started is a return flight, and that check compares distance rather than
airport identity, so flying home into a different airport of the same metro area
does not erase the country you spent the day in. A country with a place logged in
it, or marked by hand, is untouched.

Closes #1535
2026-08-17 14:48:23 +02:00
Maurice 1a0834b866 fix(maps): resolve the Places key per instance, not per whoever is asking
Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
2026-08-17 14:48:23 +02:00
Maurice c9124c9b63 fix(places): only attach tags owned by someone on the trip
A tag belongs to a user, not to a trip, and the place create/update body is an
open record the Zod pipe does not police — so any tag id could be attached, and
the tag projection ships tags.user_id, which answers who owns a tag the caller
has no way to see. collection_place_tags had it verbatim.

Scoped to the roster rather than to the caller on purpose: a member tags a shared
place, another member re-saves it and, because the update is delete-then-insert,
sends that id back. Caller-scoping would drop a co-traveller's tag on every
foreign edit; roster-scoping keeps it. Collections resolve the same question
through their own member list.

No client sends a tags array today, so this only closes the hand-crafted path.
2026-08-16 23:17:50 +02:00
Maurice 244eb56e4c fix(budget): confine payers, split members and settlement parties to the trip
knownUserIds asked whether a user exists at all, not whether they are on this
trip, so any id on the instance could be made a payer or a split member. Both
read-backs join users, and the settlement projection carries the avatar through
avatarUrl, which passes an absolute URL straight out — for an OIDC account that
is the picture claim.

Payers and split members drop off-roster ids, as before, only against the right
set. Settlements cannot: they name exactly two parties and both columns are NOT
NULL, so dropping one would either break the constraint or move money to the
wrong person. Both wrappers refuse the write and return null, which lands on the
404 the update path already had. That answer is deliberately the same one a
missing settlement gets, so the endpoint says nothing about whether an id exists
— and it retires the foreign key error that used to surface as a 500 and drew
exactly that distinction.

createSettlement grows the 404 its sibling had; the MCP tool reuses the error
string already at hand. Money rules are untouched: a settlement is a transfer, so
refusing one cannot unbalance the sum, and the cent arithmetic is unchanged.
2026-08-16 23:17:50 +02:00
Maurice 0337ebbbd2 fix(assignments): confine day participants to the trip roster
setParticipants took the ids on trust. The caller is proven to be on the trip and
the assignment is proven to sit on it, but nothing said the same about the ids in
the body, and the read-back joins users — so a stranger's name and avatar came
back to the writer and went out over the assignment:participants broadcast to
everyone else in the room.

Takes the tripId both callers already hold and filters against the roster. Same
drop-rather-than-reject rule as bag members and reservation travellers: the
participants box posts the whole list on every edit, so a trip whose membership
changed underneath it would otherwise be stuck.

ASG-SVC-028 covered a foreign key error rolling the replace back. The filter
takes an unknown id out before the insert can reach it, so that path is gone and
the case now pins what happens instead: a list of nothing but strangers clears,
like the empty list ASG-SVC-018 already pins.
2026-08-16 23:17:50 +02:00
Maurice 1eb29f8b77 fix(trips): confine assignee writes to the people on the trip
Holding packing_edit or todo_edit says you may edit this trip's lists, not that
any user id you name belongs to it. Both updateCategoryAssignees loops wrote the
body's ids straight through, and both end by re-selecting through a JOIN on
users — so an id from anywhere on the instance came back with a display name and
an avatar attached, in the response and in the WebSocket fan-out to the room. The
packing item recipient writes had the same gap.

packing already had tripRosterIds for exactly this and used it only for bag
members; its doc comment said "assigned to a bag", which is probably why the
other three writes grew up without it. The query moves to DatabaseService next to
canAccessTrip, which asks the same question as a predicate, and the helper now
delegates. Every service that needs it already injects DatabaseService.

Off-roster ids drop rather than 400: a copied trip carries assignee ids over
before its members exist, and rejecting the whole request would stall the picker
there. Guests are unaffected — a guest is a trip_members row, so the roster
includes them, which is the point of #1362.
2026-08-16 23:17:50 +02:00
Maurice 5bc041cc77 fix(journey): escape the tooltip text on the Leaflet maps
A string handed to bindTooltip becomes innerHTML. JourneyMap passed an entry
title and a track name through untouched; the GL renderer beside it escapes
both already, and JourneyPublicPage imports the Leaflet one directly. The atlas
bucket-list tooltip had the same gap.

Also pins the escape on MapViewGL's photo marker. Its Leaflet twin has
FE-COMP-MAPVIEW-074 for this, but the GL side was only ever exercised with
benign values, so escapeHtml could have been dropped there without a test
noticing.
2026-08-16 21:57:21 +02:00
Maurice c8607ba234 fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
2026-08-16 21:57:21 +02:00
Maurice 0f0bf50cd3 fix(assignments): guard the per-assignment routes like the day routes
AssignmentOpsController declared @RequirePermission('day_edit') on its
mutations but carried only JwtAuthGuard. TripAccessGuard is what reads that
decorator and what resolves :tripId, so on this controller the permission
markers were metadata nobody looked at and the trip in the URL was never
matched against the caller. Its sibling, DayAssignmentsController, has had the
pair since the migration.

The two participants handlers also went straight to the service with the id
from the path, unlike move/time/transport, which ask getAssignmentForTrip
first. They ask now too.
2026-08-16 21:57:21 +02:00
Maurice ff27624e02 fix(reservations): scope the per-day position write to the trip
updatePositions has two branches. The legacy one updates
`WHERE id = ? AND trip_id = ?`; the per-day one, three lines below it, bound
the caller's ids straight into reservation_day_positions and never touched the
tripId it was handed. That table has no trip_id of its own and its two foreign
keys only require the ids to exist, so nothing further down caught a pair that
did not belong together.

The trip now comes from the join, so the row materialises only when the
reservation and the day agree on it. Doing that in the statement rather than as
a pre-check also turns a stale id into a quiet no-op instead of a foreign-key
error surfacing as a 500, and lets an omitted day_plan_position bind the way
the wire contract has always said it may.

reorder_reservations reports a day that is not on the trip, the way its sibling
tools in that file already do, and a migration clears rows whose reservation
and day disagree.
2026-08-16 21:57:21 +02:00
Maurice 688eea23a4 fix(dashboard): keep the trip card actions on the cover and give them contrast
The edit/duplicate/archive/delete buttons sat at opacity 0 until the card was
hovered, over a white .18 fill. Two people in a row said they never found how
to unarchive or rename a trip: invisible at rest, and white-on-white over a
light cover even while hovering.

They stay on the card now at .92, the fill is a dark scrim so the white icons
carry against any photo, and the icons get a heavier stroke. The status pill
and the hero badge move to the same scrim so the cover overlays read as one
set. An archived card shows the restore icon rather than the archive one,
which is what mobile already did, and every button gains a title next to its
aria-label.
2026-08-16 21:57:21 +02:00
jubnl b1e4c830b2 fix(immich): strip leading / in immich url (#1948) 2026-08-15 23:05:44 +02:00
jubnl 2bffee8fd4 fix(auth): preserve remember-me when password change re-issues the session (#1927) 2026-08-15 22:48:22 +02:00
jubnl 72221a2e45 feat(auth): sliding session renewal past half the token lifetime (#1927) 2026-08-15 22:48:16 +02:00
jubnl 61e03fdfde feat(oidc): honor remember-me in SSO login (#1927) 2026-08-15 22:48:03 +02:00
Maurice cb41d082be feat(admin): make an admin section linkable with ?tab=
The admin page always opened on Users, whatever the address said. A support
reply, an onboarding mail or a bookmark could point at the page but not at the
panel it was about, so every one of them ended with "then click X on the left".

Read once for the initial value and written back on each change, so the address
bar keeps saying where the reader is. Replaced rather than pushed: eleven tabs
would otherwise fill the back button with the history of a single page.
2026-08-15 18:07:21 +02:00
Maurice a6c27aac51 fix(admin): stop asking for OIDC config where the route is closed
On a centrally administered install /api/admin/oidc answers 403, and the admin
page asked anyway. The promise was caught, so nothing broke, but the request
still went out and still printed a red 403 in the console of every admin who
opened the page. Swallowing a rejection hides a failure from the code, not from
the person reading the console.
2026-08-15 18:07:21 +02:00
Maurice f2329c9a63 feat(client): let a managed surface attach as an admin tab
The nav-entry seam was the wrong shape for what actually attaches here. A
subscription surface is about the install, not about a trip: /admin is already
behind an admin check, already grouped, and already where somebody looks to
change how their instance works. A top-level nav entry put it beside My Trips
for a screen opened once a month by one person.

Two lines in AdminPage rather than an overlay replacing the file: the tab list
gets the extra entries appended per group, and the body renders whichever one is
active. Appended rather than inserted because PageSidebar needs a group's tabs
contiguous.

The route and nav-entry seams stay. They are the right answer for a surface that
genuinely is a page, and both remain empty here.
2026-08-15 18:07:21 +02:00
Maurice 887e6c5026 feat(client): let a managed route require an admin
Hiding the nav entry is not access control: the path stays typeable, and a screen
about how the install is run should not open for somebody who was invited to one
trip. ProtectedRoute already knows how to refuse — the attachment point just was
not asking it to.

Optional, so a route that does not set it behaves exactly as before. It sits
next to adminOnly on the nav entry rather than being derived from it, because
the two answer different questions: who is offered the door, and who may walk
through it.
2026-08-15 18:07:21 +02:00
Maurice 7c378c574f feat(client): let a managed nav entry be admin-only
The attachment point hands the two nav bars a static list, so anything attached
there showed up for every user of the instance. What belongs there is the
operator's side of the install, and a trip's other travellers are on the instance
as guests of the person who runs it — an entry about how it is run is noise to
them.

`adminOnly` is optional and absent means "for everyone", so nothing that exists
today changes meaning. Both nav bars go through the same exported filter rather
than each writing the rule, since a rule written twice is a rule that will differ.

Still inert here: the list is empty, and the tests pin that an empty list stays
empty for either kind of user.
2026-08-15 18:07:21 +02:00
Maurice 05315ef7d9 fix(planner): pin the clock in the opening-hours test
FE-PDC-019 asserts that the collapsed row shows one specific line, but the row
shows the current day in the place's timezone — and the fixture gives Saturday
different hours from the working week. So the test passed Monday to Friday and
took the build down every weekend, which reads as a broken feature rather than a
test that forgot to say which day it is standing on.

Pinned to a Wednesday, with the Saturday case kept as its own test rather than
thrown away: it is the one that proves the day lookup happens at all, and it is
the exact case that was failing.
2026-08-15 18:07:21 +02:00
Maurice 3a0604b3f5 feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED

The switch landed last release with nothing behind it. This is what it turns
off, plus the four fixes that came out of walking every one of these surfaces.

Nothing here changes anything for an install that does not set the flag. That
is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for
any unparseable value, no other variable can turn it on, every settings key
stays writable, and splitManagedKeys hands back the same object rather than a
copy so no write path can quietly diverge on self-hosted installs only.

WHAT THE MODE WITHHOLDS

Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT
rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and
reload, the Ollama model list and pull, and the SMTP test send. Each names the
reason it is not the instance admin's, and the reason is mandatory.

Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles
and the WebAuthn pair share one handler with keys the admin does own, and the
llm_* family is reachable through per-user settings, the instance defaults and
the addon config alike, so a route-level refusal would be both too coarse and
incomplete. A locked key is skipped and reported as managed_keys; the rest of
the save goes through. Refusing the request would take the registration toggles
down with the SMTP block.

GET /api/auth/me/settings stops returning the three API keys in the clear. It
is the only endpoint that decrypts a stored credential back to the caller, which
is fine when the admin pasted it in and wrong when the operator supplied it.

Two multipart routes check in the handler rather than the guard: a guard throws
before the parser drains the body and the client gets an ECONNRESET instead of
the 403 (PROFILE-015). They still carry the marker so the boot gate sees them.

WHAT KEEPS IT FROM DRIFTING

validate-managed-routes.ts refuses to boot on a marking that is not on the
reviewed list, a list entry whose route lost its marking, or a marking with no
reason. The third case has no equivalent in the guard gate next door and is the
one that matters most: a blank reason destroys the only record of why a control
was taken away, and nothing recovers that later. It found two wrong entries in
my own list while I was writing this.

managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and
DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to
either list belongs to neither until somebody decides, and that is the failure
this prevents: the lists grow, the mode does not.

PLACES_API_BASE AND PLACES_API_KEY

The nine Places endpoints spell out googleapis.com, so an install that wants
those calls to leave through something of its own has no way to say so. One
variable, substituted where every call already funnels through. A second one
supplies the key, and when it is set the users table is not consulted at all —
a per-user key would route around whatever the endpoint counts, and those
columns are readable by any admin of the instance.

FOUR FIXES THAT ARE NOT ABOUT THE MODE

An admin setting somebody's password now bumps password_version and clears
their MCP tokens. An admin sets a password for one reason, and until now
verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the
one action taken to lock them out was the one action that did not.

The four OIDC provider calls go through the SSRF guard. Three are worse than
they look — the token, userinfo and JWKS URLs come out of the discovery
response, so they are chosen by the other end, not by an admin. safeFetchLlm is
renamed safeFetchAdminConfigured and keeps its old name as an alias, because it
is the variant that allows loopback and LAN: a self-hoster with Authentik on
localhost:9000 must keep working. The token exchange follows no redirect at all,
since one would hand client_secret to a second host.

DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half
has existed since passkeys landed; the TOTP half never did, which left
"somebody on the trip lost their phone" with no answer short of an operator in
the database. Never for the caller themselves.

TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally
administered. A warning is right for a machine its owner chose to trust; where
the person who set the flag and the people whose data is in the database are
not the same person, fail closed.

TEXTS

Four strings lose the phrase "self-hosted" because they describe somebody
else's software — an Ollama endpoint, an AirTrail instance, a service a plugin
talks to — and read better without it in both modes. The one string that
describes TREK itself keeps it, and the mode picks a second key instead, so
self-hosters lose nothing.

A grep gate in shared/ keeps the canonical locale honest, because the parity
check compares the file set and the top-level keys and never looks at a value:
re-wording a string is invisible to CI today, and so is putting it back.

* feat(maps): supply the map credential with the instance when centrally administered

MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value.
SettingsService injects it when the settings are read, and picks GL as the
provider if the reader is still on Leaflet, since a token is always present.

The key moves from the customer set to the locked set. It is the one locked key
whose value is public by design — a pk.* token has to reach the browser to be
useful — so this is not about secrecy: a per-user save landing on top of the
instance token would only break the map for that user. Both settings fields for
it go away with it.

* fix(auth): count the operator key when reporting whether Google features work

has_maps_key only looked at the users table, but getMapsKey prefers the
PLACES_API_KEY env over anything stored there. On an install that supplies the
key that way and has an empty column, every lookup worked while the client was
told there was no key and hid the search, the autocomplete and the place photos.

Found by walking the running instance rather than the code: the map came up,
the search box did not.

* feat(admin): hide the settings a centrally administered install cannot act on

A control that answers 403 is worse than no control: the AI section rendered the
refusal as "Request failed with status code 403", which tells an admin they broke
something rather than that the decision is not theirs.

So the surfaces go rather than being disabled in place:

- AI parsing and AirTrail leave the addon list, and the AI addon route now
  refuses outright. The endpoint, the model and what a document costs come with
  the instance, so there is no instance decision left — not even whether it runs.
- Immich and Synology Photos leave it too. Both connect to a server the reader
  runs at home, and there is no route from here to one.
- The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and
  origins follow the same rule: they configure something the operator owns, and
  a wrong RP ID on a shared parent domain reaches past this instance entirely.
- The About tab goes. It is about the project — what TREK is, where to file a
  bug, where to support it — and is aimed at the person running the software.
  The version and the link to the source move to the sidebar footer, on both the
  desktop page and the mobile twin, because AGPL 13 wants that offer wherever
  people reach the software over a network.
- The update check stops asking GitHub and reports nothing available.

Separately, the version footers lose the "self-hosted" suffix everywhere, not
just here. It read as a label on the software rather than on the install, and
the version alone says what it needs to.

* feat(config): refuse to boot centrally administered without an at-rest key

Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset,
the at-rest key falls back to a file in the data volume, and backupService
bundles that file into every archive it builds. The archive is downloadable by
any instance admin, so the encryption ships with its own key in the same zip.

The skip in backupService already handles this — it only omits the file when the
key came from the env — but nothing made sure the env actually carried one.
Provisioning sets it; forgetting it was silent.

validateEnvAtBoot now carries a second kind of check next to the schema: cross-
field rules that only apply with the flag set, for combinations where every value
is individually valid and the result is wrong. Empty without the flag, so an
install that never asked to be centrally administered cannot meet it.

* feat(client): add an attachment point for install-specific screens

Two empty exports on the client, an empty module on the server, and the four
places that read them. Nothing here adds a route, a nav entry or an endpoint —
the arrays are empty and the module contributes nothing, which managed-ext.test
pins so it stays that way.

Read unconditionally, never behind a flag. On the server a conditional import
would tie the module graph to an environment variable, and the graph is built
once per app while the flag is read live; on the client an empty array costs
nothing to spread, so every build walks the same path instead of one build
walking a path nobody tests.

Anything attached here follows the same rules as the rest of src/nest and
src/components: a real module with a controller and a provider, bodies through
the Zod pipe, routes under the global guards, tests.

* test(shared): move the wording gate out of src so shared typechecks again

CI installs shared with its own dependencies only (npm ci --workspace shared),
so there is no @types/node and the `node:fs` import inside src/ failed to
compile. The tests passed the whole time — vitest supplies the module — which is
why this only showed up in the typecheck step.

The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the
same way that one does. It also runs on its own now
(`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names
a line you have to go find.

* test(server): repair the call sites typecheck:tests caught

tests/ sits outside the build tsconfig, so none of this showed up in a green
test run — which is exactly the gap `npm run typecheck:tests` exists to close.
Three kinds, all mine:

- Seven `import type` paths for RuntimeEnvService carried one `../` too many.
  Type-only imports are erased, so vitest never resolved them and nothing failed.
- VersionCheckJob and PluginsController grew a constructor parameter and eleven
  hand-wired `new` calls kept passing the old count. The argument arrived as
  undefined and no assertion happened to read it.
- ConditionContext grew `managed`, and the shared context object in the
  conditions suite did not.

The condition also had no test of its own, so it has one now: both directions,
since the registry uses `is: false` to keep a notice away and a notice that only
belongs on a centrally administered install would use `is: true`.
2026-08-15 16:42:27 +02:00
Maurice bfb3bcd63c feat(config): mark an install as centrally administered
Some installs are run by somebody other than the person who administers them.
The operator owns the credentials, the configuration and the upgrade schedule,
and the admin of the instance is a user of the result rather than the one who
set it up. Nothing in the app knows that today, so it keeps offering settings
whose values are not the admin's to change, and the honest answer to half of
them would be a 403 the UI never anticipated.

TREK_MANAGED says it once. It is a single boolean and the surfaces that care
read it themselves, so adding one never comes back to the config layer.

Runtime-toggled, in the same class as DEMO_MODE, rather than a registerAs
token: roughly sixty tests mutate env mid-lifetime, and a value frozen per
built app breaks all of them. `=== true` on the way in, so an unset, empty or
misspelled value means off — a managed install sets it deliberately, and a typo
must not quietly take settings away from an admin who never asked for it.

The guard keys on a decorator, not on a path. MfaPolicyGuard documents where
the two literal path lists it replaced ended up: they stopped being maintained,
and every endpoint added afterwards silently fell on the wrong side. A marker
on the handler cannot drift away from the handler. It carries a mandatory
reason, which is for the next reader of the source rather than for the client,
so the marks stay auditable.

It registers third, after the auth and MFA guards, so a stranger still gets the
401 those two would have given them instead of a 403 that would confirm the
route exists. And it checks the mode before it touches metadata, because it
runs on every request of every install and almost all of them are self-hosted.

One 403 body, shared by REST and MCP. DEMO_MODE grew two texts for the same
condition and neither can be changed now without hunting for the other.

The flag reaches the client through app-config so the UI can stop offering what
the server would refuse anyway, but it is deliberately not persisted the way
demo_mode is: two installs can share a browser profile, and a stale "this one
is managed" would take settings away on an install that never set it.

No route carries the marker yet. The per-domain marks follow. Unset, which is
every self-hosted install, nothing about this changes anything.
2026-08-15 11:45:11 +02:00
Maurice c9193e0621 fix(maps): answer a keyless place lookup with an empty result, not a 400
An instance without a Google key already runs on the OpenStreetMap stack: search
falls back to Nominatim, autocomplete does too, photos fall back to Wikimedia.
The two details lookups were the exception. Both threw a 400 with "Google Maps
API key not configured" the moment they were handed a Google place id.

A Google id has no OpenStreetMap equivalent, so without a key there really is
nothing to resolve. That is an empty result, not a caller mistake. The callers
already treat a null place as a miss and fall back to the text-search path, so
they were doing the right thing with the wrong signal, at the cost of an error
round-trip and a logged exception each time.

It bites whenever a place that was saved while a key existed is opened after the
key is gone, and on any instance that never had one but imported Google-sourced
places from a shared trip.

The declared return type said the place was non-nullable while the OSM branch
could already return null and the wire contract has always had it nullable. It
now says what it does.
2026-08-14 23:42:51 +02:00
Maurice f02a4b5654 feat(maps): bill a place search as one session instead of per keystroke
Google charges autocomplete per request unless the requests carry a session
token. With one, the keystrokes of a single search and the details lookup that
ends it are charged together, which is also how a user experiences it: they
looked up one place.

TREK sent no token at all, so every debounced keystroke was its own billable
request. On a busy trip that is the largest line on the Places bill, and it is
paid by whoever owns the key, which for a self-hosted instance is the person
running it.

A session opens with the first suggestion request and closes when the user picks
a result. It has to close on the abandoned paths too: a token carried into the
next search would merge two searches into one billed session and scope the
suggestions wrongly. So the search box clearing it, the text-search fallback and
the error paths all end it.

The token is validated on the way in. The autocomplete body goes through the Zod
pipe, the details query does not, so the controller shape-checks it there.
Anything that does not match Google's URL-safe 36-character form is dropped
rather than forwarded, which falls back to per-request billing instead of
breaking a lookup.

A cached details hit never reaches Google and so never closes its session. That
is billing-neutral: an unclosed session is charged as a plain autocomplete
session, which is still cheaper than the per-request behaviour it replaces.
2026-08-14 23:42:51 +02:00
Maurice f23de88b74 fix(packing): authorize every single-item path against the item, not just the trip
A packing item is Common, Personal or Shared with named recipients. listItems()
always applied that rule, but every path that touches one item resolved it by
trip id alone, so trip membership plus packing_edit was enough to reach an item
the member could not see. Update, delete, clone, the contributor routes and the
sharing route all shared the lookup, and the same service methods sit behind the
MCP tools and the plugin RPC, so all three transports carried the gap.

The update was a read as well: it answered with the enriched row, which hands
back the name, category, quantity, weight, the owner's username and the recipient
list of an item the caller was never shown.

Two flows laundered restricted items into places they do not belong. Saving a
trip as a template read every row in the trip, so a template captured the names
and categories of everyone's Personal items. Copying a trip did the same and
re-inserted without is_private/owner_id, so both fell back to the column defaults
and another member's Personal or Shared item reappeared in the copy as a Common
item. That path needs only trip_create and trip access, not even packing_edit.

There is now one visibility rule in the service and every single-item lookup goes
through it. The actor is threaded through the HTTP, MCP and plugin-RPC entry
points, and a missing actor denies rather than falling through unfiltered. An
item the actor may not see is reported as missing, not as forbidden, so the
routes cannot be used to confirm that an id exists.

Two deliberate contract changes fall out of that. The sharing route answers 404
instead of 403 when the caller cannot see the item at all; a visible item they do
not own still answers 403. And a template or a trip copy now carries the Common
list plus the actor's own items, with a restricted item staying restricted and
owned by the copier.

Reported privately by Chichi (@vickyzer027-hash).
2026-08-14 22:09:21 +02:00
Konstantinos Thermos 3e52695124 Merge pull request #1932 from liketrek/feat/comaps-integration
feat(planner): open places and day routes in CoMaps
2026-08-14 19:03:35 +03:00
Maurice 4cab17a6b9 fix(airtrail): read and write the passenger list under both its names
AirTrail 3.12.0 renamed the flight's passenger list from `seats` to
`passengers` and moved flightReason from the flight onto each passenger. There
is no alias in either direction: a 3.12.0 instance returns `passengers` and
`null` for `seats`, and its save schema accepts only `passengers`.

TREK read `raw.seats` everywhere, so against a 3.12.0 instance it found nothing.
The seat never made it into the import, and because a pull rebuilds the metadata
from the AirTrail payload, flights that had already been imported lost the seat
they had. That is the report. Flights whose sync is switched off were never
re-pulled, which is why theirs survived.

Two more breaks came from the same rename and were not reported. The flight
reason stopped importing for the same reason. And the writeback pushed the seat
under `seats`, which the new schema strips, so a seat edited in TREK was quietly
dropped on the way out; it did not fail loudly because the spread of the fetched
flight carried AirTrail's own `passengers` through untouched.

Both shapes are handled rather than switched between, because instances on 3.11
and older are still out there: reads take `passengers ?? seats`, and the save
body carries the manifest under both keys. Neither schema is strict, so each
version keeps the key it knows and strips the other.

The snapshot hash deliberately keeps feeding the manifest in under the same key.
An unchanged flight on an older instance therefore hashes exactly as before and
does not re-sync, while a 3.12.0 flight hashes differently once and pulls the
missing seat back in. There is a pinned-hash test guarding the first half.

Verified against real instances rather than the docs: a 3.11.1 and a 3.12.0
container, seat imported from each, a seat edited in TREK written back and read
out of AirTrail again, and the pre-fix build failing the same checks on 3.12.0.

Closes #1931
2026-08-14 14:32:35 +02:00
Konstantinos Thermos 0e7c516279 Merge branch 'dev' into feat/comaps-integration
# Conflicts:
#	client/src/components/Planner/DayPlanSidebar.tsx
2026-08-14 15:05:00 +03:00
Maurice 681fe1fee3 fix(dashboard): keep hotels out of the upcoming reservations widget
A stay covers a range rather than a moment, so one week in a hotel would hold a
slot in a six-entry widget for the whole week and push out the bookings that
actually happen on a day. The accommodation belongs to the day plan, which shows
it across its span.

They never worked here either, which is what the report is about. A hotel keeps
its dates on the linked stay, not on the reservation, so the two ways one can
exist behaved differently:

The booking form writes reservation_time = NULL and sends no day_id, so a
form-created hotel matched neither arm of the date test and was silently absent.
That is the "incomplete" half.

The accommodation panel auto-creates its booking with day_id and with the start
day's date stamped into reservation_time. That row did show, but updateAccommodation
syncs only metadata and the confirmation number to its linked bookings, so moving
the stay left the stamp untouched and the widget kept the old date. That is the
"no effect when I change the date" half.

The type test is NULL-safe: the column defaults to 'other' but nothing stops a
NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped
those rows from the widget instead.

Closes #1934
2026-08-14 13:34:13 +02:00
Maurice f43b56a745 fix(collab): let the empty chat state fill the panel
The empty state went in without flex-1 while the message list it stands in for
has it, so it was only as tall as its own content. The composer rode up to sit
directly under the mascot with the rest of the panel left blank, which reads as a
layout that failed rather than a chat waiting for its first message.
2026-08-14 09:53:53 +02:00
Maurice 79c460fd38 fix(map): keep the layer switcher clear of the sidebar edge
At a 12px gap the pill's own shadow ran into the sidebar it sits beside, so the
two read as one surface rather than a control floating on the map. 20px.
2026-08-14 09:53:53 +02:00
Maurice 97c725ea14 feat(planner): collapse the three export buttons into one dialog
PDF, ICS and GPX each had their own button in the day-plan toolbar, and two of
them carried a hover menu of their own. That is five controls' worth of width in
a pane the user can drag narrower, so the exports were the first thing pushed off
the edge, and a hover menu is not reachable at all once its button is gone.

One button opens a dialog instead: the day plan as a PDF, the bookings as a
calendar (a one-off .ics or a live subscription) and the map data as GPX in the
three scopes a device wants. Every row reports that it is working, which the PDF
export needed most: it fetches a 226 kB builder before it renders anything, and
until now the toolbar button simply sat there.

Two strings the old ICS menu had hardcoded in English, the download and the
subscription, come from the locale files the phone export sheet already used.

The toolbar no longer needs the icsHover flag it only held to keep its menu open,
so that comes out of the sidebar's state as well.
2026-08-14 09:53:53 +02:00
Maurice 974c240043 fix(planner): give the reservation time its own chip
The status, the time and the flight or train number sat inside one pill strung
together on a middle dot, and the dot was baked into the translation itself:
planner.resPending ended with " · " in all 23 locales. That made the row read as
one run-on sentence, and it meant the separator could never move without a
locale change.

They are separate chips now, same tint so they still belong together and nowrap
so the time cannot break away from the status it qualifies. The separator is out
of the locale files, which also retires the regex the transport detail modal
needed to strip it back off.
2026-08-14 09:53:53 +02:00
Maurice 460b215e69 fix(collections): name the phone label chip after what it opens
The chip that opens the label manager was labelled with collections.linkLabel,
which is the caption of a link in the list editor. English calls that one "Label"
so it happened to read correctly there, but German got "Bezeichnung", Russian
"Название" and Ukrainian "Підпис": wrong word, and long enough to push the filter
row out of its panel. It uses collections.labels.title now, which is the manager
it opens and is shorter in every locale.

collections.select was the other one over the edge. It is only an aria-label on
the desktop filter bar, so the eight locales whose word did not fit are on a
shorter one now.

The truncate on the chip labels never worked: a flex item keeps min-width:auto,
so the row grew past the panel instead of shrinking its chips. With min-w-0 a
long label name or a bumped-up text size ellipsises inside the panel.
2026-08-14 09:53:53 +02:00
Maurice a234653b29 fix(collections): let the phone dock add a place inside a list
The centre "+" matched /collections exactly, so it only knew it was on the
collections screen while no list was selected. Picking one moves the route to
/collections/:id, the match failed, and the action fell through to the last
branch of useCreateAction, which creates a trip on the dashboard. That is the
state the screen is normally used in, so for most people the button never added
a place at all. It also explains the report that there is no way to add one: the
workaround, adding from "All saved" and assigning afterwards, is the only path
that happened to still work.

The handoff carries the id rather than dropping back to /collections. Otherwise
adding from inside a list would land on the union and the sheet would ask for the
list the user is already looking at.

Closes #1930
2026-08-14 09:53:53 +02:00
Konstantinos Thermos 8065af28ef feat(planner): open places and day routes in CoMaps
Add CoMaps, the offline OpenStreetMap navigator, alongside the existing
map integrations. A geolocated place gains a CoMaps entry in its
navigation menu, and a day's route toolbar gains a button that hands the
day's bookended stops to CoMaps in that day's travel mode.

CoMaps' route link carries only a start and a destination, so a two-stop
day goes as a route and a longer one as named pins rather than silently
losing the stops in between. Links use the https form so a device without
CoMaps installed lands on comaps.at instead of a dead scheme.

The hotel-bookend logic behind the Google Maps export was duplicated
between desktop and mobile; both now share dayExportStops so the two
links cannot disagree about which hotel legs are real.

Addresses discussion #1904
2026-08-14 10:24:14 +03:00
1041 changed files with 81240 additions and 6260 deletions
+2
View File
@@ -15,6 +15,8 @@ uploads
chart/
docs/
docker-compose.yml
docker-compose.minio-test.yml
minio-licence.key
unraid-template.xml
*.sqlite
*.sqlite-shm
+1
View File
@@ -15,6 +15,7 @@
*.sh text eol=lf
# Binary files — no line ending conversion
*.png binary
*.webp binary
*.jpg binary
*.jpeg binary
*.gif binary
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
+73 -45
View File
@@ -10,7 +10,6 @@ on:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'nest-mcp/**'
- 'plugin-sdk/**'
- '.github/workflows/test.yml'
@@ -74,41 +73,6 @@ jobs:
- name: Run tests
run: cd shared && npm test
nest-mcp-package:
name: nest-mcp Package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace nest-mcp
- name: Ensure @swc/core's Linux binary for unplugin-swc
# Same lockfile quirk as server-tests: the Linux native binary is
# omitted, and nest-mcp's vitest config uses the SWC transform too.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build
run: npm run build --workspace=nest-mcp
- name: Typecheck
run: cd nest-mcp && npm run typecheck
- name: Lint
run: cd nest-mcp && npm run lint:check
- name: Run tests
run: cd nest-mcp && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -137,19 +101,14 @@ jobs:
- name: Build shared
run: npm run build --workspace=shared
- name: Build nest-mcp
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
# (tests alias the package source, but tsc does not).
run: npm run build --workspace=nest-mcp
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
# what production runs: nest-mcp's built dist resolving the MCP SDK's
# subpath exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
# Vitest aliases the MCP SDK deep imports, so only this exercises what
# production runs: the built dist resolving the MCP SDK's subpath
# exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
@@ -175,6 +134,75 @@ jobs:
path: server/coverage/
retention-days: 7
s3-contract:
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-latest
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty
# and the AIStor container would fail to boot.
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Write MinIO license
run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key
- name: Start MinIO
# `services:` can't pass the `server /data --license ...` command to
# the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is
# load-bearing: aws-lite picks path-style only for IP/localhost hosts,
# which is all stock MinIO/AIStor speaks.
run: |
docker run -d --name minio -p 127.0.0.1:9000:9000 \
-v "$PWD/minio-licence.key:/minio.license:ro" \
-e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \
quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license
- name: Wait for MinIO
# curl was removed from the AIStor image — poll from the runner instead.
# 60 x 2s gives a wider margin on cold runners than the 30 x 1s the
# pre-6fbff835 job used.
run: |
for i in $(seq 1 60); do
curl -sf http://127.0.0.1:9000/minio/health/live && exit 0
sleep 2
done
echo "MinIO never became healthy" >&2
docker logs minio >&2
exit 1
- name: Run the S3 driver contract suite
# No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep
# holding from hermetic tests alone. The suite's beforeAll creates
# the trek-test bucket idempotently — no separate bucket-init step.
env:
TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000
TREK_TEST_S3_BUCKET: trek-test
TREK_TEST_S3_ACCESS_KEY_ID: trekci
TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret
run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts
client-quality:
# Split out of client-tests: the suite takes ~11 minutes, and having the
# gates in front of it meant a single lint finding threw away the whole test
+3
View File
@@ -65,5 +65,8 @@ coverage-*/
.scannerwork
test-data
# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key
minio-licence.key
.run
.full-review
-8
View File
@@ -31,12 +31,9 @@ FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY nest-mcp/ ./nest-mcp/
RUN npm run build --workspace=nest-mcp
COPY server/ ./server/
RUN npm run build --workspace=server
@@ -47,7 +44,6 @@ WORKDIR /app
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
# The trailing chown runs in this layer on purpose: it covers the manifests and
@@ -94,10 +90,6 @@ COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/mig
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
# hook the server already boots with.
COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
+200 -79
View File
@@ -1,17 +1,16 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
</picture>
<br />
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
@@ -41,61 +40,62 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
</div>
<br />
<div align="center">
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner · day plan & route" width="49%" /></a>
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
<a href="docs/screenshots/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" width="49%" /></a>
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
</div>
---
## What you get
<picture>
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
</picture>
<details>
<summary><b>See all features</b></summary>
<br />
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
<table>
<tr>
<td width="50%" valign="top">
#### 🧭 Trip planning
#### 🧭 Planning
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
- **Route optimisation** — auto-sort places and export to Google Maps
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
- **Category filter** — show only matching pins on the map
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
- **Public transport**: door-to-door itineraries over Transitous
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
</td>
<td width="50%" valign="top">
#### 🧳 Travel management
#### 🧳 Bookings and money
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
- **Packing lists** — categories, templates, user assignment, progress tracking
- **Bag tracking** — optional weight tracking with iOS-style distribution
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
- **PDF export** — full trip plan as PDF with cover page, images, notes
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
</td>
</tr>
@@ -104,64 +104,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 👥 Collaboration
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
- **Multi-user trips** — invite members with role-based access
- **Invite links** — one-time or reusable links with expiry
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
- **2FA** — TOTP + backup codes
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
- **Collab suite** — group chat, shared notes, polls, day check-ins
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
- **Public share**: a read-only trip page anyone can open without an account
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
#### 📔 Journal, Atlas and Vacay
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
</td>
<td width="50%" valign="top">
#### 📱 Mobile & PWA
#### 🧩 Plugins
- **Installable** — iOS and Android, straight from the browser, no App Store needed
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
- **Native feel** — fullscreen standalone, themed status bar, splash screen
- **Touch optimised** — mobile-specific layouts with safe-area handling
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
#### 🤖 AI and MCP
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
</td>
</tr>
<tr>
<td width="50%" valign="top">
#### 🧩 Addons (admin-toggleable)
#### 📱 Mobile and offline
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
- **MCP** — expose TREK to AI assistants via OAuth 2.1
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
</td>
<td width="50%" valign="top">
#### 🤖 AI / MCP
#### ⚙️ Admin, accounts and security
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
</td>
</tr>
<tr>
<td colspan="2" valign="top">
#### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
</td>
</tr>
@@ -171,6 +178,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<br />
<div align="center">
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
</div>
<br />
## AI usage
We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a
@@ -195,7 +210,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>&nbsp;&nbsp;·&nbsp;&nbsp;
<a href="#docker-compose-production">Docker Compose</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#helm-kubernetes">Helm / Kubernetes</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#install-as-app-pwa">Install as PWA</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="#reverse-proxy">Reverse Proxy</a>
</div>
@@ -462,6 +477,9 @@ Caddy handles TLS and WebSockets automatically.
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` |
| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — |
| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
@@ -471,10 +489,113 @@ Caddy handles TLS and WebSockets automatically.
<br />
<h2 id="storage">Storage</h2>
TREK separates *what* it stores (eight content categories — trip documents,
journey photos, cover images, profile pictures, place images, the two photo
caches, and backups) from *where* it stores it (named backends). Out of the
box everything lives on local disk (`uploads/` and `data/backups`). From
**Admin → Storage** you can:
- **Add S3-compatible backends** (AWS S3, Cloudflare R2, Backblaze B2, Garage,
MinIO/AIStor) and assign any category to them.
- **Replicate a backend**: edit it and pick **Mirror targets** — every write is
then also copied to each target. The classic off-site backup setup is:
add an S3 backend, edit `backups-local`, tick the S3 backend as a mirror
target, save. Replica writes happen one after another during each upload, so
a slow or unreachable target slows every upload of every category on that
backend — fine for backups, worth weighing for hot categories. Existing
objects are not replicated when you add a target — press **Sync now** on
the backend to copy them (one sync at a time; progress and cancel in the
panel).
- **Watch the Health strip**: replica failures never fail the original
request; they are recorded and shown there instead. All-clear means every
replicated write landed. Failures also notify admins over the configured
notification channels (first failure immediately, then hourly summaries) —
tune it under Admin → Notifications.
- **Test** any backend from its row (for a replicated backend it probes the
primary and each target individually). Targets must be saved before Test
can probe them.
- **Usage**: per-category and per-backend object counts and sizes, scanned
nightly and on demand.
Backend credentials are encrypted at rest. Without an explicit
`ENCRYPTION_KEY` the implicit key from the data directory is used — that
works, but the implicit key rides inside backups, so setting `ENCRYPTION_KEY`
explicitly is recommended for credentialed backends. On an existing install
that has been running without an explicit key, do not just set a new one —
that would orphan already-stored secrets; use the key-rotation procedure (see
*Rotating the Encryption Key* under Updating) to move to an explicit key.
Notes and limits: reassigning a populated category prompts to move its
existing objects — accepting copies everything to the new backend, flips the
category, then sweeps any writes that raced the copy; source objects are kept
(not deleted) and reported as reclaimable (count + size) to reclaim manually;
declining routes only new writes to the new backend, leaving old objects in
place as before. Only one storage job (a mirror sync or a category migration)
runs at a time, cancelling before the flip is always safe, and a failed copy
never flips the category; media categories on S3 work but every served byte
proxies through the server — the proxy answers conditional GETs (ETag /
Last-Modified) and a single `Range` request, while a multi-range request is
answered with the full object; AWS buckets
with Object-Lock or checksum-requiring policies
reject the server's uploads; self-hosted endpoints (MinIO/Garage) should be
addressed by IP or `localhost` unless configured for virtual-hosted buckets.
The legacy `/uploads/photos` directory from older TREK versions is still
served and included in backups, but it is not a configurable category.
Running the storage contract suite against a live MinIO/AIStor is a
license-gated procedure — CI runs it automatically for same-repo pull
requests (fork PRs skip it, since they have no access to the license
secret); to run it yourself locally, see `docker-compose.minio-test.yml`'s
header for the manual procedure.
### Provisioning at first boot (seed file)
For infrastructure-as-code setups, mount a `storage-config.json` into the
data directory — it is imported **once**, on the first boot that has no
stored storage configuration, and loudly ignored afterwards:
```jsonc
// storage-config.json — secrets may be plaintext (encrypted on import)
// or already-encrypted enc:v1: values.
{
"backends": [
{ "name": "off-site", "type": "s3", "options": {
"endpoint": "https://s3.example.com", "bucket": "trek",
"accessKeyId": "…", "secretAccessKey": "…" } },
{ "name": "backups-mirror", "type": "mirror", "options": {
"primary": "backups-local", "replicas": ["off-site"] } }
],
"categories": { "backups": "backups-mirror" }
}
```
(The panel presents this exact setup as **Mirror targets** on `backups-local`.)
```yaml
# docker-compose: add under the trek service's volumes
- ./storage-config.json:/app/data/storage-config.json:ro
```
An invalid seed file aborts boot with the exact validation error — an
actively-provisioning operator sees the problem instead of a silent default.
### Recovery and restore
To reset storage configuration to the built-in defaults (or to re-import a
seed file): stop the server, run
`sqlite3 data/travel.db "DELETE FROM app_settings WHERE key LIKE 'storage.%';"`,
and start it again.
Backups include the storage configuration (it lives in the database). If your
only backup sits on S3 and the credentials for it sit inside that backup:
start a fresh instance, enter the S3 credentials in **Admin → Storage** (or
mount a seed file), then restore the backup from the Backup panel.
## Data & Backups
- **Database** — SQLite, stored in `./data/travel.db`
- **Uploads** — stored in `./uploads/`
- **Uploads** — stored in `./uploads/` by default; every content category can be reassigned to another backend in **Admin → Storage**
- **Logs** — `./data/logs/trek.log` (auto-rotated)
- **Backups** — create and restore via Admin Panel
- **Auto-Backups** — configurable schedule and retention in Admin Panel
+5
View File
@@ -29,8 +29,13 @@
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* Country silhouettes for TREK Studio, built from the boundaries the server
* already bundles for Atlas.
*
* node scripts/build-country-shapes.mjs
*
* Writes `src/components/Studio/countryShapes.ts`. Run it when
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
* a normal build never runs this.
*
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
*
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
* map you can zoom into. A book page draws a country at 20mm across, where the
* ten thousand points describing a fjord land inside one printer dot. Shipping
* the map data to draw a stamp would cost more than the entire Studio bundle.
*
* So: project, drop the islands nobody can see at that size, simplify hard, and
* normalise each country into its own 0..100 box. What comes out is a few
* hundred bytes per country and prints identically, because at 20mm it *is*
* identical.
*
* ── The three things that are easy to get wrong ───────────────────────────
*
* 1. **Project before simplifying.** Simplifying in degrees and projecting
* afterwards removes the wrong points: a degree of longitude is 111km at the
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
* while leaving Kenya's untouched.
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
* +179 and -179. Taken literally their bounding box is the whole world and
* the silhouette is a horizontal smear. Rings that span more than half the
* globe get their negative longitudes lifted by 360 so the country is
* contiguous again.
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
* into static. Rings below a fraction of the largest one are dropped — the
* mainland is what makes a country recognisable at a glance.
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zlib from 'node:zlib'
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
/** Rings smaller than this share of the biggest one are not drawn. */
const ISLAND_CUTOFF = 0.012
/** Never draw more than this many rings for one country. */
const MAX_RINGS = 14
/** Point budget per country, across every ring it keeps. */
const MAX_POINTS = 190
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
function project([lng, lat]) {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
}
function ringArea(ring) {
let a = 0
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
}
return Math.abs(a / 2)
}
/** Perpendicular distance from p to the segment ab, squared. */
function segDistSq(p, a, b) {
let x = a[0], y = a[1]
let dx = b[0] - x, dy = b[1] - y
if (dx !== 0 || dy !== 0) {
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
}
dx = p[0] - x
dy = p[1] - y
return dx * dx + dy * dy
}
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
function simplify(points, tolerance) {
if (points.length < 3) return points
const tolSq = tolerance * tolerance
const keep = new Uint8Array(points.length)
keep[0] = 1
keep[points.length - 1] = 1
const stack = [[0, points.length - 1]]
while (stack.length) {
const [first, last] = stack.pop()
let maxSq = 0
let index = 0
for (let i = first + 1; i < last; i++) {
const d = segDistSq(points[i], points[first], points[last])
if (d > maxSq) { maxSq = d; index = i }
}
if (maxSq > tolSq) {
keep[index] = 1
stack.push([first, index], [index, last])
}
}
return points.filter((_, i) => keep[i])
}
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
function outerRings(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
return []
}
/**
* Lift a ring that straddles the antimeridian into a continuous longitude
* range. A ring whose points span more than half the globe is, in practice,
* always a wrap rather than a genuinely hemisphere-wide landmass.
*/
function unwrap(ring) {
let min = Infinity
let max = -Infinity
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
if (max - min <= 180) return ring
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
}
function build(feature) {
const rings = outerRings(feature.geometry)
.map(unwrap)
.map(ring => ring.map(project))
.map(ring => ({ ring, area: ringArea(ring) }))
.sort((a, b) => b.area - a.area)
if (!rings.length) return null
const biggest = rings[0].area
const kept = rings
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
.slice(0, MAX_RINGS)
.map(r => r.ring)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const ring of kept) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
const spanX = maxX - minX
const spanY = maxY - minY
if (!(spanX > 0) || !(spanY > 0)) return null
// The longer side becomes 100 and the shorter one keeps its proportion, so a
// country never has to be squashed to fit the box it is drawn in.
const scale = 100 / Math.max(spanX, spanY)
const w = spanX * scale
const h = spanY * scale
const normalised = kept.map(ring =>
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
// Tolerance climbs until the whole country fits its point budget. Starting
// from the diagonal keeps the loop short for a country of any size.
let tolerance = 0.14
let simplified = normalised.map(r => simplify(r, tolerance))
let guard = 0
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
tolerance *= 1.35
simplified = normalised.map(r => simplify(r, tolerance))
}
const d = simplified
.filter(ring => ring.length >= 3)
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
.join('')
// The projected bounds, kept so the same path can be replayed in world
// coordinates. A country drawn on its own wants its own box; the same country
// on a route map has to sit where it actually is, next to its neighbours. One
// normalised path plus these four numbers serves both, where storing two sets
// of coordinates would be twice the bytes and one more thing to disagree.
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
}
const round = n => Math.round(n * 10) / 10
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
const collection = JSON.parse(raw.toString('utf8'))
const shapes = new Map()
for (const feature of collection.features) {
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
if (code.length !== 2 || code === '-9') continue
const built = build(feature)
if (!built) continue
// A code appearing twice means two features for one country; keep the one
// with more outline, which is the mainland rather than a dependency.
const prev = shapes.get(code)
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
}
const codes = [...shapes.keys()].sort()
const body = codes
.map(code => {
const s = shapes.get(code)
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
})
.join('\n')
const file = `/**
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
*
* Country silhouettes, simplified from the admin-0 boundaries the server
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
* own proportions inside that box so it can be drawn without being squashed.
*
* ${codes.length} countries, built for print at postage-stamp size — islands
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
* simplified to at most ${MAX_POINTS} points.
*/
export interface CountryShape {
/** Path on a 0..100 box, outer rings only. */
d: string
/** The shape's own width and height inside that box. */
w: number
h: number
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
b: [number, number, number, number]
}
/**
* Web Mercator, matching the projection the paths were built with. Longitude
* passes through as degrees; latitude is flipped so y grows downwards like
* SVG's does.
*/
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
}
/**
* The same outline, placed in projected world coordinates.
*
* The stored path is normalised into its own box; multiplying by the box's span
* and shifting by its origin puts it back where the country actually is, which
* is what a map with more than one country on it needs.
*/
export function countryWorldPath(shape: CountryShape): string {
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
let axis = 0
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
const isX = axis++ % 2 === 0
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
return String(Math.round(v * 1000) / 1000)
})
}
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
${body}
}
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
export function countryShape(code: string | null | undefined): CountryShape | null {
if (!code) return null
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
}
`
fs.writeFileSync(OUT, file, 'utf8')
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
+24
View File
@@ -387,6 +387,30 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true }))
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true))
// An older server that does not send the field must not leave the flag
// stuck on from a previous install in the same browser profile.
setManaged.mockClear()
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({}))
)
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
+20 -3
View File
@@ -28,6 +28,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
// Every page below loads on demand. The entry chunk used to carry all twenty of
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
@@ -47,6 +48,7 @@ const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
@@ -278,7 +280,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -295,7 +297,8 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { 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; 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> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
@@ -471,6 +474,15 @@ export default function App() {
</ProtectedRoute>
}
/>
{/* Empty in this repository, and read unconditionally so the public
build walks the same path as any other. See client/src/managed. */}
{managedRoutes.map(r => (
<Route
key={r.path}
path={r.path}
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
/>
))}
<Route
path="/vacay"
element={
@@ -502,7 +514,12 @@ export default function App() {
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
</ProtectedRoute>
}
/>
>
{/* Studio is nested so the journey stays mounted underneath it and
shows through the panel's margin — it is opened on top of the
journey, not navigated away to. */}
<Route path="studio" element={<JourneyStudioPage />} />
</Route>
<Route
path="/collections"
element={
+8
View File
@@ -267,6 +267,14 @@ describe('client > endpoint wiring', () => {
{ n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' },
{ n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' },
{ n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' },
{ n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' },
{ n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' },
{ n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' },
{ n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' },
{ n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' },
{ n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' },
{ n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' },
{ n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' },
{ n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' },
{ n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' },
{ n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' },
+45 -4
View File
@@ -12,6 +12,7 @@ import {
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
type JourneyStats, type BookRecord, type BookSaveRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
@@ -46,6 +47,11 @@ import {
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
type StorageAdminState,
type StorageBackend,
type StorageConfigPut,
type StorageTestResponse,
type StorageUsage,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
@@ -636,6 +642,26 @@ export const adminApi = {
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
apiClient.put('/admin/storage', config).then(r => r.data),
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
// one retry) and a mirror probes its targets sequentially — the instance-wide
// 8s axios timeout would abort a legitimate slow probe, so this call carries
// its own generous ceiling.
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
// A full scan of a large install can exceed the 8s instance timeout.
refreshStorageStats: (): Promise<StorageUsage> =>
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
}
export const addonsApi = {
@@ -889,6 +915,20 @@ export const journeyApi = {
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
// GPX tracks of the trips this journey's entries came from (#1260).
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
// What the journey adds up to — distance, days, countries, the route (#1973).
// Read by TREK Studio, which freezes the answer into the book document.
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
// The Studio book (#1973). `book` is null for a journey that has none yet —
// Studio lays one out and the first save creates it.
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
// A 409 carries the current record in its body, so a conflict can be shown
// rather than only announced. See useBookStore.
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
deleteBook: (id: number): Promise<void> =>
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
@@ -930,9 +970,9 @@ export const journeyApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
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')),
// 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
@@ -1029,7 +1069,8 @@ export const healthApi = {
}
export const weatherApi = {
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
}
+49
View File
@@ -9,6 +9,15 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
/**
* Studio books this tab has open, so presence survives a reconnect.
*
* Same reasoning as `activeTrips`: the server forgets the room when the socket
* dies, and without rejoining, everyone else keeps showing a pointer for
* somebody who is no longer there.
*/
const activeBooks = new Set<string>()
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
@@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise<void> {
socket.onopen = () => {
reconnectDelay = 1000
activeBooks.forEach(journeyId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
}
})
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
@@ -166,6 +180,7 @@ export function disconnect(): void {
reconnectTimer = null
}
activeTrips.clear()
activeBooks.clear()
if (socket) {
socket.onclose = null
socket.close()
@@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void {
}
}
/**
* Open a Studio book — presence and pointers, for as long as the editor is up.
*/
export function joinBook(journeyId: number | string): void {
activeBooks.add(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
}
}
export function leaveBook(journeyId: number | string): void {
activeBooks.delete(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
}
}
/**
* Where this tab's pointer is, in millimetres on the spread.
*
* Dropped silently when the socket is not open. A pointer is worth nothing a
* moment later, so there is nothing to queue and nothing to retry — the next
* one is along in a tenth of a second.
*/
export function sendBookCursor(
journeyId: number | string,
spreadIndex: number,
x: number | null,
y: number | null,
): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
}
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
+8
View File
@@ -57,6 +57,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'journey:entry:deleted',
'journey:entries:reordered',
'journey:contributor:changed',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
'journey:book:saved',
// Studio presence — components/Studio/useBookPresence (its own listener:
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
@@ -18,6 +18,7 @@ import {
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -108,6 +109,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
const [loaded, setLoaded] = useState(false)
const [mapTileUrl, setMapTileUrl] = useState('')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState('')
const [mapboxStyle, setMapboxStyle] = useState('')
@@ -379,7 +381,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{mapProvider === 'mapbox-gl' && (
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,779 @@
import React, { useEffect, useRef, useState } from 'react'
import { Activity, FolderTree, HardDrive } from 'lucide-react'
import {
STORAGE_CATEGORIES,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageMigrationStatus,
type StorageTestResponse,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import { formatBytes } from '../../../utils/formatBytes'
import { relativeTime } from '../../../utils/relativeTime'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { useToast } from '../../shared/Toast'
import Section from '../../Settings/Section'
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
import {
CACHE_CATEGORIES,
adoptedMirrorFor,
computeMigrationCandidates,
effectiveCategoryMap,
foldBackends,
mirrorProbeTargets,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
settingsDocumentOf,
setMirrorTargets,
stripCategories,
upsertBackend,
usageByBackend,
type FoldedBackendRow,
type MigrationCandidate,
} from './storageModel'
import { useStorageAdmin } from './useStorageAdmin'
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
ids.map((id) => t(`storage.category.${id}`)).join(', ')
function TestResult({ result, failedLabel, okLabel }: {
result: StorageTestResponse | undefined
failedLabel: string
okLabel: string
}): React.ReactElement | null {
if (result === undefined) return null
return (
<div className="mt-2 space-y-0.5">
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
{result.targets.map((target) => (
<p key={target.name} className="text-xs text-content-faint">
{target.ok ? '✓' : '✗'} {target.name}
{target.error ? ` — ${target.error}` : ''}
</p>
))}
</div>
)
}
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
export default function AdminStoragePanel(): React.ReactElement {
const { t, locale } = useTranslation()
const toast = useToast()
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
const [editing, setEditing] = useState<{
initial: StorageBackend | null
originalName: string | null
mirror: BackendFormMirrorProps
} | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
// Open/closed only — never a snapshot of candidates. Storing the candidate
// array here would go stale the moment the operator edits a category while
// the dialog is open; the render below and moveAndSave both recompute
// computeMigrationCandidates(draft, state) fresh instead.
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
// Set by `save` right before it calls admin.save(): the pre-save mirror
// target count per row name. Consumed (and cleared) by the effect below the
// first time `admin.state` changes afterward — never touched otherwise, so
// unrelated state changes (the backfill poll included) are no-ops here.
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
// Synchronous single-flight lock for the queue effect below: `admin.state`
// only reflects a just-started migration once startMigration's awaited
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
// effect synchronously (still against the pre-POST `admin.state`) would
// otherwise dequeue and POST the next candidate before the server has
// confirmed the first — a ref (not state) so the guard is visible on that
// very next synchronous re-render, not just after a state-driven one.
const migrationStartInFlight = useRef(false)
useEffect(() => {
if (!pendingPromptCheck.current || !admin.state) return
const before = pendingPromptCheck.current
pendingPromptCheck.current = null
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
if (grown) setSyncPrompt(grown.name)
}, [admin.state])
// Queued category migrations run strictly sequentially: once a slot opens
// (no migration currently running), dequeue the next candidate and start
// it. Depends on admin.state so a poll landing a terminal status re-fires
// this without any user interaction. The in-flight lock closes the race
// where setMigrationQueue(rest) re-fires this effect synchronously while
// admin.state still shows the pre-POST world — without it, the next
// candidate could dequeue and POST before the first is server-confirmed.
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
// rule spans backfills and migrations alike, so starting while one runs
// would 409, and the queued candidate would be lost (no retry).
//
// A failed startMigration POST would otherwise strand `rest`: it was
// already dequeued into local state before the POST, so on failure nothing
// ever changes `migrationQueue` or `admin.state` again — the effect never
// re-fires and the remaining candidates sit invisibly forever, silently
// never migrated. On failure the whole remaining queue is explicitly
// cleared (never retried — the operator re-triggers via Save), named in a
// toast, and admin.state is refreshed so the panel reflects whatever the
// failed attempt's category ended up as server-side.
useEffect(() => {
if (migrationQueue.length === 0 || !admin.state) return
if (migrationStartInFlight.current) return
if (admin.state.migrations.some((m) => m.status === 'running')) return
if (admin.state.backfills.some((b) => b.status === 'running')) return
const [next, ...rest] = migrationQueue
migrationStartInFlight.current = true
setMigrationQueue(rest)
void admin.startMigration(next!.category, next!.toWire).then((error) => {
migrationStartInFlight.current = false
if (error) {
toast.error(error)
if (rest.length > 0) {
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
setMigrationQueue([])
}
void admin.refreshState().catch(() => {})
}
})
}, [migrationQueue, admin.state])
if (admin.loading) {
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
}
if (!admin.state || !admin.draft) {
return (
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
{admin.loadError || t('common.error')}
</div>
)
}
const { state, draft } = admin
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
// never shows a stale candidate set while it's open (fix: migration prompt
// staleness). moveAndSave recomputes independently at confirm time rather
// than reading this render-scoped value, since the click handler and the
// render that produced it are not guaranteed to be the same one.
const migrationCandidates = computeMigrationCandidates(draft, state)
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
// mirror-target candidates are the visible primaries only.
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
const { rows, degenerate } = foldBackends(state, draft)
const effective = effectiveCategoryMap(state, draft)
const usageSums = usageByBackend(state, draft)
const startEdit = (row: FoldedBackendRow) => {
// Editing a built-in creates a settings override row bearing its name —
// the first-class relocation path (merge-by-name).
setEditing({
initial: row.backend,
originalName: row.name,
mirror: {
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
initialTargets: row.mirrorTargets,
},
})
}
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
const renamedFrom =
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
// Widened to StorageConfig: these edit helpers are version-blind (they
// return plain StorageConfig), and admin.setDraft re-attaches the
// draft's own `version` regardless of what shape it's handed.
let next: StorageConfig | null = draft
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
next = upsertBackend(next, backend)
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
admin.setDraft(next)
setEditing(null)
}
const removeMessage = (name: string, isDegenerate: boolean): string => {
const row = rows.find((r) => r.name === name)
const assigned = isDegenerate
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
: row?.categories ?? []
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
return [
t('storage.remove.body', { name }),
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
]
.filter(Boolean)
.join(' ')
}
const setCategory = (category: StorageCategory, primaryName: string) => {
// Picking a mirrored primary routes through its adopted mirror silently.
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const categories = { ...draft.categories }
if (stateEntry.source === 'default' && target === stateEntry.backend) {
delete categories[category] // back to the default → no longer settings-owned
} else {
categories[category] = target
}
admin.setDraft({ ...draft, categories })
}
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
// in-progress `draft`) mirror target counts; the effect above compares
// them against the fresh post-save state to detect newly-added
// replicas. Folding `draft` here would already include the unsaved
// edit and mask the very growth this is meant to detect.
const snapshotMirrorTargets = (): Map<string, number> => {
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
}
const doPlainSave = async () => {
pendingPromptCheck.current = snapshotMirrorTargets()
if (await admin.save()) toast.success(t('storage.saved'))
else pendingPromptCheck.current = null
}
// Recomputes the candidate set at confirm time — never trusts whatever was
// true when the dialog opened. The operator can edit categories (or a
// background poll can land a fresher usage scan) while the dialog is on
// screen; the recomputed set is what actually drives both stripCategories
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
// so the two can never diverge from what the dialog most recently showed.
const moveAndSave = async () => {
const candidates = computeMigrationCandidates(draft, state)
pendingPromptCheck.current = snapshotMirrorTargets()
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
if (ok) {
setMigrationQueue(candidates)
toast.success(t('storage.saved'))
} else {
pendingPromptCheck.current = null
}
setMigratePromptOpen(false)
}
const routeOnlySave = async () => {
setMigratePromptOpen(false)
await doPlainSave()
}
const save = async () => {
if (migrationCandidates.length > 0) {
setMigratePromptOpen(true)
return
}
await doPlainSave()
}
const handleCancelMigration = async (m: StorageMigrationStatus) => {
const error = await admin.cancelMigration(m.category)
if (error) toast.error(error)
}
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
const handleRefreshStats = async () => {
const error = await admin.refreshStats()
if (error) toast.error(error)
}
const handleStartBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
setSyncPrompt((prev) => (prev === row.name ? null : prev))
const error = await admin.startBackfill(row.mirrorName)
if (error) toast.error(error)
}
const handleCancelBackfill = async (row: FoldedBackendRow) => {
if (!row.mirrorName) return
const error = await admin.cancelBackfill(row.mirrorName)
if (error) toast.error(error)
}
return (
<div>
{state.configError && (
<div
role="alert"
className="rounded-xl border p-3 mb-4 text-sm"
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
>
{t('storage.configError.banner', { error: state.configError })}
</div>
)}
<Section title={t('storage.health.title')} icon={Activity}>
{state.health.replicaFailures.length === 0 ? (
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
) : (
<ul className="space-y-1">
{state.health.replicaFailures.map((failure) => (
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
{t('storage.health.failureLine', {
op: failure.op,
key: failure.key,
backend: failure.backend,
error: failure.error,
})}
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
</li>
))}
</ul>
)}
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
</Section>
<Section title={t('storage.backends.title')} icon={HardDrive}>
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
<p className="text-xs mb-2 text-content-faint">
{state.usage
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
: t('storage.usage.never')}
{' '}
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
</button>
</p>
<div className="space-y-3">
{rows.map((row) => {
const resultKey = row.mirrorName ?? row.name
const result = testResultFor(resultKey)
// row.mirrorName only exists when foldBackends adopted a draft mirror
// for this row, so the draft lookup below cannot miss.
const testCandidate = row.mirrorName
? draft.backends.find((b) => b.name === row.mirrorName)!
: row.backend
const rowUsage = usageSums?.[row.name]
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
return (
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{row.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t(`storage.type.${row.type}`)}
</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
{t(`storage.source.${row.source}`)}
</span>
<span className="flex-1" />
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() =>
row.mirrorName
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
: admin.test(testCandidate)
}
>
{t('storage.actions.test')}
</button>
{row.source !== 'env' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
{t('storage.actions.edit')}
</button>
)}
{row.source === 'settings' && (
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
{t('storage.actions.remove')}
</button>
)}
</div>
<p className="text-xs mt-1 text-content-faint">
{row.categories.length > 0
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
: t('storage.backends.unused')}
</p>
{rowUsage && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
? ` (${t('storage.usage.legacyNote')})`
: ''}
</p>
)}
{row.mirrorTargets.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
</p>
)}
{row.replicaOf.length > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
</p>
)}
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
{row.mirrorTargets.length > 0 && (
<div className="mt-2">
{backfill?.status === 'running' ? (
<>
<p className="text-xs text-content-faint">
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
</p>
<p className="text-xs text-content-faint">
{t('storage.sync.counts', {
copied: String(backfill.copied),
skipped: String(backfill.skipped),
failed: String(backfill.failed),
})}
</p>
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => handleCancelBackfill(row)}
>
{t('storage.sync.cancel')}
</button>
</>
) : (
<>
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
a completed/cancelled/errored backfill must stay re-runnable
without a page reload. */}
{backfill?.status === 'done' && (
<p className="text-xs text-content-faint">
{t('storage.sync.done', {
copied: String(backfill.copied),
deleted: String(backfill.deleted),
failed: String(backfill.failed),
})}
</p>
)}
{backfill?.status === 'cancelled' && (
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
)}
{backfill?.status === 'error' && (
<p className="text-xs text-content-faint">
{t('storage.sync.error', { error: backfill.error ?? '' })}
</p>
)}
{syncPrompt !== row.name && (
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
)}
</>
)}
</div>
)}
{syncPrompt === row.name && (
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
<div className="flex items-center gap-3 mt-1">
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => handleStartBackfill(row)}
>
{t('storage.sync.now')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setSyncPrompt(null)}
>
{t('storage.sync.dismiss')}
</button>
</div>
</div>
)}
</div>
)
})}
{degenerate.map(({ backend, reason }) => {
const result = testResultFor(backend.name)
const primary = backend.type === 'mirror' ? backend.options.primary : ''
return (
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-content">{backend.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
{t('storage.type.mirror')}
</span>
<span className="flex-1" />
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
{t('storage.actions.test')}
</button>
<button className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
{t('storage.actions.remove')}
</button>
</div>
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
{result === 'running' ? (
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
) : (
<TestResult
result={result as StorageTestResponse | undefined}
okLabel={t('storage.test.ok')}
failedLabel={t('storage.test.failed')}
/>
)}
</div>
)
})}
</div>
{state.migrations.length > 0 && (
<div className="space-y-3 mt-3">
{state.migrations.map((m) => (
<div
key={m.category}
data-testid={`storage-migration-${m.category}`}
className="rounded-xl border p-4 border-edge-secondary"
>
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
{m.status === 'running' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.running', {
category: t(`storage.category.${m.category}`),
done: String(m.done),
total: String(m.total),
})}
</p>
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void handleCancelMigration(m)}
>
{t('storage.migrate.cancel')}
</button>
</>
) : m.status === 'done' ? (
<>
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
</p>
{m.failed > 0 && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
</p>
)}
{m.reclaimable && (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.reclaimable', {
objects: String(m.reclaimable.objects),
size: formatBytes(m.reclaimable.bytes),
from: m.from,
})}
</p>
)}
</>
) : m.status === 'failed' ? (
<p className="text-xs mt-1 text-content-faint">
{t('storage.migrate.failed', { error: m.error ?? '' })}
</p>
) : (
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
)}
</div>
))}
</div>
)}
{editing ? (
<BackendForm
initial={editing.initial}
backendNames={backendNames}
mirror={editing.mirror}
onCommit={commitBackend}
onCancel={() => setEditing(null)}
/>
) : (
<button
onClick={() =>
setEditing({
initial: null,
originalName: null,
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
})
}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.backends.add')}
</button>
)}
</Section>
<Section title={t('storage.categories.title')} icon={FolderTree}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
{STORAGE_CATEGORIES.map((category) => {
// The admin state's category record is exhaustive by schema contract.
const stateEntry = state.categories[category]!
const selectedPrimary = primaryNameOf(state, draft, effective[category])
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
const viaMirror = effective[category] !== selectedPrimary
return (
<div key={category} data-testid={`storage-category-${category}`}>
<label className="block text-sm font-medium mb-1 text-content-secondary">
{t(`storage.category.${category}`)}
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
{category}
</span>
</label>
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
{state.usage?.categories[category] && (
<p className="text-xs mb-1.5 text-content-faint">
{t('storage.usage.line', {
objects: String(state.usage.categories[category]!.objects),
size: formatBytes(state.usage.categories[category]!.bytes),
})}
</p>
)}
<CustomSelect
value={selectedPrimary}
onChange={(value) => setCategory(category, String(value))}
options={rows.map((row) => ({
value: row.name,
label:
stateEntry.source === 'default' && row.name === stateEntry.backend
? `${row.name} (${t('storage.categories.default')})`
: row.name,
}))}
size="sm"
style={{ maxWidth: 320 }}
/>
{changed && (
<p role="alert" className="text-xs mt-1 text-content-faint">
{t('storage.categories.reassignWarning')}
</p>
)}
{viaMirror && CACHE_CATEGORIES.includes(category) && (
<p role="note" className="text-xs mt-1 text-content-faint">
{t('storage.mirror.cacheWarning')}
</p>
)}
</div>
)
})}
</div>
</Section>
<div className="flex items-center gap-3">
<button
onClick={save}
disabled={!admin.dirty || admin.saving}
style={{
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
}}
>
{t('storage.save')}
</button>
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
</div>
{migrationQueue.length > 0 && (
<p className="text-xs mt-2 text-content-faint">
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
</p>
)}
{migratePromptOpen && (
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
{migrationCandidates.map((c) => (
<p key={c.category} className="text-xs mt-1 text-content-secondary">
{c.objects === null
? t('storage.migrate.promptLineUnknown', {
category: t(`storage.category.${c.category}`),
from: c.from,
to: c.to,
})
: t('storage.migrate.promptLine', {
category: t(`storage.category.${c.category}`),
objects: String(c.objects),
size: formatBytes(c.bytes ?? 0),
from: c.from,
to: c.to,
})}
</p>
))}
<div className="flex items-center gap-3 mt-2">
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void moveAndSave()}
>
{t('storage.migrate.move')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => void routeOnlySave()}
>
{t('storage.migrate.routeOnly')}
</button>
<button
className="text-xs underline text-content-secondary"
style={LINK_BUTTON_STYLE}
onClick={() => setMigratePromptOpen(false)}
>
{t('storage.migrate.promptCancel')}
</button>
</div>
</div>
)}
{admin.saveError && (
<div role="alert" className="text-sm mt-2 text-content">
<p>{admin.saveError}</p>
{/* A 409 leaves the draft pinned to a version the server has moved
past; "save again" can never clear it, so the banner offers the
one action that can — see useStorageAdmin.discardDraft. */}
{admin.saveConflict && (
<button
className="text-xs underline text-content-secondary mt-1"
style={LINK_BUTTON_STYLE}
onClick={() => void admin.discardDraft()}
>
{t('storage.discardAndReload')}
</button>
)}
</div>
)}
<ConfirmDialog
isOpen={confirmRemove !== null}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
if (confirmRemove) {
admin.setDraft(
confirmRemove.degenerate
? removeBackend(draft, confirmRemove.name)
: removeBackendAndMirrors(state, draft, confirmRemove.name),
)
}
setConfirmRemove(null)
}}
title={t('storage.remove.title')}
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
confirmLabel={t('storage.remove.title')}
/>
</div>
)
}
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from 'vitest';
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import BackendForm from './BackendForm';
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
type FormProps = Parameters<typeof BackendForm>[0];
function renderForm(overrides: Partial<FormProps> = {}) {
const onCommit = vi.fn();
const onCancel = vi.fn();
render(
<BackendForm
initial={null}
backendNames={NAMES}
onCommit={onCommit}
onCancel={onCancel}
{...overrides}
/>,
);
return { onCommit, onCancel };
}
const S3_INITIAL: StorageBackend = {
name: 'off-box',
type: 's3',
options: {
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
},
};
describe('BackendForm', () => {
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
renderForm();
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
});
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
});
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
const { onCommit } = renderForm({ initial: S3_INITIAL });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'off-box',
type: 's3',
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
}),
);
});
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
const { onCommit } = renderForm();
const apply = screen.getByRole('button', { name: 'Apply' });
expect(apply).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
expect(apply).toBeDisabled(); // root still empty
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
expect(apply).toBeEnabled();
fireEvent.click(apply);
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
});
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
renderForm();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
const { onCommit } = renderForm();
// Switch to s3 via the type select (CustomSelect renders options into a portal).
fireEvent.click(screen.getByText('Local'));
fireEvent.click(screen.getByText('S3'));
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
expect(committed.options).toEqual({
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
});
expect(committed.options).not.toHaveProperty('region');
});
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
renderForm({ initial: S3_INITIAL });
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
// Keeping the original name stays allowed.
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
});
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
renderForm();
fireEvent.click(screen.getByText('Local')); // open the type select
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
expect(screen.getByText('S3')).toBeInTheDocument();
});
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
});
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2); // off-box (self) excluded
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
expect(screen.getByRole('note').textContent).toContain('slows every upload');
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(
expect.objectContaining({ name: 'off-box', type: 's3' }),
['backups-local', 'uploads-local'],
);
});
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
const { onCommit } = renderForm({
initial: S3_INITIAL,
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
});
});
@@ -0,0 +1,264 @@
import React, { useState } from 'react'
import {
STORAGE_BACKEND_TYPES,
STORAGE_BACKEND_TYPE_IDS,
type StorageBackend,
type StorageBackendFieldDef,
type StorageBackendTypeId,
} from '@trek/shared'
import { useTranslation } from '../../../i18n'
import CustomSelect from '../../shared/CustomSelect'
type FieldValues = Record<string, string | string[]>
function valuesOf(backend: StorageBackend | null): FieldValues {
if (!backend) return {}
const values: FieldValues = {}
for (const [key, value] of Object.entries(backend.options)) {
values[key] = Array.isArray(value) ? value : String(value)
}
return values
}
export interface BackendFormMirrorProps {
/** Selectable replica targets (the form excludes its own name at render). */
candidates: string[]
initialTargets: string[]
}
interface BackendFormProps {
/** null = new backend */
initial: StorageBackend | null
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
backendNames: string[]
/**
* Present on non-mirror backends: renders the Mirror-targets composer
* (replicas-on-primary — panel-supplied chrome, never a registry field).
* When present, onCommit's second argument is always an array.
*/
mirror?: BackendFormMirrorProps
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
onCancel: () => void
}
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
const INPUT_CLASS =
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
/**
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
* `mirror` type is hidden from the type select — mirrors are composed via the
* Mirror-targets prop block and synthesized by the panels (the ref-kind
* renderers below stay for future registry types).
*/
export default function BackendForm({
initial,
backendNames,
mirror,
onCommit,
onCancel,
}: BackendFormProps): React.ReactElement {
const { t } = useTranslation()
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
const [name, setName] = useState(initial?.name ?? '')
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
const setValue = (key: string, value: string | string[]) =>
setValues((prev) => ({ ...prev, [key]: value }))
const filled = (field: StorageBackendFieldDef): boolean => {
const value = values[field.key]
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
return typeof value === 'string' && value.trim() !== ''
}
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
const canApply =
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
const apply = () => {
const options: Record<string, unknown> = {}
for (const field of fields) {
const value = values[field.key]
if (field.kind === 'backend-ref-list') {
options[field.key] = Array.isArray(value) ? value : []
continue
}
const text = typeof value === 'string' ? value : ''
if (text === '' && !field.required) continue // omitted → the shared schema default applies
options[field.key] = field.kind === 'number' ? Number(text) : text
}
// The options were built from the same field defs the schema is generated
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
const payload = { name: name.trim(), type, options } as StorageBackend
// Arity matters: the landed tests pin single-argument calls when no mirror
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
// mismatch), so the second argument exists only when the composer does.
if (mirror) onCommit(payload, targets)
else onCommit(payload)
}
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
const value = values[field.key]
if (field.kind === 'backend-ref') {
return (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<CustomSelect
value={typeof value === 'string' ? value : ''}
onChange={(next) => setValue(field.key, String(next))}
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
placeholder={t(field.labelKey)}
size="sm"
/>
</div>
)
}
if (field.kind === 'backend-ref-list') {
const selected = Array.isArray(value) ? value : []
return (
<div key={field.key}>
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
<div className="space-y-1">
{refOptions.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={selected.includes(candidate)}
onChange={(e) =>
setValue(
field.key,
e.target.checked
? [...selected, candidate]
: selected.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
</div>
)
}
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
return (
<label key={field.key} className={LABEL_CLASS}>
{t(field.labelKey)}
<input
type={inputType}
value={typeof value === 'string' ? value : ''}
onChange={(e) => setValue(field.key, e.target.value)}
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
</label>
)
}
return (
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
<p className="text-sm font-semibold text-content">
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
</p>
<label className={LABEL_CLASS}>
{t('storage.form.name')}
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
spellCheck={false}
autoComplete="off"
className={INPUT_CLASS}
/>
</label>
{duplicate && (
<p className="text-xs text-content-faint" role="alert">
{t('storage.form.duplicateName', { name: name.trim() })}
</p>
)}
<div>
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
<CustomSelect
value={type}
onChange={(next) => {
setType(next as StorageBackendTypeId)
setValues({}) // a different type has different fields
}}
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
value: id,
label: t(`storage.type.${id}`),
}))}
size="sm"
disabled={initial !== null}
/>
</div>
{fields.map(renderField)}
{mirror && (
<div>
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
<div className="space-y-1">
{mirror.candidates
.filter((candidate) => candidate !== name.trim())
.map((candidate) => (
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
<input
type="checkbox"
checked={targets.includes(candidate)}
onChange={(e) =>
setTargets(
e.target.checked
? [...targets, candidate]
: targets.filter((existing) => existing !== candidate),
)
}
/>
{candidate}
</label>
))}
</div>
{targets.length > 0 && (
<p className="text-xs mt-1 text-content-faint" role="note">
{t('storage.mirror.latencyNote')}
</p>
)}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={apply}
disabled={!canApply}
style={{
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
}}
>
{t('storage.form.apply')}
</button>
<button
onClick={onCancel}
style={{
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
}}
>
{t('storage.form.cancel')}
</button>
</div>
</div>
)
}
@@ -0,0 +1,407 @@
import { describe, expect, it } from 'vitest';
import {
MASKED_SETTING_VALUE,
STORAGE_CATEGORIES,
type StorageAdminState,
type StorageBackend,
type StorageCategory,
type StorageConfig,
} from '@trek/shared';
import {
categoriesPointingAt,
computeMigrationCandidates,
foldBackends,
mirrorProbeTargets,
mirrorsReferencing,
primaryNameOf,
removeBackend,
removeBackendAndMirrors,
renameBackendRefs,
replicaCandidates,
replicaOfPrimaries,
setMirrorTargets,
settingsDocumentOf,
stripCategories,
upsertBackend,
usageByBackend,
} from './storageModel';
const STATE: StorageAdminState = {
backends: [
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
{
name: 'off-box',
type: 's3',
source: 'settings',
options: {
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
},
categories: ['covers'],
},
],
categories: {
files: { backend: 'uploads-local', source: 'default' },
journey: { backend: 'uploads-local', source: 'default' },
covers: { backend: 'off-box', source: 'settings' },
avatars: { backend: 'uploads-local', source: 'default' },
places: { backend: 'place-photos-local', source: 'default' },
'photos-google': { backend: 'place-photos-local', source: 'default' },
'photos-trek': { backend: 'uploads-local', source: 'default' },
backups: { backend: 'backups-local', source: 'default' },
},
health: { replicaFailures: [] },
seedFilePresent: false,
usage: null,
backfills: [],
migrations: [],
version: 5,
configError: null,
};
describe('settingsDocumentOf', () => {
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
const doc = settingsDocumentOf(STATE);
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
expect(doc.categories).toEqual({ covers: 'off-box' });
});
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
expect(settingsDocumentOf(STATE).version).toBe(5);
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
});
});
describe('remove pre-check helpers', () => {
const draft: StorageConfig = {
backends: [
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
],
categories: { covers: 'nas', backups: 'mir' },
};
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
});
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
});
});
describe('draft edits', () => {
const draft: StorageConfig = {
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
categories: {},
};
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
expect(draft.backends).toHaveLength(1); // untouched
});
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
expect(removeBackend(draft, 'nas').backends).toEqual([]);
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
});
});
// Hoisted to module scope (not just the mirror-fold describe below) so the
// usageByBackend suite can reuse the same mirrored fixture the brief's test
// snippet assumes is in scope.
const MIRRORED_STATE: StorageAdminState = {
...STATE,
backends: [
...STATE.backends,
{
name: 'mirror', type: 'mirror', source: 'settings',
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
},
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
],
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
};
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
describe('mirror fold/synthesize (replicas-on-primary)', () => {
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
expect(degenerate).toEqual([]);
expect(rows.map((r) => r.name)).not.toContain('mirror');
const primary = rows.find((r) => r.name === 'backups-local')!;
expect(primary.mirrorTargets).toEqual(['off-box']);
expect(primary.mirrorName).toBe('mirror');
expect(primary.categories).toContain('backups'); // via the mirror
const replica = rows.find((r) => r.name === 'off-box')!;
expect(replica.replicaOf).toEqual(['backups-local']);
expect(replica.categories).toContain('covers'); // its own direct assignment survives
});
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
const mirror = next.backends.find((b) => b.type === 'mirror')!;
expect(mirror.name).toBe('backups-local-mirror');
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
});
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
const mirrors = next.backends.filter((b) => b.type === 'mirror');
expect(mirrors).toHaveLength(1);
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
});
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
// Build the synthesized state locally: default backups routed via a fresh mirror…
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
expect(dissolved.categories).not.toHaveProperty('backups');
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
expect(repointed.categories.backups).toBe('backups-local');
});
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
const draft: StorageConfig = {
backends: [
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
],
categories: {},
};
const { rows, degenerate } = foldBackends(STATE, draft);
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
['m2', 'duplicate-mirror'],
['m-env', 'env-primary'],
['m-ghost', 'missing-primary'],
]);
});
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
const draft: StorageConfig = {
backends: [
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
],
categories: { covers: 'nas' },
};
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
expect(renamed.categories.covers).toBe('nas2');
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
});
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
const draft = mirroredDraft();
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
});
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
expect(dissolved.categories.backups).toBe('backups-local');
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
});
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
});
});
describe('replicaCandidates', () => {
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
// uploads-local/place-photos-local serve categories directly;
// backups-local serves backups through the mirror it is the primary of;
// off-box carries a direct `covers` assignment. Nothing is offerable.
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
});
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
});
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
const state: StorageAdminState = {
...MIRRORED_STATE,
backends: [
...MIRRORED_STATE.backends,
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
],
};
const { rows } = foldBackends(state, settingsDocumentOf(state));
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
});
});
describe('usageByBackend', () => {
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
const usage = {
computedAt: 1,
categories: Object.fromEntries(
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
) as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 5, bytes: 50 },
};
const state = { ...MIRRORED_STATE, usage };
const sums = usageByBackend(state, mirroredDraft())!;
// backups routes via the mirror → attributed to backups-local (the primary row).
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
// this fixture's `places` and `photos-google` default to the env-sourced
// place-photos-local backend, not uploads-local) + legacy photos.
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
// places + photos-google both default to place-photos-local.
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
// covers routes to off-box directly.
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
});
it('FE-ADMIN-STORM-017: null usage yields null', () => {
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
});
});
describe('computeMigrationCandidates', () => {
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
const state: StorageAdminState = {
...STATE,
usage: {
computedAt: 1,
categories: {
files: { objects: 3, bytes: 300 },
journey: { objects: 0, bytes: 0 },
} as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 0, bytes: 0 },
},
};
const base = settingsDocumentOf(state);
const draft: StorageConfig = {
...base,
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
};
const candidates = computeMigrationCandidates(draft, state);
expect(candidates).toEqual([
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
]);
});
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
const state: StorageAdminState = {
...STATE,
backends: [
...STATE.backends,
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
{
name: 'off-box-mirror',
type: 'mirror',
source: 'settings',
options: { primary: 'off-box', replicas: ['nas'] },
categories: [],
},
],
usage: {
computedAt: 1,
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
legacyPhotos: { objects: 0, bytes: 0 },
},
};
const base = settingsDocumentOf(state);
// Reassign 'files' to the mirrored primary's WIRE name — the same value
// setCategory() writes into the draft when the operator picks a mirrored
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
const candidates = computeMigrationCandidates(draft, state);
expect(candidates).toEqual([
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
]);
});
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
const base = settingsDocumentOf(STATE);
const draft: StorageConfig = {
...base,
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
};
const strippedFiles = stripCategories(draft, STATE, ['files']);
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
const strippedCovers = stripCategories(draft, STATE, ['covers']);
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
});
});
describe('mirrorProbeTargets', () => {
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
});
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
const mirror: StorageBackend = {
name: 'mirror', type: 'mirror',
options: { primary: 'uploads-local', replicas: ['off-box'] },
};
// The draft carries an EDITED off-box (unsaved endpoint change) but never
// touches uploads-local (a built-in, never draft-owned).
const editedOffBox = {
name: 'off-box', type: 's3',
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
} as StorageBackend;
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
const targets = mirrorProbeTargets(draft, STATE, mirror);
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
// Primary: no draft entry — resolved from state (the live built-in).
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
// Replica: draft override wins — the unsaved edit, not the saved options.
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
});
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
const mirror: StorageBackend = {
name: 'mirror', type: 'mirror',
options: { primary: 'uploads-local', replicas: ['ghost'] },
};
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
});
});
@@ -0,0 +1,440 @@
import {
STORAGE_CATEGORIES,
type StorageAdminState,
type StorageBackend,
type StorageCategory,
type StorageConfig,
type StorageConfigPut,
} from '@trek/shared'
/** One entry of the effective world (GET state), as the panels render it. */
export type StateBackend = StorageAdminState['backends'][number]
/**
* The settings-owned document the PUT carries: settings-sourced backends and
* settings-sourced category assignments ONLY — built-ins and env backends are
* never in the body unless the operator overrides one by name (in which case
* the override already reports source 'settings'). Carries `version` from
* the state it was built at (audit #7 — optimistic concurrency): the hook
* re-attaches this same version on every subsequent local edit, so a PUT
* always submits the version the operator's form was loaded at, not
* whatever the server currently holds.
*/
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
return {
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
categories: Object.fromEntries(
Object.entries(state.categories)
.filter(([, entry]) => entry.source === 'settings')
.map(([category, entry]) => [category, entry.backend]),
) as StorageConfig['categories'],
version: state.version,
}
}
/**
* State options are already schema-shaped (the wire contract guarantees it;
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
* the cast re-attaches the discriminated-union type the loose state record dropped.
*/
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
}
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
.filter(([, backend]) => backend === name)
.map(([category]) => category)
}
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
return draft.backends
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
.map((b) => b.name)
}
/** Replace by name in place, or append. Never mutates. */
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
const exists = draft.backends.some((b) => b.name === backend.name)
return {
...draft,
backends: exists
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
: [...draft.backends, backend],
}
}
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
}
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
// The draft stays the wire document; folding is a view rule, synthesis a set
// of pure draft operations. Mirror NAMES never reach the UI — every helper
// that renders phrases mirrors as their primary.
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
export interface FoldedBackendRow {
name: string
type: 'local' | 's3'
source: 'built-in' | 'env' | 'settings'
backend: StorageBackend
/** Direct assignments ∪ assignments routed via the adopted mirror. */
categories: StorageCategory[]
/** Adopted mirror's replicas — [] when unmirrored. */
mirrorTargets: string[]
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
mirrorName: string | null
/** Primaries whose adopted mirror lists this backend as a replica. */
replicaOf: string[]
}
export interface DegenerateMirror {
backend: StorageBackend
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
categories: StorageCategory[]
}
/**
* Re-fetchable content — replicating it is usually wasteful. photos-google is
* the Google photo cache (place-photo-cache.service.ts); places is mostly
* provider-derived imagery.
*/
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
const map = {} as Record<StorageCategory, string>
for (const category of STORAGE_CATEGORIES) {
// The admin state's category record is exhaustive by schema contract.
map[category] = draft.categories[category] ?? state.categories[category]!.backend
}
return map
}
/** First draft mirror wrapping the named primary — the one the UI manages. */
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
}
/**
* Client-side mirror expansion for the test probe (audit fix — the server's
* `testBackend` resolves a mirror candidate's primary/replicas against its
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
* edits to those targets would silently test the SAVED options instead of
* what's on the form). Draft overrides win per name, falling back to state
* (built-in/env); a name that resolves to neither is dropped — that
* primary/replica no longer exists, and the row already renders as
* degenerate in that case.
*/
export function mirrorProbeTargets(
draft: StorageConfig,
state: StorageAdminState,
mirror: StorageBackend,
): StorageBackend[] {
if (mirror.type !== 'mirror') return [mirror]
const names = [mirror.options.primary, ...mirror.options.replicas]
return names
.map((name): StorageBackend | null => {
const draftMatch = draft.backends.find((b) => b.name === name)
if (draftMatch) return draftMatch
const stateMatch = state.backends.find((b) => b.name === name)
return stateMatch ? asWireBackend(stateMatch) : null
})
.filter((b): b is StorageBackend => b !== null)
}
export function foldBackends(
state: StorageAdminState,
draft: StorageConfig,
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
const draftNames = draft.backends.map((b) => b.name)
const effective = effectiveCategoryMap(state, draft)
const categoriesOf = (name: string): StorageCategory[] =>
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
// unless a draft row overrides the name; settings rows from the draft.
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
for (const b of state.backends) {
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
if (draftNames.includes(b.name)) continue
if (b.source === 'settings') continue // removed from the draft, pending save
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
}
for (const b of draft.backends) {
if (isMirror(b)) continue
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
}
// Adoption: the first mirror per editable primary; the rest are degenerate —
// rendered unfolded with a reason, never silently dropped.
const adopted = new Map<string, MirrorBackend>()
const degenerate: DegenerateMirror[] = []
for (const mirror of draft.backends.filter(isMirror)) {
const primary = base.find((row) => row.name === mirror.options.primary)
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
else adopted.set(primary.name, mirror)
}
const rows = base.map((row): FoldedBackendRow => {
const mirror = adopted.get(row.name)
return {
...row,
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
mirrorName: mirror?.name ?? null,
replicaOf: [...adopted.values()]
.filter((m) => m.options.replicas.includes(row.name))
.map((m) => m.options.primary),
}
})
return { rows, degenerate }
}
/**
* The rows offerable as mirror replicas (the "Mirror targets" picker).
*
* A backend that already serves a category — directly, or as the primary of a
* mirror a category routes to, which is exactly what `row.categories` unions —
* can never also be a replica: the mirror's sync sweep makes each replica
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
* sweeps the replica's entire root. The server refuses such a config outright
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
* never offers one, so the operator can't walk into the refusal.
*
* Targets already selected on this mirror stay listed regardless — they must
* remain visible to be unchecked (and a config that predates this rule is
* repaired here, not hidden).
*/
export function replicaCandidates(
rows: FoldedBackendRow[],
selfName: string | null,
currentTargets: readonly string[] = [],
): string[] {
return rows
.filter((row) => row.name !== selfName)
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
.map((row) => row.name)
}
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
const base = `${primaryName}-mirror`
if (!taken.has(base)) return base
for (let n = 2; ; n++) {
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
}
}
/**
* targets non-empty: update the adopted mirror in place (or synthesize one)
* and route every category that effectively resolves to the primary —
* default-sourced included — through it. targets empty: dissolve, re-pointing
* the mirror's categories at the primary and dropping entries that revert to
* their built-in default.
*/
export function setMirrorTargets(
state: StorageAdminState,
draft: StorageConfig,
primaryName: string,
targets: string[],
): StorageConfig {
const adopted = adoptedMirrorFor(draft, primaryName)
if (targets.length === 0) {
if (!adopted) return draft
const categories = { ...draft.categories }
for (const category of STORAGE_CATEGORIES) {
if (categories[category] !== adopted.name) continue
const stateEntry = state.categories[category]! // exhaustive by schema contract
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
else categories[category] = primaryName
}
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
}
const effective = effectiveCategoryMap(state, draft)
const mirror: StorageBackend = {
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
type: 'mirror',
options: { primary: primaryName, replicas: [...targets] },
}
const backends = adopted
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
: [...draft.backends, mirror]
const categories = { ...draft.categories }
for (const category of STORAGE_CATEGORIES) {
if (effective[category] === primaryName) categories[category] = mirror.name
}
return { backends, categories }
}
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
return {
backends: draft.backends.map((b) => {
if (!isMirror(b)) return b
return {
...b,
options: {
primary: b.options.primary === oldName ? newName : b.options.primary,
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
},
}
}),
categories: Object.fromEntries(
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
) as StorageConfig['categories'],
}
}
/**
* Remove a backend, every mirror wrapping it as primary, and every replica
* reference to it in the remaining mirrors — a mirror stripped of its last
* replica dissolves (with the same category re-pointing as an explicit
* dissolve), so the draft never carries a phantom reference the composer
* cannot render or repair.
*/
export function removeBackendAndMirrors(
state: StorageAdminState,
draft: StorageConfig,
name: string,
): StorageConfig {
const stripped = draft.backends
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
.map((b) =>
isMirror(b) && b.options.replicas.includes(name)
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
: b,
)
let next: StorageConfig = { ...draft, backends: stripped }
for (const mirror of stripped.filter(isMirror)) {
if (mirror.options.replicas.length > 0) continue
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
next = setMirrorTargets(state, next, mirror.options.primary, [])
} else {
// A degenerate duplicate stripped empty: drop it and re-point its categories.
const categories = { ...next.categories }
for (const category of STORAGE_CATEGORIES) {
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
}
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
}
}
return next
}
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
return draft.backends
.filter(isMirror)
.filter((m) => m.options.replicas.includes(name))
.map((m) => m.options.primary)
}
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
if (draftMirror) return draftMirror.options.primary
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
return backendName
}
/**
* Usage sums per VISIBLE backend row: each category's numbers land on the row
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
* the legacy photos directory lands on uploads-local — the only backend the
* served-legacy `photos` category ever resolves to.
*/
export function usageByBackend(
state: StorageAdminState,
draft: StorageConfig,
): Record<string, { objects: number; bytes: number }> | null {
if (!state.usage) return null
const sums: Record<string, { objects: number; bytes: number }> = {}
const add = (row: string, entry: { objects: number; bytes: number }) => {
const current = (sums[row] ??= { objects: 0, bytes: 0 })
current.objects += entry.objects
current.bytes += entry.bytes
}
const effective = effectiveCategoryMap(state, draft)
for (const category of STORAGE_CATEGORIES) {
const entry = state.usage.categories[category]
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
}
// The served-legacy `photos` directory always resolves to uploads-local —
// hardcoded, not derived, because it is not one of the configurable categories.
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
return sums
}
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
/** One reassigned category the operator might want to move existing objects for. */
export interface MigrationCandidate {
category: StorageCategory
from: string
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
to: string
/**
* The RAW effective draft backend name for this category — possibly a
* mirror's wire name when `to` is a mirrored primary. The migration POST
* must carry this, not `to`: posting the bare primary would silently drop
* replication for a category that was just routed onto a mirror.
*/
toWire: string
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
objects: number | null
bytes: number | null
}
/**
* Every category whose effective (draft-over-state) PRIMARY backend differs
* from what's currently saved — normalized through `primaryNameOf` so simply
* wrapping (or unwrapping) a mirror around the same primary never counts as
* a candidate; mirroring is the existing sync/backfill flow, not a category
* migration. Zero-object categories (usage present, 0 objects) are excluded —
* nothing to move; null usage (never scanned) keeps the category as a
* candidate with null counts, per spec ("unknown still prompts").
*/
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
const effective = effectiveCategoryMap(state, draft)
const candidates: MigrationCandidate[] = []
for (const category of STORAGE_CATEGORIES) {
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
const toWire = effective[category]
const to = primaryNameOf(state, draft, toWire)
if (from === to) continue
const usage = state.usage?.categories[category]
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
}
return candidates.filter((c) => c.objects !== 0)
}
/**
* Revert exactly the named categories in `draft` back to what's currently
* saved in `saved` — same default/settings convention as the rest of this
* file (drop the override when the saved entry is default-sourced, write the
* explicit backend name otherwise). Every other draft edit is untouched.
*/
export function stripCategories(
draft: StorageConfig,
saved: StorageAdminState,
categories: StorageCategory[],
): StorageConfig {
const next = { ...draft.categories }
for (const category of categories) {
const savedEntry = saved.categories[category]! // exhaustive by schema contract
if (savedEntry.source === 'default') delete next[category]
else next[category] = savedEntry.backend
}
return { ...draft, categories: next }
}
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
import { adminApi } from '../../../api/client'
import { getApiErrorMessage, type ApiError } from '../../../types'
import { settingsDocumentOf } from './storageModel'
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
function isConflictError(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
}
export interface StorageAdmin {
state: StorageAdminState | null
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
draft: StorageConfigPut | null
dirty: boolean
loading: boolean
loadError: string | null
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
saveError: string | null
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
saveConflict: boolean
saving: boolean
testResults: StorageTestResults
setDraft: (next: StorageConfig) => void
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
save: (overrideDraft?: StorageConfig) => Promise<boolean>
test: (backend: StorageBackend) => Promise<void>
/**
* Probe each target individually (client-expanded mirror primary +
* replicas — see `mirrorProbeTargets`) and merge the results into one
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
* sends the mirror stub itself — each call probes a concrete backend.
*/
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
/** null on success; the verbatim server error otherwise. */
startBackfill: (mirrorName: string) => Promise<string | null>
cancelBackfill: (mirrorName: string) => Promise<string | null>
startMigration: (category: string, to: string) => Promise<string | null>
cancelMigration: (category: string) => Promise<string | null>
refreshStats: () => Promise<string | null>
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
refreshState: () => Promise<void>
/**
* The 409's only exit: throw the local edits away and reload the saved
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
* saveError. See the implementation comment for why refreshState can't do
* this itself.
*/
discardDraft: () => Promise<void>
}
/**
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
* model: self-contained, adminApi directly, deliberately no offline core —
* hoster-level config is online-only).
*/
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
const [state, setState] = useState<StorageAdminState | null>(null)
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
const [dirty, setDirty] = useState(false)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [saveConflict, setSaveConflict] = useState(false)
const [saving, setSaving] = useState(false)
const [testResults, setTestResults] = useState<StorageTestResults>({})
// Read imperatively from the poll effect/refreshState so a stale closure
// never re-derives the draft out from under an in-flight edit.
const dirtyRef = useRef(dirty)
useEffect(() => {
dirtyRef.current = dirty
}, [dirty])
// Read imperatively at refreshState's resolve time so a poll GET that was
// in flight when a save started never applies after the save's own state.
const savingRef = useRef(saving)
useEffect(() => {
savingRef.current = saving
}, [saving])
// Bumped by every deliberate state application (initial load, save
// success). A refreshState() call captures the seq before its GET and
// drops the response if the seq moved on — i.e. a save applied its own
// fresher state — while that GET was in flight.
const stateSeq = useRef(0)
const applyState = useCallback((next: StorageAdminState) => {
stateSeq.current += 1
setState(next)
setDraftState(settingsDocumentOf(next))
setDirty(false)
setSaveError(null)
setSaveConflict(false)
}, [])
useEffect(() => {
let cancelled = false
adminApi
.getStorage()
.then((data) => {
if (!cancelled) applyState(data)
})
.catch((err: unknown) => {
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [applyState, genericError])
// Re-attaches the CURRENT draft's version onto whatever shape the caller
// hands in (the storageModel edit helpers are version-blind — they operate
// on plain StorageConfig — so this is the one place that keeps `version`
// pinned to what the operator's form was loaded at, no matter how many
// local edits run in between).
const setDraft = useCallback((next: StorageConfig) => {
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
setDirty(true)
}, [])
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
// `draft` when the operator has no unsaved edits in flight — never touches
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
// mask a pending save error.
//
// Race guard: a poll GET issued before a save's PUT resolves can resolve
// AFTER the save applied its own (fresher) response, overwriting it with
// the pre-save world. Capture the state-application sequence number before
// the await and drop the response if it moved on, or if a save is
// in-flight/just landed (read via a ref so it's current at resolve time).
//
// Declared before `save` (which calls it on a 409) — a hook's `const`s
// must exist before another callback's dependency array references them.
const refreshState = useCallback(async (): Promise<void> => {
const seq = stateSeq.current
const next = await adminApi.getStorage()
if (stateSeq.current !== seq || savingRef.current) return
setState(next)
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
}, [])
const save = useCallback(
async (overrideDraft?: StorageConfig): Promise<boolean> => {
if (!draft) return false
// Always the DRAFT's own version, even for an overrideDraft (a variant
// of the same draft with categories stripped, per stripCategories) —
// both share the same version basis: what the operator's form was
// loaded/refreshed at, never re-read from `state` here (that would
// defeat the whole check — see setDraft's re-attach comment above).
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
setSaving(true)
setSaveError(null)
setSaveConflict(false)
try {
// The response is the fresh effective world, never the request echo.
applyState(await adminApi.updateStorage(body))
return true
} catch (err: unknown) {
if (isConflictError(err)) {
// Something else (another admin's save, or a category migration's
// flip) wrote since this draft's version was captured. Refresh
// `state` so the operator sees the fresh world, but the dirty
// draft itself is left untouched for review (dirty is never
// cleared here) — refreshState() already respects dirtyRef.
setSaveError(conflictError)
setSaveConflict(true)
void refreshState().catch(() => {})
} else {
setSaveError(getApiErrorMessage(err, genericError))
}
return false
} finally {
setSaving(false)
}
},
[draft, applyState, genericError, conflictError, refreshState],
)
/**
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
* the version it was built at. refreshState deliberately leaves a dirty
* draft's version alone — bumping it there would resurrect audit #7, where
* the background poll floats a mid-edit draft past a migration flip it
* never carried — so nothing else can ever move it, and every retry 409s
* again. The operator's way out is to say so explicitly: discard the local
* edits, reload the saved settings, and start over from the fresh version.
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
* version is current, and applyState clears dirty/saveError with it.
*/
const discardDraft = useCallback(async (): Promise<void> => {
try {
applyState(await adminApi.getStorage())
} catch (err: unknown) {
setSaveError(getApiErrorMessage(err, genericError))
setSaveConflict(false)
}
}, [applyState, genericError])
const test = useCallback(
async (backend: StorageBackend): Promise<void> => {
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
try {
const result = await adminApi.testStorageBackend(backend)
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
} catch (err: unknown) {
const error = getApiErrorMessage(err, genericError)
setTestResults((prev) => ({
...prev,
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
}))
}
},
[genericError],
)
const testMirror = useCallback(
async (key: string, targets: StorageBackend[]): Promise<void> => {
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
const settled = await Promise.all(
targets.map(async (target) => {
try {
const result = await adminApi.testStorageBackend(target)
return result.targets[0] ?? { name: target.name, ok: result.ok }
} catch (err: unknown) {
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
}
}),
)
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
},
[genericError],
)
// While any backfill or category migration is running, poll GET state so
// progress/counts advance without the operator refreshing the page.
useEffect(() => {
if (
!state?.backfills.some((b) => b.status === 'running') &&
!state?.migrations.some((m) => m.status === 'running')
)
return
const iv = setInterval(() => {
// A transient poll failure is retried on the next tick — it must never
// surface as a toast or interrupt the operator's in-progress edit.
void refreshState().catch(() => {})
}, BACKFILL_POLL_MS)
return () => clearInterval(iv)
}, [state, refreshState])
const startBackfill = useCallback(
async (mirrorName: string): Promise<string | null> => {
try {
await adminApi.startStorageBackfill(mirrorName)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const cancelBackfill = useCallback(
async (mirrorName: string): Promise<string | null> => {
try {
await adminApi.cancelStorageBackfill(mirrorName)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const startMigration = useCallback(
async (category: string, to: string): Promise<string | null> => {
try {
await adminApi.startStorageMigration(category, to)
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
// queue effect's in-flight lock releases as soon as this promise
// resolves, so admin.state must already reflect the just-started
// migration by then — otherwise the next queued candidate can
// dequeue and POST before the server has confirmed this one.
await refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const cancelMigration = useCallback(
async (category: string): Promise<string | null> => {
try {
await adminApi.cancelStorageMigration(category)
void refreshState().catch(() => {})
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
},
[refreshState, genericError],
)
const refreshStats = useCallback(async (): Promise<string | null> => {
try {
const usage = await adminApi.refreshStorageStats()
setState((prev) => (prev ? { ...prev, usage } : prev))
return null
} catch (err: unknown) {
return getApiErrorMessage(err, genericError)
}
}, [genericError])
return {
state,
draft,
dirty,
loading,
loadError,
saveError,
saveConflict,
saving,
testResults,
setDraft,
save,
test,
testMirror,
startBackfill,
cancelBackfill,
startMigration,
cancelMigration,
refreshStats,
refreshState,
discardDraft,
}
}
@@ -133,4 +133,21 @@ describe('BackgroundTasksWidget', () => {
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
})
})
/*
* Warnings used to appear only when nothing at all was found, so a partly
* understood import dropped whatever it could not place without a word — a
* station the geocoder could not locate, say (#1969), whose endpoint then
* disappeared on save.
*/
it('shows the warnings on a job that did produce items', () => {
const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving'
useBackgroundTasksStore.setState({
tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })],
})
render(<BackgroundTasksWidget />)
expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument()
// and the import button is still there — the job did find something
expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument()
})
})
@@ -157,13 +157,25 @@ export default function BackgroundTasksWidget() {
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
<div>
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
{/* A partly-understood import warns too. Warnings used to be
shown only when nothing at all was found, so anything the
parse could not place — a station it could not locate, say
(#1969) — was dropped without a word on exactly the imports
that did produce something. */}
{(task.warnings?.length ?? 0) > 0 && (
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
{task.warnings!.join('\n')}
</div>
)}
</div>
) : (
<div>
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
+2 -2
View File
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
import { downloadBlob } from '../../utils/fileDownload'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
@@ -1020,7 +1020,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [note, setNote] = useState(() => readUserNote(editing))
const [total, setTotal] = useState<string>(() => {
if (editing) return editing.total_price ? String(editing.total_price) : ''
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
if (prefill?.amount != null) return String(prefill.amount)
return ''
})
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { COST_CATEGORIES, typeToCostCategory } from '@trek/shared'
import { COST_CAT_META, COST_CATEGORY_LIST, catMeta } from './costsCategories'
describe('COST_CAT_META', () => {
it('has metadata for every fixed category, including fuel and parking', () => {
for (const key of COST_CATEGORIES) {
expect(COST_CAT_META[key]).toBeDefined()
expect(COST_CAT_META[key].key).toBe(key)
}
})
it('derives COST_CATEGORY_LIST in the same order as COST_CATEGORIES', () => {
expect(COST_CATEGORY_LIST.map(m => m.key)).toEqual([...COST_CATEGORIES])
})
})
describe('catMeta', () => {
it('resolves fuel and parking directly', () => {
expect(catMeta('fuel').key).toBe('fuel')
expect(catMeta('parking').key).toBe('parking')
})
it('maps legacy/free-text aliases to the new categories', () => {
expect(catMeta('gas').key).toBe('fuel')
expect(catMeta('petrol').key).toBe('fuel')
expect(catMeta('Parking').key).toBe('parking')
expect(catMeta('car park').key).toBe('parking')
})
it('resolves a parking booking the same way whichever path created the expense', () => {
expect(catMeta(typeToCostCategory('parking')).key).toBe('parking')
expect(catMeta('parking').key).toBe(typeToCostCategory('parking'))
})
it('falls back to other for unknown categories', () => {
expect(catMeta('made-up-category').key).toBe('other')
expect(catMeta(null).key).toBe('other')
})
})
@@ -1,4 +1,4 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal } from 'lucide-react'
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
@@ -27,6 +27,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
fees: { key: 'fees', labelKey: 'costs.cat.fees', Icon: FileText, color: '#475569' },
health: { key: 'health', labelKey: 'costs.cat.health', Icon: HeartPulse, color: '#dc2626' },
tips: { key: 'tips', labelKey: 'costs.cat.tips', Icon: Coins, color: '#d97706' },
fuel: { key: 'fuel', labelKey: 'costs.cat.fuel', Icon: Fuel, color: '#f59e0b' },
parking: { key: 'parking', labelKey: 'costs.cat.parking', Icon: ParkingCircle, color: '#3b82f6' },
other: { key: 'other', labelKey: 'costs.cat.other', Icon: MoreHorizontal, color: '#6b7280' },
}
@@ -51,6 +53,8 @@ const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
@@ -13,7 +13,10 @@ export function ChatMessages(props: any) {
<>
{/* Messages */}
{messages.length === 0 ? (
<EmptyState scene="chat" title={t('collab.chat.empty')} />
// flex-1, like the message list it stands in for: without it the empty
// state was only as tall as its own content and the composer rode up to
// sit right under the mascot, with the rest of the panel left blank.
<EmptyState scene="chat" title={t('collab.chat.empty')} className="min-h-0 flex-1" />
) : (
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
@@ -539,14 +539,14 @@ describe('EntryEditor', () => {
expect(onUploadPhotos).not.toHaveBeenCalled()
})
it('FE-JRN-EDITOR-026: leaves a skeleton without any content a skeleton', async () => {
it('FE-JRN-EDITOR-026: promotes a skeleton the user saved without editing (#2008)', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 21, type: 'skeleton', title: 'Venice' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ type: undefined })
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' })
})
it('FE-JRN-EDITOR-027: removes a con row again', async () => {
@@ -836,4 +836,46 @@ describe('EntryEditor', () => {
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBe('')
})
// #1614 — entry_time was stored, sent and displayed everywhere, but the desktop
// editor never rendered an input for it.
it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' }))
// The column carries HH:MM:SS; a time input only accepts HH:MM.
const timeInput = screen.getByDisplayValue('14:30')
expect(timeInput).toHaveAttribute('type', 'time')
fireEvent.change(timeInput, { target: { value: '09:05' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' }))
})
it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' }))
fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } })
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null }))
})
it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => {
const { container } = mountEditor(buildEntry({ id: 10 }))
const inputs = Array.from(container.querySelectorAll('input[type="file"]'))
const camera = inputs.filter(i => i.getAttribute('capture') === 'environment')
const library = inputs.filter(i => !i.hasAttribute('capture'))
expect(camera).toHaveLength(1)
expect(library).toHaveLength(1)
// The library picker keeps multi-select; forcing capture onto it would drop that.
expect(library[0]).toHaveAttribute('multiple')
expect(camera[0]).not.toHaveAttribute('multiple')
})
})
@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
@@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '')
const [locationName, setLocationName] = useState(entry.location_name || '')
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
@@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [externalProvider, setExternalProvider] = useState<string | null>(null)
const [pendingProviderGroups, setPendingProviderGroups] = useState<PendingProviderGroup[]>([])
const fileRef = useRef<HTMLInputElement>(null)
// Own input: putting `capture` on the picker above would take the photo library
// away on a phone, which is the more common way in. This one only ever opens the
// camera, so tablets and laptops get the same route the phone sheet already has.
const cameraRef = useRef<HTMLInputElement>(null)
const storyRef = useRef<HTMLTextAreaElement>(null)
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
@@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
title !== (entry.title || '') ||
story !== (entry.story || '') ||
entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) ||
entryTime !== (entry.entry_time || '') ||
entryTime !== (entry.entry_time?.slice(0, 5) || '') ||
locationName !== (entry.location_name || '') ||
(locationLat ?? null) !== (entry.location_lat ?? null) ||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
@@ -140,7 +144,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
mood: mood || null,
weather: weather || null,
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)) ? 'entry' : undefined),
// An explicit Save is the user saying this suggestion is now their entry —
// it does not need a story to earn that (#2008).
type: entry.type === 'skeleton' ? 'entry' : undefined,
}, persistedEntryIdRef.current ?? undefined)
if (entryId > 0) persistedEntryIdRef.current = entryId
// upload queued files after entry is created
@@ -258,6 +264,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<div>
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
<div className="flex gap-2">
<button
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); fileRef.current?.click() }}
@@ -282,6 +289,15 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
<button
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
disabled={saving}
aria-label={t('journey.photo.add')}
title={t('journey.photo.add')}
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
>
<Camera size={14} />
</button>
<button
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
@@ -559,9 +575,23 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
<div className="grid grid-cols-[1fr_104px] gap-2">
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
</div>
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('mobileJourney.time')}</label>
<input
type="time"
value={entryTime}
onChange={e => setEntryTime(e.target.value)}
className="w-full h-[42px] px-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-transparent text-[13px] text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500 [font-variant-numeric:tabular-nums]"
/>
</div>
</div>
<div className="relative">
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
@@ -1,6 +1,7 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act } from 'react'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
@@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn(
const JOURNEY_ID = 9
// The global IntersectionObserver stub never fires; this one hands the callback
// back so the gallery's load-more sentinel can be driven into view on demand.
const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = []
function triggerIntersection() {
for (const o of galleryObservers) {
o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never)
}
}
function stubIntersectionObserver() {
galleryObservers.length = 0
vi.stubGlobal('IntersectionObserver', class {
observe: (el: Element) => void
disconnect = vi.fn()
unobserve = vi.fn()
takeRecords = vi.fn(() => [])
constructor(cb: IntersectionObserverCallback) {
const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null }
galleryObservers.push(entry)
this.observe = (el: Element) => { entry.el = el }
}
})
}
function buildGalleryPhoto(overrides: Partial<GalleryPhoto> = {}): GalleryPhoto {
return {
id: 100,
@@ -303,4 +329,27 @@ describe('GalleryView', () => {
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined))
expect(onRefresh).not.toHaveBeenCalled()
})
// #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once
// is what made scrolling it a chore.
it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => {
stubIntersectionObserver()
const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i }))
const { container } = mountGallery(many)
const tiles = () => container.querySelectorAll('.aspect-square')
expect(tiles()).toHaveLength(60)
// The observer is stubbed in this suite, so drive the trigger the way it would.
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(120)
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(145)
})
it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => {
const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })])
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
expect(container.querySelector('.animate-spin')).toBeNull()
})
})
@@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
@@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
}, [])
const allPhotos = gallery
// A long trip's gallery is hundreds of tiles, and rendering them all at once is
// what makes scrolling it a chore (#1614). The provider picker has grown its own
// way in for the same reason; this reuses it rather than inventing a second one.
const GALLERY_PAGE = 60
const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE)
const shownPhotos = allPhotos.slice(0, visibleCount)
// Deleting or adding photos must not leave the window stranded past the end.
useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length])
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<EmptyState scene="journey" title={t('journey.detail.noPhotos')} />
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map((photo, i) => (
{shownPhotos.map((photo, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
@@ -193,6 +202,9 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
))}
</div>
)}
{visibleCount < allPhotos.length && (
<ScrollTrigger onVisible={() => setVisibleCount(c => c + GALLERY_PAGE)} loading={false} />
)}
{/* Provider Photo Picker Modal */}
{showPicker && (
@@ -167,7 +167,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{/* Header */}
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
</h2>
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} />
@@ -29,6 +29,10 @@ vi.mock('leaflet', () => {
getZoom: vi.fn(() => 10),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
on: vi.fn(),
off: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0 })),
};
return {
default: {
@@ -38,6 +42,7 @@ vi.mock('leaflet', () => {
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
@@ -550,4 +555,61 @@ describe('JourneyMap', () => {
expect(colors).not.toContain('#ff0000');
expect(colors).not.toContain('#ffffff');
});
// A string handed to bindTooltip becomes innerHTML. A track name is a place
// name off a shared trip and an entry label is a journey title, so both are
// written by someone who is not necessarily the person reading the map — and
// this component is what the public journey page renders.
it('FE-COMP-JOURNEYMAP-044: escapes a track name before it becomes tooltip markup', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: '<img src=x onerror="alert(1)">' })]} />);
const line = vi.mocked(L.polyline).mock.results
.map(r => r.value as any)
.find(v => v.bindTooltip.mock.calls.length > 0);
const [label] = line.bindTooltip.mock.calls[0];
expect(label).not.toContain('<img');
expect(label).not.toContain('onerror="');
expect(label).toBe('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
});
it('FE-COMP-JOURNEYMAP-045: escapes an entry title before it becomes tooltip markup', () => {
const hostile = [{ id: 'e9', lat: 48.1, lng: 2.1, title: '<img src=x onerror="alert(1)">', mood: null, entry_date: '2025-06-04' }];
render(<JourneyMap checkins={[]} entries={hostile} />);
const labels = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
expect(labels.some(l => typeof l === 'string' && l.includes('<img'))).toBe(false);
expect(labels).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
});
// #1614 — photos placed by their own capture coordinates, collapsed by proximity.
it('FE-COMP-JOURNEYMAP-046: draws one thumbnail per cluster and counts the rest', () => {
render(
<JourneyMap
checkins={[]}
entries={[]}
photos={[
{ id: 'p1', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/a.jpg' },
{ id: 'p2', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/b.jpg' },
{ id: 'p3', lat: 48.85, lng: 2.29, thumbUrl: '/uploads/c.jpg' },
]}
/>,
);
// The mock projects every photo to the same point, so all three land in one
// bucket: one marker, and its badge says 3.
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
expect(html).toHaveLength(1);
expect(html[0]).toContain('/uploads/a.jpg');
expect(html[0]).toContain('>3<');
});
it('FE-COMP-JOURNEYMAP-047: a lone photo gets no count badge', () => {
render(
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/only.jpg' }]} />,
);
const html = vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
expect(html).toHaveLength(1);
expect(html[0]).not.toMatch(/>\d+</);
});
});
+104 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import type { JourneyTrack } from '@trek/shared'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
id: string
@@ -14,12 +14,59 @@ export interface MapMarkerItem {
dayLabel: number
}
/**
* Grid clustering in screen space.
*
* The Journey maps have never had clustering, and the library the planner uses
* hangs off react-leaflet while this map drives Leaflet directly. Bucketing by
* rounded pixel position is a few lines, is deterministic, and is enough for the
* job: photos of one place collapse into one thumbnail with a count, and pulling
* the map apart separates them again.
*/
const PHOTO_CLUSTER_PX = 64
function clusterPhotos(
map: L.Map,
photos: MapPhoto[],
): { lat: number; lng: number; members: MapPhoto[] }[] {
const buckets = new Map<string, MapPhoto[]>()
for (const photo of photos) {
const pt = map.latLngToContainerPoint([photo.lat, photo.lng])
const key = `${Math.round(pt.x / PHOTO_CLUSTER_PX)}:${Math.round(pt.y / PHOTO_CLUSTER_PX)}`
const list = buckets.get(key)
if (list) list.push(photo)
else buckets.set(key, [photo])
}
return [...buckets.values()].map(members => ({
// Anchor on the first member rather than the centroid: the thumbnail shown is
// that photo's, so the pin should point where that picture was taken.
lat: members[0].lat,
lng: members[0].lng,
members,
}))
}
function photoMarkerHtml(thumbUrl: string, count: number): string {
const badge = count > 1
? `<span style="position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:#fff;border:1.5px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#111827;line-height:1;box-sizing:border-box;">${count}</span>`
: ''
return `<div style="position:relative;width:48px;height:48px;border-radius:12px;border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);background-image:url('${encodeURI(thumbUrl)}');background-size:cover;background-position:center;"></div>${badge}`
}
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
invalidateSize: () => void
}
/** A photo that knows where it was taken (#1614). */
export interface MapPhoto {
id: string
lat: number
lng: number
thumbUrl: string
}
interface MapEntry {
id: string
lat: number
@@ -35,6 +82,9 @@ interface Props {
ref?: Ref<JourneyMapHandle>
checkins: any[]
entries: MapEntry[]
/** Photos placed by their own capture coordinates, clustered by proximity. */
photos?: MapPhoto[]
onPhotoClick?: (photoIds: string[]) => void
trail?: { lat: number; lng: number }[]
/** Routed GPX geometries from the journey's trips (#1260). */
tracks?: JourneyTrack[]
@@ -92,7 +142,7 @@ const EMPTY_TRACKS: JourneyTrack[] = []
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMap(
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
) {
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
@@ -104,6 +154,9 @@ function JourneyMap(
const highlightedRef = useRef<string | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const photoLayerRef = useRef<L.LayerGroup | null>(null)
const onPhotoClickRef = useRef(onPhotoClick)
onPhotoClickRef.current = onPhotoClick
const darkRef = useRef(dark)
darkRef.current = dark
@@ -212,8 +265,9 @@ function JourneyMap(
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' })
// Same tooltip the markers on this map use, rather than Leaflet's default box:
// it follows the appearance tokens, so it lands right in dark mode and with
// transparency switched off.
if (track.name) line.bindTooltip(track.name, { sticky: true, direction: 'top', className: 'map-tooltip' })
// transparency switched off. Escaped because a string handed to bindTooltip
// becomes innerHTML, and a track name is a place name off a shared trip.
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map)
coords.forEach(c => allCoords.push(c))
}
@@ -243,7 +297,9 @@ function JourneyMap(
})
const marker = L.marker(pos, { icon }).addTo(map)
marker.bindTooltip(item.label, {
// Escaped for the same reason as the track tooltip above: the label is an
// entry title, and this map is what the public journey page renders.
marker.bindTooltip(escapeHtml(item.label), {
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip',
@@ -281,6 +337,49 @@ function JourneyMap(
}
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
// pan because the clustering is done in screen space.
useEffect(() => {
const map = mapRef.current
if (!map) return
const draw = () => {
photoLayerRef.current?.remove()
photoLayerRef.current = null
if (!photos?.length) return
const group = L.layerGroup()
for (const cluster of clusterPhotos(map, photos)) {
const marker = L.marker([cluster.lat, cluster.lng], {
icon: L.divIcon({
className: '',
iconSize: [48, 48],
iconAnchor: [24, 24],
html: photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length),
}),
// Below the entry pins: the itinerary is the point of the map, the photos
// are context.
zIndexOffset: -500,
})
marker.on('click', () => onPhotoClickRef.current?.(cluster.members.map(m => m.id)))
group.addLayer(marker)
}
group.addTo(map)
photoLayerRef.current = group
}
draw()
map.on('zoomend', draw)
map.on('moveend', draw)
return () => {
map.off('zoomend', draw)
map.off('moveend', draw)
photoLayerRef.current?.remove()
photoLayerRef.current = null
}
}, [photos, entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
@@ -6,6 +6,8 @@ import { useTranslation } from '../../i18n'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { useAuthStore } from '../../store/authStore'
import { visibleManagedNavItems } from '../../managed'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -59,6 +61,7 @@ export default function BottomNav() {
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
const location = useLocation()
const create = useCreateAction()
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
const items: NavItem[] = [
{ to: '/dashboard', label: t('nav.myTrips'), icon: LayoutGrid },
@@ -67,6 +70,8 @@ export default function BottomNav() {
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...visibleManagedNavItems(isAdmin).map(m => ({ to: m.path, label: m.label, icon: m.Icon })),
]
// Split the items so the raised "+" sits dead centre.
const splitAt = Math.ceil(items.length / 2)
@@ -559,3 +559,63 @@ describe('Navbar styling and menu details', () => {
document.documentElement.classList.remove('trek-theme-transitioning');
});
});
/**
* The bar's three columns (#1983).
*
* The tab pill was absolutely positioned on the centre of the bar, so it had no
* relationship to what sat beside it. Its width grows with every enabled addon
* and every page plugin, and once it outgrew the free space in the middle it
* ran underneath the logo on one side and the user menu on the other. The only
* adaptation was a fixed 1024px breakpoint that drops the labels, tuned when
* two or three addons was the whole story and blind to plugins entirely.
*
* These check the shape rather than pixels, because that is where the defect
* was: three columns in the flow cannot overlap, whatever ends up in them, and
* no measurement has to be right for that to hold. jsdom reports every width as
* zero, so an assertion on how wide anything is would pass for the wrong reason.
*/
describe('Navbar layout (#1983)', () => {
const withAddons = (n: number) => {
const addons = Array.from({ length: n }, (_, i) => ({
id: `addon${i}`, name: `Addon ${i}`, icon: 'CalendarDays', type: 'global' as const, enabled: true,
}));
server.use(http.get('/api/addons', () => HttpResponse.json({ addons })));
seedStore(useAddonStore, { addons });
};
it('keeps the tab pill in the flow rather than floating over its neighbours', () => {
withAddons(4);
const { container } = render(<Navbar />);
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
expect(pill).toBeTruthy();
// The assertion that would have caught the overlap.
expect(pill.style.position).not.toBe('absolute');
});
it('gives the columns either side of it equal weight, so it stays centred', () => {
withAddons(4);
const { container } = render(<Navbar />);
const nav = container.querySelector('nav') as HTMLElement;
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
// Left brand column and right action cluster, both flex-1 basis-0.
expect(columns).toHaveLength(2);
for (const c of columns) expect(c.classList.contains('basis-0')).toBe(true);
});
it('lets the pill shrink instead of pushing the actions off the bar', () => {
withAddons(8);
const { container } = render(<Navbar />);
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
expect(pill.classList.contains('min-w-0')).toBe(true);
expect(pill.style.overflowX).toBe('auto');
});
it('still renders every addon as a reachable link, however many there are', () => {
withAddons(8);
render(<Navbar />);
for (let i = 0; i < 8; i++) {
expect(screen.getByRole('link', { name: new RegExp(`Addon ${i}`, 'i') })).toBeInTheDocument();
}
});
});
+32 -10
View File
@@ -10,6 +10,7 @@ import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, S
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -109,8 +110,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). */}
<div className="flex items-center gap-3 min-w-0 flex-1 basis-0">
{showBack && (
<button onClick={onBack}
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0 text-content-muted"
@@ -136,14 +139,27 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
</div>
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
the left brand block and the right action cluster keep their layout. */}
{/* Centred liquid-glass tab menu (design handoff).
In the flow, between two equally weighted flex columns, rather than
absolutely positioned on the centre of the bar. Out of the flow it had
no relationship to its neighbours at all: its width grows with every
enabled addon and every page plugin, and once it outgrew the free
space in the middle it simply ran underneath the logo on one side and
the user menu on the other (#1983). The only adaptation was a fixed
1024px breakpoint that drops the labels, which was tuned for two or
three addons and cannot know about plugins.
Now the three columns share the bar, so overlap is not something that
can happen: the pill takes the width it needs and the columns beside
it give way. min-w-0 lets it shrink past its content and scroll rather
than push the actions off the bar. */}
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
<div
className="trek-nav-pill"
className="trek-nav-pill min-w-0"
style={{
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', gap: 4, padding: 4, borderRadius: 14,
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
overflowX: 'auto', scrollbarWidth: 'none',
background: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
@@ -151,7 +167,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) }))
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
@@ -189,8 +207,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
/>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Right side. Same weight as the left column, so whatever is between them
is centred on the bar (#1983). Was a bare flex-1 spacer followed by
loose siblings, which centred nothing and left the pill to fend for
itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-0 justify-end">
{/* Share button */}
{onShare && (
@@ -321,6 +342,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
</div>
)}
</div>
</nav>
)
}
+31 -2
View File
@@ -916,7 +916,17 @@ describe('MapView live location', () => {
})
describe('MapView bounds fitting', () => {
it('FE-COMP-MAPVIEW-065: a day-detail fit nudges the map down once the animation ran (#1348)', async () => {
/*
* The day fit used to compensate for the day panel twice: once through the
* padding, and again 300ms later with panBy([0, 150]) (#1348). On a route
* running north to south the fit is height-bound, so the northernmost stop
* lands exactly on the top padding line and the nudge then pushed it off the
* canvas — the map framed everything and then drifted upwards (#1982).
*
* So this case is the inverse of what it used to assert: the padding does the
* work, and nothing moves the camera afterwards.
*/
it('FE-COMP-MAPVIEW-065: a day-detail fit reserves the panel in its padding and then leaves the camera alone (#1348, #1982)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
@@ -925,7 +935,26 @@ describe('MapView bounds fitting', () => {
rerender(<MapView places={places} dayPlaces={places} fitKey={2} hasDayDetail />)
expect(mapMock.fitBounds).toHaveBeenCalled()
await waitFor(() => expect(mapMock.panBy).toHaveBeenCalledWith([0, 150], { animate: true }), { timeout: 2000 })
const calls = mapMock.fitBounds.mock.calls
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
expect(opts?.paddingBottomRight?.[1]).toBe(280)
// Long enough that the old 300ms nudge would have fired by now.
await new Promise(r => setTimeout(r, 600))
expect(mapMock.panBy).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-065b: reserves only the small margin when no day panel is open (#1982)', () => {
const places = [
buildMapPlace({ id: 1, lat: 48, lng: 2 }),
buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 }),
]
const { rerender } = render(<MapView places={places} dayPlaces={places} fitKey={1} />)
rerender(<MapView places={places} dayPlaces={places} fitKey={2} />)
const calls = mapMock.fitBounds.mock.calls
const opts = calls[calls.length - 1]?.[1] as { paddingBottomRight?: [number, number] }
expect(opts?.paddingBottomRight?.[1]).toBe(60)
})
it('FE-COMP-MAPVIEW-066: a fitKey that never changed is not re-fitted', async () => {
+21 -8
View File
@@ -269,7 +269,6 @@ function MapController({ center, zoom }: MapControllerProps) {
// finished computing asynchronously — re-fit once more to include the full route
// polyline, so a route that bulges past its stops stays in view (#1128).
interface BoundsControllerProps {
hasDayDetail?: boolean
places: Place[]
routeCoords: [number, number][]
fitKey: number
@@ -278,7 +277,7 @@ interface BoundsControllerProps {
framedOnMount?: boolean
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail, framedOnMount = false }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
@@ -289,13 +288,24 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDeta
try {
const bounds = L.latLngBounds(coords)
if (bounds.isValid()) {
/*
* The padding already reserves the day panel's height at the bottom
* (paddingBox), so the fit puts the day's stops above it. There used to
* be a second, manual nudge 300ms later — panBy([0, 150]) — from the
* same change that introduced the padding, and the two compensated for
* the same panel twice.
*
* On a route that runs north to south the fit is height-bound, which
* puts the northernmost stop exactly on the top padding line; the nudge
* then pushed it off the canvas. The map appeared to frame everything
* correctly and then drift upwards, which is what the report describes
* (#1982). MapViewGL never had the nudge, so this also brings the two
* renderers back into agreement.
*/
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
if (hasDayDetail) {
setTimeout(() => map.panBy([0, 150], { animate: true }), 300)
}
}
} catch {}
}, [map, paddingOpts, hasDayDetail])
}, [map, paddingOpts])
// New fitKey (initial trip fit or a day selection): fit to the destinations now
// and arm a one-shot re-fit for when the route arrives.
@@ -827,7 +837,7 @@ export const MapView = memo(function MapView({
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} framedOnMount={initialView.framed} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
@@ -899,7 +909,10 @@ export const MapView = memo(function MapView({
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
<div style={{ position: 'absolute', left: leftWidth + 12, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
<div style={{ position: 'absolute', left: leftWidth + 20, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none' }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
</div>
</div>
@@ -851,6 +851,23 @@ describe('MapViewGL', () => {
expect(glMarkers.created[1].lngLat).toEqual([2.12, 48.12])
})
// The Leaflet twin of this is FE-COMP-MAPVIEW-074. Both renderers build their
// marker as an HTML string, so both need the escape pinned — otherwise it can
// be dropped from one of them without a single test going red.
it('FE-COMP-MAPVIEWGL-023b: an image_url that passes the prefix check is still escaped', async () => {
loadOnAttach()
render(<MapViewGL places={[buildMapPlace({
id: 13, lat: 48.13, lng: 2.13, image_url: '/uploads/x" onerror="alert(1)" y="',
})]} fitKey={1} />)
await act(async () => {})
const marker = glMarkers.created[0].element
expect(marker.innerHTML).not.toContain('onerror="alert(1)"')
expect(marker.innerHTML).toContain('&quot;')
// The escape must not break the ordinary case: the src still resolves.
expect(marker.querySelector('img')?.getAttribute('src')).toBe('/uploads/x" onerror="alert(1)" y="')
})
it('FE-COMP-MAPVIEWGL-024: the hover card shows name, category and address and follows the cursor', async () => {
loadOnAttach()
const places = [buildMapPlace({
@@ -9,6 +9,7 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
generateCoMapsUrl,
parsePluginProfile,
withHotelBookends,
} from './RouteCalculator'
@@ -572,3 +573,43 @@ describe('calculateRouteWithLegs cache eviction', () => {
expect(hits).toBe(1)
})
})
// ── generateCoMapsUrl ─────────────────────────────────────────────────────────
describe('generateCoMapsUrl', () => {
const eiffel = { lat: 48.8584, lng: 2.2945, name: 'Eiffel Tower' }
const louvre = { lat: 48.8606, lng: 2.3376, name: 'Louvre' }
const notre = { lat: 48.8530, lng: 2.3499, name: 'Notre-Dame' }
it('FE-COMP-ROUTECALCULATOR-016: no stops, no link', () => {
expect(generateCoMapsUrl([])).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-017: two stops build a real route, mode included', () => {
expect(generateCoMapsUrl([eiffel, louvre], 'walking')).toBe(
'https://comaps.at/route?sll=48.8584,2.2945&saddr=Eiffel%20Tower'
+ '&dll=48.8606,2.3376&daddr=Louvre&type=pedestrian',
)
})
it('FE-COMP-ROUTECALCULATOR-018: TREK profiles map onto CoMaps travel modes', () => {
expect(generateCoMapsUrl([eiffel, louvre], 'driving')).toContain('type=vehicle')
expect(generateCoMapsUrl([eiffel, louvre], 'cycling')).toContain('type=bicycle')
// A plugin router has no CoMaps equivalent, so it falls back rather than
// sending a mode CoMaps would reject.
expect(generateCoMapsUrl([eiffel, louvre], 'plugin:ev/fast')).toContain('type=vehicle')
})
it('FE-COMP-ROUTECALCULATOR-019: three stops go as pins, because a route link would drop the middle', () => {
const url = generateCoMapsUrl([eiffel, louvre, notre])!
expect(url).toBe(
'https://comaps.at/map?v=1&ll=48.8584,2.2945&n=Eiffel%20Tower'
+ '&ll=48.8606,2.3376&n=Louvre&ll=48.853,2.3499&n=Notre-Dame',
)
})
it('FE-COMP-ROUTECALCULATOR-020: a single stop is a pin, and a nameless one is labelled by position', () => {
expect(generateCoMapsUrl([{ lat: 48.85, lng: 2.35 }]))
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
})
})
@@ -116,6 +116,42 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
return `https://www.google.com/maps/dir/${stops}`
}
/** A stop that can carry its name into a deep link that has somewhere to put one. */
export type NamedWaypoint = Waypoint & { name?: string | null }
/** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */
function coMapsRouteType(profile: RouteProfileKey): string {
if (profile === 'walking') return 'pedestrian'
if (profile === 'cycling') return 'bicycle'
return 'vehicle'
}
/**
* Open a day's stops in CoMaps for offline navigation (#1904).
*
* CoMaps has two links and they trade against each other. `route` builds real
* turn-by-turn in the given travel mode but takes a start and a destination and
* nothing between them; `map` takes any number of named pins but routes nothing.
* So a two-stop day goes as a route — everything it has fits, mode included —
* and a longer one goes as pins, because handing over the whole day and letting
* CoMaps route leg by leg beats quietly dropping the middle of someone's plan.
* A day that needs the full itinerary as one navigable track has the GPX export.
*
* https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`.
*/
export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null {
const valid = places.filter((p) => p.lat != null && p.lng != null)
if (valid.length === 0) return null
const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`)
if (valid.length === 2) {
const [from, to] = valid
return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}`
+ `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}`
}
const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&')
return `https://comaps.at/map?v=1&${pins}`
}
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
function sqDist(a: Waypoint, b: Waypoint): number {
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
@@ -1,247 +0,0 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012
//
// JourneyBookPDF.tsx exports an async function
// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in
// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM
// structure, the HTML content and that the book follows the app language.
import { describe, it, expect, vi, afterEach } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
marked: {
parse: (str: string) => `<p>${str}</p>`,
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
// ── Helpers ──────────────────────────────────────────────────────────────────
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
return {
id: 1,
user_id: 1,
title: 'Iceland Ring Road',
subtitle: 'Two weeks around the island',
status: 'active',
cover_image: null,
cover_gradient: null,
created_at: Date.now(),
updated_at: Date.now(),
entries: [
{
id: 10,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Golden Circle',
story: 'An incredible day of geysers and waterfalls.',
entry_date: '2026-07-01',
entry_time: '09:00',
location_name: 'Thingvellir',
location_lat: 64.255,
location_lng: -21.13,
mood: 'excited',
weather: 'sunny',
tags: [],
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
visibility: 'private',
sort_order: 0,
created_at: Date.now(),
updated_at: Date.now(),
source_trip_id: null,
source_place_id: null,
source_trip_name: null,
photos: [
{
id: 100,
entry_id: 10,
provider: 'local',
file_path: 'journey/geyser.jpg',
thumbnail_path: null,
asset_id: null,
owner_id: null,
shared: 0,
caption: 'Strokkur erupting',
sort_order: 0,
created_at: Date.now(),
},
],
},
],
trips: [],
contributors: [],
stats: { entries: 1, photos: 1, cities: 1 },
...overrides,
} as unknown as JourneyDetail;
}
// ── Helpers to inspect the overlay ───────────────────────────────────────────
function getOverlay(): HTMLElement | null {
return document.getElementById('journey-pdf-overlay');
}
function getIframe(): HTMLIFrameElement | null {
return getOverlay()?.querySelector('iframe') ?? null;
}
// Echoes the key (plus any params) so the assertions read as "this string comes
// from i18n" instead of pinning English copy.
function t(key: string, params?: Record<string, string | number>): string {
if (!params) return key;
return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`;
}
const longDate = (d: string, locale: string) =>
new Date(d + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
});
// ── Setup ────────────────────────────────────────────────────────────────────
afterEach(() => {
document.getElementById('journey-pdf-overlay')?.remove();
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(getOverlay()).not.toBeNull();
expect(document.body.contains(getOverlay())).toBe(true);
});
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
await downloadJourneyBookPDF(buildJourney());
const iframe = getIframe();
expect(iframe).not.toBeNull();
const html = iframe!.srcdoc;
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
await downloadJourneyBookPDF(buildJourney());
const overlay = getOverlay()!;
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
});
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
});
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = getIframe()!.srcdoc;
expect(html).toContain('Golden Circle');
// Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.');
// Pros/cons verdict cards are included
expect(html).toContain('Amazing views');
expect(html).toContain('Crowded');
});
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey);
expect(getOverlay()).not.toBeNull();
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present. Without a
// translator the chrome falls back to the raw keys instead of throwing.
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.pdf.theEnd');
});
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
const journey = buildJourney();
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
await downloadJourneyBookPDF(journey);
const iframe = getIframe()!;
const html = iframe.srcdoc;
// The script tag, image beacon and event handler are stripped from the story.
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).not.toContain('alert(2)');
// Benign prose survives.
expect(html).toContain('Hello');
expect(html).toContain('world');
});
it('FE-COMP-JOURNEYPDF-008: renders cover, day header and closing page through i18n', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const html = getIframe()!.srcdoc;
expect(html).toContain('<html lang="de">');
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.stats.days');
expect(html).toContain('journey.stats.entries');
expect(html).toContain('journey.stats.photos');
expect(html).toContain('journey.pdf.madeWith');
expect(html).toContain('journey.pdf.theEnd');
// Day header is the parameterised key plus a date in the picked locale —
// the hardcoded 'en' formatting would not produce this string.
expect(html).toContain('journey.detail.day(number=1)');
expect(html).toContain(longDate('2026-07-01', 'de-DE'));
// Pros/cons headings too.
expect(html).toContain('journey.verdict.lovedIt');
expect(html).toContain('journey.verdict.couldBeBetter');
});
it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const overlay = getOverlay()!;
// cover + one entry page + closing page
expect(overlay.textContent).toContain('3 journey.pdf.pages');
expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf');
expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close');
});
it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => {
const journey = buildJourney();
journey.entries[0].mood = 'good';
journey.entries[0].weather = 'rainy';
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
expect(html).toContain('journey.mood.good');
expect(html).toContain('journey.weather.rainy');
// Icons come along as inline SVG inside the chips.
expect(html).toContain('class="entry-chips"');
expect(html).toContain('<svg');
});
it('FE-COMP-JOURNEYPDF-011: skips chips for values the app does not know', async () => {
// The fixture carries mood 'excited', which is not one of the four moods.
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
// Scoped to the chip row: the page elsewhere is free to carry whatever the
// fixture happens to leave undefined, and that is not what this asserts.
const chips = html.match(/<div class="entry-chips">[\s\S]*?<\/div>/)?.[0] ?? '';
expect(chips).not.toBe('');
expect(chips).not.toContain('undefined');
expect(chips).not.toContain('journey.mood.');
// The known weather value still gets its chip.
expect(chips).toContain('journey.weather.sunny');
});
it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => {
const journey = buildJourney();
journey.entries[0].mood = null;
journey.entries[0].weather = null;
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"');
});
});
@@ -1,391 +0,0 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { renderIconMarkup } from '../../utils/iconMarkup'
type Translate = (key: string, params?: Record<string, string | number>) => string
function esc(str: string | null | undefined): string {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function md(str: string | null | undefined): string {
if (!str) return ''
// marked passes embedded raw HTML through by default, so sanitise the result
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
}
function abs(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function pSrc(p: JourneyPhoto): string {
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string, locale: string): string {
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
for (const e of entries) {
if (!e.entry_date) continue
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
groups.get(e.entry_date)!.push(e)
}
return groups
}
function renderProscons(entry: JourneyEntry, tr: Translate): string {
const pc = entry.pros_cons
if (!pc) return ''
const pros = pc.pros?.filter(p => p.trim()) || []
const cons = pc.cons?.filter(c => c.trim()) || []
if (pros.length === 0 && cons.length === 0) return ''
return `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">${esc(tr('journey.verdict.lovedIt'))}</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">${esc(tr('journey.verdict.couldBeBetter'))}</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function chip(icon: LucideIcon, label: string, bg: string, color: string): string {
const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color }))
return `<span class="chip" style="background:${bg};color:${color}">${svg}${esc(label)}</span>`
}
/**
* Mood and weather were on screen but never in the book (#1848). Unknown values
* (older entries, a provider-set category we don't render) drop out silently,
* the same way MoodChip/WeatherChip return null for them.
*/
function renderMoodWeather(entry: JourneyEntry, tr: Translate): string {
const chips: string[] = []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined
if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text))
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined
if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46'))
if (chips.length === 0) return ''
return `<div class="entry-chips">${chips.join('')}</div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
<div class="photo-stack">
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
}
/**
* `opts` stays optional so the signature is additive, but every caller passes
* the page's `t`/`locale` — without them the book falls back to raw keys (#1848).
*/
export async function downloadJourneyBookPDF(
journey: JourneyDetail,
opts: { t?: Translate; locale?: string } = {},
) {
const tr: Translate = opts.t || (key => key)
const loc = opts.locale || 'en'
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
const grouped = groupByDate(entries)
const dates = [...grouped.keys()].sort()
// Build entry pages — one per entry, day header inline on first entry of day
const entryPages: string[] = []
let pageNum = 1 // cover=1
dates.forEach((date, di) => {
const dayEntries = grouped.get(date)!
dayEntries.forEach((entry, ei) => {
pageNum++
const isFirstOfDay = ei === 0
const photos = entry.photos || []
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
// Day header (inline, only on first entry of day)
const dayHeaderHtml = isFirstOfDay
? `<div class="day-header">${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry, tr)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
entryPages.push(`
<div class="entry-page">
${dayHeaderHtml}
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${renderMoodWeather(entry, tr)}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
</div>
</div>
`)
})
})
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html lang="${esc(loc.split('-')[0])}">
<head>
<meta charset="UTF-8">
<!-- The page is A4 landscape and laid out in pt, so it is about 1123px wide. Without
this, a phone lays the preview out at its default 980px and the document spills
out of the frame, which reads as being zoomed far in. Declaring the real width
makes the browser scale the whole page to fit instead. Printing is unaffected,
it goes by @page. -->
<meta name="viewport" content="width=1123">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 0; }
/* ── Cover ─── */
.cover-page {
width: 100%; height: 100vh; position: relative; overflow: hidden;
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
page-break-after: always;
}
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
/* ── TOC ─── */
.toc-page {
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
background: white; page-break-after: always;
}
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
/* ── Entry Page ─── */
.entry-page {
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
page-break-after: always;
display: flex; flex-direction: column;
}
/* Day header — inline */
.day-header {
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
display: flex; align-items: center; gap: 12pt;
}
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
/* Photos */
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
.photo-cell { overflow: hidden; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-hero { overflow: hidden; }
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
.photo-stack .photo-cell { flex: 1; }
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
.entry-chips { display: flex; flex-wrap: wrap; gap: 5pt; margin-bottom: 8pt; }
.chip { display: inline-flex; align-items: center; gap: 3pt; padding: 2.5pt 7pt; border-radius: 999pt; font-size: 8.5pt; font-weight: 600; }
.chip svg { width: 9pt; height: 9pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
.entry-story strong { font-weight: 600; color: #0a0a0f; }
.entry-story em { font-style: italic; }
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
.entry-story li { margin-bottom: 4pt; }
.entry-story a { color: #2563eb; text-decoration: none; }
/* Verdict */
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
.verdict-row { display: flex; gap: 10pt; }
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
.verdict-card.pros .verdict-label { color: #15803d; }
.verdict-card.cons .verdict-label { color: #b91c1c; }
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
.verdict-card.pros li { color: #14532d; }
.verdict-card.pros li::before { color: #22c55e; }
.verdict-card.cons li { color: #7f1d1d; }
.verdict-card.cons li::before { color: #ef4444; }
/* ── Closing ─── */
.closing-page {
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
}
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
/* ── Print ─── */
@media print {
.print-bar { display: none !important; }
body { margin: 0; }
.entry-page { orphans: 3; widows: 3; }
h2.entry-title { page-break-after: avoid; }
.verdict-row { page-break-inside: avoid; }
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
</style>
</head>
<body>
<!-- Page 1: Cover -->
<div class="cover-page">
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">${esc(tr('journey.pdf.journeyBook'))}</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.days'))}</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.entries'))}</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.photos'))}</div></div>
</div>
</div>
<div class="cover-footer">${esc(tr('journey.pdf.madeWith'))}</div>
</div>
<!-- Entry Pages -->
${entryPages.join('\n')}
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">${esc(tr('journey.pdf.theEnd'))}</div>
<div class="closing-sub">${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}</div>
</div>
</div>
</body>
</html>`
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
// This avoids window.open() which Safari iOS blocks in async callbacks
// and window.close() which doesn't work reliably in standalone PWA mode.
const overlay = document.createElement('div')
overlay.id = 'journey-pdf-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
// The chrome needs a stylesheet rather than inline styles: on a phone the title
// and the two labels do not fit on one row, and translated labels are longer
// than the English ones. Below 640px the card goes full-bleed, the decorative
// title steps aside and the two actions share the width (#1848).
const style = document.createElement('style')
style.textContent = `
#journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
#journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; }
#journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
#journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; }
#journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
#journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; }
#journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
@media (max-width: 640px) {
#journey-pdf-overlay { padding:0; }
#journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; }
#journey-pdf-overlay .jp-head { padding:8px 10px; }
#journey-pdf-overlay .jp-title { display:none; }
#journey-pdf-overlay .jp-actions { flex:1; }
#journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; }
}
`
const card = document.createElement('div')
card.className = 'jp-card'
const header = document.createElement('div')
header.className = 'jp-head'
header.innerHTML = `
<span class="jp-title">${esc(journey.title)} &middot; ${totalPages} ${esc(tr('journey.pdf.pages'))}</span>
<div class="jp-actions">
<button id="journey-pdf-save" class="jp-btn jp-save">${esc(tr('journey.pdf.saveAsPdf'))}</button>
<button id="journey-pdf-close" class="jp-btn jp-close">${esc(tr('common.close'))}</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is triggered from the parent via
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(style)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
}
+146 -1
View File
@@ -4,6 +4,7 @@ import { http, HttpResponse } from 'msw'
import { downloadTripPDF } from './TripPDF'
import { server } from '../../../tests/helpers/msw/server'
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
import { getMergedItems, getTransportForDay } from '../../utils/dayMerge'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -548,7 +549,7 @@ describe('downloadTripPDF', () => {
})
})
// FE-W5PDF-001 to FE-W5PDF-018 — multi-day transport spans, the remaining
// FE-W5PDF-001 to FE-W5PDF-030 — multi-day transport spans, the remaining
// reservation subtitles, accommodation phases and cover fallbacks.
describe('downloadTripPDF remaining branches', () => {
const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any
@@ -603,6 +604,20 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).not.toContain('reservations.span.active')
})
it('FE-W5PDF-029: a multi-day parking is labelled drop-off/pickup and skips the middle day (#1937)', async () => {
await downloadTripPDF(spanArgs([{
id: 506, title: 'Airport Parking', type: 'parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30', reservation_end_time: '2025-06-03T19:00',
}]))
const html = srcdoc()
expect(html).toContain('reservations.span.dropOff: Airport Parking')
expect(html).toContain('reservations.span.pickup: Airport Parking')
expect(html).not.toContain('reservations.span.ongoing')
// Day B holds nothing else, so it prints the empty-day hint rather than the booking.
expect(html).toContain('dayplan.emptyDay')
})
it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => {
await downloadTripPDF(spanArgs([
{ id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 },
@@ -691,6 +706,25 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).toContain('AirX · X2 · AAA → BBB')
})
it('FE-W5PDF-030: a stopover flight prints the booking code of each segment that has one (#1943)', async () => {
await downloadTripPDF(spanArgs([{
id: 518, title: 'Layover', type: 'flight', day_id: 10, confirmation_number: 'BOOK1',
metadata: JSON.stringify({
legs: [
{ airline: 'LH', flight_number: 'LH1', from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ airline: 'ANA', flight_number: 'NH2', from: 'BER', to: 'HND' },
],
}),
}]))
const html = srcdoc()
expect(html).toContain('LH · LH1 · FRA → BER · ABC123')
// A segment without one keeps the line it had before, and the booking's own
// reference still prints once for the whole card.
expect(html).toContain('ANA · NH2 · BER → HND')
expect(html).toContain('Code: BOOK1')
})
it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => {
await downloadTripPDF(spanArgs([{
id: 517, title: 'Long Haul', type: 'flight', day_id: 10,
@@ -1063,3 +1097,114 @@ describe('page breaks between days (#1292)', () => {
expect(html.slice(at - 10, at)).toContain('.pdf-flow ')
})
})
/**
* The order a day prints in (#1978).
*
* The export used to order a day by itself, on `day_plan_position` — one global
* number per booking, auto-seeded from whichever day happened to render first.
* On any other day of a span it therefore pointed at the wrong slot, and where
* it was still null the export fell back to the order the API returned rows in.
* That is why the same trip printed correctly some of the time and swapped two
* bookings the rest of it, and why adding a place and removing it again "fixed"
* it: the reseed happened to land on the day being looked at.
*
* It now goes through utils/dayMerge, the same functions the day plan uses, so
* the two cannot disagree. These cases are written from the plan's rules rather
* than from the old implementation's.
*/
describe('a printed day follows the plan (#1978)', () => {
const d1 = { id: 21, day_number: 1, title: 'Day One', date: '2025-06-01' } as any
const d2 = { id: 22, day_number: 2, title: 'Day Two', date: '2025-06-02' } as any
const srcdoc = () => getIframe()!.srcdoc
/** Where each needle first appears, so order can be asserted without parsing. */
const positions = (html: string, needles: string[]) => needles.map(n => html.indexOf(n))
it('orders the day by the clock, not by the order the API sent rows in', async () => {
await downloadTripPDF({
...minimalArgs,
trip: { id: 21, title: 'Order Trip', description: null, cover_image: null } as any,
days: [d1],
// As the API returns them: reservation_time ASC with nulls first.
reservations: [
{ id: 1, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null },
{ id: 2, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' },
{ id: 3, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' },
],
})
const [morning, evening] = positions(srcdoc(), ['Morning Flight', 'Evening Train'])
expect(morning).toBeGreaterThan(-1)
expect(evening).toBeGreaterThan(-1)
expect(morning).toBeLessThan(evening)
})
/*
* The reported shape: a booking on the last day of a span whose global
* position was seeded from a busier earlier day, which sank it below
* everything on the day it actually belongs to.
*/
it('ignores a global position seeded from another day', async () => {
await downloadTripPDF({
...minimalArgs,
trip: { id: 22, title: 'Span Trip', description: null, cover_image: null } as any,
days: [d1, d2],
reservations: [
{
id: 4, title: 'Car Return', type: 'car', day_id: 21, end_day_id: 22,
reservation_time: '2025-06-01T10:00', reservation_end_time: '2025-06-02T09:00',
// Seeded from day one, where five places had already been placed.
day_plan_position: 5,
},
{ id: 5, title: 'Late Flight', type: 'flight', day_id: 22, reservation_time: '2025-06-02T18:00' },
],
})
const html = srcdoc()
// On day two the 09:00 return comes before the 18:00 flight, whatever the
// stale global position says.
const [ret, flight] = positions(html, ['Car Return', 'Late Flight'])
expect(ret).toBeGreaterThan(-1)
expect(flight).toBeGreaterThan(-1)
expect(ret).toBeLessThan(flight)
})
/*
* The strongest form of the same claim: whatever the plan would show, the
* print shows, in that order. Asserted against getMergedItems itself rather
* than against a hand-written expectation, so the two cannot drift apart
* again without this failing.
*/
it('prints a mixed day in exactly the order the plan merges it', async () => {
const reservations = [
{ id: 8, title: 'Untimed Transfer', type: 'transfer', day_id: 21, reservation_time: null },
{ id: 9, title: 'Evening Train', type: 'train', day_id: 21, reservation_time: '2025-06-01T19:30' },
{ id: 10, title: 'Morning Flight', type: 'flight', day_id: 21, reservation_time: '2025-06-01T07:15' },
]
const dayAssignments = [
{ id: 100, day_id: 21, order_index: 0, place: { id: 1, name: 'Museum', place_time: '11:00' } },
{ id: 101, day_id: 21, order_index: 1, place: { id: 2, name: 'Market', place_time: '16:00' } },
]
await downloadTripPDF({
...minimalArgs,
trip: { id: 24, title: 'Mixed Trip', description: null, cover_image: null } as any,
days: [d1],
assignments: { '21': dayAssignments } as any,
reservations,
})
const html = srcdoc()
const expected = getMergedItems({
dayAssignments,
dayNotes: [],
dayTransports: getTransportForDay({
reservations, dayId: 21, dayAssignmentIds: dayAssignments.map(a => a.id), days: [d1],
}),
dayId: 21,
}).map(item => (item.type === 'place' ? item.data.place.name : item.data.title))
const seen = expected.map(name => html.indexOf(name))
for (const at of seen) expect(at).toBeGreaterThan(-1)
expect(seen).toEqual([...seen].sort((a, b) => a - b))
})
})
+79 -52
View File
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
@@ -208,67 +209,65 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null
const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates)
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (!startId || startId === endId) return 'single'
if (dayId === startId) return 'start'
if (dayId === endId) return 'end'
return 'middle'
}
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
const phase = pdfGetSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
/*
* The span label is the only PDF-specific piece left here. Everything else
* about how a day is ordered — which transports belong to it, what time each
* one shows, where it sits among the places — comes from utils/dayMerge, the
* same functions the day plan itself uses (#1978).
*
* There used to be a second implementation in this file. It ordered by
* `day_plan_position`, which is one global number per booking, seeded from
* whichever day happened to render first; on any other day of a span it
* pointed at the wrong slot, and when it was still null the export fell back
* to the order the API returned. That is why the PDF was right some of the
* time and swapped two bookings the rest of it.
*/
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
// Parking reuses span.pickup for its end day: you drop the car off and collect it
// again later, so the rental car's wording carries over without a second key.
if (r.type === 'parking') return tr(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
if (r.type === 'hotel') return false
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (startId == null) return false
if (endId !== startId) {
const startDay = sorted.find(d => d.id === startId)
const endDay = sorted.find(d => d.id === endId)
const thisDay = sorted.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
}
return startId === dayId
})
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const assigned = (assignments[String(day.id)] || []).slice()
.sort((a: any, b: any) => (a.order_index ?? 0) - (b.order_index ?? 0))
const notes = (dayNotes || []).filter(n => n.day_id === day.id).slice()
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => {
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r })
// Assembled exactly the way DayPlanSidebar assembles it, so the page and the
// print cannot disagree about what order a day is in.
const dayTransports = getTransportForDay({
reservations: reservations || [],
dayId: day.id,
dayAssignmentIds: assigned.map((a: any) => a.id),
days: sorted,
})
// Hotels come out in the accommodations block, a rental car's middle days
// appear only in the sidebar header, and a multi-day parking's middle days
// nowhere at all.
const merged = getMergedItems({
dayAssignments: assigned,
dayNotes: notes,
dayTransports,
dayId: day.id,
}).filter(item => {
if (item.type !== 'transport') return true
const r: any = item.data
if (r.type === 'car' && getSpanPhase(r, day.id) === 'middle') return false
return !hidesOnMiddleDay(r, day.id)
})
merged.sort((a, b) => a.k - b.k)
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'reservation') {
if (item.type === 'transport') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = reservationIconSvg(r.type)
@@ -276,13 +275,39 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
let subtitle = ''
// Flights render one subtitle line per leg (see below); everything else is a single line.
let subtitleLines: string[] = []
if (r.type === 'flight') {
/*
* A multi-leg booking arrives as one item per leg now, the way the
* day plan shows it, because the shared merge expands legs so each
* one can sit at its own time among the day's places. Previously
* the export printed a single row with every leg as a subtitle
* line, which put the second leg's departure next to the first
* leg's clock.
*/
if (r.__leg) {
const l = r.__leg
// The segment's own booking code, which the leg object does not
// carry — read back from the booking by index. At the gate that is
// the code the airline asks for (#1943), so losing it here would
// have been a real regression for a stopover flight.
const source = (r.type === 'train' ? getTrainLegs(r) : getFlightLegs(r))[l.index]
subtitleLines = [[
l.airline, l.flight_number, l.train_number,
l.platform ? `Gl. ${l.platform}` : '',
l.seat ? `Seat ${l.seat}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
source?.confirmation_number,
].filter(Boolean).join(' · ')].filter(Boolean)
}
else if (r.type === 'flight') {
const legs = getFlightLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every flight number + segment route is shown.
// Multi-leg: one line per leg so every flight number + segment route is
// shown, with the segment's own booking code where it has one (#1943).
// At the gate that is the number the airline asks for.
subtitleLines = legs.map(l =>
[l.airline, l.flight_number,
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
@@ -296,10 +321,12 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
else if (r.type === 'train') {
const legs = getTrainLegs(r)
if (legs.length > 1) {
// Multi-leg: one line per leg so every train number + segment route shows.
// Multi-leg: one line per leg so every train number + segment route shows,
// with the segment's own booking code where it has one (#1943).
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
@@ -313,9 +340,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
const locationLine = r.location || meta.location || ''
const phase = pdfGetSpanPhase(r, day.id)
const phase = getSpanPhase(r, day.id)
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const displayTime = getDisplayTimeForDay(r, day.id)
// Start and end, the way the day plan draws it (#1310). Without the
// landing time a flight reads as an open-ended block and the reader
// cannot tell how much of the day is left for anything else.
@@ -447,7 +447,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Click the Import button (Upload icon in the header)
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
expect(importBtn).toBeTruthy();
await user.click(importBtn!);
@@ -538,7 +538,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Open import modal
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
await user.click(importBtn!);
await screen.findByText('Import Packing List');
@@ -655,7 +655,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Open import modal
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
await user.click(importBtn!);
await screen.findByText('Import Packing List');
@@ -929,7 +929,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Open import modal
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
await user.click(importBtn!);
await screen.findByText('Import Packing List');
@@ -1215,7 +1215,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Open import modal
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
await user.click(importBtn!);
await screen.findByText('Import Packing List');
@@ -1500,7 +1500,7 @@ describe('PackingListPanel', () => {
const { container } = render(<PackingListPanel tripId={1} items={[]} />);
// Open import modal
const importBtn = container.querySelector('svg.lucide-upload')?.closest('button');
const importBtn = container.querySelector('svg.lucide-download')?.closest('button');
await user.click(importBtn!);
await screen.findByText('Import Packing List');
@@ -1,5 +1,5 @@
import {
X, Check, CheckCheck, Luggage, Package, FolderPlus, Upload,
X, Check, CheckCheck, Luggage, Package, FolderPlus, Download,
} from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
@@ -46,7 +46,7 @@ export function PackingHeader(S: PackingState) {
border: '1px solid var(--border-primary)', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
<Download size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{inlineHeader && canEdit && abgehakt > 0 && (
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { Upload } from 'lucide-react'
import { FileDown } from 'lucide-react'
import type { PackingState } from './usePackingListPanel'
export function BulkImportModal(S: PackingState) {
@@ -48,7 +48,7 @@ export function BulkImportModal(S: PackingState) {
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Upload size={11} /> {t('packing.importCsv')}
<FileDown size={11} /> {t('packing.importCsv')}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
@@ -14,7 +14,7 @@ import {
buildUser, buildTrip, buildDay, buildPlace, buildCategory, buildAssignment, buildDayNote, buildReservation,
} from '../../../tests/helpers/factories'
import type { Accommodation, Reservation } from '../../types'
import { calculateRouteWithLegs, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRouteWithLegs, generateCoMapsUrl, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import DayPlanSidebar from './DayPlanSidebar'
import { makeMarkerDraggable } from '../Map/markerDrag'
@@ -60,6 +60,7 @@ vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(un
vi.mock('../Map/RouteCalculator', () => ({
calculateRoute: vi.fn().mockResolvedValue({ distanceText: '5 km', durationText: '1h', coordinates: [] }),
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
optimizeRoute: vi.fn().mockImplementation((places) => places),
// One leg per waypoint gap; the connector between two stops reads distanceText.
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) => Promise.resolve({
@@ -196,6 +197,7 @@ beforeEach(() => {
}),
}))
vi.mocked(generateGoogleMapsUrl).mockReturnValue('https://maps.google.com/...')
vi.mocked(generateCoMapsUrl).mockReturnValue('https://comaps.at/...')
sessionStorage.clear()
localStorage.clear()
// Cost totals fetch FX rates for their base currency; keep the suite hermetic.
@@ -610,16 +612,17 @@ describe('DayPlanSidebar', () => {
// ── PDF export ──────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-025: PDF export button is present', () => {
it('FE-PLANNER-DAYPLAN-025: the export button is present', () => {
render(<DayPlanSidebar {...makeDefaultProps()} />)
expect(screen.getByText('PDF')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Export' })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-026: clicking PDF button calls downloadTripPDF', async () => {
it('FE-PLANNER-DAYPLAN-026: the PDF row in the export dialog calls downloadTripPDF', async () => {
const user = userEvent.setup()
const { downloadTripPDF } = await import('../PDF/TripPDF')
render(<DayPlanSidebar {...makeDefaultProps()} />)
await user.click(screen.getByText('PDF'))
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('PDF'))
await waitFor(() => {
expect(downloadTripPDF).toHaveBeenCalled()
})
@@ -827,7 +830,8 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: assigns, selectedDayId: 10, onReorder,
})} />)
// Find the Optimize button (contains 'optimize' text)
// Found by its accessible name — the button is icon-only since #1981, so
// the label lives on aria-label rather than in the button's text.
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
await user.click(optimizeBtn)
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
@@ -900,11 +904,14 @@ describe('DayPlanSidebar', () => {
// Day-title renaming moved to DayDetailPanel (#1065) — covered there.
// ── ICS export button ────────────────────────────────────────────────────
// ── ICS export ───────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-043: ICS export button is present', () => {
it('FE-PLANNER-DAYPLAN-043: the calendar export sits in the export dialog', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
expect(screen.getByText('ICS')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(await screen.findByText('Calendar')).toBeInTheDocument()
expect(screen.getByText('Download .ics')).toBeInTheDocument()
})
// ── getMergedItems: transport merged with assignments ──────────────────
@@ -1120,13 +1127,12 @@ describe('DayPlanSidebar', () => {
// ── PDF hover tooltip ─────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-054: hovering PDF button shows tooltip', async () => {
it('FE-PLANNER-DAYPLAN-054: hovering the export button shows its tooltip', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
const pdfBtn = screen.getByText('PDF').closest('button')!
await user.hover(pdfBtn)
await user.hover(screen.getByRole('button', { name: 'Export' }))
await waitFor(() => {
// Tooltip text appears (from t('dayplan.pdfTooltip'))
// Tooltip text appears (from t('dayplan.exportIntro'))
const tooltips = document.querySelectorAll('[style*="pointer-events: none"]')
expect(tooltips.length).toBeGreaterThan(0)
})
@@ -1194,10 +1200,9 @@ describe('DayPlanSidebar', () => {
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
render(<DayPlanSidebar {...makeDefaultProps()} />)
// The ICS button now opens a hover menu (Download / Subscribe) instead of
// downloading on direct click.
await user.hover(screen.getByText('ICS').closest('button')!)
await user.click(await screen.findByText('Download ICS'))
// The three export buttons collapsed into one that opens the export dialog.
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('Download .ics'))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object)))
fetchSpy.mockRestore()
createObjURL.mockRestore()
@@ -1831,8 +1836,8 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] },
})} />)
const pdfBtn = screen.getByRole('button', { name: /pdf/i })
await user.click(pdfBtn)
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(await screen.findByText('PDF'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledWith(
expect.objectContaining({ dayNotes: expect.arrayContaining([expect.objectContaining({ text: 'PDF Note' })]) })
))
@@ -1916,16 +1921,17 @@ describe('DayPlanSidebar', () => {
}
})
// ── ICS hover tooltip ─────────────────────────────────────────────────────
// ── Export dialog ─────────────────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => {
it('FE-PLANNER-DAYPLAN-090: the export button opens the dialog with every format', async () => {
const user = userEvent.setup()
render(<DayPlanSidebar {...makeDefaultProps()} />)
const icsBtn = screen.getByText('ICS').closest('button')!
await user.hover(icsBtn)
await user.click(screen.getByRole('button', { name: 'Export' }))
await waitFor(() => {
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.getByText('PDF')).toBeInTheDocument()
expect(screen.getByText('Download .ics')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
expect(screen.getByText('Whole trip')).toBeInTheDocument()
})
})
@@ -2281,6 +2287,41 @@ describe('DayPlanSidebar', () => {
expect(await screen.findByText('2 km')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-203: leg distance survives a parking on its middle days (#1937)', async () => {
const user = userEvent.setup()
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
vi.mocked(calculateRouteWithLegs as any).mockImplementation((wp: any) => Promise.resolve({
distanceText: '2 km', durationText: '10 min',
legs: Array.from({ length: Math.max(0, (wp?.length ?? 0) - 1) }, () => ({
distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min',
})),
}))
// Same trap as the car rental in -106: the parking row is hidden on day 11, so the
// through-leg between the places around it must stay keyed to the place.
const parking = {
...buildReservation({ id: 323, type: 'parking', title: 'Airport Parking', day_id: 10 }),
end_day_id: 12,
day_positions: { 11: 0.5 },
}
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const assigns = {
'11': [
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days, places: [], assignments: assigns, reservations: [parking as any],
selectedDayId: null, showRouteToolsWhenExpanded: true,
})} />)
await user.click(screen.getByRole('button', { name: 'Route' }))
expect(await screen.findByText('2 km')).toBeInTheDocument()
})
// ── Persisted / externally driven state ──────────────────────────────────
it('FE-PLANNER-DAYPLAN-110: the saved collapse state wins over expanding every day', () => {
@@ -2438,6 +2479,56 @@ describe('DayPlanSidebar', () => {
expect(screen.getAllByText('Ghost rental')).toHaveLength(1)
})
it('FE-PLANNER-DAYPLAN-200: a multi-day parking shows on the first and last day only (#1937)', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 320, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// Drop-off on day 1, pickup on day 3, nothing in between.
expect(screen.getAllByText('Airport Parking')).toHaveLength(2)
// And no smaller tag in the day header either, unlike a rental car.
expect(within(dayHeader('Day 2')).queryByText('Airport Parking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-201: a multi-day parking labels its drop-off and its pickup day', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 321, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
expect(screen.getByText('Drop-off')).toBeInTheDocument()
expect(screen.getByText('Pickup')).toBeInTheDocument()
// The generic span wording never appears for a parking.
expect(screen.queryByText('Ongoing')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-202: a day left empty by a spanning parking shows the empty-day hint', () => {
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const parking = buildReservation({
id: 322, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
})
render(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// Days 1 and 3 carry the booking, so only day 2 is empty, and it says so instead
// of leaving a blank gap where the hidden row used to be.
expect(screen.getAllByText('No places planned for this day')).toHaveLength(1)
})
// ── Route legs, hotel bookends, travel modes ─────────────────────────────
it('FE-PLANNER-DAYPLAN-120: a routed day draws the hotel bookends around its stops', async () => {
@@ -3327,7 +3418,7 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(screen.getByRole('button', { name: 'Open Website' }))
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank')
expect(openSpy).toHaveBeenCalledWith('https://louvre.fr', '_blank', 'noopener,noreferrer')
fireEvent.contextMenu(dragRow(screen.getByText('Louvre')))
await user.click(screen.getByRole('button', { name: 'Google Maps' }))
@@ -3360,15 +3451,48 @@ describe('DayPlanSidebar', () => {
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
await user.click(screen.getByRole('button', { name: 'Open in Google Maps' }))
expect(vi.mocked(generateGoogleMapsUrl)).toHaveBeenCalledWith([
{ lat: 48.85, lng: 2.35 },
{ lat: 48.86, lng: 2.34 },
{ lat: 48.87, lng: 2.33 },
{ lat: 48.85, lng: 2.35 },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
])
expect(openSpy).toHaveBeenCalledWith('https://maps.google.com/...', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('FE-PLANNER-DAYPLAN-168b: the CoMaps export hands over the same stops in the day travel mode', async () => {
const user = userEvent.setup()
const { generateCoMapsUrl } = await import('../Map/RouteCalculator')
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
const days = [
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2', default_transport_mode: 'walking' }),
buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
]
const accommodations: Accommodation[] = [{
id: 1, trip_id: 1, start_day_id: 10, end_day_id: 12,
place_lat: 48.85, place_lng: 2.35, place_name: 'Hotel Lutetia',
}]
const assignments = {
'11': [
buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.34 }) }),
buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'Orsay', lat: 48.87, lng: 2.33 }) }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({ days, assignments, accommodations, selectedDayId: 11 })} />)
await user.click(screen.getByRole('button', { name: 'Open in CoMaps' }))
// Same bookended stop list as the Google export — one source, so the two
// cannot drift — plus the day's own mode, which CoMaps can actually route in.
expect(vi.mocked(generateCoMapsUrl)).toHaveBeenCalledWith([
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
{ lat: 48.86, lng: 2.34, name: 'Louvre' },
{ lat: 48.87, lng: 2.33, name: 'Orsay' },
{ lat: 48.85, lng: 2.35, name: 'Hotel Lutetia' },
], 'walking')
expect(openSpy).toHaveBeenCalledWith('https://comaps.at/...', '_blank', 'noopener,noreferrer')
openSpy.mockRestore()
})
it('FE-PLANNER-DAYPLAN-169: picking a whole-day travel mode persists it and redraws the map', async () => {
const user = userEvent.setup()
const { daysApi } = await import('../../api/client')
@@ -4078,3 +4202,47 @@ describe('reordering the day plan with a finger (#1616)', () => {
}
})
})
/**
* The day's route-tools row (#1981).
*
* Every button in it hands the day somewhere: show the route, open it in Google
* Maps, open it in CoMaps, reorder it. Two of those were already icon-only; the
* other two carried labels on `flex: 1` with `padding: '6px 0'`, which is no
* horizontal padding at all. That was survivable until CoMaps added a fifth
* button to the row, at which point the optimize label sat hard against both
* edges of its own button.
*
* It is icon-only now, which is also what keeps the row from crowding again the
* next time something is added to it.
*/
describe('the day route-tools row', () => {
const dayWithTwoStops = () => {
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
]
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
return {
days: [day], places, selectedDayId: 10,
assignments: {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
],
},
}
}
it('keeps the optimize action reachable by name without printing it', () => {
render(<DayPlanSidebar {...makeDefaultProps(dayWithTwoStops())} />)
const btn = screen.getByRole('button', { name: /optimize/i })
// The assertion that pins the change: named, but no visible label.
expect(btn).toBeInTheDocument()
expect(btn.textContent?.trim()).toBe('')
expect(btn.getAttribute('title')).toBeTruthy()
})
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
finds the button the same way. */
})
+111 -70
View File
@@ -4,10 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { safeHttpUrl } from '../../utils/safeUrl'
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
import { type PickedPlace } from './TransitSearchPanel'
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
import GoogleMapsIcon from '../shared/GoogleMapsIcon'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -27,7 +29,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem,
} from '../../utils/dayMerge'
@@ -211,7 +213,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [undoHover, setUndoHover] = useState(false)
const [icsHover, setIcsHover] = useState(false)
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
// Transit rows fold their itinerary out inline (#1065).
const [expandedTransitIds, setExpandedTransitIds] = useState<Set<number>>(new Set())
@@ -362,6 +363,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (phase === 'single') return null
if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
// Parking reuses span.pickup for its end day on purpose: you drop the car off and
// later pick it up again, so the rental car's wording fits without a second key.
if (r.type === 'parking') return t(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
@@ -513,11 +517,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
cur = []
curHasPlace = false
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng, isPlace: false })
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle')) {
} else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle') && !hidesOnMiddleDay(r, dayId)) {
// No location: ignore for routing, but attribute the through-leg to the
// booking so its distance/duration shows under it (purely cosmetic).
// Not for a car rental's middle days though — that row isn't rendered
// in the timeline, so re-keying would drop the leg entirely (#1504).
// Not for a car rental's middle days or a multi-day parking's though:
// those rows aren't rendered in the timeline, so re-keying would drop
// the leg entirely (#1504).
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
}
}
@@ -1034,8 +1039,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
setLockHoverId,
undoHover,
setUndoHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
@@ -1209,8 +1212,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
setLockHoverId,
undoHover,
setUndoHover,
icsHover,
setIcsHover,
hoveredAssignmentId,
setHoveredAssignmentId,
dropTargetKey,
@@ -1405,8 +1406,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
t={t}
locale={locale}
toast={toast}
icsHover={icsHover}
setIcsHover={setIcsHover}
expandedDays={expandedDays}
setExpandedDays={setExpandedDays}
onUndo={onUndo}
@@ -1453,6 +1452,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
(transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng)
)
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer
/**
* The day's located stops in planned order, bookended by the accommodation
* the same way the drawn map route is (routeBookends is null when "optimize
* from accommodation" is off), so hotels aren't dropped from an exported
* route (#1372) — but only when the leg is real: no hotel prepended before an
* early check-in-day stop, none appended after a post-check-out stop (#1465).
* Names ride along for the deep links that can label a pin with one.
*/
const dayExportStops = (): NamedWaypoint[] => {
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null
return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])]
}
// Is this day's inline route currently on? Mobile toggles it per day (its
// own expandedRouteDayIds entry); desktop uses the global Route toggle on
// the selected day (#1374).
@@ -1461,6 +1482,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
// A row hidden on a middle day still sits in `merged` (the route builder above
// has to see it), so the empty-day hint counts what actually renders. Without
// that, a day whose only entry is a spanning parking would show a blank gap.
const visibleCount = merged.filter(i => !(i.type === 'transport' && hidesOnMiddleDay(i.data, day.id))).length
const dayTint = dayTints[day.id]
// Resolved once per day: the header owns a background that its hover
// handlers reassign imperatively, so both the base and the hover value have
@@ -1719,7 +1744,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) : connector
})()}
{daySchedule.byPosition[day.id]?.start.map(si => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{merged.length === 0 && !dayNoteUi ? (
{visibleCount === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
@@ -1856,7 +1881,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
ctxMenu.open(e, [
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })),
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
@@ -1955,33 +1980,38 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const confirmed = res.status === 'confirmed'
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
// The status, the time and the flight/train number used to sit in one
// pill strung together on a middle dot, which read as one run-on
// sentence. They are separate chips now: same tint so they still
// belong together, own outline so the eye can take them one at a time.
const RI = RES_ICONS[res.type] || Ticket
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
const chip: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '1px 6px', borderRadius: 5,
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
whiteSpace: 'nowrap',
}
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
const timeLabel = st || et
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
: ''
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
const carrierLabel = meta
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
: ''
return (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
// No wrapping: the time belongs to the status it qualifies, so the
// chips stay on one line even when the row gets narrow.
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
<div className={tint} style={chip}>
<RI size={8} />
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{(() => {
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
if (!st && !et) return null
return (
<span style={{ fontWeight: 400 }}>
{st ? formatTime(st, locale, timeFormat) : ''}
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
</span>
)
})()}
{(() => {
let meta: any = {}
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
{hasEndpoints && (
<button
type="button"
@@ -2100,6 +2130,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Car "active" (middle) days are shown in the day header, skip here
if (res.type === 'car' && spanPhase === 'middle') return null
// A multi-day parking says nothing on the days in between and gets
// no header badge either, only drop-off and pickup (#1937)
if (hidesOnMiddleDay(res, day.id)) return null
const TransportIcon = RES_ICONS[res.type] || Ticket
const color = '#3b82f6'
@@ -2539,24 +2572,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
// Bookend the Google Maps route with the day's accommodation the
// same way the drawn map route does (routeBookends is null when
// "optimize from accommodation" is off), so hotels aren't dropped
// from the exported route (#1372) — but only when the leg is real:
// no hotel prepended before an early check-in-day stop, none appended
// after a post-check-out stop (#1465).
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! }))
const firstStop = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
const lastAssignment = dayStops[dayStops.length - 1]
const lastStop = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, firstStop)
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, lastStop)
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng } : null
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng } : null
const url = generateGoogleMapsUrl([...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])])
const url = generateGoogleMapsUrl(dayExportStops())
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
@@ -2568,20 +2584,45 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
<GoogleMapsIcon size={14} />
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit',
}}>
<RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')}
{/* The same day, handed to CoMaps for offline navigation (#1904). The
day's own travel mode rides along, so the route it builds walks
when the plan walks. */}
<button
onClick={() => {
const url = generateCoMapsUrl(dayExportStops(), day.default_transport_mode ?? routeProfile)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openCoMaps')}
title={t('planner.openCoMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<Compass size={14} strokeWidth={2} />
</button>
{/* Icon-only, like the two map hand-offs beside it (#1981). It
was the one button here carrying a label with no room for
it: `flex: 1` alongside `padding: '6px 0'` meant the text
sat against both edges as soon as the row filled up, and
the row gained a button when CoMaps arrived. The label
stays as the accessible name and the tooltip. */}
<button
onClick={() => handleOptimize(day.id)}
aria-label={t('dayplan.optimize')}
title={t('dayplan.optimize')}
className="bg-surface-hover text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<RotateCcw size={14} strokeWidth={2} />
</button>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
{routeProfileOptions.map(p => {
@@ -1,15 +1,15 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-022
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-023
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildReservation, buildTrip } from '../../../tests/helpers/factories'
import { buildDay, buildReservation, buildTrip } from '../../../tests/helpers/factories'
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
import type { Reservation } from '../../types'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
// The subscribe dialog fetches its feed token on mount; it is exercised in its
// own test, here we only care that opening the menu entry mounts it.
// own test, and TripExportModal covers the entry that opens it.
vi.mock('./IcsSubscribeModal', () => ({
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
<div data-testid="ics-subscribe-modal">
@@ -46,8 +46,6 @@ function makeProps(overrides: Partial<React.ComponentProps<typeof DayPlanSidebar
t,
locale: 'en-US',
toast: makeToast(),
icsHover: false,
setIcsHover: vi.fn((_v: boolean) => {}),
expandedDays: new Set<number>(),
setExpandedDays: vi.fn((_v: Set<number>) => {}),
canUndo: false,
@@ -72,37 +70,42 @@ beforeEach(() => {
})
describe('DayPlanSidebarToolbar', () => {
// ── PDF export ────────────────────────────────────────────────────────────
// ── Export ─────────────────────────────────────────────────────
// PDF, ICS and GPX each used to be their own button with its own hover menu.
// The mechanics now live in TripExportModal and are tested there; what is left
// here is the single button that opens it.
it('FE-PLANNER-DPTOOLBAR-001: the PDF button exports the trip with the day notes flattened', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1' })]
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
render(<DayPlanSidebarToolbar {...makeProps({ days, dayNotes })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
it('FE-PLANNER-DPTOOLBAR-001: the toolbar carries one export button, not three', () => {
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.getByRole('button', { name: 'dayplan.export' })).toBeInTheDocument()
expect(screen.queryByText('ICS')).not.toBeInTheDocument()
expect(screen.queryByText('GPX')).not.toBeInTheDocument()
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-002: a failing PDF export surfaces the error via the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
})
it('FE-PLANNER-DPTOOLBAR-003: hovering the PDF button shows the export tooltip', async () => {
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument()
await user.hover(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(screen.getByText('dayplan.pdfTooltip')).toBeInTheDocument())
await user.unhover(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(screen.queryByText('dayplan.pdfTooltip')).not.toBeInTheDocument())
const btn = screen.getByRole('button', { name: 'dayplan.export' })
expect(btn).toHaveAttribute('aria-expanded', 'false')
await user.click(btn)
expect(btn).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
await waitFor(() => expect(screen.queryByText('dayplan.exportDocument')).not.toBeInTheDocument())
})
it('FE-PLANNER-DPTOOLBAR-003: hovering the export button shows its tooltip', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument()
const btn = screen.getByRole('button', { name: 'dayplan.export' })
await user.hover(btn)
await waitFor(() => expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument())
await user.unhover(btn)
await waitFor(() => expect(screen.queryByText('dayplan.exportIntro')).not.toBeInTheDocument())
})
// The button sits at the right edge of the leftmost pane. Its own tooltip was
@@ -111,8 +114,8 @@ describe('DayPlanSidebarToolbar', () => {
it('FE-PLANNER-DPTOOLBAR-004: the export tooltip stays inside the window', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('dayplan.pdf'))
const tip = await screen.findByText('dayplan.pdfTooltip')
await user.hover(screen.getByRole('button', { name: 'dayplan.export' }))
const tip = await screen.findByText('dayplan.exportIntro')
expect(tip.closest('[role="tooltip"]')).not.toBeNull()
expect(tip.parentElement).toBe(document.body)
expect(tip.style.position).toBe('fixed')
@@ -120,94 +123,12 @@ describe('DayPlanSidebarToolbar', () => {
await waitFor(() => expect(parseFloat(tip.style.left)).toBeGreaterThanOrEqual(0))
})
// ── ICS menu ──────────────────────────────────────────────────────────────
it('FE-PLANNER-DPTOOLBAR-005: hovering ICS opens the download/subscribe menu', async () => {
const user = userEvent.setup()
const setIcsHover = vi.fn((_v: boolean) => {})
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
await user.hover(screen.getByText('ICS'))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument()
expect(setIcsHover).toHaveBeenCalledWith(true)
})
it('FE-PLANNER-DPTOOLBAR-005b: without share_manage the menu offers the download but not the subscription', async () => {
// The subscription mints a link that reads the trip without an account, so
// it needs share_manage; the one-off download is a file this member may
// already read. Leaving the entry visible would only produce a dialog whose
// enable button the server refuses.
it('FE-PLANNER-DPTOOLBAR-005: the dialog inherits the share permission from the toolbar', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps({ canManageShare: false })} />)
await user.hover(screen.getByText('ICS'))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
expect(screen.queryByText('Subscribe to calendar')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-006: leaving the ICS area closes the menu after the grace period', async () => {
const user = userEvent.setup()
const setIcsHover = vi.fn((_v: boolean) => {})
render(<DayPlanSidebarToolbar {...makeProps({ setIcsHover })} />)
const icsButton = screen.getByText('ICS')
await user.hover(icsButton)
expect(screen.getByText('Download ICS')).toBeInTheDocument()
await user.unhover(icsButton)
await waitFor(() => expect(screen.queryByText('Download ICS')).not.toBeInTheDocument())
expect(setIcsHover).toHaveBeenLastCalledWith(false)
})
it('FE-PLANNER-DPTOOLBAR-007: re-entering the ICS area cancels the pending close', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
const icsButton = screen.getByText('ICS')
await user.hover(icsButton)
await user.unhover(icsButton)
await user.hover(icsButton)
// The close is scheduled 120ms out; coming back in has to cancel it.
await new Promise(r => setTimeout(r, 200))
expect(screen.getByText('Download ICS')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-008: "Download ICS" fetches the export and hands it to a download link', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['BEGIN:VCALENDAR'], { type: 'text/calendar' })),
} as unknown as Response)
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock')
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Download ICS'))
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
await waitFor(() => expect(clickSpy).toHaveBeenCalled())
expect(revokeObjURL).toHaveBeenCalledWith('blob:mock')
// The menu closes itself once the download starts.
expect(screen.queryByText('Download ICS')).not.toBeInTheDocument()
fetchSpy.mockRestore(); createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
})
it('FE-PLANNER-DPTOOLBAR-009: a rejected ICS export shows the failure toast', async () => {
const user = userEvent.setup()
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as unknown as Response)
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Download ICS'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
fetchSpy.mockRestore()
})
it('FE-PLANNER-DPTOOLBAR-010: "Subscribe to calendar" opens the subscribe dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('ICS'))
await user.click(screen.getByText('Subscribe to calendar'))
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
await user.click(screen.getByText('close-subscribe'))
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.export' }))
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
})
// ── Expand / collapse all ────────────────────────────────────────────────
@@ -350,105 +271,6 @@ describe('DayPlanSidebarToolbar', () => {
expect(routeBtn.style.background).toBe('var(--bg-hover)')
await user.unhover(routeBtn)
expect(routeBtn.style.background).toBe('transparent')
await user.hover(screen.getByText('ICS'))
const download = screen.getByText('Download ICS')
const subscribe = screen.getByText('Subscribe to calendar')
await user.hover(download)
expect(download.style.background).toContain('var(--bg-hover')
// Sliding down to the next entry has to clear the one we left.
await user.hover(subscribe)
expect(download.style.background).toBe('transparent')
expect(subscribe.style.background).toContain('var(--bg-hover')
await user.unhover(subscribe)
expect(subscribe.style.background).toBe('transparent')
})
it('FE-PLANNER-DPTOOLBAR-024: an aborted PDF export without an Error still reaches the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
const toast = makeToast()
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
})
// ── GPX export (#1442) ────────────────────────────────────────────────────
describe('GPX menu', () => {
let clickedHref: string | null
beforeEach(() => {
clickedHref = null
// jsdom has neither of these, and an anchor click would navigate.
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
globalThis.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clickedHref = this.href
})
})
afterEach(() => vi.restoreAllMocks())
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
await user.hover(screen.getByText('GPX'))
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse())
vi.stubGlobal('fetch', fetchMock)
render(<DayPlanSidebarToolbar {...makeProps()} />)
for (const [label, expected] of [
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
] as const) {
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText(label))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
fetchMock.mockClear()
}
})
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
render(<DayPlanSidebarToolbar {...makeProps()} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
})
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
expect(toast.error).not.toHaveBeenCalled()
expect(clickedHref).toBeNull()
})
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
await user.hover(screen.getByText('GPX'))
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
})
})
@@ -1,24 +1,12 @@
import React, { useState, useRef } from 'react'
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
import { useState } from 'react'
import { ChevronsDownUp, ChevronsUpDown, Download, Undo2, ArrowUpDown, Route as RouteIcon } from 'lucide-react'
import { DayReorderPopup } from './DayReorderPopup'
import Tooltip from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import { TripExportModal } from './TripExportModal'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
/**
* What a GPX download can carry. Worded by what someone wants on their device
* rather than by the GPX element it maps to: "everything", "just the places" for
* an offline map, "just the days" for following a plan. An omitted flag defaults
* to true server-side, so the first entry needs no query at all.
*/
const GPX_SCOPES = [
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
] as const
interface DayPlanSidebarToolbarProps {
tripId: number
trip: Trip
@@ -33,8 +21,6 @@ interface DayPlanSidebarToolbarProps {
t: (key: string, params?: Record<string, any>) => string
locale: string
toast: ReturnType<typeof useToast>
icsHover: boolean
setIcsHover: (v: boolean) => void
expandedDays: Set<number>
setExpandedDays: (next: Set<number>) => void
onUndo?: () => void
@@ -44,11 +30,9 @@ interface DayPlanSidebarToolbarProps {
lastActionLabel: string | null
canEditDays?: boolean
/**
* Gates "Subscribe to calendar" only. The one-off "Download ICS" below it
* stays open to every member: it is a file they already have the right to
* read, while the subscription mints a link that works without an account.
* Defaults to true so a caller that has not wired the permission through
* keeps today's menu rather than silently losing an entry.
* Gates "Subscribe to calendar" in the export dialog only. Defaults to true so
* a caller that has not wired the permission through keeps today's entries
* rather than silently losing one.
*/
canManageShare?: boolean
onReorderDays?: (orderedIds: number[]) => void
@@ -58,230 +42,53 @@ interface DayPlanSidebarToolbarProps {
export function DayPlanSidebarToolbar({
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
allConnectionsShown = false, onToggleAllConnections,
t, locale, toast, setIcsHover,
t, locale, toast,
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
canEditDays, canManageShare = true, onReorderDays, onAddDay,
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [subscribeOpen, setSubscribeOpen] = useState(false)
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const showIcsMenu = () => {
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
setIcsMenuVisible(true)
setIcsHover(true)
}
const hideIcsMenu = () => {
icsMenuTimeoutRef.current = setTimeout(() => {
setIcsMenuVisible(false)
setIcsHover(false)
}, 120)
}
const showGpxMenu = () => {
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
setGpxMenuVisible(true)
}
const hideGpxMenu = () => {
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
}
const downloadGpx = async (query: string) => {
setGpxMenuVisible(false)
try {
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
// 404 here means the selection is empty, which is worth its own message:
// "nothing happened" and "the download broke" look identical otherwise.
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.gpx`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('dayplan.gpxFailed')) }
}
const menuItemStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '7px 12px', border: 'none',
background: 'transparent', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
color: 'var(--text-primary)', textAlign: 'left',
transition: 'background 0.1s',
}
return (
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
<div style={{ position: 'relative', flexShrink: 0 }}>
<Tooltip label={t('dayplan.pdfTooltip')} placement="bottom">
{/* One export button instead of three: PDF, ICS and GPX each carried
their own hover menu, and on a narrower sidebar the row ran out of
width and pushed them off the edge. The dialog holds every option. */}
<Tooltip label={t('dayplan.exportIntro')} placement="bottom">
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
// Loaded on click: the PDF builder is ~226 kB and hangs off the
// days sidebar, so every trip used to pay for it whether or not
// anyone exported. A missing chunk lands in the catch below and
// shows the same error the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
type="button"
onClick={() => setExportOpen(true)}
aria-haspopup="dialog"
aria-expanded={exportOpen}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 5,
display: 'flex', alignItems: 'center', gap: 5, flexShrink: 0,
padding: '5px 10px', borderRadius: 8, border: 'none',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
<Download size={13} strokeWidth={2} />
{t('dayplan.export')}
</button>
</Tooltip>
</div>
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
>
<button
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
ICS
</button>
{icsMenuVisible && (
<div
onMouseEnter={showIcsMenu}
onMouseLeave={hideIcsMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
zIndex: 200, minWidth: 160,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
<button
onClick={async () => {
setIcsMenuVisible(false)
setIcsHover(false)
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${trip?.title || 'trip'}.ics`
a.click()
URL.revokeObjectURL(url)
} catch { toast.error(t('planner.icsExportFailed')) }
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<FileDown size={12} strokeWidth={2} />
Download ICS
</button>
{canManageShare && (
<button
onClick={() => {
setIcsMenuVisible(false)
setIcsHover(false)
setSubscribeOpen(true)
}}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<CalendarPlus size={12} strokeWidth={2} />
Subscribe to calendar
</button>
)}
</div>
)}
</div>
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
with the other exports rather than with the importer. Same hover-menu
shape as ICS beside it. */}
<div
style={{ position: 'relative', flexShrink: 0 }}
onMouseEnter={showGpxMenu}
onMouseLeave={hideGpxMenu}
>
<button
aria-haspopup="menu"
aria-expanded={gpxMenuVisible}
aria-label={t('dayplan.gpxTooltip')}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
GPX
</button>
{gpxMenuVisible && (
<div
role="menu"
onMouseEnter={showGpxMenu}
onMouseLeave={hideGpxMenu}
style={{
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
zIndex: 200, minWidth: 190,
background: 'var(--bg-card, white)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
border: '1px solid var(--border-faint, #e5e7eb)',
overflow: 'hidden',
}}
>
{GPX_SCOPES.map(scope => (
<button
key={scope.key}
role="menuitem"
onClick={() => downloadGpx(scope.query)}
style={menuItemStyle}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<scope.icon size={12} strokeWidth={2} />
{t(scope.labelKey)}
</button>
))}
</div>
)}
</div>
{subscribeOpen && canManageShare && (
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title="Subscribe to calendar"
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
onClose={() => setSubscribeOpen(false)}
/>
)}
</Tooltip>
<TripExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
tripId={tripId}
trip={trip}
days={days}
places={places}
categories={categories}
assignments={assignments}
reservations={reservations}
dayNotes={dayNotes}
t={t}
locale={locale}
toast={toast}
canManageShare={canManageShare}
/>
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-018
// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-019
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -235,6 +235,28 @@ describe('DayPlanSidebarTransportDetailModal', () => {
expect(setTransportDetail).toHaveBeenCalledWith(null)
})
it('FE-PLANNER-DPTRANSPORT-019: a segment with its own booking code gets its own blurred field (#1943)', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
const res = buildReservation({
id: 42, type: 'flight', title: 'BER → CDG → JFK', status: 'confirmed',
reservation_time: '2025-06-15T08:30:00',
confirmation_number: 'XY7Z9Q',
metadata: JSON.stringify({
legs: [
{ from: 'BER', to: 'CDG', confirmation_number: 'ABC123' },
{ from: 'CDG', to: 'JFK' },
],
}),
} as Partial<Reservation>)
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
expect(screen.getByText('BER → CDG')).toBeInTheDocument()
expect(screen.getByText('ABC123')).toHaveStyle({ filter: 'blur(5px)' })
// The booking's own reference keeps its own field, a code-less segment adds none.
expect(screen.getByText('XY7Z9Q')).toBeInTheDocument()
expect(screen.queryByText('CDG → JFK')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTRANSPORT-018: clicking the backdrop closes, clicking the card does not', async () => {
const user = userEvent.setup()
const setTransportDetail = vi.fn()
@@ -57,6 +57,16 @@ export function DayPlanSidebarTransportDetailModal({
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
}
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
// A stopover booking can carry its own reference per segment (#1943); the
// flat fields above only ever describe the first leg. Marked sensitive so
// the blur setting covers them like the booking's own code.
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
for (const leg of meta.legs) {
if (!leg?.confirmation_number) continue
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
detailFields.push({ label: segment || t('reservations.confirmationCode'), value: leg.confirmation_number, sensitive: true })
}
}
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
return (
@@ -90,7 +100,7 @@ export function DayPlanSidebarTransportDetailModal({
<div className={res.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{
padding: '3px 8px', borderRadius: 6, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
}}>
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
{res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')}
</div>
</div>
@@ -307,7 +307,22 @@ describe('PlaceDetailsColumn — hours and rating', () => {
})
}
// The collapsed row shows the CURRENT day in the place's timezone, so a test
// that names a specific line has to say which day it is standing on. Without
// this the suite passed Monday to Friday and failed every weekend, which reads
// as a broken build rather than a test that forgot to pin its clock.
// SELECTION is in Cologne, so midday UTC is safely the same date in Berlin.
const pinDay = (iso: string) => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date(iso))
}
afterEach(() => {
vi.useRealTimers()
})
it('FE-PDC-019: shows only today collapsed, not the whole week', async () => {
pinDay('2026-08-12T12:00:00Z') // Wednesday
withHours()
renderColumn()
@@ -318,6 +333,19 @@ describe('PlaceDetailsColumn — hours and rating', () => {
expect(screen.queryByText('10:00-14:00')).not.toBeInTheDocument()
})
it('FE-PDC-019b: the collapsed row follows the day, it is not just the first line', async () => {
// The case that used to take the suite down every Saturday. Worth keeping as
// a test rather than only pinning 019: it is the assertion that the day
// lookup happens at all.
pinDay('2026-08-15T12:00:00Z') // Saturday
withHours()
renderColumn()
await screen.findByText('inspector.openingHours')
expect(screen.queryAllByText('10:00-14:00')).toHaveLength(1)
expect(screen.queryByText('09:00-18:00')).not.toBeInTheDocument()
})
it('FE-PDC-020: opens the full week on click', async () => {
withHours()
renderColumn()
@@ -20,6 +20,7 @@ import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { Place, Category, Assignment, BudgetItem } from '../../types'
import { NumericInput } from '../shared/NumericInput'
import { PlacesSession } from '../../utils/placesSession'
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
// category_id is normalised, plus any files chosen before the place existed.
@@ -106,6 +107,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
// Ties one search's keystrokes and its details lookup into a single Google
// billing session (see utils/placesSession).
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
@@ -244,7 +248,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const controller = new AbortController()
acAbortRef.current = controller
try {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err: unknown) {
@@ -263,6 +267,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
setAcSuggestions([])
setAcHighlight(-1)
placesSessionRef.current.end()
return
}
@@ -349,7 +354,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// clickable instead of dead-ending on "Place search failed". (#1192)
let place: Record<string, unknown> | null = null
try {
const result = await mapsApi.details(suggestion.placeId, language)
// Spends the session the suggestions opened, so Google bills the search
// once rather than per keystroke.
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
if (result.place && result.place.lat != null && result.place.lng != null) {
place = result.place
}
@@ -373,6 +380,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
setIsSearchingMaps(false)
placesSessionRef.current.end()
}
}
@@ -425,6 +425,103 @@ describe('PlaceInspector', () => {
expect(screen.getByText('Museum Ticket')).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
const onEditReservation = vi.fn();
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditReservation={onEditReservation}
/>
);
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
expect(strip).toBeTruthy();
fireEvent.click(strip);
expect(onEditReservation).toHaveBeenCalledWith(reservation);
});
it('FE-PLANNER-INSPECTOR-030e: a transport booking goes to the transport editor (#2012)', () => {
const onEditTransport = vi.fn();
const onEditReservation = vi.fn();
// A ferry is a transport, and ReservationModal has no transport type to hold it.
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditTransport={onEditTransport}
onEditReservation={onEditReservation}
/>
);
fireEvent.click(screen.getByText('Ferry to Corfu').closest('[role="button"]') as HTMLElement);
expect(onEditTransport).toHaveBeenCalledWith(reservation);
expect(onEditReservation).not.toHaveBeenCalled();
});
it('FE-PLANNER-INSPECTOR-030f: no transport handler means no affordance on a transport (#2012)', () => {
// A member who may edit bookings but not days must not get a button that no-ops.
const reservation = buildReservation({ title: 'Ferry to Corfu', status: 'pending', type: 'ferry', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditReservation={vi.fn()}
/>
);
expect(screen.getByText('Ferry to Corfu').closest('[role="button"]')).toBeNull();
});
it('FE-PLANNER-INSPECTOR-030c: Enter and Space open it too (#2012)', () => {
const onEditReservation = vi.fn();
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
onEditReservation={onEditReservation}
/>
);
const strip = screen.getByText('Museum Ticket').closest('[role="button"]') as HTMLElement;
expect(strip.getAttribute('tabindex')).toBe('0');
fireEvent.keyDown(strip, { key: 'Enter' });
fireEvent.keyDown(strip, { key: ' ' });
expect(onEditReservation).toHaveBeenCalledTimes(2);
});
it('FE-PLANNER-INSPECTOR-030d: without a handler the strip stays a read-only summary', () => {
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
render(
<PlaceInspector
{...defaultProps}
selectedDayId={1}
selectedAssignmentId={99}
assignments={{ '1': assignmentInDay }}
reservations={[reservation]}
/>
);
// A viewer with no edit right gets no target, and no misleading pointer.
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toBeNull();
expect(screen.getByText('Museum Ticket')).toBeTruthy();
});
// ── Participants ───────────────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-031: participants section shown when tripMembers > 1 and selectedAssignmentId is set', () => {
@@ -953,7 +1050,7 @@ describe('PlaceInspector', () => {
fireEvent.click(screen.getByText('Open Website').closest('button')!);
expect(open).toHaveBeenCalledTimes(2);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank']);
expect(open.mock.calls[1]).toEqual(['https://example.org', '_blank', 'noopener,noreferrer']);
vi.unstubAllGlobals();
});
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { safeHttpUrl } from '../../utils/safeUrl'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -29,6 +30,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
import { useTripStore } from '../../store/tripStore'
import { formatDistance, formatElevation } from '../../utils/units'
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
import { NavigationMenu } from '../shared/NavigationMenu'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine } from './placeHoursFormat'
@@ -142,6 +144,11 @@ interface PlaceInspectorProps {
selectedAssignmentId?: number | null
assignments?: AssignmentsMap
reservations?: Reservation[]
/** Editors for the linked booking, each omitted when the user lacks that right —
* a transport needs day_edit, anything else reservation_edit. Both absent leaves
* the strip the read-only summary it has always been (#2012). */
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onClose: () => void
onEdit?: () => void
onDelete?: () => void
@@ -167,7 +174,7 @@ interface PlaceInspectorProps {
export default function PlaceInspector({
place, categories, mode = 'trip', days = [], selectedDayId = null, selectedAssignmentId = null,
assignments = {}, reservations = [],
assignments = {}, reservations = [], onEditTransport, onEditReservation,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, onUploadImage, onRate,
leftWidth = 0, rightWidth = 0,
@@ -318,6 +325,10 @@ export default function PlaceInspector({
detailLng,
googleDetails?.open_now,
)
// Allow-listed rather than passed straight through: window.open runs a
// javascript: URL in this origin, and the stored value predates the check the
// server does on the way in now.
const websiteUrl = safeHttpUrl(place.website) ?? safeHttpUrl(googleDetails?.website)
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
const navigationTargets = getNavigationTargets(
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
@@ -421,7 +432,8 @@ export default function PlaceInspector({
{mode === 'trip' && (
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers} locale={locale}
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants} />
timeFormat={timeFormat} t={t} onSetParticipants={onSetParticipants}
onEditTransport={onEditTransport} onEditReservation={onEditReservation} />
)}
{/* Opening hours + Files — side by side on desktop only if both exist */}
@@ -513,8 +525,8 @@ export default function PlaceInspector({
)}
</>
)}
{(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
{websiteUrl && (
<ActionButton onClick={() => window.open(websiteUrl, '_blank', 'noopener,noreferrer')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
<div style={{ flex: 1 }} />
@@ -809,7 +821,7 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
}
function PlaceReservationParticipants({ selectedAssignmentId, reservations, assignments, selectedDayId,
tripMembers, locale, timeFormat, t, onSetParticipants }: any) {
tripMembers, locale, timeFormat, t, onSetParticipants, onEditTransport, onEditReservation }: any) {
return (
<>
{(() => {
@@ -825,8 +837,25 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
{/* Reservation */}
{res && (() => {
const confirmed = res.status === 'confirmed'
// The strip summarised the booking but went nowhere, so its
// attachments and fields had no route from the map (#2012).
// A transport has its own form; picking by type is what the day
// sidebar does, and an absent handler means this user may not
// open this one — so the strip stays inert rather than lying.
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
const open = editor ? () => editor(res) : undefined
return (
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
<div
role={open ? 'button' : undefined}
aria-label={open ? t('inspector.editRes') : undefined}
tabIndex={open ? 0 : undefined}
onClick={open}
onKeyDown={open ? (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
} : undefined}
title={open ? t('inspector.editRes') : undefined}
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
@@ -1,4 +1,4 @@
import { Search, Plus, X, Upload, ChevronDown, Check, MapPin, Star } from 'lucide-react'
import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star } from 'lucide-react'
import { getCategoryIcon } from '../shared/categoryIcons'
import Tooltip from '../shared/Tooltip'
import type { SidebarState } from './usePlacesSidebar'
@@ -54,7 +54,7 @@ export function PlacesHeader(S: SidebarState) {
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
<FileDown size={11} strokeWidth={2} /> {t('places.importFile')}
</button>
<button
onClick={() => setListImportOpen(true)}
@@ -1,4 +1,4 @@
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-072
// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-078
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -66,15 +66,16 @@ describe('ReservationsPanel', () => {
it('FE-COMP-RES-005: shows Manual Booking add button', () => {
render(<ReservationsPanel {...defaultProps} />);
// Button text is reservations.addManual = "Manual Booking"
expect(screen.getByText('Manual Booking')).toBeInTheDocument();
// Button text is reservations.addManual = "Manual Booking" — in the toolbar
// and, on an empty list, in the empty state's call to action too (#2007).
expect(screen.getAllByText('Manual Booking').length).toBeGreaterThan(0);
});
it('FE-COMP-RES-006: clicking Manual Booking button calls onAdd', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ReservationsPanel {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getByText('Manual Booking'));
await user.click(screen.getAllByText('Manual Booking')[0]);
expect(onAdd).toHaveBeenCalled();
});
@@ -239,6 +240,39 @@ describe('ReservationsPanel', () => {
expect(codeEl.style.filter).toBe('none');
});
const layoverFlight = () => buildReservation({
id: 7, type: 'flight', status: 'confirmed', confirmation_number: 'BOOK1',
metadata: JSON.stringify({
departure_airport: 'FRA', arrival_airport: 'HND',
legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND' },
],
}),
});
it('FE-PLANNER-RESP-077: a segment with its own booking code shows it under its route (#1943)', () => {
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
// The booking's own reference keeps its own cell.
expect(screen.getByText('BOOK1')).toBeInTheDocument();
expect(screen.getByText('FRA → BER')).toBeInTheDocument();
expect(screen.getByText('ABC123')).toBeInTheDocument();
// A segment without its own code adds no cell.
expect(screen.queryByText('BER → HND')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-078: a segment code obeys blur_booking_codes and reveals with the card', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
const legCode = screen.getByText('ABC123');
expect(legCode.style.filter).toContain('blur');
// One reveal per card, not per segment: the booking's own cell uncovers too.
await user.hover(legCode);
expect(legCode.style.filter).toBe('none');
expect(screen.getByText('BOOK1').style.filter).toBe('none');
});
it('FE-PLANNER-RESP-024: reservation notes are shown', () => {
const res = buildReservation({ notes: 'Window seat requested', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
@@ -621,7 +655,7 @@ describe('ReservationsPanel', () => {
it('FE-PLANNER-RESP-058: the add button dims on hover and restores on leave', () => {
render(<ReservationsPanel {...defaultProps} />);
const add = screen.getByText('Manual Booking').closest('button') as HTMLButtonElement;
const add = screen.getAllByText('Manual Booking')[0].closest('button') as HTMLButtonElement;
act(() => { add.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
expect(add.style.opacity).toBe('0.88');
act(() => { add.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -22,7 +22,8 @@ import type { ViewContribution } from '../../api/client'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import EmptyState from '../shared/EmptyState'
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
@@ -329,6 +330,41 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)
})()}
{/* Per-segment booking codes (#1943). A stopover booking can carry its own
reference per leg, and the cell above only ever shows the booking's own.
The reveal is shared with that cell on purpose: uncovering the codes is
one gesture per card, not one per segment. */}
{(() => {
if (r.type !== 'flight' && r.type !== 'train') return null
const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r)
if (legs.length < 2) return null
const coded = legs.filter(l => l.confirmation_number)
if (coded.length === 0) return null
return (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: coded.length > 1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}>
{coded.map((l, i) => (
<div key={i}>
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
onClick={() => blurCodes && setCodeRevealed(v => !v)}
className={`${fieldValueClass} text-center`}
style={{
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
cursor: blurCodes ? 'pointer' : 'default',
transition: 'filter 0.2s',
}}
>
{l.confirmation_number}
</div>
</div>
))}
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
@@ -342,7 +378,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null
@@ -669,6 +705,14 @@ interface ReservationsPanelProps {
tripMembers?: TripMember[]
}
/** The empty state's call-to-action buttons — same shape as the toolbar's. */
const CTA_STYLE: CSSProperties = {
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10,
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations', tripMembers = [] }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
@@ -892,7 +936,32 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
{/* Content */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 28px 80px' }} className="max-md:!px-4 max-md:!pt-4">
{total === 0 && reservations.length === 0 ? (
<EmptyState scene={contributionView === 'transports' ? 'transport' : 'bookings'} title={t('reservations.empty')} />
<EmptyState
scene={contributionView === 'transports' ? 'transport' : 'bookings'}
title={t('reservations.empty')}
// On a fresh trip the toolbar controls sit far right above an empty
// page, so the import in particular went unnoticed (#2007).
action={canEdit ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<button onClick={onAdd} className="bg-accent text-accent-text" style={CTA_STYLE}>
<Plus size={14} strokeWidth={2} />
{t(addManualKey)}
</button>
{onImport && bookingImportAvailable && (
<button onClick={onImport} className="bg-surface-card text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Download size={14} strokeWidth={2} />
{t('reservations.import.cta')}
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{ ...CTA_STYLE, border: '1px solid var(--border-primary)' }}>
<Plane size={14} strokeWidth={2} />
{t('reservations.airtrail.cta')}
</button>
)}
</div>
) : undefined}
/>
) : total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<p className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('places.noneFound')}</p>
@@ -1,4 +1,4 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-053
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -650,6 +650,105 @@ describe('TransportModal', () => {
expect(payload.metadata.legs).toBeUndefined();
});
// ── Per-segment booking references (#1943) ─────────────────────────────────
function multiLegFlight(): Reservation {
const res = buildReservation({ id: 31, title: 'BRU → HEL → JFK', type: 'flight' });
return Object.assign(res, {
day_id: 10,
end_day_id: 12,
reservation_time: '2026-08-01T08:00',
reservation_end_time: '2026-08-03T15:00',
confirmation_number: 'BOOK1',
metadata: {
airline: 'Brussels Airlines', flight_number: 'SN 1234', departure_airport: 'BRU', arrival_airport: 'JFK',
legs: [
{ from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', confirmation_number: 'ABC123', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30' },
{ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '14:00', arr_day_id: 12, arr_time: '15:00' },
],
},
endpoints: [
{ id: 1, reservation_id: 31, role: 'from', sequence: 0, name: 'Brussels (BRU)', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2026-08-01', local_time: '08:00' },
{ id: 2, reservation_id: 31, role: 'stop', sequence: 1, name: 'Helsinki (HEL)', code: 'HEL', lat: 60.31, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2026-08-02', local_time: '14:00' },
{ id: 3, reservation_id: 31, role: 'to', sequence: 2, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2026-08-03', local_time: '15:00' },
],
}) as unknown as Reservation;
}
it('FE-PLANNER-TRANSMODAL-058: each segment of a stopover flight saves its own booking code', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const airports = screen.getAllByTestId('airport-select');
fireEvent.change(airports[0], { target: { value: 'BRU' } });
fireEvent.change(airports[1], { target: { value: 'HEL' } });
fireEvent.change(airports[2], { target: { value: 'JFK' } });
// Two departing waypoints get a code field, the booking's own field stays last.
const codes = screen.getAllByPlaceholderText('e.g. ABC12345');
expect(codes).toHaveLength(3);
fireEvent.change(codes[0], { target: { value: 'ABC123' } });
fireEvent.change(codes[1], { target: { value: 'XYZ789' } });
fireEvent.change(codes[2], { target: { value: 'BOOK1' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
// The booking's own reference stays where it was and is never mirrored.
expect(payload.confirmation_number).toBe('BOOK1');
expect(payload.metadata.confirmation_number).toBeUndefined();
});
it('FE-PLANNER-TRANSMODAL-059: no per-segment field while the route writes no legs', async () => {
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'BRU' } });
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
// Only the booking's own field: a direct flight stores no legs.
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
// A third waypoint whose airport is never picked drops out of the route, so
// the legs are still not written and the field must stay away. Otherwise the
// typed code would vanish on save.
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
});
it('FE-PLANNER-TRANSMODAL-060: editing a stopover flight pre-fills every segment code and a re-save keeps them', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
expect(payload.confirmation_number).toBe('BOOK1');
});
it('FE-PLANNER-TRANSMODAL-061: dropping the stop drops the segment codes with the legs, never the booking code', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
// Removing the only stop collapses the route to a direct flight, so there are
// no legs left to hold a per-segment code. Documented behaviour, not a bug:
// the field disappears with them and the booking keeps its own reference.
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.metadata.legs).toBeUndefined();
expect(payload.confirmation_number).toBe('BOOK1');
});
it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
@@ -85,9 +85,12 @@ interface WaypointForm {
airline: string
flight_number: string
seat: string
// Booking reference of the leg leaving this waypoint: airlines often issue one
// per flight rather than one per booking (#1943). Empty means the booking's own.
confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
@@ -103,9 +106,10 @@ interface StationWaypointForm {
train_number: string
platform: string
seat: string
confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
const TYPE_OPTIONS = [
@@ -264,6 +268,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// No fallback to src.confirmation_number: that one belongs to the
// whole booking and stays in the form's own field, otherwise a
// plain re-save would copy it onto the first leg.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -305,6 +313,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -399,6 +409,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -426,6 +437,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -630,6 +642,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}),
]
// The per-leg booking code is only offered where handleSubmit actually writes
// metadata.legs: the SAME condition, over the waypoints that become endpoints,
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
return (
<Modal
isOpen={isOpen}
@@ -792,7 +810,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesFlightLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
@@ -805,6 +823,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
</div>
{writesFlightLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesTrainLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
@@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
</div>
{writesTrainLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -0,0 +1,222 @@
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-014
import { render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildTrip } from '../../../tests/helpers/factories'
import { TripExportModal } from './TripExportModal'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
// The subscribe dialog fetches its feed token on mount; it is exercised in its
// own test, here we only care that the entry mounts it.
vi.mock('./IcsSubscribeModal', () => ({
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => (
<div data-testid="ics-subscribe-modal">
{title}
<button onClick={onClose}>close-subscribe</button>
</div>
),
}))
const t = (key: string, params?: Record<string, unknown>) =>
params ? `${key}|${Object.values(params).join('|')}` : key
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
function makeToast() {
return {
success: vi.fn((_m: string) => {}),
error: vi.fn((_m: string) => {}),
warning: vi.fn((_m: string) => {}),
info: vi.fn((_m: string) => {}),
}
}
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
return {
isOpen: true,
onClose: vi.fn(),
tripId: 1,
trip,
days: [],
places: [],
categories: [],
assignments: {},
reservations: [],
dayNotes: {},
t,
locale: 'en-US',
toast: makeToast(),
...overrides,
} as React.ComponentProps<typeof TripExportModal>
}
let clickedHref: string | null
beforeEach(() => {
vi.clearAllMocks()
clickedHref = null
// jsdom has neither of these, and an anchor click would navigate.
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
globalThis.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clickedHref = this.href
})
})
afterEach(() => vi.restoreAllMocks())
const okResponse = (body = '<gpx/>') =>
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
describe('TripExportModal', () => {
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
render(<TripExportModal {...makeProps({ isOpen: false })} />)
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
})
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
render(<TripExportModal {...makeProps()} />)
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportMaps · GPX')).toBeInTheDocument()
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
// ── PDF ───────────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1' })]
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
const toast = makeToast()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
})
// ── Calendar ──────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
vi.stubGlobal('fetch', fetchMock)
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
})
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
expect(clickedHref).toBeNull()
})
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
const user = userEvent.setup()
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
await user.click(screen.getByText('close-subscribe'))
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
})
// The subscription mints a link that reads the trip without an account, so it
// needs share_manage; the one-off download is a file this member may already
// read. Leaving the entry visible would only produce a dialog whose enable
// button the server refuses.
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
})
// ── GPX (#1442) ───────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse())
vi.stubGlobal('fetch', fetchMock)
for (const [label, expected] of [
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
] as const) {
const view = render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText(label))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
fetchMock.mockClear()
view.unmount()
}
})
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
})
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
const user = userEvent.setup()
const toast = makeToast()
const onClose = vi.fn()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
expect(toast.error).not.toHaveBeenCalled()
expect(clickedHref).toBeNull()
// Nothing was downloaded, so the dialog stays put.
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
})
@@ -0,0 +1,246 @@
import { useState, type ReactNode } from 'react'
import { CalendarDays, CalendarPlus, ChevronRight, Download, FileText, Loader2, MapPin, Route as RouteIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import Modal from '../shared/Modal'
import { IcsSubscribeModal } from './IcsSubscribeModal'
import { useToast } from '../shared/Toast'
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
/**
* What a GPX download can carry. Worded by what someone wants on their device
* rather than by the GPX element it maps to: "everything", "just the places" for
* an offline map, "just the days" for following a plan. An omitted flag defaults
* to true server-side, so the first entry needs no query at all.
*/
const GPX_SCOPES = [
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: Download },
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
] as const
interface TripExportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
trip: Trip
days: Day[]
places: Place[]
categories: Category[]
assignments: AssignmentsMap
reservations: Reservation[]
dayNotes: Record<string, DayNote[]>
t: (key: string, params?: Record<string, any>) => string
locale: string
toast: ReturnType<typeof useToast>
/**
* Gates "Subscribe to calendar" only. The one-off ICS download above it stays
* open to every member: it is a file they already have the right to read,
* while the subscription mints a link that works without an account.
*/
canManageShare?: boolean
}
/**
* Every way a trip leaves TREK, in one dialog: the day plan as a PDF, the
* bookings as a calendar (a one-off .ics or a live subscription) and the map
* data as GPX in the three scopes a device actually wants.
*
* It replaces three separate toolbar buttons, two of which opened hover menus of
* their own. On a narrow sidebar that row simply ran out of width and the
* exports dropped off the edge; one button cannot.
*/
export function TripExportModal({
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
t, locale, toast, canManageShare = true,
}: TripExportModalProps) {
const [subscribeOpen, setSubscribeOpen] = useState(false)
// Which row is working, so the dialog can say so instead of looking inert
// while a 226 kB PDF builder is fetched and a document is rendered.
const [busy, setBusy] = useState<string | null>(null)
const fileBase = trip?.title || 'trip'
// Shared tail of every download: Firefox and Safari cancel the download when
// the object URL is revoked before they picked the blob up, hence the delay.
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
}
const exportPdf = async () => {
if (busy) return
setBusy('pdf')
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) })),
)
try {
// Loaded on click: the PDF builder is ~226 kB and hangs off the days
// sidebar, so every trip used to pay for it whether or not anyone
// exported. A missing chunk lands in the catch and shows the same error
// the export already had.
const { downloadTripPDF } = await import('../PDF/TripPDF')
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale })
onClose()
} catch (e) {
console.error('PDF error:', e)
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(null)
}
}
const downloadIcs = async () => {
if (busy) return
setBusy('ics')
try {
const res = await fetch(`/api/trips/${tripId}/export.ics`, { credentials: 'include' })
if (!res.ok) throw new Error()
saveBlob(await res.blob(), `${fileBase}.ics`)
onClose()
} catch {
toast.error(t('planner.icsExportFailed'))
} finally {
setBusy(null)
}
}
const downloadGpx = async (key: string, query: string) => {
if (busy) return
setBusy(`gpx:${key}`)
try {
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
// 404 here means the selection is empty, which is worth its own message:
// "nothing happened" and "the download broke" look identical otherwise.
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
if (!res.ok) throw new Error()
saveBlob(await res.blob(), `${fileBase}.gpx`)
onClose()
} catch {
toast.error(t('dayplan.gpxFailed'))
} finally {
setBusy(null)
}
}
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="lg"
title={
<span className="flex items-center gap-2.5">
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-accent-subtle text-accent-on">
<Download size={16} strokeWidth={2} />
</span>
{t('dayplan.export')}
</span>
}
>
<p className="-mt-1 mb-5 text-caption text-content-muted">{t('dayplan.exportIntro')}</p>
<Section label={t('dayplan.exportDocument')}>
<ExportRow
icon={FileText}
title={t('dayplan.pdf')}
sub={t('dayplan.pdfTooltip')}
busy={busy === 'pdf'}
disabled={busy != null}
onClick={exportPdf}
/>
</Section>
<Section label={t('dayplan.exportCalendar')}>
<ExportRow
icon={CalendarDays}
title={t('mobileTrip.icsDownload')}
sub={`${fileBase}.ics`}
busy={busy === 'ics'}
disabled={busy != null}
onClick={downloadIcs}
/>
{canManageShare && (
<ExportRow
icon={CalendarPlus}
title={t('mobileTrip.icsSubscribe')}
sub={t('mobileTrip.icsSubscribeSub')}
disabled={busy != null}
onClick={() => setSubscribeOpen(true)}
/>
)}
</Section>
{/* GPX — the counterpart to the GPX import in the places sidebar. The
format name carries more than a translated heading would, so it is
spelled out beside the plain-language one. */}
<Section label={`${t('dayplan.exportMaps')} · GPX`}>
{GPX_SCOPES.map(scope => (
<ExportRow
key={scope.key}
icon={scope.icon}
title={t(scope.labelKey)}
busy={busy === `gpx:${scope.key}`}
disabled={busy != null}
onClick={() => downloadGpx(scope.key, scope.query)}
/>
))}
</Section>
</Modal>
{subscribeOpen && canManageShare && (
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title={t('mobileTrip.icsSubscribe')}
description={t('mobileTrip.icsSubscribeSub')}
onClose={() => setSubscribeOpen(false)}
/>
)}
</>
)
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<section className="mb-4 last:mb-0">
<h3 className="mb-1.5 px-1 text-caption font-bold uppercase tracking-[0.07em] text-content-faint">{label}</h3>
<div className="divide-y divide-edge-faint overflow-hidden rounded-xl border border-edge-faint bg-surface">
{children}
</div>
</section>
)
}
function ExportRow({ icon: Icon, title, sub, busy = false, disabled = false, onClick }: {
icon: LucideIcon
title: string
sub?: string
busy?: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-50"
>
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-surface-tertiary text-content-secondary transition-colors group-enabled:group-hover:bg-accent-subtle group-enabled:group-hover:text-accent-on">
{busy ? <Loader2 size={16} strokeWidth={2} className="animate-spin" /> : <Icon size={16} strokeWidth={1.9} />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body font-semibold text-content">{title}</span>
{sub && <span className="block truncate text-caption text-content-muted">{sub}</span>}
</span>
<ChevronRight
size={15}
strokeWidth={2}
className="flex-none text-content-faint transition-transform group-enabled:group-hover:translate-x-0.5"
/>
</button>
)
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import type { Place } from '../../types'
// FE-PLANNER-COMAPS-001 to FE-PLANNER-COMAPS-004
function place(overrides: Partial<Place> = {}): Place {
return { name: 'Stephansdom', lat: 48.2038, lng: 16.3616, ...overrides } as unknown as Place
}
describe('getCoMapsUrlForPlace', () => {
it('FE-PLANNER-COMAPS-001: drops a named pin at the place', () => {
expect(getCoMapsUrlForPlace(place()))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616&n=Stephansdom')
})
it('FE-PLANNER-COMAPS-002: links over https, so an install-less device lands somewhere', () => {
// The cm:// scheme opens the app but dead-ends in a browser without it;
// comaps.at is both the app link and the install page.
expect(getCoMapsUrlForPlace(place())).toMatch(/^https:\/\/comaps\.at\//)
})
it('FE-PLANNER-COMAPS-003: a nameless place still gets its pin', () => {
expect(getCoMapsUrlForPlace(place({ name: '' })))
.toBe('https://comaps.at/map?v=1&ll=48.2038,16.3616')
})
it('FE-PLANNER-COMAPS-004: without coordinates there is nothing to pin', () => {
expect(getCoMapsUrlForPlace(place({ lat: null, lng: null }))).toBeNull()
expect(getCoMapsUrlForPlace(null)).toBeNull()
})
})
@@ -0,0 +1,22 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
/**
* Open a place in CoMaps, the privacy-focused offline OpenStreetMap navigator.
* Requested in discussion #1904.
*
* The https form rather than the `cm://` scheme CoMaps also answers to: both open
* the app when it is installed, but only this one has somewhere to land when it is
* not — comaps.at, which is the install page. A PWA cannot ask whether an Android
* app exists, so the fallback has to be the link itself.
*
* Coordinates are required: CoMaps places the pin from `ll` and uses `n` only to
* label it, so a name on its own has nothing to attach to.
*/
export function getCoMapsUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place || place.lat == null || place.lng == null) return null
const name = place.name?.trim()
const n = name ? `&n=${encodeURIComponent(name)}` : ''
return `https://comaps.at/map?v=1&ll=${place.lat},${place.lng}${n}`
}
@@ -1,4 +1,3 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
import type { Place } from '../../types'
@@ -31,7 +30,7 @@ afterEach(() => { vi.restoreAllMocks() })
describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-001: offers every app that can resolve a place with coordinates', () => {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets[0].label).toBe('Google Maps')
})
@@ -59,7 +58,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-004b: an Android phone does not, because nobody there wants it', () => {
const restore = withUserAgent('Mozilla/5.0 (Linux; Android 15; Pixel 9)')
try {
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm'])
expect(getNavigationTargets(place()).map(t => t.id)).toEqual(['google', 'waze', 'osm', 'comaps'])
} finally { restore() }
})
@@ -67,7 +66,7 @@ describe('getNavigationTargets', () => {
const restore = withUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')
try {
const targets = getNavigationTargets(place())
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
// q next to ll labels the pin rather than searching blindly.
expect(targets[2].url).toBe('https://maps.apple.com/?q=Stephansdom&ll=48.2038,16.3616')
} finally { restore() }
@@ -88,6 +87,13 @@ describe('getNavigationTargets', () => {
expect(targets.map(t => t.id)).toEqual(['google', 'osm'])
})
it('FE-PLANNER-NAV-007b: CoMaps needs the position, so a place without one loses it', () => {
// CoMaps drops a pin from `ll` and only labels it with `n` — a name alone
// has nothing to attach to, the same reason Waze and Apple Maps drop out.
const targets = getNavigationTargets(place({ lat: null, lng: null, name: 'Stephansdom' }))
expect(targets.map(t => t.id)).not.toContain('comaps')
})
it('FE-PLANNER-NAV-008: no place at all yields nothing', () => {
expect(getNavigationTargets(null)).toEqual([])
expect(getNavigationTargets(place({ lat: null, lng: null, name: '' }))).toEqual([])
@@ -95,7 +101,7 @@ describe('getNavigationTargets', () => {
it('FE-PLANNER-NAV-009: a nameless place still reaches every app, just without a label', () => {
const targets = getNavigationTargets(place({ name: '' }))
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm'])
expect(targets.map(t => t.id)).toEqual(['google', 'waze', 'apple', 'osm', 'comaps'])
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
})
})
@@ -1,10 +1,11 @@
import type { AssignmentPlace, Place } from '../../types'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
export interface NavigationTarget {
id: NavigationAppId
@@ -86,6 +87,11 @@ export function getNavigationTargets(
const osmUrl = getOpenStreetMapUrlForPlace(place)
if (osmUrl) targets.push({ id: 'osm', label: 'OpenStreetMap', url: osmUrl })
// Last, beside the OSM entry it shares a map source with: CoMaps is the offline
// end of this list, the one that still works with no signal.
const coMapsUrl = getCoMapsUrlForPlace(place)
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
return targets
}
@@ -643,7 +643,7 @@ describe('usePlacesSidebar context menu', () => {
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Open Website' }));
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank');
expect(open).toHaveBeenCalledWith('https://cafe.example', '_blank', 'noopener,noreferrer');
fireEvent.contextMenu(screen.getByTestId('row-3'));
await user.click(screen.getByRole('button', { name: 'Google Maps' }));
@@ -14,6 +14,7 @@ import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { safeHttpUrl } from '../../utils/safeUrl'
export interface PlacesSidebarProps {
tripId: number
@@ -276,7 +277,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
{ divider: true },
+35 -1
View File
@@ -2,6 +2,7 @@ import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'luc
import React from 'react';
import { useTranslation } from '../../i18n';
import Section from './Section';
import { useAuthStore } from '../../store/authStore';
interface Props {
appVersion: string;
@@ -9,6 +10,7 @@ interface Props {
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t, locale } = useTranslation();
const managed = useAuthStore((s) => s.managed);
return (
<Section title={t('settings.about')} icon={Info}>
@@ -22,7 +24,11 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
className="text-content-secondary"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
>
{t('settings.about.description')}
{/* The stock line calls TREK self-hosted and points at 'your own server'.
Both are true for the reader who set it up and neither is for a customer
of a hosted instance, so the mode picks the sentence rather than the
wording being watered down for everybody. */}
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
</p>
<p
className="text-content-faint"
@@ -52,6 +58,14 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
</span>
</p>
{/* Ko-fi, Buy Me a Coffee, Discord, and the issue/discussion links assume
the reader runs this install and can act on it. On a centrally
administered one they support somebody they are not the customer of,
and file bugs against an instance they do not operate. The version and
the source link below stay in both modes: AGPL §13 wants the source
offered prominently to the people using it over a network, and that is
not the part being trimmed here. */}
{!managed && (<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
@@ -263,6 +277,26 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
</>)}
{/* What replaces the grids above. AGPL §13 asks for the source to be
offered prominently to whoever uses the software over a network, and a
customer of a hosted instance is exactly that reader. The support and
bug-report links go; this does not. */}
{managed && (
<a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline"
>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.sourceTitle')}</div>
<div className="text-xs text-content-faint">{t('settings.about.sourceHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
)}
</Section>
);
}
@@ -10,6 +10,7 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
interface OAuthPreset {
id: string
@@ -96,11 +97,18 @@ interface McpToken {
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = useAuthStore((s) => s.managed)
return (
<>
<PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.llmEnabled && <LlmConnectionSection />}
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
runs themselves. On a managed install there is none, and every one of
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{S.llmEnabled && !managed && <LlmConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
@@ -66,7 +66,7 @@ describe('LlmConnectionSection', () => {
// The endpoint is instance configuration since #1772, so it has no field here.
expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument();
expect(
screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
screen.getByText('A local (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'),
).toBeInTheDocument();
});
@@ -20,6 +20,7 @@ import {
normalizeStyleForProvider,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
interface MapPreset {
name: string
@@ -161,6 +162,7 @@ export default function MapSettingsTab(): React.ReactElement {
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<Provider>(initialProvider)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
@@ -322,7 +324,9 @@ export default function MapSettingsTab(): React.ReactElement {
{/* GL settings */}
{provider !== 'leaflet' && (
<div className="space-y-3">
{provider === 'mapbox-gl' && (
{/* The token comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{provider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input
@@ -0,0 +1,176 @@
import type { BookDocument } from '@trek/shared'
import { SpreadView } from './SpreadView'
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets'
/**
* The book laid out for a press.
*
* ── One renderer ─────────────────────────────────────────────────────────
*
* These are the same `SpreadView`s the editor draws, at 1:1 with `print` on.
* That is the whole reason Studio renders in DOM and positions in millimetres:
* a second renderer for output is a second renderer to proofread against, and
* the day it drifts is the day someone finds out by opening a printed book.
*
* ── What is added on top ─────────────────────────────────────────────────
*
* Only what a press needs and a screen does not: the bleed carried out past the
* trim on all four sides, and crop marks outside it saying where to cut.
* Nothing here changes where anything sits on the page.
*/
export function BookSheetsView({
doc, mode, marks,
}: {
doc: BookDocument
mode: SheetMode
/** Crop marks, and the room around the bleed they need. */
marks: boolean
}) {
const sheets = sheetsFor(doc, mode)
return (
<div className="bx-book">
{sheets.map((sheet, i) => (
<SheetView
key={`${sheet.spread.id}-${sheet.offset}`}
sheet={sheet}
doc={doc}
marks={marks}
last={i === sheets.length - 1}
/>
))}
</div>
)
}
function SheetView({
sheet, doc, marks, last,
}: {
sheet: Sheet
doc: BookDocument
marks: boolean
last: boolean
}) {
/*
* Each sheet is sized to what is on it, not to the widest in the book.
*
* A cover is one page wide and a spread is two, and in spread mode they sit
* in the same document. Making every sheet the wider of the two — so that one
* `@page` rule would cover them all — put a cover in the middle of a
* spread-sized page with white either side, which is not a cover. The print
* CSS carries a named page rule for the narrow ones instead.
*/
const box = sheetBox(sheet.width, sheet.height, doc.page.bleed, marks)
return (
<div
className={`bx-sheet${sheet.single ? ' is-single' : ''}`}
data-label={sheet.label}
style={{
position: 'relative',
width: `${box.width}mm`,
height: `${box.height}mm`,
background: '#ffffff',
// The last sheet takes no break after it: an empty final page is the
// most common defect in a browser-printed PDF.
breakAfter: last ? 'auto' : 'page',
overflow: 'hidden',
}}
>
{/*
The window on the spread. One page wide plus bleed on both sides, which
is why a photograph crossing the gutter comes out aligned: both halves
are the same spread seen through openings a page apart.
*/}
<div
style={{
position: 'absolute',
left: `${box.margin - box.bleed}mm`,
top: `${box.margin - box.bleed}mm`,
width: `${sheet.width + box.bleed * 2}mm`,
height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides.
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: `${box.bleed - sheet.offset}mm`,
top: `${box.bleed}mm`,
width: `${sheet.spreadWidth}mm`,
height: `${sheet.height}mm`,
}}
>
<SpreadView
spread={sheet.spread}
page={doc.page}
spreadIndex={sheet.spreadIndex}
big
print
/>
</div>
</div>
{marks && <CropMarks box={box} width={sheet.width} height={sheet.height} />}
</div>
)
}
/**
* Where to cut.
*
* Eight hairlines, two per corner, starting at the bleed edge and reaching
* outward — never crossing the trim, because a mark printed inside the trim is
* a mark that stays in the finished book.
*/
function CropMarks({
box, width, height,
}: {
box: ReturnType<typeof sheetBox>
width: number
height: number
}) {
const left = box.margin
const top = box.margin
const right = left + width
const bottom = top + height
const gap = box.bleed
const line = (x: number, y: number, w: number, h: number, key: string) => (
<div
key={key}
style={{
position: 'absolute',
left: `${x}mm`,
top: `${y}mm`,
width: `${w}mm`,
height: `${h}mm`,
background: '#000000',
}}
/>
)
const marks = []
for (const [x, dx] of [[left, -1], [right, 1]] as const) {
for (const [y, dy] of [[top, -1], [bottom, 1]] as const) {
// Horizontal arm, reaching out from the corner past the bleed.
marks.push(line(
dx < 0 ? x - gap - MARK_LENGTH : x + gap,
y - MARK_WEIGHT / 2,
MARK_LENGTH, MARK_WEIGHT,
`h${x}-${y}`,
))
// Vertical arm.
marks.push(line(
x - MARK_WEIGHT / 2,
dy < 0 ? y - gap - MARK_LENGTH : y + gap,
MARK_WEIGHT, MARK_LENGTH,
`v${x}-${y}`,
))
}
}
return <>{marks}</>
}
@@ -0,0 +1,98 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
/**
* The folios.
*
* Drawn by the renderer rather than stored as elements, because the number a
* page carries is a function of where the spread sits in the book — and an
* element holding "14" would say 14 after the spread was moved, duplicated or
* deleted. Nothing else in the document has that property, which is why this is
* the one thing on the page that is not an element.
*
* That also settles where it belongs: inside `SpreadView`, so the editor and
* the print renderer produce the same number. Stamping them with pdf-lib
* afterwards would mean proofreading a book on screen whose page numbers are
* not the ones that get printed.
*
* The cover and the back cover carry none. A folio on a cover is a mistake in
* every book ever bound.
*/
export function PageNumbers({
spread, page, spreadIndex,
}: {
spread: BookSpread
page: BookPageSetup
/** Position in the document, cover included. */
spreadIndex: number
}) {
const cfg = page.pageNumbers
if (!cfg?.show || spread.role !== 'inner') return null
/*
* The cover is one sheet with a front and a back, so the first inner spread
* opens on `startAt` — 2 by default, the page you see when you open a book
* whose cover is page 1. `spreadIndex` counts the cover, hence the -1.
*/
const left = cfg.startAt + (spreadIndex - 1) * 2
const right = left + 1
const size = cfg.size
const y = page.pageHeight - cfg.margin
/** Where the number sits on each page, given which edge it hangs from. */
const place = (side: 'left' | 'right') => {
const pageX = side === 'left' ? 0 : page.pageWidth
if (cfg.position === 'centre') {
return { x: pageX, w: page.pageWidth, align: 'center' as const }
}
// Outer is the cut edge, inner is the gutter — the distinction only exists
// on a spread, and getting it backwards puts both numbers in the fold.
const outward = cfg.position === 'outer'
const atLeftEdge = side === 'left' ? outward : !outward
return atLeftEdge
? { x: pageX + cfg.margin, w: page.pageWidth * 0.4, align: 'left' as const }
: { x: pageX + page.pageWidth * 0.6 - cfg.margin, w: page.pageWidth * 0.4, align: 'right' as const }
}
return (
<>
{([['left', left], ['right', right]] as const).map(([side, number]) => {
const at = place(side)
/*
* Sampled at the middle of the number's own box, which is where the
* digits actually are — the box is 40% of the page wide so that the
* text can align inside it, and its left edge is often over something
* else entirely.
*/
const ink = cfg.autoColor
? folioInk(spread, at.x + at.w / 2, y - size * 0.18)
: { color: cfg.color, shadow: undefined }
return (
<div
key={side}
style={{
position: 'absolute',
left: `${at.x}mm`,
top: `${y}mm`,
width: `${at.w}mm`,
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: 500,
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
textShadow: ink.shadow,
fontVariantNumeric: 'tabular-nums',
pointerEvents: 'none',
}}
>
{number}
</div>
)
})}
</>
)
}
@@ -0,0 +1,52 @@
import type { BookPeer } from './useBookPresence'
import { peerColour } from './peerColour'
/**
* Who else has the book open.
*
* In the bar rather than in a panel, because it answers a question people ask
* before they start moving things: am I the only one in here. A pointer answers
* it too, but only once the other person moves — someone reading a spread in
* silence is invisible without this.
*
* The same colour as their pointer, so the two read as one person.
*/
export function PeerBadges({ peers, t }: { peers: BookPeer[]; t: (k: string) => string }) {
if (peers.length === 0) return null
/*
* By person, not by socket.
*
* The presence list is keyed by socket because two tabs are two pointers, and
* that is right for pointers. It is wrong here: one person with the book open
* twice is one person, and showing them as two says the room is busier than
* it is.
*/
const seen = new Map<number, BookPeer>()
for (const p of peers) if (!seen.has(p.userId)) seen.set(p.userId, p)
const people = [...seen.values()]
const shown = people.slice(0, 4)
const rest = people.length - shown.length
return (
<div className="st-peers" title={people.map(p => p.username).join(', ')}>
<span className="st-peer-faces">
{shown.map(p => (
<span
key={p.userId}
className="st-peer-dot"
style={{ background: peerColour(p.userId) }}
aria-label={p.username}
>
{p.avatar
? <img src={p.avatar} alt="" />
: (p.username?.[0] ?? '?').toUpperCase()}
</span>
))}
</span>
{rest > 0 && <span className="st-peer-more">+{rest}</span>}
<span className="st-peer-label">{t('journey.studio.peersHere')}</span>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import type { PeerCursor } from './useBookPresence'
import { peerColour } from './peerColour'
/**
* The other people's pointers.
*
* ── Why they are interpolated ────────────────────────────────────────────
*
* They arrive about ten times a second, because sending sixty would be five
* times the traffic for something the eye cannot use. Drawn as they arrive,
* that reads as a stutter; drawn towards where they last were, it reads as a
* hand moving. The catch-up is deliberately quick — a pointer that eases
* elegantly into place is a pointer that is telling you where somebody was a
* third of a second ago.
*
* The colour is derived from the person's id — see peerColour.ts.
*/
/** How much of the remaining distance is covered per frame. */
const EASE = 0.28
/** Below this, in millimetres, the pointer is simply put where it belongs. */
const SNAP_MM = 0.15
export function PeerCursors({
cursors, spreadIndex, zoom,
}: {
cursors: PeerCursor[]
/** Only the pointers on the spread being looked at are drawn. */
spreadIndex: number
/** The sheet's scale, so the arrow can undo it and stay one size. */
zoom: number
}) {
const here = cursors.filter(c => c.spreadIndex === spreadIndex)
if (here.length === 0) return null
return (
<>
{here.map(cursor => (
<Pointer key={cursor.socketId} cursor={cursor} zoom={zoom} />
))}
</>
)
}
function Pointer({ cursor, zoom }: { cursor: PeerCursor; zoom: number }) {
const [at, setAt] = useState({ x: cursor.x, y: cursor.y })
const target = useRef({ x: cursor.x, y: cursor.y })
const frame = useRef<number | null>(null)
target.current = { x: cursor.x, y: cursor.y }
useEffect(() => {
const step = () => {
setAt(current => {
const dx = target.current.x - current.x
const dy = target.current.y - current.y
if (Math.abs(dx) < SNAP_MM && Math.abs(dy) < SNAP_MM) {
frame.current = null
return target.current
}
frame.current = requestAnimationFrame(step)
return { x: current.x + dx * EASE, y: current.y + dy * EASE }
})
}
if (frame.current == null) frame.current = requestAnimationFrame(step)
return () => {
if (frame.current != null) cancelAnimationFrame(frame.current)
frame.current = null
}
}, [cursor.x, cursor.y])
const colour = peerColour(cursor.userId)
return (
<div
className="st-peer"
style={{
// Placed in the sheet's millimetres, which is what the position means,
// and then scaled back out: an arrow that grew with the zoom would be a
// postage stamp at 30% and a dinner plate at 400%.
left: `${at.x}mm`,
top: `${at.y}mm`,
transform: `scale(${1 / Math.max(zoom, 0.05)})`,
transformOrigin: 'top left',
}}
aria-hidden
>
{/*
The arrow itself. Drawn rather than an icon so the fill can be the
person's colour and the outline can stay white — a solid pointer
disappears into a dark photograph, and a white one into a white page.
*/}
{/*
Bigger than it first was: at 14px the 1.2 outline ate most of the fill
and every pointer read as the same white arrow. The outline is what
keeps it visible on a dark photograph, so the arrow grew instead.
*/}
<svg width="18" height="23" viewBox="0 0 14 18" fill="none">
<path
d="M1 1L1 14.5L4.6 11.3L7.1 16.6L9.6 15.4L7.1 10.2L11.6 10.2L1 1Z"
fill={colour}
stroke="#ffffff"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{cursor.username && (
<span className="st-peer-name" style={{ background: colour }}>
{cursor.username}
</span>
)}
</div>
)
}
@@ -0,0 +1,74 @@
import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react'
import type { BookRecord } from '@trek/shared'
import type { SaveState } from './useBookStore'
/**
* Whether the book is safe.
*
* Autosave without a sign of it is a promise you are asking someone to take on
* faith, and the moment they do not is the moment they close the tab to check.
* So: a tick when it is written, a spinner while it is being written, and — the
* one that matters — a real choice when someone else got there first.
*
* Quiet by design. This sits in the top bar of an editor, and a status that
* announces every successful save is a status people learn to stop reading,
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
}) {
if (state.status === 'conflict') {
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveConflict')}</span>
{/*
Two ways out, both stated plainly. There is no merge here and
pretending otherwise would be worse than saying so: one of these
documents is going to be the book.
*/}
<button onClick={() => onAcceptTheirs(state.current)}>
{t('journey.studio.saveTakeTheirs')}
</button>
<button onClick={() => onKeepMine(state.current)}>
{t('journey.studio.saveKeepMine')}
</button>
</div>
)
}
if (state.status === 'error') {
return (
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
<CloudOff size={13} />
<span>{t('journey.studio.saveFailed')}</span>
</button>
)
}
if (state.status === 'saving') {
return (
<div className="st-save">
<RefreshCw size={13} className="st-spin" />
<span>{t('journey.studio.saving')}</span>
</div>
)
}
if (state.status === 'saved') {
return (
<div className="st-save is-quiet">
<Check size={13} />
<span>{t('journey.studio.saved')}</span>
</div>
)
}
return null
}
+563
View File
@@ -0,0 +1,563 @@
import type { CSSProperties } from 'react'
import type {
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { FONT_STACKS, photoSrc } from './bookRender'
import { iconComponent } from './iconLibrary'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
/**
* One spread, drawn.
*
* This component is the whole reason Studio renders in DOM rather than on a
* canvas: the *same* tree is what the print renderer will run in headless
* Chromium. Edit mode adds handles and outlines on top; print mode is this and
* nothing else. There is no second renderer to drift against.
*
* Everything is positioned in millimetres. CSS maps mm onto the PDF with a fixed
* ratio, so what you see at `scale(0.4)` is the same box model the printer gets
* at 1:1 — not an approximation of it.
*/
function frameStyle(el: BookElement): CSSProperties {
return {
position: 'absolute',
left: `${el.frame.x}mm`,
top: `${el.frame.y}mm`,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
opacity: el.opacity,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
}
}
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100
/** #rrggbb plus an alpha, for the fades a cover panel needs. */
function hexToRgba(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
const FILTERS: Record<string, string | undefined> = {
none: undefined,
bw: 'grayscale(1) contrast(1.05)',
warm: 'saturate(1.1) sepia(0.16)',
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
// Faded, the way a print left in the light goes: lifted blacks, less colour.
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
contrast: 'contrast(1.22) saturate(1.06)',
}
/**
* How much of the frame the decoration around a picture eats, as a fraction of
* the shorter side.
*
* A Polaroid's chin is the reason this is a table rather than one number: the
* bottom border is roughly three times the others, and getting that ratio wrong
* is the difference between a Polaroid and a photo with a white edge.
*/
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
none: { pad: 0, bottom: 0 },
polaroid: { pad: 0.055, bottom: 0.17 },
white: { pad: 0.035, bottom: 0.035 },
shadow: { pad: 0, bottom: 0 },
film: { pad: 0.075, bottom: 0.075 },
tape: { pad: 0, bottom: 0 },
}
/**
* A shape, drawn.
*
* Rectangles and ellipses stay `<div>`s — a box with a corner radius is exactly
* what CSS is good at, and those two are what every document written before the
* shape library existed contains. Everything else is an SVG path.
*
* The viewBox is in millimetres and the element is that many millimetres wide,
* so one user unit is one millimetre and a stroke width means the same thing on
* every side. The path is drawn into a box inset by half the stroke, which puts
* the whole stroke inside the frame the editor snapped — the same thing
* `box-sizing: border-box` does for the div case, and the reason a snapped edge
* and a drawn edge line up.
*/
function ShapeView({ el }: { el: BookShapeElement }) {
const fill = el.fill ?? 'transparent'
const gradientId = `g-${el.id}`
const gradient = el.gradient !== 'none' && el.fill
if (el.shape === 'rect' || el.shape === 'ellipse') {
const background = !gradient
? fill
: `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},`
+ ` ${hexToRgba(el.fill!, 0)} 0%,`
+ ` ${hexToRgba(el.fill!, 0.55)} 46%,`
+ ` ${hexToRgba(el.fill!, 1)} 100%)`
return (
<div
style={{
...frameStyle(el),
background,
border: el.stroke ? `${el.strokeWidth}mm ${el.strokeStyle} ${el.stroke}` : undefined,
boxSizing: 'border-box',
borderRadius: el.shape === 'ellipse' ? '50%' : el.radius ? `${el.radius}mm` : undefined,
}}
/>
)
}
const sw = el.stroke ? el.strokeWidth : 0
const w = Math.max(0.01, el.frame.w - sw)
const h = Math.max(0.01, el.frame.h - sw)
const d = scalePath(SHAPE_PATHS[el.shape], w, h)
// Proportional to the stroke, so a dashed outline keeps its rhythm whether it
// is a hairline on a caption rule or a 2mm band around a cover panel.
const dash = el.strokeStyle === 'dashed' ? `${sw * 3} ${sw * 2}`
: el.strokeStyle === 'dotted' ? `${sw * 0.01} ${sw * 2}`
: undefined
return (
<div style={frameStyle(el)}>
<svg
width={`${el.frame.w}mm`}
height={`${el.frame.h}mm`}
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
style={{ display: 'block', overflow: 'visible' }}
>
{gradient && (
<defs>
<linearGradient id={gradientId} x1="0" y1={el.gradient === 'up' ? '1' : '0'} x2="0" y2={el.gradient === 'up' ? '0' : '1'}>
<stop offset="0%" stopColor={el.fill!} stopOpacity="0" />
<stop offset="46%" stopColor={el.fill!} stopOpacity="0.55" />
<stop offset="100%" stopColor={el.fill!} stopOpacity="1" />
</linearGradient>
</defs>
)}
<path
d={d}
transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}
fill={gradient ? `url(#${gradientId})` : fill}
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
stroke={el.stroke ?? undefined}
strokeWidth={sw || undefined}
strokeDasharray={dash}
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
strokeLinejoin="round"
/>
</svg>
</div>
)
}
/**
* A picture — possibly cut to a shape, possibly sitting in something.
*
* The mask and the decoration are independent on purpose: a heart in a Polaroid
* is a silly thing to want and there is no reason to stop anyone wanting it, and
* keeping them separate means neither has to know about the other.
*/
function PhotoView({ el, big, print, dropLabel }: {
el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string
}) {
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none
const side = Math.min(el.frame.w, el.frame.h)
// Rounded to a hundredth of a millimetre — the same precision the document
// stores. Without it a fraction of the frame lands in the CSS as
// `10.200000000000001mm`, which is not wrong but is float noise in a
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side)
const bottom = round2(deco.bottom * side)
const clipId = `c-${el.id}`
const clipped = el.mask && el.mask !== 'rect'
/*
* An empty frame is a template's promise: it says where a picture goes before
* anyone has chosen which — and it says it *in the frame it will wear*.
*
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
* placeholder was an ordinary dashed rectangle until a photograph landed in
* it and the chin appeared from nowhere. The two branches have been folded
* together: the surround is drawn once, and only what goes inside it differs.
*/
const empty = el.photoId == null
// In the printed book an unfilled frame is nothing at all — a hatch and an
// instruction on a page someone paid to have bound would be a defect.
if (empty && print) return null
// The label is sized in millimetres so it scales with the zoom exactly as the
// page does, and it steps aside on a frame too small to hold it.
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085))
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34
const hatch = (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background:
'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{roomy && dropLabel && (
<span
style={{
fontFamily: FONT_STACKS.sans,
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{dropLabel}
</span>
)}
</div>
)
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{empty ? hatch : (
<img
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
loading="lazy"
style={{
width: '100%',
height: '100%',
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: FILTERS[el.filter],
display: 'block',
}}
/>
)}
</div>
)
return (
<div
style={{
...frameStyle(el),
background: el.frameStyle === 'polaroid' || el.frameStyle === 'white' ? '#ffffff'
: el.frameStyle === 'film' ? '#141414'
: undefined,
// A photograph lying on a page, rather than printed into it. Soft and
// low: a hard shadow reads as a UI card, not as paper.
boxShadow: el.frameStyle === 'shadow' || el.frameStyle === 'polaroid'
? '0 1.2mm 3mm rgba(0,0,0,.22)'
: undefined,
}}
>
{clipped && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={unitPath(SHAPE_PATHS[el.mask!])} />
</clipPath>
</defs>
</svg>
)}
{picture}
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
so the count follows the width instead of being fixed at a size that
only looks right on one frame. */}
{el.frameStyle === 'film' && [0, 1].map(row => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background: 'repeating-linear-gradient(90deg,'
+ ` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/* Two strips of tape, held at an angle across opposite corners. */}
{/*
Two strips, one over each top corner, each centred *on* its corner so it
overhangs the picture evenly on both sides — which is what a piece of
tape does. Offsetting by a fraction of the strip instead left them
sitting beside the corners rather than across them.
*/}
{el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => {
const tapeW = side * 0.26
const tapeH = side * 0.075
return (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
)
})}
</div>
)
}
/**
* A lucide drawing, at the size the frame gives it.
*
* Kept proportional rather than stretched to the box. A shape is scaled by
* moving its points and looks deliberate at any ratio; a stroked icon does not
* — a stretched compass reads as a mistake — so it sits centred in whatever
* rectangle it was given, which is also what the handles then appear to do.
*
* The stroke follows the drawing rather than staying a fixed width, because an
* icon set 60mm across with a hairline is a diagram, not a mark. `lineWidth` is
* the weight against lucide's own 24-unit grid, exactly as the icons are drawn.
*/
function IconView({ el }: { el: BookIconElement }) {
const Icon = iconComponent(el.name)
return (
<div style={{ ...frameStyle(el), display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon
color={el.color}
strokeWidth={el.lineWidth}
style={{ width: '100%', height: '100%', display: 'block' }}
/>
</div>
)
}
export function ElementView({
el, big, print = false, dropLabel = '',
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
if (el.kind === 'photo') {
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
// Above the catch-all below, which routes everything that is not text into
// the travel elements and draws null for anything they do not know.
if (el.kind === 'icon') return <IconView el={el} />
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
// figures. They live in their own file because they are a different kind of
// drawing: type and vector graphics laid out from data, rather than one box
// with one property set.
return <TravelElementView el={el} frameStyle={frameStyle(el)} big={big} />
}
return (
<div
style={{
...frameStyle(el),
color: el.color,
// pt, not px: the document speaks the print's language, and CSS knows
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: FONT_STACKS[el.font],
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
hyphens: 'auto',
}}
>
{el.text}
</div>
)
}
/**
* The sheet. `mode="print"` is exactly what the renderer will produce; the
* editor draws the same thing and layers its chrome above it.
*/
export function SpreadView({
spread,
page,
spreadIndex = 0,
big = false,
showGuides = false,
print = false,
dropLabel = '',
}: {
spread: BookSpread
page: BookPageSetup
/**
* Where this spread sits in the book. Only the folios need it — a page
* number is the one thing on the page that is a function of position rather
* than of the document.
*/
spreadIndex?: number
big?: boolean
showGuides?: boolean
/** The print renderer passes this: no guides, no placeholders, no chrome. */
print?: boolean
dropLabel?: string
}) {
const isSingle = spread.role !== 'inner'
const w = isSingle ? page.pageWidth : page.pageWidth * 2
return (
<div
style={{
position: 'absolute',
inset: 0,
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
/*
* On screen the page ends at the trim, so anything past it is clipped.
* In print it must not be: the bleed is the part that runs off the edge
* and gets cut away, and clipping it here would leave a white hairline
* along every edge of the finished book. The sheet does the clipping
* instead — see BookSheetsView, whose window is trim plus bleed.
*/
overflow: print ? 'visible' : 'hidden',
}}
>
{spread.elements.map(el => (
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
))}
<PageNumbers spread={spread} page={page} spreadIndex={spreadIndex} />
{showGuides && (
<>
{/* Safe area, per page: on a spread the inner margin belongs to the
gutter, so one box around the whole sheet would be a lie. */}
{(isSingle ? [0] : [0, page.pageWidth]).map(offset => (
<div
key={offset}
style={{
position: 'absolute',
left: `${offset + page.safe}mm`,
top: `${page.safe}mm`,
width: `${page.pageWidth - page.safe * 2}mm`,
height: `${page.pageHeight - page.safe * 2}mm`,
border: '1px dashed rgba(0,0,0,.14)',
pointerEvents: 'none',
}}
/>
))}
<div
aria-hidden
style={{ position: 'absolute', inset: 0, width: `${w}mm`, pointerEvents: 'none' }}
/>
</>
)}
</div>
)
}
/**
* The fold down the middle of an open book.
*
* Preview chrome, not content — which is why it lives outside `SpreadView`. A
* printed book has a physical crease; a *printed* shadow down the gutter would
* be a defect. So the editor and the page thumbnails draw this, and the renderer
* never sees it.
*
* Two layers, because that is what makes paper read as curving rather than as a
* grey stripe: the shadow ramps into the spine and darkens hard at the crease,
* and just outside it a pale band lifts, the way the sheet catches light as it
* comes back up out of the binding.
*/
export function SpreadFold({ page, scaled }: { page: BookPageSetup; scaled: number }) {
// Wide on purpose: the lift has to ramp over a long distance to read as
// paper curving. A narrow band reads as two painted stripes instead.
const width = 52 * scaled
const left = page.pageWidth * scaled - width / 2
return (
<div style={{ position: 'absolute', left, top: 0, width, bottom: 0, pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(90deg,'
+ ' rgba(255,255,255,0) 0%,'
+ ' rgba(255,255,255,.07) 12%,'
+ ' rgba(255,255,255,.17) 27%,'
+ ' rgba(255,255,255,.11) 37%,'
+ ' rgba(255,255,255,0) 43%,'
+ ' rgba(0,0,0,.035) 45.5%,'
+ ' rgba(0,0,0,.10) 48%,'
+ ' rgba(0,0,0,.18) 49.6%,'
+ ' rgba(0,0,0,.21) 50%,'
+ ' rgba(0,0,0,.18) 50.4%,'
+ ' rgba(0,0,0,.10) 52%,'
+ ' rgba(0,0,0,.035) 54.5%,'
+ ' rgba(255,255,255,0) 57%,'
+ ' rgba(255,255,255,.11) 63%,'
+ ' rgba(255,255,255,.17) 73%,'
+ ' rgba(255,255,255,.07) 88%,'
+ ' rgba(255,255,255,0) 100%)',
}}
/>
{/* The crease itself. Sub-pixel at small zoom, which is right — you should
not see a hard line on a page shown at 15%. */}
<div
style={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: Math.max(0.5, 0.35 * scaled),
marginLeft: -Math.max(0.25, 0.175 * scaled),
background: 'rgba(0,0,0,.16)',
}}
/>
</div>
)
}
@@ -0,0 +1,453 @@
import { useEffect, useState } from 'react'
import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react'
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { FONT_STACKS } from './bookRender'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* Where the four rotation handles sit, as percentages of the selection box.
*
* The push diagonally outwards lives in the stylesheet rather than here: it is
* a fixed number of screen pixels clear of the resize handle on the same
* corner, so it must not scale with the element the way a percentage would.
*/
const ROTATE_CORNERS = [
{ id: 'nw', left: '0%', top: '0%' },
{ id: 'ne', left: '100%', top: '0%' },
{ id: 'se', left: '100%', top: '100%' },
{ id: 'sw', left: '0%', top: '100%' },
] as const
const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }> = {
nw: { left: '0%', top: '0%', cursor: 'nwse-resize' },
n: { left: '50%', top: '0%', cursor: 'ns-resize' },
ne: { left: '100%', top: '0%', cursor: 'nesw-resize' },
e: { left: '100%', top: '50%', cursor: 'ew-resize' },
se: { left: '100%', top: '100%', cursor: 'nwse-resize' },
s: { left: '50%', top: '100%', cursor: 'ns-resize' },
sw: { left: '0%', top: '100%', cursor: 'nesw-resize' },
w: { left: '0%', top: '50%', cursor: 'ew-resize' },
}
/**
* The sheet plus everything you do to it.
*
* The page itself is `SpreadView`, byte for byte what the print renderer will
* draw. Selection outlines, handles and snap guides live in a layer *above* it
* and never touch the document, so nothing you see while editing can end up in
* the book.
*/
export function StudioCanvas({
spread,
spreadIndex,
page,
zoom,
pxPerMm,
bookView,
dropLabel,
cursors,
onCursor,
}: {
spread: BookSpread | null
spreadIndex: number
page: BookPageSetup
zoom: number
pxPerMm: number
bookView: boolean
dropLabel: string
/** The other editors' pointers, on this spread and elsewhere. */
cursors?: PeerCursor[]
/** Where this one is, in the spread's millimetres. Null once it leaves. */
onCursor?: (x: number | null, y: number | null) => void
}) {
const selection = useStudioStore(s => s.selection)
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
const raise = useStudioStore(s => s.raise)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
const [dropTarget, setDropTarget] = useState<string | null>(null)
const [editing, setEditing] = useState<string | null>(null)
/** Where on the spread, in millimetres, a drop landed. */
/**
* Turn the selection by a number of degrees.
*
* Kept in the range a person reads as an angle rather than as a winding
* number: -180 to 180, so an element turned all the way round reads as
* straight rather than as 360.
*/
const rotateBy = (deg: number) => {
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => {
if (!selection.includes(e.id) || e.locked) return e
let next = (e.rotation + deg) % 360
if (next > 180) next -= 360
if (next < -180) next += 360
return { ...e, rotation: Math.round(next * 10) / 10 }
}),
})),
}))
}
const pointInMm = (e: React.DragEvent) => {
const r = e.currentTarget.getBoundingClientRect()
return { x: (e.clientX - r.left) / scaled, y: (e.clientY - r.top) / scaled }
}
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
el.kind === 'photo'
&& !el.locked
&& x >= el.frame.x && x <= el.frame.x + el.frame.w
&& y >= el.frame.y && y <= el.frame.y + el.frame.h,
) ?? null
const scaled = pxPerMm * zoom
const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
spread, spreadIndex, page, pxPerMm: scaled,
})
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault()
removeElements(spreadIndex, selection)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spreadIndex, removeElements])
if (!spread) return null
const single = spread.role !== 'inner'
const sheetW = single ? page.pageWidth : page.pageWidth * 2
const sel = spread.elements.filter(e => selection.includes(e.id))
return (
<div
className="st-stage"
style={{ width: sheetW * scaled, height: page.pageHeight * scaled }}
onPointerMove={e => {
onPointerMove(e)
if (!onCursor) return
const r = e.currentTarget.getBoundingClientRect()
onCursor((e.clientX - r.left) / scaled, (e.clientY - r.top) / scaled)
}}
// Leaving the stage sends null, so the arrow goes rather than sticking
// where the pointer happened to cross the edge.
onPointerLeave={() => onCursor?.(null, null)}
onPointerUp={finish}
onPointerCancel={finish}
onDragOver={e => {
if (!e.dataTransfer.types.includes('application/x-trek-photo')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
const p = pointInMm(e)
setDropTarget(frameUnder(p.x, p.y)?.id ?? null)
}}
onDragLeave={() => setDropTarget(null)}
onDrop={e => {
const raw = e.dataTransfer.getData('application/x-trek-photo')
if (!raw) return
e.preventDefault()
setDropTarget(null)
const photoId = Number(raw)
if (!Number.isFinite(photoId)) return
const p = pointInMm(e)
const target = frameUnder(p.x, p.y)
if (target) {
// Dropped onto a frame: fill it. That is the whole point of an empty
// frame, and replacing a picture is the natural way to swap one.
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(el => (el.id === target.id ? { ...el, photoId } : el)),
})),
}))
select([target.id])
return
}
// Dropped on bare paper: a new frame, centred on the cursor.
const w = Math.min(page.pageWidth, page.pageHeight) * 0.5
const h = w * 0.72
const id = `p-${Math.random().toString(36).slice(2, 9)}`
addElement(spreadIndex, {
id, kind: 'photo',
frame: { x: p.x - w / 2, y: p.y - h / 2, w, h },
rotation: 0, opacity: 1, locked: false,
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask: null, frameStyle: 'none',
} as BookElement)
select([id])
}}
>
<div
className="st-sheet"
style={{
width: `${sheetW}mm`,
height: `${page.pageHeight}mm`,
transform: `scale(${zoom})`,
}}
onPointerDown={() => select([])}
>
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
{cursors && cursors.length > 0 && (
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
)}
{/* Hit targets sit above the page so a photo's own <img> never eats the
gesture, and so a locked element simply is not grabbable. */}
{spread.elements.map(el => (
<div
key={el.id}
onPointerDown={e => { if (editing !== el.id) startMove(e, el) }}
onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
style={{
position: 'absolute',
left: `${el.frame.x}mm`,
top: `${el.frame.y}mm`,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
cursor: el.locked ? 'default' : 'move',
// The target stays live even when locked, or the element could
// not be selected — and an element that cannot be selected cannot
// be unlocked.
pointerEvents: 'auto',
}}
/>
))}
</div>
{/*
Editing happens *on the page*, in the element's own type at the element's
own size. A dialog with a text box would be easier to build and would
make you guess how the words break — the whole reason to type here is to
watch the line endings while you do it.
*/}
{editing && (() => {
const el = spread.elements.find(e => e.id === editing)
if (!el || el.kind !== 'text') return null
return (
<textarea
className="st-inline-edit"
autoFocus
defaultValue={el.text}
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: FONT_STACKS[el.font],
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
color: el.color,
}}
onBlur={e => {
const text = e.target.value
setEditing(null)
if (text !== el.text) {
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(x => (x.id === el.id ? { ...x, text, overridden: true } : x)),
})),
}))
}
}}
onKeyDown={e => {
// Escape here means "stop editing", not "close Studio" — so it must
// not reach the shell's handler.
if (e.key === 'Escape') { e.stopPropagation(); (e.target as HTMLTextAreaElement).blur() }
}}
/>
)
})()}
{/* Chrome layer: drawn in screen pixels so outlines stay hairline at any
zoom instead of growing with the page. */}
<div className="st-chrome">
{bookView && !single && <SpreadFold page={page} scaled={scaled} />}
{/* The frame a drop would land in. Without it you are aiming blind. */}
{dropTarget && (() => {
const el = spread.elements.find(e => e.id === dropTarget)
if (!el) return null
return (
<div
className="st-drop"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
}}
/>
)
})()}
{sel.map(el => (
<div
key={el.id}
className="st-select"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
}}
>
{/* No resize handles on a locked element: they would offer a drag
that is refused, which reads as the editor being broken rather
than as the element being locked. */}
{/*
A rotation handle diagonally outside each corner.
Four rather than one, because which corner is reachable depends on
where the element sits: a handle off the top right alone is under
the inspector for anything in the right page's upper half.
Positioned in percentages of the selection box, so they follow it
at any zoom and turn with it once it is turned — the box itself
carries the rotation, and everything inside it comes along.
Outside the box rather than on it, because a handle on the corner
is a handle you cannot tell from the one that resizes — and this
is the gesture where grabbing the wrong one is most annoying to
undo. Held to 15° steps unless shift is down.
*/}
{sel.length === 1 && !dragging && !el.locked && ROTATE_CORNERS.map(c => (
<div
key={c.id}
className={`st-rotate is-${c.id}`}
style={{ left: c.left, top: c.top }}
onPointerDown={startRotate}
title="Rotate"
>
<RotateCw size={11} />
</div>
))}
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
<span
key={h}
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
onPointerDown={e => startResize(e, h)}
/>
))}
</div>
))}
{/*
The quick bar, the way Canva and Figma do it: the three or four things
you reach for constantly, right where you are already looking, instead
of a trip to the panel on the far side of the screen. It hides while
you drag — chrome that follows your hand around is noise.
*/}
{sel.length >= 1 && !dragging && (() => {
const x0 = Math.min(...sel.map(e => e.frame.x)) * scaled
const x1 = Math.max(...sel.map(e => e.frame.x + e.frame.w)) * scaled
const y0 = Math.min(...sel.map(e => e.frame.y)) * scaled
const locked = sel.every(e => e.locked)
return (
<div
className="st-quickbar"
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button onClick={() => duplicate(spreadIndex, selection)} title="Duplicate">
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title="Bring to front">
<ChevronsUp size={14} />
</button>
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title="Send to back">
<ChevronsDown size={14} />
</button>
</>
)}
{/*
Turn it, a step at a time.
Fifteen degrees per press, and a press with shift is one degree
for the times it has to line up with something. Steps rather
than a handle you drag: a photograph on a page is almost always
either square to it or tilted a little on purpose, and dragging
a rotation past the angle you wanted and back is a worse way to
arrive at either.
*/}
<button
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title="Rotate left"
>
<RotateCcw size={14} />
</button>
<button
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title="Rotate right"
>
<RotateCw size={14} />
</button>
<span className="st-quickbar-sep" />
<button
onClick={() => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
})),
}))}
title={locked ? 'Unlock' : 'Lock'}
>
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button className="is-danger" onClick={() => removeElements(spreadIndex, selection)} title="Delete">
<Trash2 size={14} />
</button>
</div>
)
})()}
{guides.map((g, i) => (
<div
key={i}
className="st-guide"
style={g.axis === 'x'
? { left: g.at * scaled, top: 0, width: 1, height: '100%' }
: { top: g.at * scaled, left: 0, height: 1, width: '100%' }}
/>
))}
</div>
</div>
)
}
export type { BookElement }
@@ -0,0 +1,200 @@
import { useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import ToggleSwitch from '../Settings/ToggleSwitch'
/**
* The inspector's vocabulary.
*
* ── Why this file exists ─────────────────────────────────────────────────
*
* The panel had grown four ways of saying "this one is on" — a filled pill, a
* ring, a tinted border, ink on a tint — and used the *same* pill for a choice
* between four things and for a switch that turns one thing off. A map showed
* seventy controls in ten sections that were all open at once, all labelled in
* the same small capitals, so nothing on the panel looked more important than
* anything else. None of that was a decision; it was what happens when a panel
* grows one control at a time.
*
* So there are now exactly three ways to answer a question here, and each looks
* like what it is:
*
* **Choose one** is a row of chips, and the chosen one is filled.
* **Choose several** is a row of chips that carry a tick when they are on,
* because a set with three of seven picked has to be readable at a glance.
* **On or off** is the app's switch, on its own line beside the words —
* the one control in TREK that already means exactly that.
*
* A section can be folded away, which is the other half of the answer: a
* property panel is a reference, and a reference nobody can collapse is a wall.
*/
/**
* One group of properties.
*
* `defaultOpen={false}` is for the groups you set once and rarely return to —
* the frame's numbers, mostly. The state is per mounted section rather than
* remembered: coming back to an element and finding its panel folded up
* differently from last time is worse than opening one group again.
*/
export function Section({ label, children, defaultOpen = true, hint }: {
label: string
children: ReactNode
defaultOpen?: boolean
/** A line under the label, for a group whose name cannot carry the whole idea. */
hint?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
<button className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
<span className="st-section-label">{label}</span>
<ChevronDown size={13} className="st-section-caret" />
</button>
{open && (
<div className="st-section-body">
{hint && <p className="st-hint st-section-hint">{hint}</p>}
{children}
</div>
)}
</div>
)
}
/** A labelled line. The label sits left, the control fills what is left. */
export function Line({ label, children, wide = false }: {
label: string
children: ReactNode
/** Put the control under the label rather than beside it, when it needs the width. */
wide?: boolean
}) {
return (
<div className={`st-line ${wide ? 'is-wide' : ''}`}>
<span className="st-line-label">{label}</span>
<div className="st-line-body">{children}</div>
</div>
)
}
/** Choose one of a few. The chosen one is filled in. */
export function Choice<T extends string | number>({ value, options, onPick, ariaLabel }: {
value: T
options: { value: T; label: ReactNode; title?: string; disabled?: boolean }[]
onPick: (v: T) => void
ariaLabel?: string
}) {
return (
<div className="st-row" role="radiogroup" aria-label={ariaLabel}>
{options.map(o => (
<button
key={String(o.value)}
role="radio"
aria-checked={o.value === value}
className={`st-chip ${o.value === value ? 'is-on' : ''}`}
disabled={o.disabled}
title={o.title}
onClick={() => onPick(o.value)}
>
{o.label}
</button>
))}
</div>
)
}
/**
* Choose several. A tick marks what is in the set.
*
* Not switches: seven metrics as seven switched lines is a column of switches
* taller than the element it configures, and what matters about a set is which
* of it is in — a question a row answers and a list does not.
*/
export function Picks<T extends string>({ value, options, onToggle }: {
value: readonly T[]
options: { value: T; label: ReactNode; disabled?: boolean }[]
onToggle: (v: T) => void
}) {
return (
<div className="st-row">
{options.map(o => {
const on = value.includes(o.value)
return (
<button
key={o.value}
role="checkbox"
aria-checked={on}
className={`st-chip is-pick ${on ? 'is-on' : ''}`}
disabled={o.disabled}
onClick={() => onToggle(o.value)}
>
{on && <Check size={12} strokeWidth={3} />}
{o.label}
</button>
)
})}
</div>
)
}
/** On or off, said the way the rest of TREK says it. */
export function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
return (
<div className="st-switch">
<span className="st-switch-label">{label}</span>
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
</div>
)
}
/**
* A number, with its unit.
*
* Held as text while it is being typed and only read back on blur or Enter,
* which fixes three things at once: "0." no longer snaps to "0" the moment the
* point is typed, an emptied field no longer collapses the element to zero,
* and a value is one undo step rather than one per keystroke.
*/
export function NumField({
label, value, onChange, min, max, step = 0.5, unit,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
max?: number
step?: number
unit?: string
}) {
const shown = String(Math.round(value * 100) / 100)
const [draft, setDraft] = useState<string | null>(null)
const commit = (raw: string) => {
setDraft(null)
const v = Number(raw)
if (raw.trim() === '' || !Number.isFinite(v)) return
const clamped = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v))
if (clamped !== value) onChange(clamped)
}
return (
<label className="st-num">
<span className="st-num-label">{label}</span>
<span className="st-num-box">
<input
className="st-input"
type="number"
value={draft ?? shown}
step={step}
min={min}
max={max}
onChange={e => setDraft(e.target.value)}
onBlur={e => commit(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
if (e.key === 'Escape') setDraft(null)
}}
/>
{unit && <span className="st-num-unit">{unit}</span>}
</span>
</label>
)
}
@@ -0,0 +1,443 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Minus, Quote, Search, Square, X } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { GRIDS, defaultGridBox, gridElements } from './grids'
import { PanelHead } from './StudioPanelHead'
import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary'
/**
* Everything you can add that does not come from the journey: type, shapes,
* picture frames, photo grids and the icon set.
*
* Four tabs rather than four more entries on the rail. They are all the same
* kind of answer to "what do I put on this page", and a rail long enough to
* hold them separately stops being faster to hit than a word would be.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/** How many icons the grid draws at a time. Two screens' worth at four across. */
const ICON_PAGE = 120
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
/**
* A shape as a tile.
*
* The same path the page will draw, so what you pick is what you get. In frame
* mode it is filled with a sky-and-hill gradient, which is Canva's trick and a
* good one: an outline tells you the silhouette, but only something
* photograph-shaped inside it says "a picture goes here".
*/
export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) {
const gid = `sg-${photo ? 'p' : 's'}-${shape}`
return (
<svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-hidden>
{photo && (
<defs>
{/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */}
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#cfe8f7" />
<stop offset="52%" stopColor="#e8f2f8" />
<stop offset="53%" stopColor="#8fbf59" />
<stop offset="100%" stopColor="#5d8f33" />
</linearGradient>
</defs>
)}
<path
d={SHAPE_PATHS[shape]}
fill={photo ? `url(#${gid})` : 'currentColor'}
fillRule={HOLED_SHAPES.has(shape) ? 'evenodd' : undefined}
/>
</svg>
)
}
/** A miniature of what the decoration around a picture does. */
function FrameStyleGlyph({ style }: { style: FrameStyle }) {
const pad = style === 'polaroid' ? 3 : style === 'white' ? 2 : style === 'film' ? 4 : 0
const bottom = style === 'polaroid' ? 9 : pad
return (
<span
className="st-frame-glyph"
style={{
// theme-lint-disable — a frame preview shows paper and film, not themed surfaces
background: style === 'film' ? '#141414'
: style === 'polaroid' || style === 'white' ? '#ffffff'
: 'transparent',
boxShadow: style === 'shadow' || style === 'polaroid' ? '0 2px 4px rgba(0,0,0,.35)' : undefined,
}}
>
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
{/* Both strips, because both is what gets placed — a preview showing one
made the second one look like a bug the first time it appeared. */}
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
</span>
)
}
function IconCell({ name, onPick }: { name: string; onPick: (name: string) => void }) {
const Icon = iconComponent(name)
return (
<button
className="st-shape-btn is-glyph"
onClick={() => onPick(name)}
aria-label={name}
title={iconLabel(name)}
>
<Icon strokeWidth={1.7} />
</button>
)
}
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids' | 'icons'>('shapes')
const [iconQuery, setIconQuery] = useState('')
/*
* How much of the library is on screen.
*
* Fourteen hundred icons is fourteen hundred React components, each a
* forwardRef around several SVG nodes, and rendering them all costs about a
* second of blocked main thread on the first click of the tab — for a grid
* whose first two rows are all anybody sees. So it grows as it is scrolled,
* and a search resets it: the results of a search are short, and starting
* them part-drawn would be wrong.
*/
const [iconLimit, setIconLimit] = useState(ICON_PAGE)
const iconTail = useRef<HTMLDivElement>(null)
const addElement = useStudioStore(s => s.addElement)
const commit = useStudioStore(s => s.commit)
const select = useStudioStore(s => s.select)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) =>
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
rotation: 0, opacity: 1, locked: false,
text: sample, font: 'sans', size, weight, italic: false,
align: 'left', leading: size > 16 ? 1.1 : 1.5, tracking: size > 16 ? -0.02 : 0,
color: '#1a1a1a', binding: null, overridden: false, ...extra,
} as BookElement)
/**
* Placed at a size that suits the shape rather than always as a square.
*
* A banner wants to be wide and a pin wants to be tall; dropping every shape
* as one 60mm square means resizing each one before it is any use, which is
* a tax on the most common action in the panel.
*/
const addShape = (shape: BookShapeId, opts: { outline?: boolean } = {}) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3
const wide = shape.startsWith('banner') || shape === 'ticket' || shape === 'wave'
|| shape === 'arrow-right' || shape === 'arrow-left' || shape === 'arrow-both'
|| shape === 'capsule'
const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down'
const isRule = shape === 'line'
addElement(active, {
id: uid('s'), kind: 'shape',
frame: isRule
? centre(page.pageWidth * 0.5, 0.5)
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
rotation: 0, opacity: 1, locked: false,
// A rule is a shape too — a hairline box rather than its own element type,
// so it moves, colours and snaps like everything else on the page.
shape: isRule ? 'rect' : shape,
fill: opts.outline ? null : '#141414',
gradient: 'none',
stroke: opts.outline ? '#141414' : null,
strokeWidth: opts.outline ? 0.5 : 0,
strokeStyle: 'solid',
radius: 0,
} as BookElement)
}
/**
* An icon, placed as a square.
*
* Square because that is what it is: unlike a shape, an icon keeps its
* proportions inside whatever frame it is given, so a rectangle would only
* pad it with air on two sides.
*/
const addIcon = (name: string) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.18
addElement(active, {
id: uid('i'), kind: 'icon', frame: centre(side, side),
rotation: 0, opacity: 1, locked: false,
name, color: '#111827', lineWidth: 2,
} as BookElement)
}
/** An empty frame, cut to a shape and waiting for a picture. */
const addFrame = (mask: BookShapeId | null, frameStyle: FrameStyle = 'none') => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.45
addElement(active, {
id: uid('p'), kind: 'photo',
// A Polaroid is taller than it is wide, because of the chin. Placing it
// square would put the picture in a box the decoration then eats.
frame: centre(side, frameStyle === 'polaroid' ? side * 1.16 : side),
rotation: 0, opacity: 1, locked: false,
photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask, frameStyle,
} as BookElement)
}
const matchedIcons = useMemo(() => searchIcons(iconQuery), [iconQuery])
const shownIcons = useMemo(() => matchedIcons.slice(0, iconLimit), [matchedIcons, iconLimit])
/*
* Grow the grid when its foot comes into view.
*
* An observer rather than a scroll handler: the panel is the scroller, the
* foot is the thing that matters, and asking the browser to tell us when they
* meet costs nothing per frame.
*/
useEffect(() => {
const tail = iconTail.current
if (!tail) return
const io = new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) setIconLimit(n => n + ICON_PAGE)
})
io.observe(tail)
return () => io.disconnect()
}, [shownIcons.length, matchedIcons.length])
return (
<>
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs">
<button className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
<button className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
{t('journey.studio.frames')}
</button>
<button className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
{t('journey.studio.grids')}
</button>
<button className={tab === 'icons' ? 'is-on' : ''} onClick={() => setTab('icons')}>
{t('journey.studio.icons')}
</button>
</div>
<div className="st-panel-scroll">
{tab === 'shapes' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
<button className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
<Minus size={20} strokeWidth={2} />
</button>
<button
className="st-shape-btn"
onClick={() => addShape('rect', { outline: true })}
title={t('journey.studio.shapeKind.outline')}
>
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
</button>
<button
className="st-shape-btn"
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
title={t('journey.studio.quoteMark')}
>
<Quote size={20} strokeWidth={1.6} />
</button>
</div>
</div>
{SHAPE_GROUPS.map(group => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
<div className="st-shape-grid">
{group.shapes.map(shape => (
<button
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addShape(shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} />
</button>
))}
</div>
</div>
))}
</>
)}
{tab === 'frames' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
<div className="st-shape-grid">
{([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as [FrameStyle, string][]).map(([style, key]) => (
<button
key={style}
className="st-shape-btn is-frame"
onClick={() => addFrame(null, style)}
title={t(`journey.studio.${key}`)}
aria-label={t(`journey.studio.${key}`)}
>
<FrameStyleGlyph style={style} />
</button>
))}
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
<div className="st-shape-grid">
{FRAME_SHAPES.map(shape => (
<button
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addFrame(shape === 'rect' ? null : shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} photo />
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
</div>
</>
)}
{tab === 'grids' && (
<div className="st-section">
<div className="st-section-label">{t('journey.studio.grids')}</div>
<div className="st-grid-grid">
{GRIDS.map(grid => (
<button
key={grid.id}
className="st-grid-btn"
onClick={() => {
const els = gridElements(grid, defaultGridBox(page, single))
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) =>
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
}))
// Selected as a group, so the block can be moved or resized
// as one thing straight away rather than cell by cell.
select(els.map(e => e.id))
}}
aria-label={grid.id}
title={`${grid.cells.length}`}
>
{/* The arrangement itself: "hero-two" means nothing until you
have seen it, and a diagram is faster to read than a name. */}
<span className="st-grid-preview">
{grid.cells.map((cell, i) => (
<span
key={i}
style={{
left: `${cell.x * 100}%`,
top: `${cell.y * 100}%`,
width: `${cell.w * 100}%`,
height: `${cell.h * 100}%`,
}}
/>
))}
</span>
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
</div>
)}
{tab === 'icons' && (
<div className="st-section">
{/*
The search sits inside the scroller rather than above the tab
strip, unlike the Content panel's: that one filters both of its
tabs, this one belongs to a single tab, and hanging it above the
strip would make the strip move when you switch away from it.
*/}
<div className="st-search" style={{ margin: '0 0 10px' }}>
<Search size={14} />
<input
value={iconQuery}
onChange={e => { setIconQuery(e.target.value); setIconLimit(ICON_PAGE) }}
placeholder={t('journey.studio.searchIcons')}
aria-label={t('journey.studio.searchIcons')}
spellCheck={false}
/>
{iconQuery && (
<button onClick={() => { setIconQuery(''); setIconLimit(ICON_PAGE) }} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
{/* The shelf, until somebody searches. */}
{!iconQuery && (
<>
<div className="st-section-label">{t('journey.studio.iconsForTravel')}</div>
<div className="st-shape-grid" style={{ marginBottom: 14 }}>
{FEATURED_ICONS.map(name => (
<IconCell key={name} name={name} onPick={addIcon} />
))}
</div>
<div className="st-section-label">{t('journey.studio.iconsAll')}</div>
</>
)}
<div className="st-shape-grid">
{shownIcons.map(name => (
<IconCell key={name} name={name} onPick={addIcon} />
))}
</div>
{/* What the observer below watches for. */}
{shownIcons.length < matchedIcons.length && <div ref={iconTail} style={{ height: 1 }} />}
{!matchedIcons.length && (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.noMatches')}</p>
)}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react'
import type { BookDocument } from '@trek/shared'
import { BookSheetsView } from './BookSheetsView'
import { sheetBox, sheetsFor, type SheetMode } from './bookSheets'
import { printSheets } from './printSheets'
/**
* Getting the book out.
*
* ── Two questions, not a settings panel ──────────────────────────────────
*
* Leaves or spreads, and marks or no marks. Everything else a print dialog
* usually asks — resolution, colour profile, embedding — either has one right
* answer here or is not ours to give: the images go out at full size because
* anything else is a worse book, and the profile belongs to whoever is doing
* the printing.
*
* ── Why the sheets are rendered here rather than built as a string ───────
*
* They are the editor's own components, and those read context — the active
* locale, most visibly, which is what a stats element and a date line are
* written in. Rendering them to markup outside the tree would mean standing up
* that context again and getting a book in English for someone who wrote it in
* German. So they render inside the app, off screen, and the markup is taken
* from the DOM afterwards.
*/
export function StudioExport({
doc, title, t, onClose,
}: {
doc: BookDocument
title: string
t: (key: string, params?: Record<string, string | number>) => string
onClose: () => void
}) {
const [mode, setMode] = useState<SheetMode>('pages')
const [marks, setMarks] = useState(true)
/** Set once the user has asked for it — this is what triggers the render. */
const [building, setBuilding] = useState(false)
const stage = useRef<HTMLDivElement>(null)
const sheets = sheetsFor(doc, mode)
/*
* Two sizes, because spread mode mixes them: covers are one page and
* everything between them is two. The wider one is the document's page box
* and the narrower gets a named rule — see printSheets.
*/
const widest = Math.max(...sheets.map(s => s.width), doc.page.pageWidth)
const box = sheetBox(widest, doc.page.pageHeight, doc.page.bleed, marks)
const single = sheetBox(doc.page.pageWidth, doc.page.pageHeight, doc.page.bleed, marks)
useEffect(() => {
if (!building) return
const html = stage.current?.innerHTML
if (!html) return
printSheets({
html,
sheetWidth: box.width,
sheetHeight: box.height,
singleWidth: single.width,
singleHeight: single.height,
title,
labels: {
save: t('journey.studio.exportSave'),
close: t('common.close'),
count: t('journey.studio.exportSheetCount', { count: sheets.length }),
preparing: t('journey.studio.exportPreparing'),
},
})
setBuilding(false)
onClose()
// Runs once per build. Re-running on every render of the options would
// open a second print view behind the first.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building])
return (
<div className="st-ex" role="dialog" aria-modal="true" aria-label={t('journey.studio.export')}>
<div className="st-ex-card">
<div className="st-ex-head">
<span>{t('journey.studio.export')}</span>
<button className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
<X size={15} />
</button>
</div>
<div className="st-ex-body">
<Choice<SheetMode>
label={t('journey.studio.exportLayout')}
options={[
{
id: 'pages',
icon: FileText,
name: t('journey.studio.exportPages'),
hint: t('journey.studio.exportPagesHint'),
},
{
id: 'spreads',
icon: BookOpen,
name: t('journey.studio.exportSpreads'),
hint: t('journey.studio.exportSpreadsHint'),
},
]}
value={mode}
onPick={setMode}
/>
<div className="st-ex-field">
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
<button
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
onClick={() => setMarks(!marks)}
aria-pressed={marks}
>
<Scissors size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{t('journey.studio.exportMarks')}</span>
<span className="st-ex-hint">
{t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })}
</span>
</span>
</button>
</div>
<p className="st-ex-note">
{t('journey.studio.exportNote', {
sheets: sheets.length,
width: round1(box.width),
height: round1(box.height),
})}
</p>
</div>
<div className="st-ex-foot">
<button className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
<button className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
<Printer size={14} />
<span>{t('journey.studio.exportOpen')}</span>
</button>
</div>
</div>
{/*
The sheets, rendered where nobody can see them.
Off screen rather than `display: none`: a hidden subtree lays nothing
out, and these are measured in millimetres by the same CSS that will
print them — a book built from an unlaid-out tree is a book of empty
boxes. `aria-hidden` keeps the whole thing out of the reading order.
*/}
{building && (
<div
ref={stage}
aria-hidden="true"
style={{ position: 'fixed', left: '-20000mm', top: 0, pointerEvents: 'none' }}
>
<BookSheetsView doc={doc} mode={mode} marks={marks} />
</div>
)}
</div>
)
}
const round1 = (n: number) => Math.round(n * 10) / 10
function Choice<T extends string>({
label, options, value, onPick,
}: {
label: string
options: { id: T; icon: typeof FileText; name: string; hint: string }[]
value: T
onPick: (id: T) => void
}) {
return (
<div className="st-ex-field">
<span className="st-ex-label">{label}</span>
{options.map(opt => (
<button
key={opt.id}
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
onClick={() => onPick(opt.id)}
aria-pressed={value === opt.id}
>
<opt.icon size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{opt.name}</span>
<span className="st-ex-hint">{opt.hint}</span>
</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,757 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
ChevronsDown, ChevronsUp, Copy, Italic, Lock, Trash2, Unlock,
} from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import type { JourneySource } from './StudioSidebar'
/** How many icons the swap grid offers at once. A search narrows it below this. */
const ICON_CHOICES = 60
/**
* Properties of whatever is selected.
*
* ── What it shows, and in what order ─────────────────────────────────────
*
* Deliberately narrow: what the selected thing actually has, and nothing else.
* A panel that lists every property of every element with most of them greyed
* out reads as a settings dialog, not as a tool.
*
* The order is the same for every kind, because the panel is used by muscle
* memory more than by reading: **what it says** first, then **how it looks**,
* then **where it sits**, then **what is in front of what**. A photo's crop and
* a mark's words are the same step in that sequence, so they sit in the same
* place on the panel.
*
* The frame's numbers are folded away by default. They are the least-used group
* in the panel — dragging is how anyone actually positions an element — and
* four number fields left open at the top pushed everything worth looking at
* below the fold.
*
* ── What is not here any more ────────────────────────────────────────────
*
* Duplicate, lock and delete are in the panel head rather than in a section of
* their own: they act on the element rather than describing it, and a group
* called "Arrange" that held two of those next to the stacking buttons was the
* one group in the panel whose name did not say what was inside it.
*/
export function StudioInspector({
spreadIndex,
page,
stats,
source,
setPageNumbers,
t,
locale,
}: {
spreadIndex: number
page: BookPageSetup
setPageNumbers: (patch: Partial<BookPageNumbers>) => void
/** Live journey figures, so a travel element can be brought up to date. */
stats: JourneyStats | null
/** The journal itself, so a bound mark can be re-set from it. */
source: JourneySource
t: (k: string) => string
locale: string
}) {
const [iconQuery, setIconQuery] = useState('')
/*
* A page of results, memoised because the inspector re-renders on every
* pointer move of a drag and the library is fourteen hundred names long.
*/
const iconChoices = useMemo(() => searchIcons(iconQuery).slice(0, ICON_CHOICES), [iconQuery])
const doc = useStudioStore(s => s.doc)
const selection = useStudioStore(s => s.selection)
const update = useStudioStore(s => s.updateElement)
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const duplicate = useStudioStore(s => s.duplicate)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
if (!sel.length) {
/*
* Nothing selected, so this is where the *book's* own settings belong.
* The panel was showing one line of hint text and nothing else, and page
* numbers have nowhere else to go: they are not a property of any element,
* because the number a page carries depends on where its spread sits.
*/
const folios = page.pageNumbers
return (
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<Section label={t('journey.studio.pageNumbers')}>
<Switch
label={t('journey.studio.pageNumbers')}
on={folios.show}
onToggle={() => setPageNumbers({ show: !folios.show })}
/>
{folios.show && (
<>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.position')}>
<Choice
value={folios.position}
options={(['outer', 'inner', 'centre'] as const).map(pos => ({
value: pos, label: t(`journey.studio.folio.${pos}`),
}))}
onPick={position => setPageNumbers({ position })}
/>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioStart')}
value={folios.startAt}
min={0}
max={9999}
step={1}
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
/>
<NumField
label={t('journey.studio.size')}
value={folios.size}
min={4}
max={48}
step={0.5}
unit="pt"
onChange={v => setPageNumbers({ size: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.folioMargin')}
value={folios.margin}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => setPageNumbers({ margin: v })}
/>
</div>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.folioAuto')}
on={folios.autoColor}
onToggle={() => setPageNumbers({ autoColor: !folios.autoColor })}
/>
</div>
{/* Picking a colour is the other way to turn automatic off, so
the row stays reachable and simply reads as inactive. */}
<div style={{ marginTop: 10, opacity: folios.autoColor ? 0.45 : 1 }}>
<Line label={t('journey.studio.colour')} wide>
<Swatches
value={folios.color}
onPick={c => setPageNumbers({ color: c, autoColor: false })}
/>
</Line>
</div>
</>
)}
</Section>
<p className="st-hint" style={{ paddingTop: 10 }}>
{t('journey.studio.inspectorEmpty')}
</p>
<p className="st-hint" style={{ paddingTop: 6 }}>
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
</p>
</div>
</aside>
)
}
const el = sel[0]
const one = sel.length === 1
// A property edit is one undo step on its own; only drags open a gesture.
const set = (patch: Partial<BookElement>) => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? ({ ...e, ...patch } as BookElement) : e)),
})),
}))
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
return (
<aside className="st-panel st-inspector">
{/*
The head says what is selected and carries the three things you do TO
it. It used to say only the kind, in the same small capitals as every
section label below it, which left the panel with no top.
*/}
<div className="st-panel-head st-inspector-head">
<span className="st-what">
{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}
{sel.length > 1 && <em>{sel.length}</em>}
</span>
<span className="st-head-acts">
<button
className="st-act"
onClick={() => duplicate(spreadIndex, selection)}
title={t('journey.studio.duplicate')}
aria-label={t('journey.studio.duplicate')}
>
<Copy size={14} />
</button>
<button
className={`st-act ${el.locked ? 'is-on' : ''}`}
onClick={() => set({ locked: !el.locked })}
title={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-label={t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
aria-pressed={el.locked}
>
{el.locked ? <Lock size={14} /> : <Unlock size={14} />}
</button>
<button
className="st-act is-danger"
onClick={() => removeElements(spreadIndex, selection)}
title={t('journey.studio.delete')}
aria-label={t('journey.studio.delete')}
>
<Trash2 size={14} />
</button>
</span>
</div>
<div className="st-panel-scroll">
{el.kind === 'text' && (
<>
<Section label={t('journey.studio.text')}>
<textarea
className="st-input st-textarea"
value={el.text}
rows={5}
onChange={e => live({ text: e.target.value, overridden: true })}
onBlur={e => set({ text: e.target.value, overridden: true })}
/>
{el.binding && !el.overridden && (
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
)}
{/*
How a point is set. Only a coordinate binding has the question,
and answering it re-sets the words on the spot rather than at
the next open — switching a notation and seeing nothing happen
reads as a broken control. Deliberately not `overridden`:
choosing a format is not writing the text.
*/}
{el.binding?.source === 'entry.location' && el.binding.format && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.coordsMark')}>
<Choice
value={el.binding.format}
options={[
{ value: 'dms' as const, label: t('journey.studio.coordsDms') },
{ value: 'decimal' as const, label: t('journey.studio.coordsDecimal') },
]}
onPick={f => {
const entry = source.entries.find(e => e.id === el.binding!.entryId)
const text = entry?.lat != null && entry.lng != null
? formatBookCoords(entry.lat, entry.lng, f)
: el.text
set({ binding: { ...el.binding!, format: f }, text })
}}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.typography')}>
{/*
Each family set in itself, because a list of names tells you
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<div className="st-fonts">
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight would render a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
})}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.weight')}>
<Choice
value={el.weight}
options={([400, 500, 600, 700] as const).map(w => ({
value: w,
label: String(w),
disabled: !hasWeight(el.font, w),
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={weight => set({ weight })}
/>
</Line>
<Line label={t('journey.studio.align')}>
<div className="st-row">
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
<button
key={a}
className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`}
onClick={() => set({ align: a })}
aria-label={a}
>
<Icon size={14} />
</button>
))}
{/* Italic sits with the alignment because both are things
you do to a line rather than to a typeface. It had no
control at all before, though the renderer drew it. */}
<button
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={t('journey.studio.italic')}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
<Italic size={14} />
</button>
</div>
</Line>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField label={t('journey.studio.size')} value={el.size} min={4} max={200} step={0.5} unit="pt" onChange={v => set({ size: v })} />
<NumField label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div style={{ marginTop: 10 }}>
{/*
Letter spacing was rendered but had no control, so type the
layout had tracked out could be edited in every way except the
one that made it look like that.
*/}
<NumField
label={t('journey.studio.tracking')}
value={el.tracking}
min={-0.2}
max={1}
step={0.01}
unit="em"
onChange={v => set({ tracking: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.text')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
</Line>
</Section>
</>
)}
{el.kind === 'photo' && (
<>
<Section label={t('journey.studio.crop')}>
<Line label={t('journey.studio.crop')} wide>
<Choice
value={el.fit}
options={(['cover', 'contain'] as const).map(f => ({
value: f, label: t(`journey.studio.fit.${f}`),
}))}
onPick={fit => set({ fit })}
/>
</Line>
{/*
An empty frame has no picture to aim at, and asking for one by
id produced a request for /api/photos/null/thumbnail and a
broken image under a caption telling you to drag it.
*/}
{el.photoId != null ? (
<FocalPad
photoId={el.photoId}
x={el.focalX}
y={el.focalY}
onDrag={(x, y) => live({ focalX: x, focalY: y })}
onCommit={(x, y) => set({ focalX: x, focalY: y })}
hint={t('journey.studio.focalHint')}
/>
) : (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
)}
</Section>
<Section label={t('journey.studio.look')}>
<Line label={t('journey.studio.look')} wide>
<Choice
value={el.filter}
options={(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => ({
value: f, label: t(`journey.studio.filter.${f}`),
}))}
onPick={filter => set({ filter })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.frameStyle')} wide>
<Choice
value={el.frameStyle}
options={([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={frameStyle => set({ frameStyle })}
/>
</Line>
</div>
{/* A corner radius and a mask are the same idea at different
resolutions, so the radius steps aside once a shape is cut.
It did not, and edited a property the renderer ignored. */}
{(!el.mask || el.mask === 'rect') && (
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
)}
</Section>
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
>
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
<button
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
</Section>
</>
)}
{el.kind === 'shape' && (
<>
<Section label={t('journey.studio.shape')}>
{/* Every shape, not just the two the element started as: swapping
a placed shape keeps its position, size and colour, which
deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
<button
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
<div style={{ marginTop: 10 }}>
{/* A photo had a corner radius and a shape did not, although
both store the same field and the renderer draws both. */}
<NumField
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
unit="mm"
onChange={v => set({ radius: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.colour')}>
{/*
Fill and stroke, both named. The panel used to show one
unlabelled swatch row that wrote `fill`, so on an outline-only
shape it lit up a colour that was not being drawn and there was
no way to reach the line you could actually see.
*/}
<Line label={t('journey.studio.fill')} wide>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</Line>
<Line label={t('journey.studio.stroke')} wide>
<Swatches
value={el.stroke ?? '#141414'}
onPick={c => set({ stroke: c, strokeWidth: el.strokeWidth || 0.5 })}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.fillOn')}
on={el.fill !== null}
onToggle={() => set({
// Turning the fill off on a shape with no stroke would make
// it invisible, so the outline comes on with it.
fill: el.fill === null ? '#111111' : null,
...(el.fill !== null && !el.stroke ? { stroke: '#141414', strokeWidth: el.strokeWidth || 0.5 } : {}),
})}
/>
<Switch
label={t('journey.studio.gradient')}
on={el.gradient !== 'none'}
onToggle={() => set({ gradient: el.gradient === 'none' ? 'down' : 'none' })}
/>
</div>
{el.gradient !== 'none' && (
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.gradient')}>
<Choice
value={el.gradient}
options={[
{ value: 'down' as const, label: t('journey.studio.gradientDown') },
{ value: 'up' as const, label: t('journey.studio.gradientUp') },
]}
onPick={gradient => set({ gradient })}
/>
</Line>
</div>
)}
</Section>
<Section label={t('journey.studio.strokeStyle')} defaultOpen={false}>
<Line label={t('journey.studio.strokeStyle')} wide>
<Choice
value={el.strokeStyle}
options={([
['solid', 'strokeSolid'],
['dashed', 'strokeDashed'],
['dotted', 'strokeDotted'],
] as const).map(([style, key]) => ({ value: style, label: t(`journey.studio.${key}`) }))}
onPick={strokeStyle => set({
strokeStyle,
// A stroke style with no stroke is invisible, so asking for
// dashes turns the outline on rather than doing nothing.
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
})}
/>
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.strokeWidth')}
value={el.strokeWidth}
min={0}
max={20}
step={0.1}
unit="mm"
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
/>
</div>
</Section>
</>
)}
{el.kind === 'icon' && (
<>
<Section label={t('journey.studio.icon')}>
{/*
A search rather than the whole library: the picker in the panel
can afford to grow as it is scrolled, but the inspector is a
column beside a page and its job here is swapping one drawing
for a near neighbour. Swapping keeps the position, size and
colour that deleting and re-placing would lose.
*/}
<input
className="st-input"
value={iconQuery}
onChange={e => setIconQuery(e.target.value)}
placeholder={t('journey.studio.searchIcons')}
aria-label={t('journey.studio.searchIcons')}
spellCheck={false}
/>
<div className="st-mini-shapes" style={{ marginTop: 8 }}>
{iconChoices.map(name => {
const Glyph = iconComponent(name)
return (
<button
key={name}
className={`st-mini-shape ${el.name === name ? 'is-on' : ''}`}
onClick={() => set({ name })}
aria-label={name}
title={iconLabel(name)}
>
<Glyph strokeWidth={1.7} />
</button>
)
})}
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.icon')} wide>
<Swatches value={el.color} onPick={c => set({ color: c })} />
</Line>
<div style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.lineWidth')}
value={el.lineWidth}
min={0.25}
max={4}
step={0.25}
onChange={v => set({ lineWidth: v })}
/>
</div>
</Section>
</>
)}
<TravelInspector el={el} stats={stats} set={set} t={t} locale={locale} />
{/*
Where it sits, folded away.
Everything in here can be done by dragging, which is how it is
actually done; the numbers are for the one time in ten you need the
same margin on two pages. Opacity and rotation live here too — both
were painted by the renderer and neither had a control anywhere.
*/}
{one && (
<Section label={t('journey.studio.position')} defaultOpen={false}>
<div className="st-grid2">
<NumField label="X" value={el.frame.x} unit="mm" onChange={v => set({ frame: { ...el.frame, x: v } })} />
<NumField label="Y" value={el.frame.y} unit="mm" onChange={v => set({ frame: { ...el.frame, y: v } })} />
<NumField label={t('journey.studio.width')} value={el.frame.w} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, w: v } })} />
<NumField label={t('journey.studio.height')} value={el.frame.h} min={4} unit="mm" onChange={v => set({ frame: { ...el.frame, h: v } })} />
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={v => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={v => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
</Section>
)}
<Section label={t('journey.studio.arrange')} defaultOpen={false}>
<div className="st-row">
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
</Section>
</div>
</aside>
)
}
/**
* Where the picture is anchored inside its frame.
*
* Two sliders were the first draft and they were the wrong instrument: a focal
* point is one position in a plane, not two unrelated numbers, and nobody can
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
x: number
y: number
onDrag: (x: number, y: number) => void
onCommit: (x: number, y: number) => void
hint: string
}) {
const box = useRef<HTMLDivElement>(null)
const last = useRef({ x, y })
const from = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect()
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
last.current = { x: nx, y: ny }
return last.current
}
return (
<div className="st-focal-wrap">
<div
className="st-focal"
ref={box}
onPointerDown={e => {
(e.target as Element).setPointerCapture(e.pointerId)
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerMove={e => {
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerUp={() => onCommit(last.current.x, last.current.y)}
>
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
</div>
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
</div>
)
}
@@ -0,0 +1,15 @@
/**
* The bar at the top of a Studio panel: what it is, and how many.
*
* Its own file so the three panels can share it without any of them having to
* import the others — `StudioSidebar` owns the rail, and a panel that reached
* back into it for a header would make the sidebar impossible to split.
*/
export function PanelHead({ label, count }: { label: string; count?: number }) {
return (
<div className="st-panel-head">
<span>{label}</span>
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
</div>
)
}
@@ -0,0 +1,467 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { StudioSidebar } from './StudioSidebar'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioWordmark } from './StudioWordmark'
import { SaveIndicator } from './SaveIndicator'
import { downloadSpread } from './spreadFile'
import { StudioExport } from './StudioExport'
import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
/**
* The Studio shell: top bar, page rail, workbench, inspector.
*
* It is a child route of the journey and portals itself over it, so the journey
* stays mounted underneath and shows through the blurred 16px margin. That
* margin is the whole point — you are meant to see that you never left.
*
* `.trek-dash` on the root is not decoration: every colour, radius and shadow in
* studio.css resolves against those tokens, so Studio follows the user's theme
* and accent without a second palette to keep in sync.
*/
export default function StudioShell() {
const s = useJourneyStudio()
const navigate = useNavigate()
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
if (s.isMobile) {
return createPortal(
<div className="st-phone trek-dash">
<BookOpen size={30} strokeWidth={1.5} style={{ color: 'var(--ink-3)' }} />
<div style={{ fontSize: 17, fontWeight: 600, letterSpacing: '-0.02em' }}>
{s.t('journey.studio.desktopOnly')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-3)', maxWidth: 320, lineHeight: 1.55 }}>
{s.t('journey.studio.desktopOnlyHint')}
</div>
<button
onClick={() => navigate(s.backTo, { replace: true })}
className="st-tool is-primary"
style={{ marginTop: 6 }}
>
{s.t('journey.studio.backToJourney')}
</button>
</div>,
document.body,
)
}
const originStyle = s.origin
? ({ '--st-ox': `${s.origin.x}px`, '--st-oy': `${s.origin.y}px` } as React.CSSProperties)
: undefined
return createPortal(
<>
<div
className={`st-scrim ${s.closing ? 'st-scrim-exit' : 'st-scrim-enter'}`}
onClick={s.close}
aria-hidden
/>
<div
className={`st-root trek-dash ${s.closing ? 'st-exit' : 'st-enter'}`}
style={originStyle}
role="dialog"
aria-modal="true"
aria-label={s.t('journey.studio.title')}
>
<StudioBar s={s} bookView={bookView} setBookView={setBookView} onExport={() => setExporting(true)} />
<div className="st-body">
<StudioSidebar
page={s.page}
pxPerMm={s.pxPerMm}
bookView={bookView}
source={s.source}
stats={s.stats}
path={s.path}
t={s.t}
locale={s.locale}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
source={s.source}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
</div>
{exporting && s.doc && (
<StudioExport
doc={s.doc}
title={s.doc.title || s.journey?.title || ''}
t={s.t}
onClose={() => setExporting(false)}
/>
)}
</div>
</>,
document.body,
)
}
type Studio = ReturnType<typeof useJourneyStudio>
function StudioBar({
s, bookView, setBookView, onExport,
}: { s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void }) {
return (
<div className="st-bar">
<button onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<ArrowLeft size={16} />
{s.coverUrl
? <img src={s.coverUrl} alt="" className="st-cover" />
: <span className="st-cover" />}
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
</button>
<div className="st-sep" />
{/*
The lockup, not a name field. The book is the journey's, and it was
already titled after it — a second place to type the same name is a
field whose only job is to be left alone. What the bar is short of is
somewhere to say what this thing *is*.
*/}
<div className="st-brand">
<StudioWordmark style={{ height: 26 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<PeerBadges peers={s.peers} t={s.t} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => { void s.saveNow() }}
/>
<div className="st-bar-group">
<button
className={`st-tool ${bookView ? 'is-on' : ''}`}
onClick={() => setBookView(!bookView)}
title={s.t('journey.studio.bookView')}
>
<BookOpen size={14} />
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
</button>
<div className="st-sep" />
<FormatPicker s={s} />
<div className="st-sep" />
<button
className="st-tool is-icon"
disabled={!s.canUndo}
onClick={s.undo}
title={s.t('journey.studio.undo')}
aria-label={s.t('journey.studio.undo')}
>
<Undo2 size={15} />
</button>
<button
className="st-tool is-icon"
disabled={!s.canRedo}
onClick={s.redo}
title={s.t('journey.studio.redo')}
aria-label={s.t('journey.studio.redo')}
>
<Redo2 size={15} />
</button>
<div className="st-sep" />
<AutoLayoutButton s={s} />
<button
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
</div>
</div>
)
}
/**
* Lay out again — this spread, or the whole book.
*
* A split button rather than two: they are the same action at two scopes, and
* the spread is the one you want nine times out of ten. Rebuilding the whole
* book is behind the chevron, where it cannot be hit by accident, and both are
* ordinary undo steps.
*/
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
return (
<div className="st-picker" ref={box}>
<button
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button
className="st-menu-item"
role="menuitem"
onClick={() => { s.relayoutBook(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
)
}
/**
* The format picker.
*
* A native <select> was the first draft and it was wrong: the browser draws the
* list in its own chrome, so it ignores the theme entirely and drops a list that
* runs off the bottom of the screen. This is a small popover instead — themed,
* anchored under the bar, and it shows each format's proportions rather than
* only naming them.
*/
function FormatPicker({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
// Swallow it, or the shell's Escape handler would close Studio itself.
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
return (
<div className="st-picker" ref={box}>
<button
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span className="st-tool-label">
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="listbox">
{PAGE_PRESET_ORDER.map(id => {
const p = PAGE_PRESETS[id]
const on = id === s.preset
return (
<button
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => { s.setPreset(id); setOpen(false) }}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
</span>
{on && <Check size={14} />}
</button>
)
})}
{/*
The two fields live under the list rather than behind a "custom"
entry that opens a second popover: picking the custom row and then
typing into the fields it reveals is two steps for one decision, and
typing into them *is* picking custom — the hook switches the preset
for you.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={v => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={v => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
{/*
What the press takes off, and what it must not print into.
Under the trim size because they answer the same question — how big
is this book, really — and because a vendor states all three in one
breath. They were fixed at 3 and 5, which is what most photo-book
printers ask for and is useless to the one that asks for 5 and 10.
*/}
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.bleed')}
value={s.page.bleed}
min={0}
max={20}
step={0.5}
onCommit={v => s.setPageEdge('bleed', v)}
/>
<span className="st-menu-times" />
<TrimField
label={s.t('journey.studio.safeArea')}
value={s.page.safe}
min={0}
max={40}
step={0.5}
onCommit={v => s.setPageEdge('safe', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
)
}
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
return (
<div className="st-work-wrap">
<div className="st-work" ref={s.workRef}>
<StudioCanvas
spread={s.spread}
spreadIndex={s.activeSpread}
page={s.page}
zoom={s.zoom}
pxPerMm={s.pxPerMm}
bookView={bookView}
dropLabel={s.t('journey.studio.dropPhotoHere')}
cursors={s.cursors}
onCursor={(x, y) => s.moveCursor(s.activeSpread, x, y)}
/>
</div>
<div className="st-zoom">
<button onClick={() => s.stepZoom(-1)} disabled={!s.canZoomOut} aria-label={s.t('journey.studio.zoomOut')}>
<Minus size={15} />
</button>
<span className="st-zoom-value">{s.zoomPercent}%</span>
<button onClick={() => s.stepZoom(1)} disabled={!s.canZoomIn} aria-label={s.t('journey.studio.zoomIn')}>
<Plus size={15} />
</button>
<button onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<Maximize2 size={14} />
</button>
{/*
The page, as a file.
Here rather than in a menu because it belongs to the spread on screen
— the zoom bar is the only chrome that is about *this* page rather
than about the book. What comes out is the design without the
photographs, which is the thing worth passing on.
*/}
<span className="st-zoom-sep" />
<button
onClick={() => s.spread && downloadSpread(s.spread, s.page, s.journey?.title || 'spread')}
disabled={!s.spread}
aria-label={s.t('journey.studio.downloadSpread')}
title={s.t('journey.studio.downloadSpreadHint')}
>
<Download size={14} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,666 @@
import { useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, X } from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
import { useStudioStore } from '../../store/studioStore'
import { formatDate } from '../../utils/formatters'
import { SpreadFold, SpreadView } from './SpreadView'
import { photoSrc } from './bookRender'
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
import { coordValue } from './resolveBindings'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
/**
* The left side of Studio: a narrow rail of sections and one wide panel showing
* whichever is open.
*
* The shape is Canva's and it is the right one here for a plain reason — a book
* has four completely different kinds of thing you reach for (which page, what
* from the journey, what to draw, what arrangement), and putting them behind
* tabs in a single 200px column would make each of them cramped. An icon rail
* costs 52px and gives the panel the room a photo browser actually needs.
*/
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
export interface JourneySource {
entries: {
id: number
title: string | null
story: string | null
location: string | null
date: string | null
/** Where the stop is, when the entry recorded one. Null on a written-only entry. */
lat: number | null
lng: number | null
mood: string | null
weather: string | null
pros: string[]
cons: string[]
}[]
photos: { photoId: number; caption?: string | null }[]
/** photoId to the words of the entry it belongs to, lower-cased. */
photoEntries: Record<number, string>
}
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/**
* How wide a page preview may be drawn.
*
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
* out by CSS — the sheet inside them is sized in millimetres — so they need an
* actual pixel width, and it is **measured** rather than derived.
*
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
* padding and the button's own border and padding are all knowable from
* studio.css; the scrollbar is not. It appears only once the list is long
* enough, takes a width that depends on the platform, and the arithmetic that
* ignored it produced a card overhanging its own selection outline. Measuring
* the row the cards sit in gets all four contributions at once and cannot drift
* when any of them changes.
*/
const MIN_PREVIEW_W = 120
/** The button's border and padding, which the row's width still includes. */
const THUMB_CHROME = (2 + 3) * 2
export function StudioSidebar({
page, pxPerMm, bookView, source, stats, path, t, locale,
}: {
page: BookPageSetup
pxPerMm: number
bookView: boolean
source: JourneySource
/** What the journey adds up to, for the travel elements. Null while loading or on failure. */
stats: JourneyStats | null
/** The roads the trip took, for a map that draws them. Empty when it has none. */
path: [number, number][][]
t: (k: string) => string
locale: string
}) {
const [section, setSection] = useState<Section>('pages')
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
]
return (
<>
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
{SECTIONS.map(sec => (
<button
key={sec.id}
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
onClick={() => setSection(sec.id)}
title={t(sec.labelKey)}
aria-label={t(sec.labelKey)}
>
<sec.icon size={18} strokeWidth={1.7} />
<span>{t(sec.labelKey)}</span>
</button>
))}
</nav>
<aside className="st-panel st-side">
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
{section === 'content' && <ContentPanel source={source} page={page} t={t} locale={locale} />}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'travel' && <StudioTravelPanel page={page} stats={stats} path={path} t={t} locale={locale} />}
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
</aside>
</>
)
}
function PagesPanel({
page, pxPerMm, bookView, t,
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const setActive = useStudioStore(s => s.setActiveSpread)
const addSpread = useStudioStore(s => s.addSpread)
const insertSpread = useStudioStore(s => s.insertSpread)
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
const removeSpread = useStudioStore(s => s.removeSpread)
const moveSpread = useStudioStore(s => s.moveSpread)
const canEditSpread = useStudioStore(s => s.canEditSpread)
const spreads = doc?.spreads ?? []
const list = useElementSize<HTMLDivElement>()
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
/** Where a new spread would land: after the last inner one. */
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
const fileInput = useRef<HTMLInputElement>(null)
const [importError, setImportError] = useState<string | null>(null)
return (
<>
<Head label={t('journey.studio.pages')} count={spreads.length} />
<div className="st-panel-scroll">
<div className="st-thumbs" ref={list.ref}>
{spreads.map((sp, i) => {
const single = sp.role !== 'inner'
const wMm = single ? page.pageWidth : page.pageWidth * 2
// The same component at a smaller scale, not a second drawing of the
// page — a spread can never look different here than on the sheet.
const scale = THUMB_W / (wMm * pxPerMm)
const editable = canEditSpread(i)
return (
<div className="st-thumb-row" key={sp.id}>
<button
className={`st-thumb ${i === active ? 'is-active' : ''}`}
onClick={() => setActive(i)}
>
<div className="st-thumb-sheet" style={{ width: THUMB_W, height: page.pageHeight * pxPerMm * scale }}>
<div
style={{
position: 'absolute', left: 0, top: 0,
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<SpreadView spread={sp} page={page} spreadIndex={i} />
</div>
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div>
<span className="st-thumb-label">
{sp.role === 'cover' ? t('journey.studio.cover')
: sp.role === 'back' ? t('journey.studio.backCover')
: `${i * 2} – ${i * 2 + 1}`}
</span>
</button>
{/*
The actions sit on the thumbnail rather than in a menu bar of
their own: what you are acting on is the page you are pointing
at, and a toolbar somewhere else would need you to select
first and act second. The cover and the back cover have no
actions — a book has exactly one of each, and moving or
deleting them is not a thing anyone means to do.
*/}
{editable && (
<div className="st-thumb-actions">
<button
onClick={() => moveSpread(i, -1)}
disabled={!canEditSpread(i - 1)}
title={t('journey.studio.movePageUp')}
aria-label={t('journey.studio.movePageUp')}
>
<ChevronUp size={13} />
</button>
<button
onClick={() => moveSpread(i, 1)}
disabled={!canEditSpread(i + 1)}
title={t('journey.studio.movePageDown')}
aria-label={t('journey.studio.movePageDown')}
>
<ChevronDown size={13} />
</button>
<button
onClick={() => duplicateSpread(i)}
title={t('journey.studio.duplicatePage')}
aria-label={t('journey.studio.duplicatePage')}
>
<Copy size={12} />
</button>
<button
className="is-danger"
onClick={() => removeSpread(i)}
title={t('journey.studio.deletePage')}
aria-label={t('journey.studio.deletePage')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Insert between two spreads, where the new one will go — a
single "add" at the end cannot express "another page here". */}
{editable && (
<button
className="st-thumb-insert"
onClick={() => addSpread(i)}
title={t('journey.studio.addPageAfter')}
aria-label={t('journey.studio.addPageAfter')}
>
<Plus size={12} />
</button>
)}
</div>
)
})}
</div>
{/*
Two ways to get a new page: an empty one, or somebody else's design.
Side by side because they answer the same question — the import is
not a setting buried in a menu, it is the other half of "add".
*/}
<div className="st-add-row">
<button className="st-add-page" onClick={() => addSpread(lastInner)}>
<Plus size={14} />
{t('journey.studio.addPage')}
</button>
<button
className="st-add-page is-import"
onClick={() => fileInput.current?.click()}
title={t('journey.studio.importSpreadHint')}
>
<FileDown size={14} />
{t('journey.studio.importSpread')}
</button>
</div>
<input
ref={fileInput}
type="file"
accept=".json,application/json"
hidden
onChange={async e => {
const file = e.target.files?.[0]
// Cleared straight away so choosing the same file twice in a row
// still fires a change event the second time.
e.target.value = ''
if (!file) return
setImportError(null)
// Checked before reading rather than after: the point of a limit on
// a file someone else wrote is not to parse it in the first place.
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
try {
const spread = importSpread(JSON.parse(await file.text()), page)
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
insertSpread(lastInner, spread)
} catch {
setImportError(t('journey.studio.importSpreadFailed'))
}
}}
/>
{importError && <p className="st-add-error">{importError}</p>}
</div>
</>
)
}
/**
* The journey's own material.
*
* This is what separates a book maker from a drawing program: the pictures and
* the words are already written, and the job is putting them on pages. Clicking
* an item drops it on the current spread, centred, at a sensible size.
*/
function ContentPanel({
source, page, t, locale,
}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) {
const [tab, setTab] = useState<'photos' | 'text'>('photos')
const [query, setQuery] = useState('')
/*
* Filtering, not a search index: a journey holds tens or hundreds of items, and
* a substring match over what is already in memory answers instantly. A photo
* matches on its own caption *and* on the entry it belongs to — most photos
* carry no words at all, so matching only captions would make the box look
* broken on exactly the journeys that need it.
*/
const q = query.trim().toLowerCase()
const entries = q
? source.entries.filter(e =>
[e.title, e.story, e.location, ...e.pros, ...e.cons]
.some(v => v && v.toLowerCase().includes(q)))
: source.entries
const photos = q
? source.photos.filter(p =>
(p.caption && p.caption.toLowerCase().includes(q))
|| (source.photoEntries[p.photoId] || '').includes(q))
: source.photos
const addElement = useStudioStore(s => s.addElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const centre = (w: number, h: number) => {
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const dropPhoto = (photoId: number) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
addElement(active, {
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
rotation: 0, opacity: 1, locked: false,
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
} as BookElement)
}
/**
* One piece of the entry, dropped on the page and still attached to it.
*
* `format` is what separates a place name from a set of coordinates: both
* read the entry's location, and which of the two gets printed is a property
* of the binding rather than a second source — see the contract. `width`
* exists because a line of coordinates arriving inside a full-column
* selection rectangle looks like a bug; only the short marks pass it.
*/
const dropText = (
value: string,
size: number,
weight: 400 | 700,
entryId: number,
kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date',
opts: { format?: CoordFormat; width?: number; value?: string } = {},
) => {
const w = opts.width ?? page.pageWidth - 32
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
rotation: 0, opacity: 1, locked: false,
text: value, font: 'sans', size, weight, italic: false,
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
color: '#1a1a1a',
binding: {
source: kind,
entryId,
...(opts.format ? { format: opts.format } : {}),
// The fact behind the words, for the marks whose words are formatted —
// see `value` in the contract.
...(opts.value ? { value: opts.value } : {}),
},
overridden: false,
} as BookElement)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
}
/**
* A mood or a weather mark, drawn with the icon the journey page uses.
*
* `code` holds the key rather than the label, so the element carries what the
* entry recorded and the renderer decides how to draw it — which is what lets
* the icon and the palette come from one place.
*/
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
addElement(active, {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
// Set here as well as in the contract: this object is cast, not parsed.
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
variant, text: label, sub: '', code, style: 'plain',
} as BookElement)
/** Both columns of an entry's pros and cons, as one element. */
const dropProsCons = (pros: string[], cons: string[]) => {
const rows = Math.max(pros.length, cons.length, 1)
addElement(active, {
...base, id: uid('li'), kind: 'list',
// Matches what ListView needs for `rows` lines at its largest line size,
// so the element arrives filled rather than with a third of it empty.
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
items: [
...pros.map(text => ({ text, tone: 'pro' as const })),
...cons.map(text => ({ text, tone: 'con' as const })),
],
layout: 'columns', showMarks: true,
proLabel: t('journey.editor.pros'),
conLabel: t('journey.editor.cons'),
} as BookElement)
}
return (
<>
<Head label={t('journey.studio.content')} />
<div className="st-search">
<Search size={14} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('journey.studio.searchContent')}
aria-label={t('journey.studio.searchContent')}
spellCheck={false}
/>
{query && (
<button onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-tabs">
<button className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
{t('journey.studio.photos')} <em>{photos.length}</em>
</button>
<button className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.entries')} <em>{entries.length}</em>
</button>
</div>
<div className="st-panel-scroll">
{tab === 'photos' ? (
<div className="st-photo-grid">
{photos.map(p => (
<button
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
title={t('journey.studio.addToPage')}
draggable
onDragStart={e => {
// HTML5 drag is the right tool for exactly this shape of
// interaction — one item, from a list, onto a target — and it
// gives us the thumbnail as the drag image for free. The
// canvas uses pointer events instead, because free transform
// needs a live position that a drop event cannot provide.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
e.dataTransfer.effectAllowed = 'copy'
}}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
{!photos.length && (
<p className="st-hint">{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}</p>
)}
</div>
) : (
<div className="st-entries">
{entries.map(e => (
<div key={e.id} className="st-entry">
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
{(e.date || e.location) && (
<div className="st-entry-meta">
{/* Two facts, two badges. Joined by a dot they read as one
string and the eye has to parse where the date ends. The
date follows the app's language, like every other date in
TREK — see utils/formatters.ts. */}
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
</div>
)}
<div className="st-row" style={{ marginTop: 7 }}>
{e.title && (
<button className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
{t('journey.studio.addTitle')}
</button>
)}
{e.story && (
<button className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
{t('journey.studio.addStory')}
</button>
)}
{e.location && (
<button className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
{t('journey.studio.addPlace')}
</button>
)}
{/*
The day and the point. Both are set the way the rest of the
book sets them — the long date, coordinates in degrees — so a
mark placed here and one the auto layout placed read alike.
*/}
{e.date && (
<button
className="st-chip"
onClick={() => dropText(
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
{ width: page.pageWidth * 0.36, value: e.date! },
)}
>
{t('journey.studio.dateMark')}
</button>
)}
{e.lat != null && e.lng != null && (
<button
className="st-chip"
onClick={() => dropText(
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
)}
>
{t('journey.studio.coordsMark')}
</button>
)}
{/*
What the entry recorded besides its story. Each chip shows
the value rather than the field name — "Sunny" tells you
what you are about to place; "Weather" does not.
*/}
{e.mood && MOOD_CONFIG[e.mood] && (
<button
className="st-chip"
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
>
{t(MOOD_CONFIG[e.mood].label)}
</button>
)}
{e.weather && WEATHER_CONFIG[e.weather] && (
<button
className="st-chip"
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
>
{t(WEATHER_CONFIG[e.weather].label)}
</button>
)}
{(e.pros.length > 0 || e.cons.length > 0) && (
<button className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
{t('journey.studio.addProsCons')}
<em>{e.pros.length + e.cons.length}</em>
</button>
)}
</div>
</div>
))}
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div>
)}
</div>
</>
)
}
/**
* Layouts for the current spread.
*
* Each card draws the arrangement itself rather than naming it — "hero + story"
* means nothing until you have seen it, and a diagram is faster to read than a
* label anyway.
*/
function TemplatesPanel({
page, pxPerMm, t, onOpenContent,
}: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const commit = useStudioStore(s => s.commit)
const spread = doc?.spreads[active]
const list = useElementSize<HTMLDivElement>()
const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
const doc2 = doc
const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner'
// A cover card is one page wide, not two — scaling it as a spread would draw
// the layout at half size in the left half of the card.
const scale = useMemo(
() => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm),
[CARD_W, page.pageWidth, pxPerMm, cardIsSingle],
)
if (!spread) return null
const single = spread.role !== 'inner'
/*
* The cover has its own set. It used to have none — the panel said covers
* are designed by hand, which was true and unhelpful: the cover is the page
* people care most about and the one they had no starting point for.
*/
const templates = single ? COVER_TEMPLATES : TEMPLATES
return (
<>
<Head label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
{(
<div className="st-thumbs" ref={list.ref}>
{templates.map(tpl => {
const slots = tpl.build(page)
return (
<button
key={tpl.id}
className="st-thumb"
onClick={() => {
let empties = 0
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => {
if (i !== active) return sp
const next = applyTemplate(sp, tpl, page)
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
return next
}),
}))
// The layout left frames waiting for a picture — so put the
// pictures within reach instead of making the user go and
// find the panel that holds them.
if (empties > 0) onOpenContent()
}}
>
<div
className="st-thumb-sheet"
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
>
{slots.map((slot, i) => (
<span
key={i}
className={`st-tpl-slot is-${slot.kind}`}
style={{
left: slot.frame.x * pxPerMm * scale,
top: slot.frame.y * pxPerMm * scale,
width: slot.frame.w * pxPerMm * scale,
height: Math.max(2, slot.frame.h * pxPerMm * scale),
}}
/>
))}
{!single && (
<span
className="st-tpl-fold"
style={{ left: page.pageWidth * pxPerMm * scale }}
/>
)}
</div>
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
</button>
)
})}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,37 @@
/**
* The colour picker, shared by everything in the inspector that has a colour.
*
* Its own file because both the plain elements and the travel elements need it,
* and the second copy of a swatch row is how two parts of one panel end up
* offering different palettes.
*
* The palette is print ink rather than app chrome — it does not follow the
* user's theme, because a book is printed once and read on paper, where an
* accent chosen for a dark interface means nothing.
*/
const SWATCHES = [
'#111111', '#ffffff', '#8a8578',
'#c2410c', '#b45309', '#0f766e',
'#1e3a8a', '#9f1239', '#4c1d95',
]
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
return (
<div className="st-swatches">
{SWATCHES.map(c => (
<button
key={c}
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
onClick={() => onPick(c)}
aria-label={c}
/>
))}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
<input type="color" value={value} onChange={e => onPick(e.target.value)} />
</label>
</div>
)
}
@@ -0,0 +1,766 @@
import { useState } from 'react'
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { routeFor } from './travelRefresh'
import { Choice, Line, Picks, Section, Switch } from './StudioControls'
import { fetchRoads } from './roadRoute'
/**
* Properties of the travel elements.
*
* Its own file because these four are a different kind of thing from a photo or
* a paragraph: what you adjust is *what the element says*, not how a box looks.
* Keeping them here leaves StudioInspector.tsx about the elements that have
* been there since the beginning.
*/
export interface TravelInspectorProps {
el: BookElement
/** Live figures, for the refresh button. Null while loading or on failure. */
stats: JourneyStats | null
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
locale: string
}
interface Props<T> {
el: T
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
}
export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
&& el.kind !== 'badge' && el.kind !== 'list'
) {
return null
}
const stale = isStale(el, stats)
return (
<>
{stale && (
<div className="st-section">
<p className="st-stale">{t('journey.studio.staleHint')}</p>
<button
className="st-chip"
onClick={() => {
if (!stats) return
const patch = refreshPatch(el, stats)
if (patch) set(patch)
}}
>
{t('journey.studio.refresh')}
</button>
</div>
)}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} stats={stats} />}
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} />}
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} />}
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} />}
{el.kind === 'list' && <ListProps el={el} set={set} t={t} />}
{/*
* Type, for all five alike.
*
* These elements set their own words — a figure over its caption, a
* country's name over its outline — and until now the only thing you
* could change about that was the size. A book set in a serif with its
* stats block in the default sans is a book with one element in the
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')} defaultOpen={false}>
<div className="st-fonts">
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight renders a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
} as Partial<BookElement>)}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.weight')}>
<Choice
value={el.weight}
options={([400, 500, 600, 700] as const).map(w => ({
value: w,
label: String(w),
disabled: !hasWeight(el.font, w),
title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={weight => set({ weight } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.textScale')}>
<Choice
value={el.textScale}
options={([0.7, 0.85, 1, 1.2, 1.5] as const).map(scale => ({
value: scale, label: `${scale}×`,
}))}
onPick={textScale => set({ textScale } as Partial<BookElement>)}
/>
</Line>
</div>
</Section>
{/*
* Colour, for all five alike, and in one group.
*
* These were two sections called Accent and Colour, one under the other,
* each ten unlabelled swatches wrapping onto two rows: twenty squares and
* no way to tell which of them painted what. The role now sits beside the
* row and says it — the accent is the mark's own colour, the ink is what
* is written on it.
*
* They ship with one warm accent so a book made without touching anything
* still reads as designed. That is a starting point, not a house style: a
* book printed to match a cover, or one that simply should not be orange,
* needs both of these.
*/}
<Section label={t('journey.studio.colour')}>
<Line label={t('journey.studio.accent')} wide>
<Swatches value={el.accent} onPick={c => set({ accent: c } as Partial<BookElement>)} />
</Line>
<Line label={t('journey.studio.text')} wide>
{/*
Picking a colour is what turns automatic off, so the swatch does
both and the switch below is the way back rather than the way in.
Only marks have it; nothing else here has a fill of its own for the
words to answer to.
*/}
<Swatches
value={el.color}
onPick={c => set(
(el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial<BookElement>,
)}
/>
</Line>
{el.kind === 'badge' && (
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.autoColour')}
on={el.autoColor}
onToggle={() => set({ autoColor: !el.autoColor } as Partial<BookElement>)}
/>
</div>
)}
</Section>
</>
)
}
function ListProps({ el, set, t }: Props<BookListElement>) {
return (
<Section label={t('journey.studio.addProsCons')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={[
{ value: 'columns' as const, label: t('journey.studio.layoutGrid') },
{ value: 'stacked' as const, label: t('journey.studio.layoutColumn') },
]}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
<div className="st-grid2" style={{ marginTop: 10 }}>
<label className="st-field">
<span>{t('journey.editor.pros')}</span>
<input
className="st-input"
value={el.proLabel}
onChange={e => set({ proLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
<label className="st-field">
<span>{t('journey.editor.cons')}</span>
<input
className="st-input"
value={el.conLabel}
onChange={e => set({ conLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
</div>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.showMarks')}
on={el.showMarks}
onToggle={() => set({ showMarks: !el.showMarks } as Partial<BookElement>)}
/>
</div>
</Section>
)
}
function MapProps({ el, set, t, stats }: Props<BookMapElement> & { stats: JourneyStats | null }) {
const [roadState, setRoadState] = useState<'idle' | 'busy'>('idle')
const [roadDone, setRoadDone] = useState(0)
const hasRoads = (el.roads ?? []).some(r => r && r.length > 1)
/*
* One leg at a time, paced, and written into the document when it is done
* rather than leg by leg: a hundred commits would be a hundred undo steps and
* a hundred autosaves for one action.
*/
const loadRoads = async () => {
setRoadState('busy')
setRoadDone(0)
try {
const roads = await fetchRoads(
el.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ onProgress: done => setRoadDone(done) },
)
set({ roads } as Partial<BookElement>)
} finally {
setRoadState('idle')
}
}
const sources = useMapSources(el.frame, el.points, { source: el.source, tileUrl: el.tileUrl })
/** Only trips that put stops on the route: a map of the others is a blank. */
const tripsWithRoute = (stats?.trips ?? []).filter(t => t.points > 0)
/*
* The grid this element will really fetch, asked for here so the panel can
* say what it will print at. Same call the renderer makes, same answer.
*/
const view = el.source === 'tiles' && el.tileUrl
? tileView(el.points, { w: el.frame.w, h: el.frame.h }, el.tileUrl, el.zoom)
: null
const dpi = view ? printDpi(view, el.tileUrl) : null
return (
<>
{/*
Which trip, when the journey is made of more than one.
A journey of two trips drawn as one route puts a line from the last
stop of the first to the first stop of the second: usually the longest
leg on the page, and one nobody travelled. Switching here re-cuts the
route on the spot rather than at the next refresh, because a control
that changes nothing visible reads as broken.
*/}
{tripsWithRoute.length > 1 && (
<Section label={t('journey.studio.mapScope')}>
<Choice
value={el.tripId ?? 0}
options={[
{ value: 0, label: t('journey.studio.mapWholeJourney') },
...tripsWithRoute.map(trip => ({
value: trip.id,
label: trip.title || t('journey.studio.untitled'),
})),
]}
onPick={id => {
if (!stats) return
const tripId = id === 0 ? null : id
const route = routeFor(stats, tripId)
set({
tripId,
countries: tripId == null
? stats.countries.map(c => c.code)
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
} as Partial<BookElement>)
}}
/>
</Section>
)}
{/* Where the picture comes from and what it looks like: one question
with two halves, which is one group rather than two. */}
<Section label={t('journey.studio.routeMap')}>
<Line label={t('journey.studio.mapSource')} wide>
<Choice
value={sourceIdOf(el, sources)}
options={sources.map(src => ({ value: src.id, label: t(src.labelKey) }))}
onPick={id => {
const src = sources.find(x => x.id === id)
if (!src) return
set({
source: src.source,
tileUrl: src.url,
// The credit travels with the source. Switching to imagery and
// leaving the previous attribution would print the wrong one.
attribution: src.attribution,
} as Partial<BookElement>)
}}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['minimal', 'outline', 'paper', 'dark'] as const).map(style => ({
value: style, label: t(`journey.studio.mapStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
/>
</Line>
</div>
{el.source !== 'vector' && (
<>
<p className="st-hint" style={{ paddingTop: 8 }}>
{t(SOURCE_HINT[sourceIdOf(el, sources)] ?? 'journey.studio.mapSourceHint')}
</p>
{/*
What this map will actually print at, at the size it is now.
A number rather than a warning icon: 300 means nothing to argue
with, and somebody who wants a soft map on purpose can have one.
*/}
{dpi != null && (
<p className={`st-hint ${dpi < 150 ? 'st-stale' : ''}`} style={{ paddingTop: 6 }}>
{t('journey.studio.mapPrintDpi')} {dpi} dpi
{dpi < 150 && ` — ${t('journey.studio.mapPrintDpiLow')}`}
</p>
)}
</>
)}
</Section>
{/*
── What the map shows ─────────────────────────────────────────────
Three questions, and they are not the same question: how close the view
is, how much air is left around it, and what shape the picture is cut
to. The first two used to be decided for you, and the decision was
wrong often enough to be the loudest complaint about this element: a
trip that stayed inside one city was drawn as the whole country with
two dots in the middle of it.
*/}
<Section label={t('journey.studio.mapFraming')}>
<Line label={t('journey.studio.mapFit')}>
<Choice
value={el.fitToCountries ? 'country' : 'stops'}
options={[
{ value: 'stops' as const, label: t('journey.studio.mapFitStops') },
{ value: 'country' as const, label: t('journey.studio.mapFitCountry') },
]}
onPick={v => set({ fitToCountries: v === 'country' } as Partial<BookElement>)}
/>
</Line>
{/*
Room around what is drawn, as a share of it: the same setting has to
look right around a walk across a city and a drive across a
continent, and millimetres cannot do that.
*/}
<Line label={t('journey.studio.mapPadding')}>
<Choice
value={([0.04, 0.18, 0.5, 1.2] as const).find(v => Math.abs(el.fitPadding - v) < 0.001) ?? 0.18}
options={[
{ value: 0.04, label: t('journey.studio.mapPadTight') },
{ value: 0.18, label: t('journey.studio.mapPadNormal') },
{ value: 0.5, label: t('journey.studio.mapPadWide') },
{ value: 1.2, label: t('journey.studio.mapPadFar') },
]}
onPick={fitPadding => set({ fitPadding } as Partial<BookElement>)}
/>
</Line>
{/*
And the shape. A rectangle is a map in a box, which is what a full
page wants; cut to the coastline it stops being a figure and becomes
an illustration that can sit next to anything.
*/}
<Line label={t('journey.studio.mapShape')}>
<Choice
value={el.clip}
options={[
{ value: 'rect' as const, label: t('journey.studio.mapClipRect') },
{
value: 'country' as const,
label: t('journey.studio.mapClipCountry'),
disabled: el.countries.length === 0,
title: el.countries.length === 0 ? t('journey.studio.mapClipNeedsCountry') : undefined,
},
]}
onPick={clip => set({ clip } as Partial<BookElement>)}
/>
</Line>
</Section>
{el.source === 'tiles' && (
<Section label={t('journey.studio.mapZoom')} defaultOpen={false}>
<Choice
value={el.zoom ?? -1}
options={[
{ value: -1, label: t('journey.studio.mapZoomAuto') },
...[3, 5, 7, 9, 11, 13, 15, 17].map(z => ({ value: z, label: String(z) })),
]}
onPick={z => set({ zoom: z === -1 ? null : z } as Partial<BookElement>)}
/>
</Section>
)}
{/*
How the line itself is drawn, and whether it follows real roads.
Open by default, unlike the groups below it. It was folded, on the
reasoning that a look is chosen once per book — but a control nobody can
see is a control nobody has, and the first thing anyone wants from a map
of a journey is the line across it.
*/}
<Section label={t('journey.studio.routeLook')}>
<Line label={t('journey.studio.routeStyle')} wide>
<Choice
value={el.routeStyle}
options={[
{ value: 'plain' as const, label: t('journey.studio.routePlain') },
{ value: 'drawn' as const, label: t('journey.studio.routeDrawn') },
]}
onPick={routeStyle => set({ routeStyle } as Partial<BookElement>)}
/>
</Line>
{/*
The roads, fetched once and kept.
Not a switch, because it is not free: it asks a public routing service
one question per leg, from this browser, and the answer is then frozen
into the document so the book prints the same line offline and next
year. A switch would imply it could simply be turned back on later,
on a page that is being exported, which is exactly when it cannot.
*/}
<div style={{ marginTop: 10 }}>
{/*
Which way the line goes between two stops.
A choice rather than a button, because that is what it is from where
the user sits: the roads, or the way a map has always drawn a leg it
does not know. That the roads have to be fetched once and are then
kept in the book is an implementation detail, and the only place it
shows is the counter while it works.
*/}
<Line label={t('journey.studio.roads')} wide>
{roadState === 'busy' ? (
<div className="st-row">
<span className="st-chip is-on">
{t('journey.studio.roadsBusy')} {roadDone}/{Math.max(1, el.points.length - 1)}
</span>
</div>
) : (
<Choice
value={hasRoads ? 'roads' : 'direct'}
options={[
{ value: 'roads' as const, label: t('journey.studio.roadsFollow') },
{ value: 'direct' as const, label: t('journey.studio.roadsDirect') },
]}
onPick={v => {
if (v === 'direct') {
set({ roads: [] } as Partial<BookElement>)
} else {
void loadRoads()
}
}}
/>
)}
</Line>
{hasRoads && roadState !== 'busy' && (
<div className="st-row" style={{ marginTop: 8 }}>
<button className="st-chip" onClick={() => void loadRoads()}>
{t('journey.studio.roadsAgain')}
</button>
</div>
)}
<p className="st-hint" style={{ paddingTop: 6 }}>
{t(hasRoads ? 'journey.studio.roadsHave' : 'journey.studio.roadsHint')}
</p>
</div>
{el.routeStyle === 'drawn' && (
<>
<Line label={t('journey.studio.routeArc')} wide>
<Choice
value={el.routeArc}
options={[
{ value: 'straight' as const, label: t('journey.studio.routeStraight') },
{ value: 'bow' as const, label: t('journey.studio.routeBow') },
]}
onPick={routeArc => set({ routeArc } as Partial<BookElement>)}
/>
</Line>
{el.routeArc === 'bow' && (
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.routeDashArcs')}
on={el.routeDash === 'arcs'}
onToggle={() => set({
routeDash: el.routeDash === 'arcs' ? 'solid' : 'arcs',
} as Partial<BookElement>)}
/>
</div>
)}
<div style={{ marginTop: 10 }}>
<Line label={t('journey.studio.mapStops')} wide>
<Choice
value={el.pinStyle}
options={[
{ value: 'dot' as const, label: t('journey.studio.pinDot') },
{ value: 'photo' as const, label: t('journey.studio.pinPhoto') },
]}
onPick={pinStyle => set({ pinStyle } as Partial<BookElement>)}
/>
</Line>
</div>
{el.pinStyle === 'photo' && !el.points.some(p => p.photoId != null) && (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.pinPhotoNone')}</p>
)}
</>
)}
</Section>
{/* Four things that are either drawn or not. They used to be the same
pill as the style choice above, so a layer looked like an option. */}
<Section label={t('journey.studio.mapLayers')}>
{el.source !== 'tiles' && el.source !== 'static' && (
<Switch label={t('journey.studio.showLand')} on={el.showLand} onToggle={() => set({ showLand: !el.showLand } as Partial<BookElement>)} />
)}
<Switch label={t('journey.studio.showRoute')} on={el.showRoute} onToggle={() => set({ showRoute: !el.showRoute } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showPins')} on={el.showPins} onToggle={() => set({ showPins: !el.showPins } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showLabels')} on={el.showLabels} onToggle={() => set({ showLabels: !el.showLabels } as Partial<BookElement>)} />
</Section>
</>
)
}
function StatsProps({ el, set, t }: Props<BookStatsElement>) {
const toggleMetric = (m: BookMetric) => {
const next = el.metrics.includes(m) ? el.metrics.filter(x => x !== m) : [...el.metrics, m]
// An empty panel would be a blank rectangle nobody could get back out of,
// so the last figure cannot be switched off.
if (!next.length) return
set({ metrics: next } as Partial<BookElement>)
}
return (
<>
{/*
Which figures, out of seven. A set rather than a row of choices, and it
looks like one now: the picked ones carry a tick instead of being the
same filled pill the layout below uses for "this one".
*/}
<Section label={t('journey.studio.metrics')}>
<Picks
value={el.metrics}
options={BOOK_METRICS.map(m => ({ value: m, label: t(`journey.studio.metric.${m}`) }))}
onToggle={toggleMetric}
/>
</Section>
<Section label={t('journey.studio.layout')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['grid', 'row', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.units')}>
<Choice
value={el.units}
options={[
{ value: 'metric' as const, label: 'km' },
{ value: 'imperial' as const, label: 'mi' },
]}
onPick={units => set({ units } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
{/* Its label used to be "Elements", which is the name of a panel on
the other side of the screen and not what this draws. */}
<Switch
label={t('journey.studio.showIcons')}
on={el.showIcons}
onToggle={() => set({ showIcons: !el.showIcons } as Partial<BookElement>)}
/>
</div>
</Section>
</>
)
}
function CountriesProps({ el, set, t }: Props<BookCountriesElement>) {
return (
<Section label={t('journey.studio.countries')}>
<Line label={t('journey.studio.layout')}>
<Choice
value={el.layout}
options={(['list', 'grid', 'column'] as const).map(layout => ({
value: layout,
label: t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`),
}))}
onPick={layout => set({ layout } as Partial<BookElement>)}
/>
</Line>
{/* These three read as raw English out of the code before: `left`,
`center`, `right`, straight from the value. */}
<Line label={t('journey.studio.align')}>
<Choice
value={el.align}
options={(['left', 'center', 'right'] as const).map(align => ({
value: align, label: t(`journey.studio.align.${align}`),
}))}
onPick={align => set({ align } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch label={t('journey.studio.showOutline')} on={el.showOutline} onToggle={() => set({ showOutline: !el.showOutline } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showFlag')} on={el.showFlag} onToggle={() => set({ showFlag: !el.showFlag } as Partial<BookElement>)} />
<Switch label={t('journey.studio.showName')} on={el.showName} onToggle={() => set({ showName: !el.showName } as Partial<BookElement>)} />
</div>
{/*
The names themselves, one per line.
They arrive resolved from the journey and are usually right, but "usually"
is not a thing you can print: a country whose English name nobody uses,
a territory the geocoder names differently from the way the trip talked
about it, a line that wants to read "Scotland" rather than "United
Kingdom". A textarea rather than a field per country because the list is
two names on one page and twenty on another, and lines are how anyone
already edits a list.
Editing only the names is deliberate: the codes stay as they were, so the
outlines and flags keep matching the places actually visited.
*/}
{el.showName && (
<label className="st-field" style={{ marginTop: 10 }}>
<span>{t('journey.studio.countryNames')}</span>
<textarea
className="st-input st-textarea"
rows={Math.min(8, Math.max(2, el.codes.length))}
value={el.names.join('\n')}
onChange={e => {
const lines = e.target.value.split('\n')
// One name per country, in the order they were visited: a removed
// line must not shift every name after it onto the wrong outline.
set({
names: el.codes.map((code, i) => (lines[i] ?? el.names[i] ?? code).slice(0, 80)),
} as Partial<BookElement>)
}}
/>
</label>
)}
</Section>
)
}
/** What each imagery source is for, said once where it is chosen. */
const SOURCE_HINT: Record<string, string> = {
relief: 'journey.studio.mapSourceReliefHint',
satellite: 'journey.studio.mapSourceSatelliteHint',
}
/** The variants that draw something as well as saying something. */
const ICON_VARIANTS = ['flag', 'country', 'mood', 'weather']
function BadgeProps({ el, set, t }: Props<BookBadgeElement>) {
const showIcon = el.showIcon !== false
const showLabel = el.showLabel !== false
const parts = (icon: boolean, label: boolean) =>
set({ showIcon: icon, showLabel: label } as Partial<BookElement>)
return (
<>
<Section label={t('journey.studio.marks')}>
<Line label={t('journey.studio.text')}>
<input
className="st-input"
value={el.text}
onChange={e => set({ text: e.target.value } as Partial<BookElement>)}
/>
</Line>
<Line label={t('journey.studio.styleCaption')}>
<input
className="st-input"
value={el.sub}
onChange={e => set({ sub: e.target.value } as Partial<BookElement>)}
/>
</Line>
{/* These four printed the raw value — `plain`, `chip` — in every one of
the twenty-three languages. */}
<Line label={t('journey.studio.style')} wide>
<Choice
value={el.style}
options={(['plain', 'chip', 'outline', 'stacked'] as const).map(style => ({
value: style, label: t(`journey.studio.markStyle.${style}`),
}))}
onPick={style => set({ style } as Partial<BookElement>)}
/>
</Line>
</Section>
{/*
The icon, for the marks that have one.
Three combinations rather than two switches: a mark with neither its
picture nor its words is an empty box, and offering it as a state people
can reach means offering them a way to lose an element on the page.
*/}
{ICON_VARIANTS.includes(el.variant) && (
<Section label={t('journey.studio.icon')}>
<Line label={t('journey.studio.shows')} wide>
<Choice
value={showIcon && showLabel ? 'both' : showIcon ? 'icon' : 'label'}
options={[
{ value: 'both' as const, label: t('journey.studio.iconAndLabel') },
{ value: 'icon' as const, label: t('journey.studio.iconOnly') },
{ value: 'label' as const, label: t('journey.studio.labelOnly') },
]}
onPick={v => parts(v !== 'label', v !== 'icon')}
/>
</Line>
{showIcon && (
<>
{/*
A mood's icon carries the journal's own colour and the others
carry the element's accent — which is right until it is not, and
until now there was no way past it: the swatch below is the only
thing that reaches the icon, because lucide takes its colour as a
prop rather than inheriting it.
*/}
<Line label={t('journey.studio.colour')} wide>
<Swatches
value={el.iconColor}
onPick={c => set({ iconColor: c, autoIconColor: false } as Partial<BookElement>)}
/>
</Line>
<div style={{ marginTop: 10 }}>
<Switch
label={t('journey.studio.autoColour')}
on={el.autoIconColor !== false}
onToggle={() => set({ autoIconColor: el.autoIconColor === false } as Partial<BookElement>)}
/>
</div>
</>
)}
</Section>
)}
</>
)
}
@@ -0,0 +1,514 @@
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead'
import { TravelPreview } from './TravelPreview'
import { formatBookCoords } from './entryText'
import { routeFor } from './travelRefresh'
import { useMapSources } from './mapSources'
import { fetchRoads } from './roadRoute'
/**
* The journey's own figures, as things you can put on a page.
*
* This is the panel that makes Studio a *travel* book designer rather than a
* layout tool that happens to be full of holiday photographs. The route, the
* distance, the countries and the dates are already in TREK; the work is
* offering them as objects rather than as numbers someone has to retype.
*
* ── Why every tile is a real preview ──────────────────────────────────────
*
* These elements differ from one another by what they *say*, not by their
* shape. Four map styles are four maps; three summary layouts are the same six
* numbers arranged three ways. A named row with the value beside it — which is
* what this panel was first — tells you none of that: you cannot tell the dark
* map from the paper one, or a row of figures from a grid of them, until you
* have placed one and undone it.
*
* So each tile builds the element it would place and renders it through the
* page's own renderer, with this journey's real numbers in it. You are picking
* from the things themselves.
*
* Every button resolves its values *now* and writes them into the element, so
* what lands on the page is finished rather than a placeholder that needs the
* server to mean anything. The reasoning is in TravelElements.tsx, and the
* short version is that the print renderer must never depend on a fetch.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/*
* How many cards share a row.
*
* A stats panel is the width of a spread; shown two-up its figures scale down
* past reading, so it gets the row to itself. Maps, country lists and marks are
* compact enough that two-up still tells them apart, and two-up is what makes
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** Kilometres, grouped for the reader's locale. */
function formatDistance(metres: number, locale: string): string {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres / 1000)} km`
}
function Card({ label, onClick, children, recommended = false, title }: {
label: string
onClick: () => void
/**
* Marked as the one to reach for, with a ring rather than with words.
*
* The label sits in a grid cell about eighty pixels wide, where "Satellite ·
* recommended" is two lines of ellipsis. A ring says the same thing in no
* space at all, and the tooltip carries the word for anyone who wonders why
* this card looks different.
*/
recommended?: boolean
title?: string
children: React.ReactNode
}) {
return (
<button
className={`st-travel-card ${recommended ? 'is-recommended' : ''}`}
onClick={onClick}
title={title ? `${label} — ${title}` : label}
>
{children}
<span className="st-travel-card-label">{label}</span>
</button>
)
}
export function StudioTravelPanel({
page, stats, path, t, locale,
}: {
page: BookPageSetup
stats: JourneyStats | null
/** The travelled way, already thinned. Empty when the trip has no geometry. */
path: [number, number][][]
t: (k: string) => string
locale: string
}) {
const addElement = useStudioStore(s => s.addElement)
const updateElement = useStudioStore(s => s.updateElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const mapSide = Math.min(page.pageWidth, page.pageHeight) * 0.72
/*
* The imagery this instance can reach, so the relief card below places a map
* that already has its template and its credit rather than an empty one the
* user then has to point at a source.
*
* Asked for above the empty-state return, and not beside the card that reads
* it: a journey whose figures are still loading renders this panel once with
* no stats at all, and a hook that only runs on the second render is a hook
* count that changes between them.
*/
const sources = useMapSources(
{ x: 0, y: 0, w: mapSide, h: mapSide * 0.78 },
(stats?.points ?? []).map(p => ({ lat: p.lat, lng: p.lng })),
)
if (!stats) {
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<p className="st-hint">{t('journey.studio.travelEmpty')}</p>
</div>
</>
)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const,
stale: false,
}
/**
* Country names in the reader's language, not the server's.
*
* The API answers in English, because it has no idea who will read the book.
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
* a German book says "Island" and an English one says "Iceland" — from one set
* of codes, with no translation table to keep in step.
*/
const countryName = (code: string): string => {
try {
const display = new Intl.DisplayNames([locale], { type: 'region' })
return display.of(code.toUpperCase()) || code.toUpperCase()
} catch {
return stats.countries.find(c => c.code === code)?.name || code.toUpperCase()
}
}
const values: Record<string, number> = {
distance: stats.distance,
days: stats.days,
steps: stats.steps,
photos: stats.photos,
countries: stats.countries.length,
places: stats.places,
furthest: stats.furthest,
}
/*
* Each builder returns the finished element. The tile renders it and the
* click places it, so the preview and the result are the same object — there
* is no second description of what a card means.
*/
/** The linked trips that actually put stops on the route. */
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
/* Satellite first: the recommendation should not be the second thing read. */
const imagery = sources
.filter(s => s.id === 'relief' || s.id === 'satellite')
.sort((a, b) => (a.id === 'satellite' ? -1 : b.id === 'satellite' ? 1 : 0))
/**
* One country, as the entry a list would have made of it.
*
* The list and the grid are gone: they were compositions somebody else made,
* deciding the order, the spacing and the type size, and the only way to
* change any of that was to not use them. What is left is the same entry —
* name over silhouette, set the same way — as an element you place one at a
* time. Three of them down a page is the list, with the spacing yours; one of
* them at 80mm is a chapter opener, which the list could never be.
*
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
const countryEl = (code: string): BookElement => ({
...base, id: uid('co'), kind: 'countries',
frame: centre(page.pageWidth * 0.34, 34),
codes: [code], names: [countryName(code)],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
} as BookElement)
const mapEl = (
style: 'minimal' | 'outline' | 'dark' | 'paper',
source: 'vector' | 'tiles' | 'static' = 'vector',
url = '',
attribution = '',
clip: 'rect' | 'country' = 'rect',
/** Null is the whole journey; a trip id is a map of that trip alone. */
tripId: number | null = null,
): BookElement => {
const route = routeFor(stats, tripId)
return {
...base, id: uid('mp'), kind: 'map',
frame: centre(mapSide, mapSide * 0.78),
style, source, tileUrl: url, attribution, zoom: null, clip, tripId,
/*
* White over a photograph, ink on paper. The accent IS the line, so this is
* how a satellite map arrives drawn the way the reference draws it, while
* the colour stays something the user can change afterwards.
*/
accent: source === 'tiles' || source === 'static' ? '#ffffff' : base.accent,
showLand: true, showRoute: true, showPins: true, showLabels: false,
/*
* A map placed today gets the drawn treatment; the contract defaults to the
* plain line so that every book made before this existed opens unchanged.
* Set explicitly rather than inherited because this object is cast, not
* parsed.
*/
routeStyle: 'drawn', routeArc: 'bow', routeDash: 'arcs', pinStyle: 'photo',
countries: tripId == null
? stats.countries.map(c => c.code)
: [...new Set(route.map(p => p.country).filter((c): c is string => !!c))],
points: route.map(p => ({ lat: p.lat, lng: p.lng, label: p.label, photoId: p.photoId ?? null })),
/*
* Frozen into the element, like every other travel figure: a page that
* fetches its own route at print time is a page that changes when someone
* edits the trip, and prints empty when the export runs signed out.
*/
path,
// Empty until somebody asks for the roads; see roadRoute.ts.
roads: [],
fitPadding: 0.5,
fitToCountries: true,
} as BookElement
}
/*
* A mark is placed at the size that suits what it holds.
*
* One size for all six put a flag and a country name into a box built for a
* line of coordinates, which is how a small mark ends up floating inside a
* large selection rectangle. Fractions of the page, so the proportions hold
* on any format.
*/
const MARK_SIZE: Record<string, [number, number]> = {
flag: [0.25, 0.062],
date: [0.16, 0.14],
day: [0.17, 0.062],
coords: [0.36, 0.05],
country: [0.22, 0.078],
distance: [0.26, 0.066],
}
const badgeEl = (
variant: 'flag' | 'date' | 'day' | 'coords' | 'country' | 'distance',
text: string,
sub: string,
code: string | null,
style: 'plain' | 'chip' | 'outline' | 'stacked' = 'plain',
): BookElement => {
const [fw, fh] = MARK_SIZE[variant] ?? [0.24, 0.08]
return {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111',
frame: centre(page.pageWidth * fw, page.pageHeight * fh),
variant, text, sub, code, style,
} as BookElement
}
/**
* One figure on its own.
*
* A summary panel is a composition; a single figure is a mark you drop next
* to a photograph. Both are the same element with a different metric list,
* and offering only the composition meant anyone wanting "14 DAYS" beside a
* picture had to place all four and delete three.
*/
const singleEl = (metric: BookMetric): BookElement => ({
...base, id: uid('st'), kind: 'stats',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.13),
metrics: [metric], layout: 'grid', showIcons: true, units: 'metric', values,
} as BookElement)
/** Place a copy — the previewed element keeps its own id for React. */
const place = (el: BookElement) => {
const placed = { ...el, id: uid(el.kind[0]) } as BookElement
addElement(active, placed)
/*
* A map arrives following the roads.
*
* Asked for after the element is on the page rather than before, so the map
* appears at once and the line firms up a moment later: a panel that sat
* there for fifteen seconds after a click would read as broken, and the
* straight line it starts with is the one it would have had anyway.
*
* Legs long enough to have been flights are never sent (see roadRoute.ts),
* the router's own cache answers a second placement of the same journey for
* free, and a failure leaves the leg exactly as it was.
*/
if (placed.kind === 'map' && placed.points.length > 1) {
void fetchRoads(placed.points.map(pt => ({ lat: pt.lat, lng: pt.lng })))
.then(roads => {
if (roads.some(r => r && r.length > 1)) {
updateElement(active, placed.id, { roads } as Partial<BookElement>)
}
})
.catch(() => { /* No roads is a state the map already draws. */ })
}
}
const first = stats.points[0] ?? null
const firstCountry = stats.countries[0] ?? null
const startDay = stats.start ? new Date(`${stats.start}T00:00:00`) : null
const marks: { el: BookElement; label: string }[] = []
if (startDay) {
marks.push({
el: badgeEl('date', String(startDay.getDate()),
startDay.toLocaleDateString(locale, { month: 'long' }).toUpperCase(), null, 'stacked'),
label: t('journey.studio.dateMark'),
})
}
marks.push({
el: badgeEl('day', `${t('journey.studio.dayWord')} 1`, '', null, 'chip'),
label: t('journey.studio.dayMark'),
})
if (first) {
marks.push({
el: badgeEl('coords', formatBookCoords(first.lat, first.lng), first.label, null),
label: t('journey.studio.coordsMark'),
})
}
if (firstCountry) {
marks.push({
el: badgeEl('flag', '', countryName(firstCountry.code), firstCountry.code),
label: t('journey.studio.flagMark'),
})
}
marks.push({
el: badgeEl('distance', formatDistance(stats.distance, locale), t('journey.studio.metric.distance'), null, 'outline'),
label: t('journey.studio.distanceMark'),
})
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<div className="st-section">
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
<div className="st-travel-grid">
{BOOK_METRICS.map(metric => {
const el = singleEl(metric)
return (
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={62} />
</Card>
)
})}
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
{stats.points.length ? (
<>
<div className="st-travel-grid">
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
const el = mapEl(style)
return (
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
{/*
The two imagery maps, as cards rather than as settings.
They were reachable only by placing an outline map and then
changing its source in the inspector, which is a thing nobody
finds. They are different pictures, not variants of one: relief
is land shaded in its own colours against a dark sea, drawn at
a scale that suits a continent, and satellite is the ground
itself, which is what a page about one country wants.
*/}
{imagery.map(src => (
<Card
key={src.id}
/*
* Satellite is marked rather than merely listed. The three
* imagery options are not equals: relief stops at about six
* hundred metres to the pixel, which is a continent's worth
* of detail and a smudge at country size, and the outlines
* are a diagram. For the page most people are making —
* a route across one or two countries — this is the one
* that prints, and saying so beats letting them find out
* after the book is bound.
*/
label={t(src.labelKey)}
recommended={src.id === 'satellite'}
title={src.id === 'satellite' ? t('journey.studio.recommended') : undefined}
onClick={() => place(mapEl('minimal', 'tiles', src.url, src.attribution))}
>
<TravelPreview
el={mapEl('minimal', 'tiles', src.url, src.attribution)}
minHeight={62}
maxHeight={80}
/>
</Card>
))}
</div>
{/*
A map per trip, for a journey made of more than one.
Two trips printed as a single route draw a line from the last
stop of the first to the first stop of the second, which is
usually the longest leg on the page and one nobody travelled.
Offered rather than imposed: some journeys really are one route
across several trips, and those still get the map above.
Only trips that have stops on the route are listed — a linked
trip nobody wrote about would place an empty frame.
*/}
{tripsWithRoute.length > 1 && (
<div style={{ marginTop: 12 }}>
<div className="st-section-label">{t('journey.studio.mapPerTrip')}</div>
<div className="st-travel-grid">
{tripsWithRoute.map(trip => {
const el = mapEl('minimal', 'vector', '', '', 'rect', trip.id)
return (
<Card
key={trip.id}
label={trip.title || t('journey.studio.untitled')}
onClick={() => place(el)}
>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
</div>
</div>
)}
</>
) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p>
)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.countries')}</div>
{stats.countries.length ? (
<>
{/*
One country per element, rather than a block listing them all.
A list of countries is a composition somebody else made: it
decides the order, the spacing and the type size, and the only
way to change any of it is to not use it. Placed one at a time
they are ordinary elements — numbered, moved, set at different
sizes, put on different pages — which is what a book of a trip
through several countries actually wants.
*/}
<div className="st-travel-grid">
{stats.countries.slice(0, 12).map(c => {
const el = countryEl(c.code)
return (
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={64} />
</Card>
)
})}
</div>
{/*
And the same country as a mark: the outline beside the name
rather than under it, at the size of a line of type. It belongs
next to a date or a coordinate, not on a page of its own.
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', countryName(c.code), '', c.code)
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p>
)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.marks')}</div>
<div className="st-travel-grid">
{marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={34} maxHeight={52} />
</Card>
))}
</div>
</div>
</div>
</>
)
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
import type { BookElement } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
import { ElementView } from './SpreadView'
/**
* A travel element, drawn small.
*
* The same component the page uses, at a smaller scale — not a second drawing
* of it. This is the trick the page thumbnails already use, and it is worth
* more here than anywhere else in Studio: these elements differ from each other
* by *what they say*, not by their shape, so a generic icon of a map tells you
* nothing about which of the four map styles you are about to place. Rendered
* for real, with this journey's own figures, the tile answers the question the
* label was only gesturing at.
*
* It also cannot drift. If the map gains a layer or the summary changes how it
* sets a figure, the preview shows that on the same commit.
*
* ── Why the width is measured ─────────────────────────────────────────────
*
* The tile is a grid cell, so its width is the panel's minus padding, gaps, the
* card's own border and whatever the scrollbar takes. Passing a guess in was
* tried and was two pixels out, which `overflow: hidden` turned into a clipped
* right edge. Measuring costs one ResizeObserver and cannot be wrong.
*/
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
const PX_PER_MM = 96 / 25.4
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
el: BookElement
minHeight?: number
maxHeight?: number
}) {
const box = useElementSize<HTMLSpanElement>()
const width = box.width
/*
* The tile takes the element's proportions rather than a fixed box.
*
* A stats panel is three times wider than it is tall and a country list is
* taller than it is wide. Fitted into one shape, the wide ones would sit in a
* thin band with empty tile above and below — and, worse, would be scaled
* down to fit a height they did not need, which is what made the figures
* unreadable. Following the element uses the full width for everything and
* costs only a slightly uneven grid.
*/
const drawnW = el.frame.w * PX_PER_MM
const drawnH = el.frame.h * PX_PER_MM
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
return (
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
{scale > 0 && (
<span
style={{
position: 'absolute',
left: (width - drawnW * scale) / 2,
top: (height - drawnH * scale) / 2,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
>
{/* Drawn at the origin: the tile is the frame, so the element's own
position on the spread is not part of what is being shown. */}
<ElementView el={{ ...el, frame: { ...el.frame, x: 0, y: 0 } } as BookElement} big={false} />
</span>
)}
</span>
)
}
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { PAGE_MAX_MM, PAGE_MIN_MM } from './pagePresets'
/**
* One side of the trim size.
*
* ── Why it holds a draft ─────────────────────────────────────────────────
*
* The value goes through `clampPageSize` on the way in, and it used to do so on
* every keystroke: typing 500 meant the field saw 5, clamped it to the minimum
* and wrote that back under the cursor, so the number could not be typed at
* all — only nudged with the arrows. So while a number is being typed the field
* keeps what was typed, and the document only hears about values it can use.
*
* The arrows still work as before, because a value they produce is already in
* range and is committed the moment it appears. Leaving the field commits what
* is there, clamped — someone who typed 5 and clicked away meant the smallest
* book, not the one they had.
*/
export function TrimField({
label, value, onCommit, min = PAGE_MIN_MM, max = PAGE_MAX_MM, step = 1,
}: {
label: string
value: number
onCommit: (mm: number) => void
/**
* The range this particular measurement lives in.
*
* Defaulted to the page's own, which is what the field was built for. The
* bleed and the safe margin are millimetres of the same kind and want the
* same field, but a floor of 60mm would make a 3mm bleed untypeable and its
* arrows useless — the range is a property of the measurement, not of the
* control.
*/
min?: number
max?: number
step?: number
}) {
const [draft, setDraft] = useState<string | null>(null)
const shown = draft ?? String(Math.round(value * 10) / 10)
const settle = () => {
if (draft === null) return
const n = Number(draft)
if (draft.trim() !== '' && Number.isFinite(n)) onCommit(n)
setDraft(null)
}
return (
<label>
<span>{label}</span>
<input
type="number"
inputMode="decimal"
value={shown}
min={min}
max={max}
step={step}
onChange={e => {
const raw = e.target.value
setDraft(raw)
// Anything already usable goes straight through, so the arrows and a
// fully typed number both take effect without waiting for a blur.
const n = Number(raw)
if (raw !== '' && Number.isFinite(n) && n >= min && n <= max) {
setDraft(null)
onCommit(n)
}
}}
onBlur={settle}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
if (e.key === 'Escape') setDraft(null)
}}
/>
</label>
)
}
@@ -0,0 +1,237 @@
import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import type { SpreadTemplate } from './bookTemplates.data'
import { formatBookCoords, formatBookDate } from './entryText'
import { coordValue } from './resolveBindings'
/**
* Laying an entry out on a template somebody drew.
*
* ── Why this exists ──────────────────────────────────────────────────────
*
* The layouts written as code in autoLayout.ts are what a function can reason
* its way to: a picture here, words there, margins that add up. What they are
* not is *designed* — a panel bled off one edge, a shape running off the
* corner, a strip of pictures at two sizes with the coordinates set under them.
* Those decisions come from looking at a page, and the editor is where looking
* at a page happens.
*
* So the templates are drawn in Studio and read back out (see
* bookTemplates.data.ts). This is the other half: taking one of those spreads
* and putting an entry into it.
*
* ── What it fills, and what it leaves alone ──────────────────────────────
*
* Only what the entry can answer for:
*
* - text carrying a `binding` — the title, the story, a caption
* - empty photo frames, taking the entry's photographs in order
* - the day, coordinate, date and country marks
* - the stats and countries elements, from the journey's figures
*
* Everything else is the design and is copied as drawn. A panel stays where it
* was put; a decorative shape keeps its colour. That division is the whole
* contract between the person who draws a template and the code that uses it.
*
* ── Sizes ────────────────────────────────────────────────────────────────
*
* The template's numbers are fractions of the page it was drawn on, so they are
* multiplied back up here. A template built on a 210mm square lays out on an A4
* landscape book without being redrawn.
*/
export interface TemplateEntry {
id: number
title: string | null
story: string | null
location: string | null
date: string | null
photos: { photoId: number; width: number | null; height: number | null; caption?: string | null }[]
lat?: number | null
lng?: number | null
country?: string | null
dayNumber?: number | null
dayCount?: number | null
}
export interface TemplateContext {
page: BookPageSetup
locale: string
stats: JourneyStats | null
/** Already translated, because this module has no translation context. */
dayLabel: string
}
let seq = 0
const uid = (p: string) => `${p}-${(seq++).toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
/** How well a template suits an entry: more is better, -1 means unusable. */
export function templateFit(template: SpreadTemplate, entry: TemplateEntry): number {
const frames = template.elements.filter(e => e.kind === 'photo').length
const photos = entry.photos.length
const wantsStory = template.elements.some(e => e.kind === 'text' && e.binding?.source === 'entry.story')
const hasStory = !!(entry.story || '').trim()
/*
* A template is a template *for an entry* when it uses one.
*
* The distinction has to come out of the drawing rather than from a label
* somebody remembers to set: a summary spread is all figures and country
* outlines and reads perfectly well on its own, and dropping a day's
* photographs into it would be nonsense. Bindings are how a page says it
* expects an entry.
*/
const usesEntry = template.elements.some(
e => ('binding' in e && e.binding) || (e.kind === 'photo' && e.photoId == null),
)
if (!usesEntry) return -1
/*
* A template with three frames and an entry with one picture leaves two
* placeholders on a finished page, which is worse than a simpler layout.
* More pictures than frames is fine — the extra ones are parked.
*/
if (frames > photos + 1) return -1
if (wantsStory && !hasStory) return -1
// Closest match wins: a two-frame template for two pictures beats a one-frame
// one, and both beat a template that would leave a hole.
return 100 - Math.abs(frames - photos) * 10 - (wantsStory === hasStory ? 0 : 5)
}
/** Fill a template with an entry, and hand back the spread it makes. */
export function applyTemplate(
template: SpreadTemplate,
entry: TemplateEntry,
ctx: TemplateContext,
): BookSpread {
const { page } = ctx
const PW = page.pageWidth
const PH = page.pageHeight
let nextPhoto = 0
const story = (entry.story || '').trim()
const heading = entry.title || entry.location || ''
const elements = template.elements.map(el => {
const out = {
...el,
id: uid(el.kind[0]),
frame: {
x: round2(el.frame.x * PW),
y: round2(el.frame.y * PH),
w: round2(el.frame.w * PW),
h: round2(el.frame.h * PH),
},
} as BookElement
// Sizes came out as fractions of the page height, so they go back up.
if ('size' in out && typeof out.size === 'number') out.size = round2(out.size * PH)
if ('radius' in out && typeof out.radius === 'number') out.radius = round2(out.radius * PW)
if ('strokeWidth' in out && typeof out.strokeWidth === 'number') {
out.strokeWidth = round2(out.strokeWidth * PW)
}
if (out.kind === 'text' && out.binding) {
const source = out.binding.source
const format = out.binding.format
// What the words were made from, where they were made rather than copied.
let value: string | undefined
if (source === 'entry.title') out.text = heading
else if (source === 'entry.story') out.text = story
else if (source === 'entry.date') {
out.text = formatBookDate(entry.date, ctx.locale)
value = entry.date ?? undefined
}
// The place, as its name or as its point — see the `format` note in the
// contract. A stop the entry cannot answer keeps the words the template
// was drawn with, the same bargain fillBadge makes below.
else if (source === 'entry.location') {
if (format && entry.lat != null && entry.lng != null) {
out.text = formatBookCoords(entry.lat, entry.lng, format)
value = coordValue(entry.lat, entry.lng)
} else if (!format && entry.location) {
out.text = entry.location
}
}
out.binding = { ...out.binding, entryId: entry.id, ...(value ? { value } : {}) }
}
if (out.kind === 'photo' && out.photoId == null) {
const photo = entry.photos[nextPhoto++]
if (photo) out.photoId = photo.photoId
}
if (out.kind === 'badge') fillBadge(out, entry, ctx)
if (out.kind === 'countries' && ctx.stats) {
out.codes = ctx.stats.countries.map(c => c.code)
out.names = ctx.stats.countries.map(c => c.name)
}
// The figures live in one map on the element, keyed by metric — the same
// shape the panels write when a stats element is placed by hand.
if (out.kind === 'stats' && ctx.stats) {
out.values = {
distance: ctx.stats.distance,
days: ctx.stats.days,
steps: ctx.stats.steps,
photos: ctx.stats.photos,
places: ctx.stats.places,
countries: ctx.stats.countries.length,
furthest: ctx.stats.furthest,
}
}
return out
})
return {
id: uid('sp'),
role: 'inner',
background: template.background,
elements,
parked: [],
entryId: entry.id,
}
}
/**
* The small marks, filled from the stop.
*
* A badge whose value the entry cannot answer keeps the one it was drawn with:
* a template showing "DAY 1" on an entry with no date is a template that reads
* as a placeholder, which is exactly what it is until a date exists.
*/
function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ctx: TemplateContext) {
if (el.variant === 'day' && entry.dayNumber) {
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
}
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
el.text = formatBookCoords(entry.lat, entry.lng)
}
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
el.code = entry.country
el.text = countryName(entry.country, ctx).toUpperCase()
}
if (el.variant === 'date' && entry.date) {
const d = new Date(`${entry.date}T00:00:00`)
if (!Number.isNaN(d.getTime())) {
el.text = String(d.getDate())
el.sub = d.toLocaleDateString(ctx.locale, { month: 'long' }).toUpperCase()
}
}
if (el.variant === 'distance' && ctx.stats?.distance) {
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString('en-US')} km`
}
}
function countryName(code: string, ctx: TemplateContext): string {
const known = ctx.stats?.countries.find(c => c.code === code)
try {
return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code
} catch {
return known?.name ?? code
}
}
const round2 = (n: number) => Math.round(n * 100) / 100

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