Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:
- one MikroORM migration for tour_types, tours and tour_waypoints,
idempotent so instances that ran the old positional steps keep
their data; the hike tour type is seeded inside it, the addon row
in the AddonSeeder
- entities and repositories for the three tables, tours domain on
repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
repository code
Fixes found while porting:
- GPX import checks addon, trip access and permission in the handler
instead of guards, so a refused upload gets a 403/404 instead of a
connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction
Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
* base entities
* update deps
* fix date as string
* rename entities
* migrations + seeders
* feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text
* feat(db): discover entities from an explicit list instead of a glob
* test(db): createTestOrm binds an ORM to a suite's own SQLite handle
* feat(db): dialect helper is the one place SQL functions are spelled
* test(db): dialect helper refuses malformed column references and day counts
* feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips)
* fix(db): repositories mirror the statement's column set and guard their row types
* feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests
* test(server): integration suites boot on the migrated schema snapshot
* test(server): snapshot harness keeps the legacy id semantics
* fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config
* test(db): coverage ratchet entries for the ORM foundation
* test(server): websocket suites boot on the migrated schema snapshot
* test(server): coverage's uncovered-file pass goes through the SWC transform
* fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer
* test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema
* chore(server): promise safety net and sweep tooling for the async migration
* refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork
* fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test)
* test(doc-sync): unit suites await the methods the async sweep made async
* refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork
* chore(server): lint rule refusing a Promise used as a plain value, and the misses it found
* test(server): suites await the methods Task 1's forced edits made async
* refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork
* test(oauth): controller suite awaits the async service
* chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await
* refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork
* refactor(plugins): async DB-touching methods; transactions through UnitOfWork
* chore(server): db-call-graph --sync stops its transitive walk at an async callee
* chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub
* refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork
* refactor(auth, files): async DB-touching methods; transactions through UnitOfWork
* test(server): suites await the methods earlier sweep groups made async
* refactor(tokens): async DB-touching methods; the auth token handlers await them
* test(files): upload filters fail closed on an excluded or unresolvable type
* refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork
* refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork
* chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects
* refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork
* fix(vacay): type the loop accumulators the async sweep introduced
* refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork
* fix(server): the journey hooks are awaited by their callers, not detached
* refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork
* refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias
Recipe R4 trap named in the Task 12 brief: a .bind alias of an
AssignmentsService/AccommodationsService method is typed any and hides a
missing await from tsc and the promise lint rules. createAccommodation
belongs to this task's group, so rewritten as an arrow forwarding to the
method instead of a bound reference.
* refactor(assignments): drop the unused Trip type alias
* refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork
* refactor(roadtrip, reservations): return await on transactional; document the single-flight promises
* refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork
* refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork
* refactor(trips, feeds): drop added return annotations; no double await on the feed guard
* fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports
* fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test
* test(db): entity metadata must match the migrated schema; fix the four drifts it found
* feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings)
* test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways
* test(db): numeric ids for the primary-relation spike
* feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules
* fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures
* fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives
* refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity
* fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case
* chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked
* feat(db): AppSettings and Users repositories; forFeature wiring pattern
* refactor(permissions): permission flags through AppSettingsRepository
* fix(server): boot-time settings read runs inside an explicit ORM request context
* refactor(audit): audit log writes through AuditLogRepository
* fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config
* docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes
* fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs
* fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors
* refactor(categories, tags): repositories replace raw SQL
* refactor(addons): addon catalog and photo providers through repositories
* fix(db): generator captures inline UNIQUE constraints
SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_
entry before entity-generator ever sees it, so a table-level
UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX
(settings' UNIQUE(user_id, key) among ~40 others) never reached the
generated entity's uniques: metadata. Adds a general
RULE9_addImplicitUniqueConstraints (skips a column set identical to
the table's own primary key — AppSettings.key/Addons.id noise) fed by
a PRAGMA index_list/index_info scan, and regenerates every entity.
* chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories
* fix(platform): well-known gate tolerates a synchronous addon read from the context helper
* fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes
* refactor(settings): user and instance settings through repositories
SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces
the per-user settings table's raw SQL; SettingsService drops DatabaseService
entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/
resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead
of a raw connection.
An earlier version resolved those two repositories itself from the active
MikroORM request context, so none of its six callers needed to change. That
worked in production and e2e (always inside a real request) but broke
service construction in plain unit tests across every calling domain
(addons, auth x2, maps, transit, unsplash - 185 failures on a full run),
since none of them wrap a request context. Each of those six services now
injects its own AppSettingsRepository/UsersRepository and passes them
through explicitly, matching how every other converted repository in this
migration is consumed; their own SQL is untouched (outside Plan 3a).
* test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host
* fix(db): generator names relation properties in captured UNIQUE constraints
* fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions
* fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed
* fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned
* feat(db): generated entities declare their repository type; test casts dropped
* fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed
* feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository
* refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed
* refactor(tokens): MCP and API tokens through McpTokensRepository
* refactor(auth): passkeys and registration invites through repositories
* fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned
* fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned
* refactor(oauth): OAuth server tables through repositories
OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.
* fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned
* fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped
* refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default
* refactor(auth): AuthService and account cleanup through repositories
* refactor(oidc): OIDC identities and settings through repositories
* fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned
* fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing
* refactor(server): trip-access primitives async at every caller
* refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster
* refactor(days): days and day notes through repositories
* refactor(assignments): day assignments through repositories
* refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories
* refactor(trip-members): membership, guests and the trip read model through repositories
* refactor(places): places, tags, ratings and place images through repositories
* fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned
* refactor(places): import paths through repositories
* refactor(trips): trips through repositories
* fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed
* refactor(trips): trip copy through repositories for the owned tables
* fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate
Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.
PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.
DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.
Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.
Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.
* test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB
* fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets
* feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets
* refactor(roadtrip): roadtrip tables through repositories
* refactor(reservations): reservations, endpoints and travelers through repositories
* refactor(feeds): ICS feed tokens and anonymous feeds through repositories
* refactor(accommodations): stays through repositories inside the existing transactions
* test(server): plan 3d — MCP test helper constructors follow the accommodations and days services
* refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables
* refactor(calendar): calendar reads and reservation visibility through one predicate
* feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit
* fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories
* refactor(photos): trek photos, memories access and cache sweeps through repositories
* refactor(files): trip files and file links through repositories, with transactions added
* test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors
* refactor(memories): unified, immich and synology photo sync through repositories
* refactor(collab): notes, polls, messages and links through repositories
* refactor(budget): budget items, members, payers and settlements through repositories
* refactor(todo): todo items through repositories
* refactor(packing): packing items, bags and templates through repositories
* feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins
* refactor(school-holidays): country and region catalog through repositories
* refactor(notifications): in-app notifications and channel preferences through repositories
* refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories
* fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories
* refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories
* refactor(system-notices): notice dismissals through a Nest provider and repository
* refactor(atlas): visited countries, regions and bucket list through repositories
* feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations
* refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories
* feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep
* fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories
* refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories
* refactor(journey): public share links, TREK Studio books and the journal facades through repositories
* refactor(collections): access model, hydration, export/import and list CRUD through repositories
* refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories
* refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories
* refactor(collections): saved places, copy-to-trip, labels and invitations through repositories
* refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories
* refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories
* test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum)
* fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories
* feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations
* feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness
* refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap
* refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories
* feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision
* refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories
* test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4)
* refactor(plugins): the trip-write role gate through a repository, in isolation
* refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService
* fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations
* refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories
* fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository
* refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories
* refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories
* refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories
* test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot
* test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot
* fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories
* refactor(server): plan 4 — convert the program's orphaned raw-SQL callers
* fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity)
* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services)
* refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces)
* fix(server): plan 4 — RULE12 paren-count scanner ignores string literals
The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression
paren-balance scanner counted every `(`/`)` character regardless of
whether it fell inside a single-quoted SQL string literal. A CHECK
expression like `status IN ('confirmed)', 'tentative')` has a literal `)`
inside its first string value; the scanner would hit depth 0 at that
in-string character and truncate the expression, losing its own real
closing paren. The scanner now tracks single-quote string state (handling
the doubled `''` SQLite escape) and skips parens while inside one.
Latent-bug fix — no shipped entity's CHECK expression has hit this shape
yet (3d's ledger: "nothing affected today").
* refactor(server): plan 4 — unify the trip-access predicate's Kysely copies
TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the
identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL)
through the Kysely expression builder. Task 7's security review (M1)
evaluated a shared helper and set it aside for time, falling back to the
TRIPREPO-041 cross-method parity test as a regression guard instead.
Extracted the boolean into one generic helper, tripAccessExpr
(_shared/trip-access.ts), following the reservation-visibility.ts
publicReservationExpr precedent: fully generic over <DB, TB> since the
predicate has no correlated subquery, so it composes across
TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower
fixed-alias shape. The LEFT JOIN itself stays hand-written at each call
site — tripSelectQuery's own docstring already explains why a shared join
helper can't be typed across these differently-joined outer queries.
feeds.service.ts and memories-access.service.ts no longer duplicate this
predicate at all (Task 2's facade-inline sweep already converted both onto
TripsRepository.findAccessible/getFeedTokenIfReachable/
listReachableActiveTrips, which read through the canonical MikroORM
QueryBuilder form, accessibleTripsQuery) — the remaining duplication this
task closes is the three Kysely copies inside this file.
TRIPREPO-041 (64/64 in the file) stays green as the unification's own
regression guard.
* fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL
The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly
make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer
(TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path)
has always bound a real string — 3e's R5 confirmed this. Appends a new
migration rebuilding the table with cache_key NOT NULL (SQLite has no
ALTER COLUMN, so this follows the same rebuild recipe
Migration20200101020000 uses in the opposite direction) and updates the
entity/generated metadata to match. Schema tightening, not a behaviour
change.
* refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23)
Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/
listByTripAndIds and Trips.repository.ts's findPublicForShare all took a
raw `.where('col = ?', [v])` string condition even though their id/trip_id
parameters were already plain `number` (or, for findPublicForShare,
narrowed to `number` here after confirming its one caller,
share.service.ts#getSharedTripData, only ever passes a number sourced
from a Kysely-typed share_tokens row, never an unconverted URL param) —
converted all six to MikroORM's typed `.where({...})` filter object.
The remaining raw string-form conditions in src/db/repositories/** are
NOT converted here: they are the `number | string` raw-bind seam
findAccessible/isOwner's own docstrings describe (trip_id/id threaded
through unconverted from req.params), which structurally requires the
deferred guard-level single toRowId parse to land first — narrowing any
of those methods' signatures now would either break the '0x10'/'007' id
parity those docstrings warn about, or duplicate the deferred item's own
work ahead of it landing.
* refactor(server): plan 4 — trek-photos.repository.ts finishes its rename
3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService,
since the name collided with the generated ORM TrekPhotos.repository.ts) but
left the FILE and its test file's names unchanged. Renames both to
trek-photo-registration.service.ts / .test.ts.
The old path is kept as a thin re-export stub (the src/websocket.ts shape)
rather than repointed at every one of its ~20 importers in this pass: several
of those files sit in two sibling tasks' exclusively-owned windows on this
shared branch (Task 3's budget/packing/todo/accommodations/places ripple,
reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts;
Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts)
and are off limits here. Flagged for a follow-up once those tasks land: delete
the stub and repoint every remaining importer at ./trek-photo-registration.service.
* fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic
install/discovery.ts#upsert refreshed a plugin's actions and settings-field
descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no
transaction spanning either pair, let alone both: a crash between a delete
and its own reinsert left that plugin with an EMPTY actions or
settings-field set until the next discovery run, instead of its previous
still-valid rows. Wraps both pairs in one uow.transactional, so a discovery
refresh for a plugin's descriptors is now all-or-nothing.
Deliberate behaviour change, named for the user per the task brief.
DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other
production caller, registry.service.ts) picks it up the same @Optional()
way PluginRuntimeService already does, with the same guard-throw. Updates
the two hand-constructed test call sites that actually exercise discovery
(discovery.test.ts, registry.test.ts) to pass a real UnitOfWork.
* refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags)
Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds
directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and
DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings
in accommodations.service.ts (placesRepo was already injected there).
BudgetService/BudgetMcp/TodoService collapse their constructors, dropping
the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts
register TripMembers on forFeature). PackingService keeps DatabaseService as
a vestigial param behind an optional TripMembersRepository with a fallback
in tripRosterIds: an in-flight Task 5c file
(tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService
positionally without the new repository, so its arity cannot change this
task; the fallback keeps that file's DatabaseService.rosterUserIds spy
working until Task 4 drops the param with the class itself.
* test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline
Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed
DatabaseService constructor arg (budgetRepoArgs() now appends a
TripMembersRepository so most spread call sites need no other change);
budget.service.test.ts's rosterHas() helper and svc() factory move from a
spied DatabaseService to a stubbed TripMembersRepository. The
AccommodationsService helper (tests/helpers/accommodations-service.ts) and
its two direct callers drop the same arg. PackingService call sites are
untouched (its new param is optional, so every existing hand-built instance
still resolves rosterUserIds through the DatabaseService fallback).
* fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too
Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow):
PluginRuntimeService's own discoveryRepos getter still built the bundle
without a uow field. Threads its already-injected, @Optional() uow through
so its own onApplicationBootstrap/reload/dev-link discovery calls also get
the transactional DI5-DI8 refresh, not just PluginRegistryService's.
No throwing guard here, unlike PluginRegistryService's: several hand-built
partial-DI-graph test instances of this class (boot-registry-order.test.ts,
in a sibling task's held file window) construct it with uow omitted and
exercise onApplicationBootstrap's discovery call directly. Discovery must
never refuse outright just because a test built a partial graph, so it
degrades to the pre-existing untransacted sequence when uow is absent
(DiscoveryRepos.uow's own docstring covers this), the same "boot must
never block app init" defensiveness this file already applies elsewhere.
* fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2)
TripAccessGuard/TripOwnerGuard already parse :tripId exactly once
(Number(), guarded by Number.isFinite) and hand the resolved TripAccess
row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing
TripAccessGuard's own lookup when both are on the same route rather than
re-querying — verified by reading both guards directly, no change needed
there.
PlacesService.verifyTripAccess's own docstring separately claimed its
returned `tid` gets "returned... for every downstream call to reuse
(never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading
every call site (not assumed) shows this is false: `.tid` has exactly one
reader (PlacesController.requireTrip itself), which discards it — none of
requireTrip's 13 callers read `.tid` off the trip it returns; each passes
the original raw tripId string on to the write/read method it calls next,
which toRowId-parses it again itself. That second parse cannot be
eliminated by this gate alone: the same service methods are called
directly by places.mcp.ts too, an entry point with no requireTrip gate at
all (its own numeric tripId is re-stringified, String(tripId), to call
them), so each method owns its own id validation regardless of entry
point. Threading `tid` all the way through would mean giving every one of
~15 methods a second, number-only call shape for the REST path alone to
use — a real refactor, out of this docstring fix's own scope. Corrected
the docstring to say so plainly instead of repeating the false claim.
* refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub
JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a
3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/
JourneyEntryPhotosRepository were still mid-flight). Now that those
repositories are stable, relocate: JS7/JS10 (photo/asset validation joins)
onto JourneyPhotosRepository (already injected into JourneyShareService as
photosRepo, no constructor change); JS13 (public entry list) onto
JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo
read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped
against that repository's own JP_COLUMNS select list instead of a
hand-duplicated copy. JourneyShareService gains two new @InjectRepository
params (both already registered in journey-domain.module.ts's forFeature),
rippled through mcp-test-controllers.ts and journey-share.service.test.ts
(P04/P05 parity tests now pin the new homes).
* refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates
3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after
unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as
"same class as OauthClients:147" but left them unexamined per-site. Decided
per site: document, don't delete — TypeScript still types
executeTakeFirst()'s result as `| undefined` even though SQL guarantees
exactly one row for these unqualified aggregates, so the optional chain
can't be removed without a non-null assertion (worse, not safer). Six
sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42,
countStatsPlaces JG67), JourneyEntryPhotos.repository.ts
(maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder
JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction
VC2). No behavior change — comments only.
* refactor(server): plan 4 — unify the two user-data-erasure enqueue paths
UserCleanupService.erasePluginUserData (the account-deletion transaction's
own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the
emitUserDeleted sink's post-commit enqueue) independently walked the
identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data
filter + INSERT OR IGNORE — and both genuinely run for every account
deletion, since every emitUserDeleted call site (auth.service.ts,
admin.service.ts, trip-members.service.ts's guest removal) fires
immediately after deleteUserCompletely.
Extracted the shared filter into enqueueHookUserDataErasures
(nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it.
UC2/UC3 also come off DatabaseService onto PluginsRepository/
PluginUserErasureQueueRepository in the same change (they were the last
raw statements standing in the way of sharing one implementation) — this
narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1
only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which
still stays raw, unaffected. UserCleanupService's orphan-data-directory
scan (a plugin uninstalled with data retained) has no equivalent on the
enqueueUserErasure side and stays there, now via the same repository.
Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this
idempotent) — this unifies the IMPLEMENTATION, not the call count; not
touching the emitUserDeleted event-sink wiring itself was a deliberate,
smaller-blast-radius choice for this task.
* refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number
Both accepted trip_id: number | string per 3c's carry note (their "stays
loose for other callers" reasoning), pending the guard-level single parse
landing first. Re-checked every caller directly: setOwner's one production
caller (trip-members.service.ts#transferOwnership) already passes trip.id,
a real number resolved earlier in the same transaction; remove's callers
are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod-
typed tool input, already a number) and TripMembersController.removeMember
(the one genuine raw-string caller, :id off the route with no class-level
TripAccessGuard on this controller by design).
TripMembersController.removeMember now parses :id once (toRowId, not
Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to
both its own access check and removeMember, instead of passing the raw
string through twice. TripMembersService.removeMember and both repository
methods narrow to number accordingly; setOwner's WHERE also moves off its
raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and
remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter
(user_id keeps its own raw bind — a different, still-live NaN-safety seam,
unrelated to this narrowing).
TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam
on setOwner/remove, no longer typecheck against the narrowed signatures
and are removed with an explanatory comment in their place.
* refactor(server): plan 4 — U6 gate-level id parsing for todo item ids
Rule 21's carry from 3e Task 8's review: TodoController.update/.remove
passed the raw :id route string all the way to TodoItemsRepository,
relying on SQLite's column-affinity CAST to match it instead of a typed
filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's
TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread
the number down through TodoService.updateItem/deleteItem into
TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all
narrowed from number | string to number. todo.rpc.ts's num()-derived
todoId no longer needs its String() wrapper. tripId stays string | number
— a separate, already-accepted carry (23c292f2f). Two new e2e tests pin
the non-numeric-id 404 at the gate.
* test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4)
3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests.
17 cases pinning every read against the legacy statement run raw on the
same connection — findRawByTrip/findTokenByTrip with every nullable
column both null and set, findValidByToken/findTripAndShareMapByToken/
findTripIdByToken's shared token+expiry predicate (valid, future-expiry,
past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/
deleteByCreator's write correctness (deleteByCreator's cross-trip erasure
kept distinct from deleteByTrip's single-trip scope, per the repository's
own docstring). The listPublicForShare family (the other half of 3h L4)
is a separate, larger carry — not in this file.
* test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry)
Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts
reads on ReservationsRepository flagged by the 3d Task 7 review as having
no repository-level toEqual(<legacy raw>) coverage (listEndpointsForShare,
listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare,
listScheduledForPublicApi, listUnscheduledForPublicApi,
listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new
cases against the legacy statement run raw on the same connection.
* test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)
Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.
* test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)
Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.
* test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks
235f03665 landed two versions of the same DaysRepository.listPlanDays and
PlacesRepository.isTrackInTrip parity coverage side by side (a merge
artifact from concurrent work on the same carry item). Keeps one block per
method, drops the duplicate describe/it blocks and their colliding test ids.
* test(server): plan 4 — budget calc suite onto real repositories (3e carry)
* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)
Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.
* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry)
Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key
has no at-rest pin", "document_connections.secrets ... has no enc:v1: check",
"trip_document_links.webhook_secret - no at-rest pin"). Three targets, each
proven by reading the raw column and asserting the stored value is
enc:v1:-prefixed ciphertext that round-trips, never the plaintext:
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the
repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the
service layer, and DocSyncConfigService's connection secrets map plus
createLink's webhook secret.
* test(server): plan 4 — drop duplicate AirTrail encryption repository test
Task 8b-2 item 4 follow-up: 6965869dd already covers
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest
ciphertext at the service layer (airtrail.service.test.ts's
AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins
(DOCSYNC-ENC-001/002); this repository-level file duplicated the same
AirTrail assertion a second time.
* test(server): plan 4 — atlas read-model parity remainder (3f L6 carry)
Task 8b-2 item 3 (atlas half): full-key toEqual(<legacy raw>) coverage for
the atlas read models the 3f Task 7 review's L6 finding listed as missing
(minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places.
listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/
listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/
listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/
listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser),
AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/
countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34
(BucketList.listForUser/findById/findForUser), AT41 (Places.
listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone
files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which
had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids
only by the existing consumer test — full-row parity for those three is
NOT done here (carried forward, same as the review's own open item).
* test(server): plan 4 — vacay read-model parity remainder (3f L6 carry)
Task 8b-2 item 3 (vacay half): full-key toEqual(<legacy raw>) coverage for
the vacay read models the 3f Task 7 review's L6 finding listed as missing
(minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the
getPlanData/getStats composite's own plan read), VacayYears.listForPlan
(listYears), VacayCompanyHolidays.listForPlan (the company-holidays list),
VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads),
VacayUserSettings.findForUser (user year settings), VacayUserYears.
findForYear (getStats rows, including the NULL vacation_days case the
review named explicitly), and VacayPlanMembers.listAvailableForFusion/
listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven
new standalone files — only VacayShares had a repository-level test before
this. getSharedCalendars itself is a service-level composite over these
same reads plus VacayCompanyHolidays.listDatesForRange (already exercised
elsewhere); its own dedicated test is NOT added here.
* test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4)
Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH13 read. Seeds Common items with every nullable
column both null and set, plus Personal and Shared-with-people items on
both sides of the is_private/owner/recipient predicate, and proves only
the Common tier surfaces.
* test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4)
Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH14 read. Seeds two items — every nullable column
set and every nullable column null — plus a foreign-trip item, and proves
the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has
none).
* test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4)
Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH16 read. Seeds a root message, a reply (reply_to
set), a deleted message (must be excluded) and a foreign-trip message, and
proves the narrower username/avatar-only join shape (no reply_text/
reply_username, unlike joinedQuery).
* test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4)
Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH12 read. Seeds a categorised place with every
nullable column set and an uncategorised place with every nullable column
null, plus a foreign-trip place, and proves the named 20-column allow-list
never leaks an owner-only column (reservation_status, google_place_id,
…).
* test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4)
Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the SH9 read. Seeds assignments across multiple days (one excluded day_id
never leaks in), every nullable place/assignment column both null and
set, and proves the six owner-only columns SharePublicAssignmentRow omits
(google_place_id, google_ftid, osm_id, amap_poi_id, stop_type,
fill_percent) never appear on the row at all.
* test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests
* test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up)
* fix(server): add the missing numbered migration for the booked-night reseat step (#242)
reseatBookedNights (legacy step 242) was never invoked from any shipped
MikroORM migration -- only from the retired db/migrations.ts array --
so an install upgrading through the numbered chain never got the
one-time reorder. Adds Migration20200101040300, which calls the frozen
function through the native better-sqlite3 handle so it runs inside
the migration's own transaction.
orm-driver.ts's BoundSqliteConnection now also captures that handle
onto the field SqliteConnection.getNativeClient() reads, which its
createKyselyDialect() override previously left unset -- needed for
this migration (and any future one) to reach it in production, not
just under the bare test driver.
RESEAT-009 moves off the legacy createTables+runMigrations builder
onto the real Migrator via tests/helpers/migration-step.ts, plus a
new fresh-install guard (RESEAT-011).
* test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder
day-notes.service.test.ts, public-api-scopes.test.ts,
registration-invites.service.test.ts, trip-invite.service.test.ts onto
createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent),
dropping their own hand-rolled createTables()+runMigrations() harness.
trip-invite.service.test.ts and registration-invites.service.test.ts
also drop their now-redundant DatabaseService wrapper (its .connection
was always just the same test db handle). expect( assertions unchanged.
user-cleanup.service.test.ts held back: its constructor call was
updated to match UserCleanupService's new EntityManager-based shape,
but deleteUserCompletely() now deadlocks against a real UnitOfWork
(reproduced in isolation, independent of this test file -- see the
report), which looks like a genuine bug in erasePluginUserData's new
MaintenanceRepository(em) call versus UnitOfWork.transactional(), not
a test-harness problem.
* test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb()
Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in
auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for
their own archiver/filesystem assertions, which broke
createSnapshotTestDb()'s read of the schema snapshot through
schema-snapshot.ts's own fs import.
auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now
built via a small helper that points fsMock's three calls at the real
filesystem (fetched through vi.importActual, bypassing the mock) just
long enough to build the db, before the test installs its own
existsSync stub -- never touching schema-snapshot.ts itself.
demo-reset-path.test.ts: fs.existsSync is selectively spied to answer
only for the baseline path, so the db is now built before that spy is
installed instead of after.
* test(server): plan 4 — retire the legacy-runner-only migration hygiene checks
migration-hygiene.test.ts deleted outright: every block in it (the
destructive-op allowlist, the empty-catch guard, the full-chain smoke)
scans or runs the legacy db/migrations.ts array being deleted this
plan, never the numbered src/db/migrations/*.ts chain, so there is
nothing in it to carry forward.
leg-mode-incoming.test.ts: deleted its first describe block (the
legacy-runner column-add + replay-safety smoke, also against
db/migrations.ts); the read-path parity block, already on the modern
createSnapshotTestDb() harness, is untouched.
* chore(server): delete the legacy schema/migration/seed builders
MikroORM's migrations are the only schema source now, including for
tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are
gone, along with tests/unit/db/schema-parity.test.ts (the parity gate
that guarded them, already red on 8a's cache_key NOT NULL) and
createTestDb() + its now-dead imports out of tests/helpers/test-db.ts
(its last two callers moved onto createSnapshotTestDb() this plan).
Their three entries drop from eslint.config.mjs's better-sqlite3
allow-list -- the files they exempted no longer exist.
Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts,
document-provider-seed.ts) and every numbered migration in
src/db/migrations/ are untouched.
Precondition grep (server/tests + server/src, from the task brief) is
now empty:
grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts"
* docs: plan 4 — the repository layer is the only data access path
DatabaseService/DatabaseModule are gone; queries go through repositories
(server/src/db/repositories/) and UnitOfWork (server/src/nest/database/
unit-of-work.ts) for transactions. MikroORM's migrations are the only
schema source, including for tests — the hand-kept schema.ts/migrations.ts/
seeds.ts test builders and their parity test are deleted. Documents the
db/database.ts (connection only) / db/orm-driver.ts (bound driver) /
UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints,
the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/
check:entities) as the schema-parity guard. Corrects two stale nest/README.md
mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1
trip-access bullet) and marks the legacy src/services/ migration recipe as a
historical changelog rather than current guidance.
* refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters
Removes the now-dead DatabaseService injection (or private get db() getter)
from addons.service.ts, trips.service.ts, trip-invite.mcp.ts,
place-photo-cache.service.ts, trek-photo-registration.service.ts,
categories.mcp.ts, journey-domain.service.ts, plugins.service.ts,
plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts
and packing.service.ts — every one of them was fully repository-backed
already (Task 2/3's facade inline sweep), just still carrying the param.
PackingService's TripMembersRepository is required now (the
this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's
uow is no longer @Optional() — both moved ahead of the optional
registry?/hostFactory? params, since TypeScript refuses a required
parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow
is required to match, and its upsert() always runs the descriptor
refresh inside one transaction instead of falling back to an
un-transacted sequence.
user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's
"stays raw" plugin-table trio) moves off DatabaseService onto a new
MaintenanceRepository.deletePluginUserData method — the rule-4-permitted
connection.execute() home, built from a directly-injected EntityManager.
Threads the active UnitOfWork.transactional's transaction context through
execute()'s ctx param: without it, a raw connection.execute() call issued
from inside deleteUserCompletely's open transaction contends for the same
single-connection checkout the transaction already holds and deadlocks
(never released, since the transaction is itself awaiting the call).
tests/unit/db/transaction-yield.test.ts's probe B already pinned this
exact hazard class for a raw better-sqlite3 statement; this is the same
mechanism reached through the ORM's connection pool instead.
nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim)
is deleted, with every importer repointed to trek-photo-registration.service
directly. The ~7 files that only imported the TripAccess/PlaceWithTags
type re-export from database.service.ts now import them from
Trips.repository.ts/Places.repository.ts directly, the actual single source.
* refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts
Grep gate confirmed zero src/ importers of DatabaseService left outside
these three files (Task 1/2/3's earlier conversions + this task's own
vestigial-param sweep). Deletes database.service.ts, database.module.ts
and database.tokens.ts, drops DatabaseModule from app.module.ts's imports
and the five other modules that still listed it
(accommodations/calendar/doc-sync/feeds/trip-members) — none of them had
a live DatabaseService injection left, so the import was already dead.
OrmModule is the module that survives DatabaseModule's deletion, per the
Phase 0 ledger's own ruling; untouched.
db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test
files' vi.mock('…/db/database', …) factories return a { db: <mock
handle>, … } shape, and their own `import { db as testDb } from
'…/db/database'` is typed against this file's real exports regardless of
the runtime mock — vi.mock swaps the module at runtime, but tsc resolves
the import statically. Deleting the export would fail typecheck:tests
across the whole suite for a test-harness idiom this task's file set
doesn't own (Track B's territory). src/ has zero remaining importers, so
the ESLint no-restricted-imports gate this task also shrinks still holds
this file to its permanent allow-list entry either way.
* test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule
Mechanical follow-through of the previous two commits across ~110 test
files:
- The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their
partial Test.createTestingModule graphs; the import and the array entry
are dropped (nothing in any composed domain module injected
DatabaseService any more, so the entry was already dead weight).
- The shared test helpers (mcp-test-controllers.ts, plugin-host.ts,
accommodations-service.ts, notifications.ts, test-addons.ts,
test-uow.ts) each held a DatabaseService/DatabaseService-shaped param
used only for its raw better-sqlite3 handle; every one now takes that
handle directly, and every constructor call site the helpers make
(PackingService, PlacesService, JourneyDomainService,
PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService,
UserCleanupService, AddonsService, PluginsService, PluginRuntimeService)
drops the removed param and, where PluginRuntimeService's uow is
involved, reorders it ahead of the optional registry?/hostFactory?
params to match the real constructor.
- ~60 further unit/integration test files hand-constructed one or more of
those same services directly; same fix, file by file. Four of them
(memories-access.service.test.ts, places.directions.test.ts,
places.service.test.ts, unified-memories.service.test.ts) also drop a
vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner'
| 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those
methods delegated through DatabaseService — dead now that the services
read the owning repository directly.
- database-service.test.ts and database-service.delegation.test.ts tested
DatabaseService directly and are deleted with it.
- tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw
DatabaseService statement's dirty-read behaviour inside an open
transaction (rule 24), now issues the same raw better-sqlite3
statement directly against the shared test handle — the exact shape
DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(),
bypassing MikroORM/Kysely entirely) — so the hazard it documents is
unchanged. Verified still green.
No expect(...) assertion changed in any file; only construction/import
plumbing. typecheck, typecheck:tests and the full touched-file run
(101 files / 2668 tests) are green, plus the compiled boot
(bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real
e2e boot file (trips.e2e.test.ts).
tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this
commit — see the next one.
* test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix
This file arrived already modified and unstaged in the shared tree —
Task 6 (the parallel legacy-schema-deletion task) had swapped its DB
harness onto createSnapshotTestDb() and updated the UserCleanupService
construction to this task's new EntityManager-first shape while landing
its own commits. Finished here: USER-CLEANUP-005 ("survives a slim
schema without the plugin tables") passed a fake `{} as unknown as
EntityManager`, which doesn't reach the real UC1 path any more (the
previous raw DatabaseService.run swallowed everything in a try/catch
regardless); it now builds a real EntityManager bound to the slim
handle via sharedTestOrm(slim), so the per-statement query against the
missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state
tables is what's actually being proven caught, not a construction-time
throw.
USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them
exercising UC1 inside the real uow.transactional wrap) are the
regression test for the previous commit's deadlock fix in
user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every
one of these hung and timed out. Green now, verified directly.
* chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list
Per ruling 6: database.service.ts's entry comes off (the file is
deleted). The five plugin entries (plugin-route-normalize.ts,
plugin-host-state.ts, install/discovery.ts, settings-defaults.ts,
signature-status.ts) come off too — re-verified none of the five import
better-sqlite3 or DatabaseService any more (3j's own conversion). The
src/demo/** blanket entry was already gone (3i's own task landed it).
reseat-booked-nights.ts and document-provider-seed.ts stay, with the
`// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7),
invoked from inside numbered, already-shipped MikroORM migrations, never
Plan 4's or any future plan's to touch.
Permanent allow-list after this commit: src/db/database.ts,
src/db/orm-driver.ts, src/db/durability.ts (the driver homes),
src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a
plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts
(3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts
and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts
entries were already removed by Task 6 when it deleted those files.
0 new lint errors; warning count unchanged at 2123 (well under the 2247
lean-gate ceiling) — every file the shrink touches was already fully
converted.
* refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids
Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:settlementId/:userId route param in BudgetController is parsed ONCE at
the gate with toRowId, matching the legacy affinity-seam "not found" outcome
for a malformed id on each route (404 'Budget item not found'/'Settlement
not found', except toggleMemberPaid — the one legacy handler that never
404'd an unknown item/member either, so a malformed id there skips the write
and keeps the same 200 { member } + Number(id)/Number(userId) broadcast
shape it always had).
The parsed numbers thread down through BudgetService's update/updateMembers/
setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and
their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/
applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's
getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById
and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById,
all narrowed from number | string to number; trip_id stays loose, a
separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no
longer needs its String() wrapper on the update/delete calls; budget.mcp.ts
was already Zod-typed to number throughout. Two new e2e tests pin the
non-numeric-id 404 at the gate; controller/service unit tests cover the
narrowed call args and the toggleMemberPaid malformed-id parity case.
* refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids
Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:bagId/:templateId/:userId route param in PackingController is parsed
ONCE at the gate with toRowId, matching the legacy affinity-seam "not found"
outcome for a malformed id on the routes with a pre-ORM Express precedent
(update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers ->
'Bag not found', applyTemplate -> 'Template not found or empty').
#858's sharing/contributors routes (setSharing/clone/addContributor/
removeContributor) are native Nest code with no pre-ORM precedent (landed in
7eabf6066, after the migration) — a malformed id there just 404s the same
way an unknown one already does. removeContributor's :userId used to reach
contributorsRepo.deleteOne through a bare Number.parseInt(), the exact
NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too.
The parsed numbers thread down through PackingService's updateItem/
setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/
getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed
from number | string to number; tripId stays loose, a separate,
already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/
update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/
update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply
narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer
need their String() wrappers on the update/delete/updateBag/deleteBag/
setBagMembers calls; packing.mcp.ts was already Zod-typed to number
throughout. Two new e2e tests pin the non-numeric-id 404 at the gate;
controller/rpc unit tests cover the narrowed call args and the
removeContributor malformed-userId parity case.
AdminPackingTemplatesController (/api/admin/packing-templates) is out of
scope — a separate admin-gated surface with its own string-typed template
CRUD, not part of this trip-scoped controller's :id chain.
* test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3)
* fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1)
An install the retired positional runner migrated has a schema_version row
and no mikro_orm_migrations rows, so its first boot on this branch replayed
all 244 migrations over an already-migrated schema: eight refuse the boot,
and the replay-safe ones overwrite user data (step 26 rewrites every
day_assignments.assignment_time).
runSchemaBootstrap now records the baseline plus every migration whose
"Legacy migration step N" docstring is <= schema_version as executed, in one
transaction through the migrator's own storage, before the ordinary run
applies the rest. The step map is derived from the discovered migrations'
source, never hand-kept, and anything unmappable (a newer or malformed
schema_version, a duplicated, missing or out-of-order step) refuses the boot.
* test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline
SQL dumps of databases the retired runner itself built: schema_version 205
(main at fa406b708), 242 (417d0445b, the runner's last commit) and 82
(v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead
and run for the first time). Each gets canary rows: user-set assignment
times and a booked night.
buildApp() on each asserts the baseline and the applied rest in order, the
assignment times untouched, the booked-night reseat run exactly once on 205
and 82 and not at all on 242, schema_version kept, no half-built *_new
table, a schema identical to a fresh install's, and an idempotent re-run.
* refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1)
demo-reset.ts was the one src/ consumer of the db Proxy left, reached
through a runtime require that a grep on `from` misses. It now imports
closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts
does, so the Proxy genuinely has no src/ importer (the claim in database.ts
and server/CLAUDE.md now holds). The path test mocks the module with vi.mock
instead of teaching Node's require loader to answer for a .ts file.
* test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3)
ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and
the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so
each id names one test again. No test body changes.
* refactor(server): derive the two restated public row types instead of copying their columns (m4)
JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen
columns with only the parent key changed; it is now that row with
journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely
table shape restated PlaceRow's columns; both now Pick them off PlaceRow.
Types only. The review's CPD method over Plan 4's new src lines drops from
about 3.4% to 0.64%.
* test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5)
Neither read had any test reference. Each is now pinned with
toEqual(<legacy statement run raw on the same connection>), the
listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus
a missing id (TRIPREPO-067/068), and VC90 over one world exercising every
exclusion (caller, guest, existing share, plan owner, accepted member) and
a missing plan.
* fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1)
The legacy runner ran the running release's createTables() on every boot
before its pending steps, and about 30 tables exist only there. Marking the
baseline migration as executed left installs older than those tables (below
legacy step 68) failing with "no such table: photo_provider_fields" and, since
the rows were committed before the run, stuck half-baselined for every later
boot.
Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs
first, as the legacy boot did. The rows are logged inside the same
transaction as migrator.up({ transaction }), so a failed first boot records
nothing and the next boot baselines again.
A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied,
canaries kept. Its two schema differences from a fresh install
(users.immich_access_token, reservations.accommodation_id INTEGER) come
from the legacy chain itself and are pinned as carried.
* fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames
feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.
* ci: give the server lint step an 8 GB heap for the type-aware rules
* test(server): keep the no-promise-as-value rule tester out of single-run mode on CI
With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.
* refactor(server): name the new migrations after the first seven words of their purpose, like the rest
* fix(server): keep tsx a production dependency so image key rotation can load it
* test(server): re-pin the repositories branch floor at the measured 89.83%
* test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom
* test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL
* test(client): keep console output for failing tests only and answer the health probe
* test(client): drop the health handler, it races the admin page's first load
* chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures
---------
Co-authored-by: Maurice <mauriceboe@icloud.com>
The Tours surfaces now use the same building blocks as the rest of the
planner: tinted head bands, eyebrow labels, white field boxes and pills,
place-style list rows and the inspector frame for the map detail.
- Tour detail on the map uses the place inspector's frame, head band,
labelled metric fields, collapsible track colour and footer buttons
- Tour rows in the Plan sidebar and the Tours tab look like place rows,
with the facts as pills and a "+" that opens the trip's days as a menu
- Tours tab rails get the planner head bands, editor fields and
waypoint rows in the day card style; notices share one look
- Map hint shortened to one line and the empty state reads as a single
sentence in all locales
- New "tours" mascot scene with a pack, a trekking pole, hills and a
trail, used by the Tours empty states
PlacesSidebarHeader uses the UNCATEGORIZED constant, placesBrowserModel stops
re-exporting matchesCategoryFilter (its test imports it from utils/placesFilter),
and the orphan comment in the en places strings joins the places.filters key.
The panel button is named "Filters" like the map control, its badge counts
kinds of filter through countActivePlacesFilters (the pool, shown as chips
above it, stays out), and picking a rating floor clears the selection as
picking a pool does.
The header claimed the lists and the map always agree. They apply the same
predicate to the same values, but the map narrows "planned" to the open day
(#2024) and leaves collapsed days' stops off.
MChip only showed its state through colour. A pressable option renders
aria-pressed from active; the places filter's Show and rating floor chips use
it, and the tests now check the chosen chip by its pressed state.
MapTogglePill always set aria-pressed, so the phone map's Filters control read
as a toggle although it opens a sheet. An opensDialog option drops aria-pressed
for aria-haspopup="dialog"; the other map toggles are unchanged.
The fallback from "Tracks" to "All" once the last track disappears lived in
the desktop sidebar and the phone places browser, neither of which is mounted
while the phone map is in front: the map could sit empty under a filter no
control offers any more. useTripPlanner now owns it, once, for every screen.
Map-Features gets a "Filtering the places on the map" section: the markers
follow the places list's Show, category and rating filters, and on a phone
the Filters button of the plan map opens the same choices in a sheet.
Places-and-Search says the rating floor narrows the map markers too.
The sheet opens over the phone map, and with the default bar glass the map's
place names showed through the pool chips, the rating floors and the category
rows, which made them hard to read at 390px wide. It now uses the opaque sheet
material, like the other menus that open over busy content.
On a phone the only way to narrow the trip's pins was to leave the map for
the places list, and the rating floor could not be set at all. The phone map
now has a round "Filters" control at the foot of its right-hand stack, badged
with how many filters are on, opening a sheet with the pool (all / unplanned /
planned / tracks), the minimum rating (the desktop list's floors), the trip's
categories including "no category", and a reset. It is hidden on the road trip
stage, which draws its own pins.
The places browser's filter panel gains the same rating floor; its category
rows move into MPlacesFilterControls so the panel and the sheet share one
copy. Everything writes the trip store, so list and map stay in step.
MapTogglePill takes an optional count badge.
The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
overflow-wrap:anywhere alone lets a long URL wrap inside a flex row; break-words
is overridden by it and word-break:break-word is deprecated. MarkdownText already
gets the rule from .collab-note-md, so its call sites drop the duplicate.
- Make the “Manage preferences” text in notification emails clickable.
- Link directly to `/settings?tab=notifications`.
- Preserve the localized link text and existing email styling.
- Add a regression test for the generated preferences URL.
A flight or train with several legs comes out of the calendar feed as one event per leg, titled with its route and carrying the leg's own departure, arrival and segment reference, so the layover shows as the gap between them. A booking with a leg missing its departure time stays one event, as before. (#2389)
Admin > Notifications gets a Defaults for users matrix: each event and channel starts On, Off (a user may still turn it on) or Blocked (off for everyone). A user's own choice is kept against the default, and a value equal to it is stored as no row, so a later change of the default reaches everyone who never chose. Blocked cells show as locked in the user settings on desktop and phone, and the server ignores any attempt to turn one on. (#1536)
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582)
Place photos, link previews, webhooks and the integrations fetch through the guard's own dispatcher and so ignored the outbound proxy, which left a server that can only reach the internet through one without them. They now read HTTP_PROXY, HTTPS_PROXY and NO_PROXY themselves and connect through the proxy, after the guard has vetted the target. The Helm chart passes the three variables through. (#1754)
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
While a trip is running, today's chip on the phone carries a ring and a dot, and a button beside the rail jumps back to today from wherever it was scrolled, in the plan and on the map. It stays in place, dimmed, while today is open, so the rail never shifts under the thumb. (#2392)
The phone's collection place sheet gets the Navigation button the desktop detail and the trip's place sheet already have, first among its actions: one offered app opens straight away, more open the same picker. (#2091)
The handles on the day plan and the places panel only listened to the mouse, so on an iPad they did nothing, and in the portrait band they were left out. They now follow a finger as well, show a grip on touch screens, keep the page and the map from taking the gesture, and stay in the narrow layout within the room the map needs. As separators they also move with the arrow keys. (#1012)
The password policy moves to @trek/shared, where the server keeps enforcing it unchanged and every form that sets a password now runs it before sending: the forced change after the first login, registration, the reset link, the account settings on desktop and phone, and creating or editing a user in admin. Under each new password a checklist ticks off length, upper and lower case, a number and a special character, and a refusal names the actual reason, a too common or too repetitive password included. (#1200)
Every dialog, the shared frame and the older modal alike, moves the focus in when it opens: on a desktop into the first text field, on a phone onto the panel so no keyboard pops up. Tab wraps around inside instead of running through the page behind, and closing hands the focus back to what opened it. Fields that open a list on focus are skipped, and the older modal's close button got a label and a tooltip. (#1302)
A booked stay's pill in the day head gets a second target that opens the booking; the name still opens the place. The place panel of a hotel, on the desktop and on the phone, lists the stay booked for it next to the bookings pinned to the stop. (#2363)
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
The notes of a flight, train or rental print under its booking code, like the notes of a place. A Transport notes switch next to Page break per day leaves them out and is remembered; it only shows when a transport has a note. (#1571)
The search box of the places list matched only name and address. It now also reads what was written about a place, through one helper the desktop list, its filter counts and the phone's browser share. (#2252)
The image carries org.opencontainers.image.source, url, documentation, licenses, title, description and version; the release and pre-release workflows add the commit and the build time, and the multi-arch index gets source, version and licenses as annotations. (#1498)
Providers like Pocket ID send the full name in 'name', which became 'JaneDoe'. OIDC_USERNAME_CLAIM=preferred_username builds the username from that claim instead. Unset, or with the claim empty, the old order applies (name, preferred_username, the email's local part), and it is only read when an account is created. (#1677)
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542)
The public journey page drew the old "Powered by" block above the shared
frame's footer, so the credit showed twice; only the shared footer is left.
Its tests read the new frame: the title in the hero, the counts in the hero
pills, the loading skeleton and the cover as an image.
The shared trip tests look for markers inside the map, since the unplanned
card names the same places. The place form clicks the search button by name,
now that a picked place adds the nearby button to that row. The journey
editors send the photo order as one PUT, so their tests follow that request,
and a refused order rolls back as a whole. Payments carry their note, and a
saved mobile entry carries its draft flag.
nodemailer goes to 10.0.13 and brace-expansion to 2.1.7 for the advisories
Docker Scout reports (CVE-2026-102276, CVE-2026-102278, GHSA-v53p-9fqp-m79j).
A double tap counted every lift of a finger, the end of a swipe included, so
two quick swipes zoomed in. Only a touch that stays in place is a tap now.
The client tests follow the fields and arguments the qol sweep added.
The hand-written e2e schemas gain the new columns and place_regions, and the
unit assertions take the new arguments and fields. The Public API page names
GET /api/auth/app-config for the running version.
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
Sonar counts a promise that is neither awaited nor caught as a reliability bug, and it reads every such call in a touched file as new. Each of these already reports its own failure (toast, rollback or the sheet's catch), so they are marked as fire and forget rather than changed.
The overview holds its camera frame across content edits and only publishes a
fresh one on activation or on a trip switch. Activation is covered; the trip
switch branch was not, so a regression there would leave the map framed on
the previous trip. The new case keeps days and stops identical and changes
only the trip id, so the switch alone has to produce the new frame.
A departure or arrival row wrote its zone out ("Europe/Berlin") next to the
phase label, and on the narrow day plan that ate the width the route needed,
so "BER → HAM" ended in an ellipsis. The zone is now a small globe after the
time that names it on hover, the time pill carries only the time, and the
route gets its room back.
The phone booking and transport sheets collect picked files and upload them
after the save, but only when the record was new. Editing an existing booking
showed the picked files in the sheet, then dropped them on Update without a
word. The upload now runs after every save that comes back with a record,
through one helper both sheets share. A save that fails uploads nothing.
Closes#2534
The quick picks of the public transit search listed the day's places first
and the trip's stays after them, then cut the list at eight. On a day with
eight or more located stops the hotel was always cut, so it could not be
picked as the start or the end of a connection.
The stay the day wakes up in and the one it ends in now come first and are
never cut. The day's places and the trip's other stays follow, capped at
eight as before.
Closes#2538
With ALLOWED_ORIGINS set, every request carrying an Origin that was not on
the list was failed with an unhandled error, which the exception filter
answered with a 500. A browser sends Origin on every POST and WebSocket
upgrade, including same-origin ones, so opening TREK under any address the
list did not name (the LAN IP, a second hostname, http instead of https)
broke the login with "Internal Server Error" while another browser that
used the listed address worked.
A request whose Origin is the host it was sent to is now treated as
same-origin and always passes, for REST and the /ws upgrade alike. A
foreign origin is still refused, now with a 403 "Not allowed by CORS" and a
warning in the log naming the origin to add.
Closes#2543
Booking a night writes the hotel onto its check-in day as a stop, seated first
or at its check-in, so the drive reaches it even without hotel bookends. With
"Start and end each day at your stay" on, the evening row already ends that day
at the same hotel, so the road trip drove check-out, tonight's hotel, the day's
places and back to the hotel again. Days hides that stop and draws the stay at
the day's edges only.
seatNightBookends now passes over the stop tonight's booking wrote whenever it
seats that stay as the day's evening, in the browser and in calculate_roadtrip
alike. The stops keep their stored index, a hotel the traveller placed stays,
and the booking's stop stays while it is the day's last one or no evening is
seated. With the switch off nothing changes.
Closes#2546
The opening camera of the public share map was computed for the browser
window, while the map lives in a card a fraction of that size. Leaflet then
rounded the fractional zoom up, and the first fit skipped its correction
because it believed the map had already opened framed. Only picking a day and
going back to the whole trip ran a real fit.
The card is now measured before the map mounts and the opening frame is
computed for it, on a whole zoom level, with the same padding and zoom cap the
later fits use. A card without layout falls back to the first fit.
Closes#2549
The link chip held its title on one line with an ellipsis, and nothing in
the UI showed the rest: the tooltip names the action, not the link, and a
phone or the PWA has no hover at all. The title now wraps and the chip grows
in height, the host drops to its own line under a wrapped title, and the
chip keeps rounded corners instead of pill ends once it is taller.
Closes#2550
The day pills, Total Cost at the foot of the plan and the same figures in
the PDF added up the price column of every planned place. No form sets
that price since expenses replaced it, and nothing in Costs changes it,
so deleting an expense left the plan's total where it was, and a place
planned on two days counted twice.
With the Costs addon on, the plan now adds up the expenses: each one once,
on the day its booking starts, else the first day its place is planned,
else its date, and in the total either way. A foreign expense counts at
the rate frozen when it was entered and a refund nets against its day,
so Total Cost matches Total trip spend in Costs. With Costs off, a
place's own price still counts, once per place.
Closes#2551
Sonar counts a promise that is neither awaited nor caught as a reliability bug. Every one of these already reports its own failure (toast or optimistic rollback), so they are marked as fire and forget rather than changed.
A switch at the end of the list's tab row shows every list's items in name
order, so checking whether something is already on the list no longer means
reading all of it. The order follows the reader's language (accents beside
their letter, "Shirt 2" before "Shirt 10") and the empty list stand-in stays
at the bottom.
Sorting only changes what is shown: the manual order stays stored and comes
back with a second click. While sorted the drag grips step aside, the box
keeps its column, and the choice is remembered per browser.
Every guide of the four screens is recorded again with the new dialogs.
The guide scripts follow the new markup, and a review of the pictures
fixed what they taught wrong: the Kyoto list holds Kyoto places with
their photos, the invite and share steps show the picked user, the shared
calendar step shows one shared with you, the Dawarich and wishlist steps
frame the whole dialog, and the create journey guide links a trip with
places.
The export dialog was the last popup on the old card: it is now the shared
dialog frame with its options as rows, and it takes Escape for itself so
the key no longer reaches Studio behind it. Its stylesheet goes.
The Atlas country card printed the flag emoji, which Windows draws as the
two letters of the country code; it uses the shared CountryFlag now.
Every popup of the four pages moves to the shared dialog frame the trip
uses: head band with its icon, the scrolling body, the footer with its
actions, and ConfirmDialog for the questions.
- Vacay: settings, removing a year (now says so when it fails), the
fusion request, inviting a person, a person's colour and sharing the
calendar.
- Collections: new and edited lists, adding a place, importing a file and
a trip's places, sharing, labels and assigning them, copying to a trip,
moving or duplicating, saving a trip's places and deleting a list. The
stylesheet of the old import view goes.
- Atlas: the country popup with the flag in its head, its choices as rows
and the wishlist step; Dawarich's Atlas dialog and its accept dialog on
the desktop.
- Journey: creating a journey, its settings, linking a trip, inviting a
contributor, the entry editor (discarding asks in a dialog) and the
photo provider picker; the entry card's menu is the shared one.
The phone keeps its own sheets: where a dialog is shared with it
(Dawarich, the contributor invite, the provider picker) only the desktop
frame changes.
The redesigned share page reads collapsedDays, toggleDay and pickDayOnMap
from its hook; the map test's stand-in left them out and every case there
failed on the missing set.
The page a share link opens is rebuilt from top bar to footer: the
planner's light top bar with the language picker, the trip as a hero
made of its cover, a tab bar that stays in reach, the days as the
planner's cards beside a map that stays in view, bookings as the
booking cards, packing and costs as cards with their progress and
shares, the chat as bubbles, and empty states for sections the owner
shared with nothing in them yet.
The map picks its day in a select with a step back and forth instead
of a row of chips a three-week trip did not fit into.
Share opens on the shared dialog frame: the trip's name and the number of
people in the head band, members and guests on the left, the public link
and the invite link as two cards on the right. Rows carry their actions as
icon buttons named by their tooltips, and removing a member, leaving,
handing over the trip or removing a guest asks in the app's own dialog
instead of the browser's. The invite link can no longer be created twice by
a double click. The phone, which opens the same dialog from its More sheet,
keeps the sheet it had; both run on one set of hooks.
Save to list has the place's name in the head band, Mark visited as a pill
beside it, the lists as rows with a tick, and View at its foot.
The public link's hint loses its dash in every language, and the help
guides and wiki pages of the Share dialog show the new look.
The wiki pictures of the trip are taken again with the fixtures the help
guides use, so the Bookings tab shows a booking of every kind, Files has
documents and What's Next has something ahead. New pictures: the map's
hover card, the Subscribe to calendar dialog, the PDF preview, the To-Do
view, and the Transports tab's own timeline and booking detail. Day
selection in the screenshot run clicks the day's number, now that the
middle of a day header is the booked night.
Home, Real-Time Collaboration, Road Trip, Map Features and the bookings
page are brought in line with the rewritten pages, and sixteen assets no
page uses any more (mostly old GIFs, 22 MB) are removed.
The guides of the map, bookings, costs, files, collab, lists, road trip and
transports now describe the desktop as it is after the redesign: the bars
with their icon buttons and filter popovers, the dialogs with the title in
the head band and status and type as pills, the three booking views and
the booking detail, the compact note cards, the Costs table. Two guides
are new: the booking views (cards, list, timeline, the Trip and Day zoom
and the detail) and the Costs table view. Every changed text is translated
into every locale, and the Plan tab texts call the bookings tab by the
label it shows, Book.
Every picture of the thirteen trip screens is taken again against the new
layout, on one picture day. Scripts follow the new markup; the Dawarich
fixture uploads its recording again when the stored one belongs to another
run, and the spare poll gets its vote from a member so its winner shows
green.
server-launch.mjs skips the server build with E2E_SERVER_PREBUILT, so
several recordings on their own port pairs can share one dist.
vi called the Tracks filter "songs", sv the pending status "commuting", nl
the open-now pill "opening hours", and tr showed "its plan" on the Plan tab
and "being suspended" for pending. The road trip strings added in August
had lost their accents in eight languages (Arrive and Leave, the stay
buttons, the whole day and halfway filters, the avoidance options), uk had
a Latin i in "halfway", hu and tr called No tolls "free of charge", and az
put an en dash between the two ends. sv and vi said "I owe" for I'm owed and
You're owed, sv had "Namnn" as the table's Name, and th said "split
equally" for Split matches total.
The climate hint of the day details and the Unfinished tooltip in Costs
lose their dash in every language.
The PDF preview on the Files tab and the document preview of a Collab note
were about 150 px tall: the object carried flex-1, and in a column without a
fixed height that falls back to the object's own height instead of 74vh.
A tooltip no longer opens when a closing dialog hands the focus back to the
button that opened it. It floated over whatever came next, at the place the
button had before the page moved. Keyboard focus still opens it.
The table view sorts its dates with localeCompare, and two NaN fallbacks and
an unused import go.
The detailed weather asked the forecast API for every day up to 16 days out,
past days included. Once a day is a couple of months back that API answers
nulls instead of an error, and the day details showed 0° while the planner,
which already reads the archive, showed the real figure.
A past day now comes from the archive, with the same daily figures, sunrise,
sunset and hourly series as the climate path, which shares the new helper. A
recent day the archive has no figures for yet goes on to the forecast API as
before, and nulls from either source now answer no_forecast instead of 0°.
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.
On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.
The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:
- Day plan: each day is a card with a head band (day tile with weather,
stay and rental pills, a "+" menu for place, stay, transport, transit
and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
detail popup first; Edit leads to the editor. The popup is one host
shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.
Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
The Bookings and Transports tabs on the desktop take the phone's card
language: a tinted status band, the type chip and framed fields. One bar
holds the search, a filter popover for status, types and travelers, the
view switch and the view options.
Three views over the same filtered entries: cards (four per row at most),
a dense list grouped by day, and a timeline with a trip scale and a day
scale by the hour. A booking opens as a dialog with everything it holds,
the booking code blurred by the existing setting, and "On map".
Work in progress: only en and de are translated, and tests, help guides
and the wiki still follow.
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.
The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.
The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.
The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.
Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
Brings the v4.3.3 release back into the dev line, so the 4.4.0 release
merges into main without conflicts. All twelve conflicts resolve to the
dev side, which carries the same fixes adapted to dev (migration order
of #2527, dev only code around the costs and PWA fixes). Against the dev
fixes alone, the only change is the version bump to 4.3.3.
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.
Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.
The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.
Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.
MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.
Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.
The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.
Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.
MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).
The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.
Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.
StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.
The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.
The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).
The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.
Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.
StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.
The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.
The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.
Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.
A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.
The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.
Kept from before: the desktop totals recompute when rates arrive, a
booking's linked costs are labelled in the trip currency (every linked
row, the link picker and the phone's linked costs), a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.
The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.
A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.
The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.
Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.
The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
On dev the trigger takes schema version 244, the slot it has on main, so a
4.3.3 install upgrading to 4.4.0 does not skip it. Web Push moves up to
245 and is idempotent, so a 4.3.3 install gets its table there. An
instance that already ran Web Push at 244 never runs 244 again, so 246
creates the trigger once more; everywhere else that is a no-op.
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.
buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.
hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.
The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.
Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.
The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.
The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.
Both screens use the theme tokens instead of hex literals. New keys are
translated in all 27 locales.
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.
The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.
On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.
A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.
A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.
A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
Dropping the worker ends a Web Push subscription with it, so on such a device
the settings card shows push as off until it is switched on again. A missing
sw-push.js now fails the worker install with a 404 rather than index.html; the
comments that described the old answer say so.
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.
Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.
Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.
Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.
Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.
Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.
Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.
A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.
The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.
Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.
The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.
A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.
The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.
Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.
The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.
buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.
hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.
The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.
Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.
The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.
The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.
Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.
The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.
On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.
A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.
A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.
A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.
Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.
Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.
Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
Discussion #2084: a cost bought later (the seat upgrade for a flight) could
not be tied to the booking it belongs to, and a booking held one expense.
- The Costs block of the booking, transport and place forms lists every
linked expense with edit, unlink (the expense stays in Costs) and delete,
and offers the expenses that belong nowhere yet under Link existing
expense. The phone sheets get the same block in their own design, and
their Files row lists the attached files and links an existing one.
- PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC)
take reservation_id / place_id; create and update refuse an id from
another trip on all three paths.
- The booking's mirrored price is the sum of its expenses when they share
one currency (none counts as the trip's), else the first one, with that
currency, and follows every change to a linked expense. The booking
form's price field leaves a booking with several expenses alone.
- Deleting a booking deletes all of its linked expenses and broadcasts each
one, also from the MCP delete tools.
- Cards show the price with its currency.
- Booking and transport dialogs: Booking Type and Travelers as dropdowns at
the top, the URL beside Files (transports gain the URL field). The row is
one shared component; linking and unlinking files go through the store.
Help center pictures and texts for the booking, transport and expense
guides are redone in all 27 languages, five Costs labels that were still
English in 16 languages are translated, and the wiki follows.
Rows get the packing list's filled checkbox, priority, due date and list
as quiet badges, and the assignee as an avatar with the name as its
tooltip. The tasks sit in a card whose head names the filter and holds
the sort, Priority or Due date, which moved out of the sidebar; a second
click on the active one goes back to the manual order. The progress card
matches the packing list's.
The detail pane is a card with a box in its head that ticks the task
off, and Delete and Save changes stay in sight: the To-Do view now fills
the space under the Lists bar instead of a fixed height that pushed the
pane's foot off screen. The detail pane and the new-task form share one
set of fields, and a new list is named in a dialog, from the sidebar
and from the form; the dialog moved to shared/NameDialog for both lists.
The To-Do help pictures are re-rendered, the texts that described the
old sidebar and form follow in all 27 languages, and the wiki page is
updated. The phone screens are unchanged.
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.
The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.
Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
The packing list bar gets an export menu next to the import button, both
now icon only. The menu prints the open view as a TREK styled page (trip,
dates, packed count, one card per category, bags), which the browser can
also save as a PDF, and downloads it as a Markdown checklist or as a CSV
in the exact format the import reads, bags included, so a list works as a
personal template without admin rights.
The import now also reads Markdown checklists: headings become
categories, "- [x]" marks an item packed, a "3x" prefix sets the
quantity and a "(200 g)" suffix the weight. The phone action sheet
offers the same exports through the shared usePackingExport hook.
The help center gets an export guide with new pictures, the import guide
and the wiki page describe the Markdown format, in all 27 languages.
Addresses discussions #1420 and #875.
A searchProvider plugin can now implement an optional suggest() beside
search(). Its places then show up in the place search's typed-ahead list,
after the core suggestions and marked with the plugin's name, on desktop
and phone. An index that cannot take a request per keystroke leaves it
out and keeps answering the full search only.
The child reports which functions each hook object carries at load, so
GET /api/plugin-search/suggest only asks the plugins that implement
suggest: from the second character, three rows at most across all of
them, 800 ms to answer. The feed marks plugins holding
hook:search-provider and the client only asks the route while one is
installed. A plugin row carries its whole place, so picking it skips the
details lookup, which knows no plugin id. The phone's search results now
name their source as the desktop ones do, and the desktop dropdown
scrolls instead of running past the dialog.
Addresses discussion #2221.
A new manifest capability poiCategories (up to 4, icon from an allow-list,
strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the
hook:poi-category-provider permission. GET /api/plugin-pois in the plugin
contributions module answers them in the /api/maps/pois shape, with
search_plugin_pois as the MCP twin. plugin: ids no longer count as Google
place ids. SDK 1.8.0 carries the types, validation and docs.
On the map, one hook merges the built-in categories with the ones active
plugins declare, and the pill shows the plugin chips after a divider on
desktop and phone, scrolling sideways instead of shrinking a chip below
its touch target. A plugin chip searches through a repo that refuses
offline and parses the answer strictly. Its pins, on Leaflet and GL, use
the category's own icon and color, both checked again before they reach
any markup, and the hover card lists the plugin's details as escaped
text. A plugin POI can be added as a place like any other. The admin
panels on desktop and phone show a chip for the new permission and the
categories a plugin would add, from one shared module.
Addresses discussion #1781.
The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".
A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.
After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.
Addresses discussion #1587.
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.
VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.
Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.
Addresses discussion #894.
Settings > General > Language & region gets "Week starts on" (Monday,
Sunday or Saturday), and Admin > User Defaults a default for everyone who
has not picked one. Monday stays the built-in value, so nothing changes
for anyone who leaves it alone.
Both calendar implementations follow it: CustomDatePicker, used by trip
dates, bookings, costs, to-dos and the admin school holidays on desktop
and phone, and the journey date picker, whose weekday headers were
hardcoded English and now speak the app language. The grid math and the
header row live in one helper (utils/calendarWeek.ts). The day names on
the options come from Intl, so only the label and a hint needed
translating. Vacay keeps its own per-plan week start.
The value is validated like the other regional settings: VALID_VALUES for
admin defaults, the display-settings MCP tool, and the managed key list.
Addresses discussion #2029.
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers th
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers sk
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers et
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers az
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.
GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.
Addresses discussion #1881.
On a slow line an OIDC-only login page drew the email and password form while it
asked the server which sign-in it offers, then the SSO button once it knew, and only
then left for the identity provider. Both looked like something to fill in or click.
Until the config answers, the page now shows a short wait instead of the form, and
on an OIDC-only instance it becomes a screen that only says where it is going,
"Taking you to Keycloak…", with nothing to fill in. A repeat visit announces that
redirect at once from the cached config. Should the redirect not have left the
page after eight seconds, a quiet link continues by hand.
The redirect still never runs on a cached config, right after a sign-out or after a
failed sign-in at the provider. That last case now loads the config as well, so an
OIDC-only instance shows its own screen with the error rather than a password form
nobody can use.
"Keep the documents in step with a Nextcloud folder" walked through Nextcloud
alone: its app password, its base folder, the question where the trip should live
in Nextcloud. The dialog offers five stores. The guide now reads for all of them:
how each files things (by tag, in a folder, in a space), what each signs in with
(an API token for Paperless-ngx, an API key and the organisation for Papra, username
and app password for Nextcloud, username and app token for OpenCloud, username,
password and a two-factor code for Synology Drive), that the base folder belongs to
Nextcloud and Synology Drive, and that the trip lives in a tag, folder or space. The
pictures keep Nextcloud as the example, and the text says so. All 23 languages use
the labels of their own interface.
"Add, reorder and rename days" still described the reorder popup as it was before
#2485: Move up, Move down and one Add day at the end, and its picture showed that
popup. The guide has five steps now. The reorder step names the bin on each day and
the two ways of adding one, the next date or Without date, and a new step shows the
question before a day goes, with what goes with it, answered with Cancel. The first
step says that shortening the dates lists the days that go, and the tip says moving
days can be undone and deleting one cannot. All 23 languages use the labels their
own interface shows.
The picture script looked for the Add day button that #2485 removed, so it could not
run any more. It frames the popup itself now, asks about the second day so the
question has places and a booking to list, and clicks the first day a second time
when the steps before left it selected and the first click folded its panel away.
The pictures picked up five faults that the run could not see, because a
passing step only proves the click landed.
The ring vanished on the lightbox: the accent is nearly black and so is
the overlay. It now measures the surface behind it and puts a white
hairline outside itself and the number only where the contrast is too
low, so every other picture keeps the single line it had.
The number fell outside the picture whenever the target was wider than
the frame, which is every full-width row: the frame is known before the
ring is drawn, so the number moves inside far enough to clear the edge.
The satellite imagery was a grey rectangle although every tile came back
200. Leaflet fades a tile in over 200 ms and takes the progress from
`+new Date()`, and the browser's clock is pinned to the picture day, so
the opacity it writes on every frame is always 0. A stylesheet on
Leaflet's own `leaflet-tile-loaded` outranks that inline style. The
satellite guide also waits for decoded tiles now instead of for a tile
element to exist.
The uploaded files looked like nothing anyone would upload. A PDF is
drawn with an issuer band, a reference, a table of fields and a footer,
and the two photographs are gone: a drawn photograph reads as clip art
at any size, so the trip carries a map screenshot with a pin and a
booking confirmation in a browser window, which are graphics in reality
too.
In the panel, a step picture was covered into a 16:10 box and lost its
sides; contained everywhere now, the hero and the result in 16:9.
Beside that: the place guide gained the drag-and-drop route to a day as
its fourth step, in all 23 languages, and `e2e/help/README.md` now holds
the whole visual contract, the traps that cost this round (the pinned
clock, prettier against the client sources, a killed run holding its
ports) and how to find out which pictures a change forces to be retaken.
The whole set, 914 files, made in one pass on one machine against one
calendar: 41 screens, 225 guides, 764 steps. What changed beyond the new
guides: the pictures carry the run day's dates now, the Files tab's
preview shows a real PDF page, the Place details card has its hours, its
ring and its phone number, the trip map and the Atlas carry the Dawarich
control the new bullets describe, and the Files toolbar has Document
sync beside Trash.
Ninety-eight keys in the other 22 languages: the nine guides of this
round, the reworked read-place and import-booking-file, and the two
screen bullets. Every UI label in them is the app's own string in that
language, and where a label is still English in a locale, the text keeps
it English so the reader finds it on screen.
The first round could not photograph a few screens because the service
behind them was not there: Dawarich, AirTrail, a document store, the
booking extractor. They are there now, so the guides are too.
- map-dawarich-trail, dawarich-countries and dawarich-wishes: the run
uploads a recording of the seeded trip, and of the year before it, into
a Dawarich of its own (dawarich-track.ts) and reads it back.
- airtrail-import: four flights are put into an AirTrail account, two of
them connecting in Tokyo, so the picker offers the layover.
- import-booking-file and import-transport-file: the confirmations carry
their facts as schema.org data the way portals send them, the extractor
reads them, and the guides go on to the filled-in form and the card.
- files-sync and document-providers: a Nextcloud folder with two
documents in it, bound to the trip, and the switches in the admin panel.
- read-place: three seeded places carry their OpenStreetMap id, so the
card shows the week's hours, the phone number and the Open or Closed
ring. The rating stays Google's, and the texts say so now.
- map-compass on the MapLibre renderer, which the guide switches to and
back; files-preview opens a PDF, which needs the full browser rather
than the headless shell (channel: chromium on the help-media project).
Each of those takes an address and a key from e2e/help/media.env, which
is not committed; media.env.example and the README say what each one
needs and why the address has to be the machine's own on the network.
The seed's dates are offsets from the day the pictures are taken
(e2e/dates.ts): the trip ends today, so the boarding pass, What's Next
and the forecast have something to show whenever the run happens, and
nobody bumps a date in three files every few weeks. run.mjs fixes the day
once per run so the projects and the workers agree across midnight.
The Links section still described the panel as it was before #2414: pin
or delete, and add again to correct a typo. It names Edit link now, says
the form takes only http and https addresses and what the chip shows, and
it is a panel beside Notes rather than a tab, except on the phone.
shared.other labels the same fallback group on the public share page that
the app itself calls Sonstiges and Otros, so de and es said one thing in
the trip and another beside it. admin.tabs.addons was Russian in uk.
reservations.fromPlan, packing.suggestions, packing.suggestionsTitle,
packing.allSuggested, packing.emptyHint and the packing.suggestions.items
array are referenced by no component; the suggestions block left the
packing list in #14 and the keys stayed behind in all 23 languages. The
VORSCHLAEGE constant beside them was exported and imported nowhere.
With the one structured entry gone, a catalogue value is a string and
nothing else, so TranslationValue says so and the placeholder spec no
longer needs to skip what is not one.
Rendered in place, its fixed overlay sat inside the trip page's stacking
context, below the navbar's z-[200]: the bar painted over the lightbox's
header, so Open in new tab, Download and the x were unreachable. The two
document previews beside it already portal into the body for this reason.
The tests read the lightbox through baseElement now, since a portal puts
it outside the container Testing Library returns.
- The lightbox header's button group is the right thing to ring, but its centre
falls between the buttons where the picture underneath takes the pointer, so
the hover never landed. The pointer goes on a button, the ring stays on the
group.
- The collection the place is saved into was matched by a name another guide's
list starts with.
- help-media gets four minutes a guide instead of two and a half. A guide is a
whole task and a few of them wait on the network more than once; the
nearby-places one runs two POI searches and opens the place form on top.
The + at the end of a place row belongs to the open day, and closing the day's
details panel closes the day with it, so there was no + to click and the guide
waited for one until the test gave up.
- place-to-collection found the place already in a list, because the Collections
screen's guides run earlier and leave it there. It clears the place first now,
with the same helper its cleanup uses.
- The road trip rail leaves out a day with nothing to drive, which its own
helper says, and the fold step was the last place still pointing at day 1.
- The transport form's location search took whatever the geocoder ranked first,
and for a landmark that is as often a shop down the road. It picks the
suggestion that answers the query.
Every one of these passed when its own file was run alone and failed when all
of them ran against one seeded trip, because another screen's fixture had put
something in the way.
- The Bookings fixture names a booking after a place, so the Assign File dialog
offered two entries opening with the same words.
- The Lists tab has a Shared button, which the loose name of the navbar's Share
also matches, so opening a trip on that tab never settled.
- A traveller's avatar is named by the initial it draws, not by the username;
the title carries the name.
- Departure and Arrival are what a booking gets when it crosses days, so the
guide about them points at the flight, not at the train that starts and ends
on one day.
- The What's Next stop was timed from the wall clock of whoever runs this,
while the browser's clock is pinned to the day the pictures are taken on.
Both now come from the same constant.
- The map was panned with the arrow keys, which Leaflet only reads while its
container has focus, and focusing it from here did not take. It is dragged
now, from a point checked to be free of markers, because a press on a marker
drags the place onto a day instead.
The duplicate-trip guide dropped its copy by position, keeping whatever the
trips endpoint happened to answer first. The copy survived, and three files
later the journey dialog offered two entries starting "Weekend in Lisbon" and
the create-journey guide could not say which one it meant.
The copy is now dropped by name and the delete is checked, and the journey
dialog picks its trip by accessible name rather than by a substring another
trip shares. Each file passed on its own before this; only the whole suite in
one run showed it.
Forty-five values were rewritten after a second pass over the running app found
the old text naming a control that is not there or describing one in the wrong
order. Each locale's existing translation was edited rather than replaced, so
the register and the vocabulary of the block it sits in are kept.
All ten screens of a trip are now in every language TREK speaks: 2134 help keys
in each of the 23 locales. i18n:parity:strict passes for the first time since
the help center landed, which also un-skips every CI job that was waiting
behind it.
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides over 360 steps, each one a task a reader can
follow, with the Playwright script that will photograph it. The fixtures they
need go in beside ensureDaysFixtures, and every screen is a child of the trip,
so the panel folds them under it.
The capture now pins the browser's clock to the day the seeded trip ends. A
picture has to look the same whoever takes it and whenever: a day later the
trip is over, My Trips moves it out of the boarding pass, What's Next has
nothing left to list, and a forecast for a day in the past is not a forecast.
Only `Date` is pinned, so timers keep running.
trip-days is green end to end after its locators were checked against the
running app; its route guides read day 6, whose two stops sit next to each
other, because day 1's are separated by the flight and have no leg of their own.
Place details, Day details, Map, Transports, Bookings, Lists, Costs, Files,
Collab and Road Trip: 77 guides and 360 steps, written against the running app
rather than against the components, so every label a guide names is a string the
reader will actually see. Places, Day details, Map and Files are translated into
the other 22 languages; the rest follow.
Also reworded the Settings overview: it called TREK self-hosted, which the
repository's own wording rule forbids and which nothing was running to catch,
because the test that does needs i18n parity and parity has been red.
Two cases replaced window.location wholesale to spy on reload. The copy is flat,
the real object's fields are getters on its prototype, so the replacement has no
href for a relative request URL to resolve against and the DELETE the leave-trip
case waits for never reached the mock server. It also stayed replaced for the
rest of the file, which took six later cases down with it.
The leave-trip case asserts the request, so it keeps the real location: jsdom's
reload only logs. The transfer case asserts the reload, so it keeps the stub,
and the original is put back after every test.
Nobody saw this: the client coverage job needs i18n parity, which has been red.
'2 sub-screens' stopped identifying one button the moment a second screen had
two of them, and the trip is about to have a dozen. The toggle is looked up
within the row it belongs to instead, which says what the assertion means.
`collab.notes.expand` was never added to the catalogue, and `t()` answers with
the key itself, so the `|| 'Expand'` fallback never ran and the tooltip read
collab.notes.expand. `common.expand` says the same thing and is already
translated everywhere, so the button uses that, and it now carries the label
for assistive tech as well.
What the ? in the navbar opens, what an overview and a guide contain, how the
search and the screen switcher work, where the pictures come from and what
happens on a screen that has no help yet. Linked from the sidebar, from the
features list and from In-App-Help, which documents the other thing and was
being confused with it.
All eight guides of Trip > Places now have their pictures, taken from the real
app, plus the hero of the screen, and the block is translated into the other
22 languages.
Making the pictures showed three texts describing a UI that is not there, and
they are corrected: the file import dialog has no Enrich places via Google
switch (only the list import has one, and only with a Google key), a GPX import
closes with a message while only KML and KMZ leave a summary behind, and both
list providers are always offered. The search and the details column need no
Google key either: they fill themselves from the TREK index, OpenStreetMap,
Wikipedia, Wikivoyage and Wikimedia, and only ratings are Google's.
The script needed the same honesty. ConfirmDialog and the two import dialogs
are portals of their own with none of Modal's classes, so they get their own
locators, and create-place now looks up a place whose entry actually carries a
picture, opening hours and a description, instead of photographing an empty
details column.
Every context file carried its own copy of the same little guide factory and
the same context literal. Thirteen screens of a trip would have made thirteen
copies of it, which is both the duplication a quality gate counts and the
hand-mirrored state the repository asks us not to keep, so the shape moves into
defineScreen and a context file becomes data: ids, counts, docs, cross-links.
Four doc links never resolved. Trip-Planner and Sharing-and-Collaboration are
not wiki pages (Trip-Planner-Overview and Trip-Members-and-Sharing are), the
places import anchor is importing-multiple-places, and the offline anchor was
written in GitHub's slug, which is not the one the in-app reader produces.
Nothing caught them, so the registry test now checks every slug and every
anchor against wiki/, with the heading slug itself lifted out of HelpPage so
the reader and the test cannot drift apart.
A help icon on every screen opens a panel with the screen's overview, its
sub-screens, task guides with a framed picture per step, search across guides
and the wiki, and a switch to any other screen's help. Guides exist for the
dashboard, Vacay, Atlas, Collections, Journey (list, journal, studio), Settings
(all tabs), Admin (all tabs) and the trip frame; the trip's own screens are
in progress. Pictures are produced by Playwright (client/e2e/help), texts in
all 23 languages.
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.
Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.
It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.
The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.
The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.
With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
shared/roadtrip so Days and the road trip read one copy.
A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.
calculate_roadtrip runs the same code and its description says all of the above.
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.
FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.
When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.
A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.
The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.
Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.
The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.
On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.
The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.
On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.
normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.
Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.
Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.
placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.
Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.
Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.
An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.
The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
with a 400, over REST and through update_collection_place alike. A list
has no base currency, so such an amount was read in each viewer's own
default currency and went into a trip as the trip's. A price sent alone
keeps the stored currency, and a zero price or clearing both fields
still goes through. The check sits in the service because the MCP tool
spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
now prettier clean.
Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
it turns it off; saving the same URL keeps it as before. The wiki says
so.
Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
over. A day with a single stop opens it as a pin again, on the desktop
and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
route tools, since there is nothing left to draw. With stations they
stay.
- Phone: the drive between two stays on a day without stops or bookings
shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
booleans in a row.
Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.
Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).
- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
nullable), currency (three letters, trimmed and upper-cased), website
(http or https, as on save) and phone (trimmed, capped at 60 like the file
import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
transaction. update_collection_place spreads the same shape, so REST and
MCP take and refuse the same values; its description now names the price
and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
currency picker, website and phone, and show them in read mode (price only
above zero, phone as a call link, website as a link chip through
safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
sends only the fields the user changed and always pairs a price with its
currency, since a list has no base currency. An amount that does not parse
marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.
Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.
CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.
Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.
Refs #2478
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.
- The fallback drive between the two hotels is no longer drawn when any
such booking is on the day, located or not. A day without a booking
keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
reaches the hotel, not the first, so an earlier drive to the same spot
no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
describe that same drive, return nothing, and on the desktop and the
phone the two buttons are left out whenever fewer than two stops
remain, instead of doing nothing on a tap.
A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).
Immich gets the same switch, per user and off by default:
- users.immich_allow_insecure_tls (migration 243, column guarded) and a
settings row labelled with the existing skipSSLVerification string, so
no new translations. The settings card on desktop and phone draws it
from that row.
- Every request to the user's Immich (status, browse, search, asset info
and proxy, albums, upload) passes rejectUnauthorized from the switch.
Only 1 counts as on. A shared photo follows its owner's switch, since
it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
boolean. Absent on a save keeps the stored choice, so an older client
cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.
Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.
Wiki: Photo-Providers.md documents the switch.
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.
The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.
On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.
The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.
The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.
A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.
The wiki says both.
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.
On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.
The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.
The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.
The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.
The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.
The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.
On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.
A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.
A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.
The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.
The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.
amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.
The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.
Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
The MCP setup page gave Open WebUI's redirect URI as
{your Open WebUI URL}/oauth/clients/{connection id}/callback. Open WebUI puts
mcp: in front of the ID it builds that path from, so a client registered by
hand with the bare ID never matched and the consent step answered
"Unregistered redirect_uri". The page now names the real path and adds how
Open WebUI connects: it registers and trades the code from its own server, so
its container has to reach the public TREK address, and its WebUI URL has to
be the public https address.
After a server upgrade the app wrote the new version into its marker
before the service worker had fetched the new build. When that fetch
failed, when the new precache broke off halfway, or when the browser had
already swapped workers before the version check answered, the page kept
the old bundle, and every later launch took the marker at its word and
never tried again. An installed PWA went on showing the previous UI.
The marker now moves only once the handover is done or turned out to be
unnecessary, so a failed attempt is simply retried on the next launch. A
failure no longer reloads into the shell the old worker serves again, a
worker that took over before the check answered still gets its reload,
and no path reloads more than once per version and session. Caches and
workers are still never deleted.
With "Blur booking codes" on, the bookings list and the transport details
hid a code, but opening the same booking to edit it printed the code in
the clear: the reservation and transport forms with every per-segment
code of a flight or train, the hotel editor in the day panel and the
three phone sheets. The stay's own code in the day panel, the booking
strip in the place inspector and the booking lines under a stay and a
stop on the phone showed it plainly as well.
One shared BookingCodeInput and BlurredCode now carry the setting to all
of them. A filled code field stays blurred until it is hovered or
focused, so clicking, tapping or tabbing into it still reveals the code
for editing, and the value is saved exactly as typed. With the setting
off nothing changes, and the PDF export and the share pages stay as
they were.
A booking switched on with "On map" stayed drawn on every day of the
phone's plan map, so a flight from the first day crossed the map of the
third, although a day chip frames one day and drops the other days' pins.
The plan map now draws such a booking only on the days it runs on, the
arrival day of an overnight one included, and the all-days view still
shows every booking that is switched on. The switch itself stays
trip-wide, and the desktop, collections and the road trip stage draw
exactly as before.
"On map" in a booking's sheet now reads as on only where the plan map
draws the route. From any other day a tap takes the map to the booking's
day, switching the route on when it is off, instead of hiding a route
that is not drawn there anyway.
The phone settings also gain the "Always show booking routes" switch the
desktop already had. As on the desktop it sets the default for trips not
yet touched on this device, so a trip where a route was switched on or
off by hand keeps that choice.
The day list draws a day by its starts, the road trip drives the order
it is stored in. Moving a stop with a start onto another day, or adding
a place that carries one, stored it at the end of the day or where it
was dropped, so the list showed it among the stops its hour falls
between while the road trip went there last. It is now stored right
behind the row the list draws it after, with a booked night read by its
check-in as the server's time sort reads it. A stop without a start
lands exactly where it did before, and wherever a moved stop lands in
the middle of a day, the via points drawn on that day stay on the legs
they were drawn for.
Behind a later start the road trip reached such a stop late, and the
night spill took its earlier clock for a midnight: the stop and every
stop after it opened the next day's card, and a day left with one stop
fell to the placeholders at the bottom. A stop the schedule marks late
now stays on the day the schedule files it on, and the stops behind it
are read from its hour, so a clock that goes back past that hour still
starts the next day. The desktop rail, the phone's road trip tab and
the MCP calculate_roadtrip tool share that spill.
The check-in is an anchor now, not an earliest arrival, and a booked night
leads its check-in day, which the road trip and accommodation pages said
otherwise. Upgrading gains a 4.3.1 section for the reseat migration and for
what better-sqlite3 13 asks of a source install. The collaboration page knows
that links are chips and editable, the audit log knows the new admin action
and the SSO register row, and the MCP pages know the provider parameter and
the Google-only switch.
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
The corridor search in the browser cut its tiles around a ride, the MCP tool
did not: it tiled and measured along the straight line between two terminals,
so an assistant searching a flight day got boxes and hits under the flight
path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use
it. A departure less than the check-in allowance after midnight no longer
pins on the previous evening, and a hire car whose pick-up desk has no
position is not seamed at its return desk.
The audit over the 4.3.1 release found the seating rule reading a night by one
value and its neighbours by another, so two nights on one day never agreed:
the migration reported moves that changed nothing, flipped the pair on the
next run and dropped the road drawn between them, and in the service every
notes edit swapped them. One rule now lives in accommodations/night-seat.ts,
measures every night by its own check-in, breaks a tie by the booking made
first, and carries the vias; the service, the reseat migration and a trip
date change all seat through it, the last of which used to append the night
at the end of its day again. An account created by a first SSO login writes
the user.register row the password path writes. Tests cover the equal and
absent check-ins, the date change, the booking form path and a day that is
nothing but its night.
Both renderers read window.innerWidth once while rendering, so the button
that turns GPS on was decided by the width the map happened to see when it
mounted: a phone turned sideways crosses the 768px cut and kept whatever it
had, and turning back did not bring it home either. Both now use the same
useIsPhone the shell switches on. The test stub for matchMedia answers width
queries from window.innerWidth and re-evaluates on a resize, so a breakpoint
can be driven from a test the way the browser drives it.
Registering an OAuth client by hand and getting only "Failed to register
OAuth client" left nothing to act on: the server names the rule it broke (a
scope, the ten-client cap, a redirect URI it will not take) and the toast
swallowed it in a bare catch. Both shells show the server's message now and
keep the generic text for a failure that carries none.
A client handed https://host/mcp as the server address looks for the
well-known documents below that path instead of asking the origin, and every
one of those probes fell through to the web app: 200 with an HTML body, which
reads to the client as "this server has no OAuth" (Open WebUI reports it as an
unexpected mimetype). The three documents are served under /mcp/.well-known
now, anything else there answers a JSON 404, and the RFC 8414 path-inserted
form /.well-known/oauth-authorization-server/mcp serves the metadata instead
of 404. The wiki says which redirect URI belongs to the client and that a
plain-HTTP APP_URL leaves the discovery document pointing at localhost.
With places set to "Trip owner" a member who may edit days still saw Edit and
Delete on a place in the day plan's context menu, the inspector's action row,
its inline rename and the phone timeline's edit circle, and every one of them
ended in the server's 403. The day plan gates the two entries on place_edit
(taking a place off the day stays a day right), the inspector drops its edit,
delete and update handlers without the right the way the places pool already
does, the phone timeline offers the pencil only with it, and openPlaceEditor
and handleDeletePlace guard like their siblings.
Closes#2446
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false. The nested objects a tool takes, authored inline or spread from a
shared schema (legs, stops, links, transit places), are strict at this
boundary for the same reason; the shared schemas themselves stay open for
REST. A whole Zod schema, the way a plugin's manifest-built tool arrives,
passes through as its author wrote it.
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.
Closes#2440
The tool passes `{ googleOnly }` to the search since #2436; four assertions
still expected the four-argument call. Pinned the new argument, and the one
case the contract added: `provider: 'google'` reaches the search as
`googleOnly: true`, any other value is refused by the schema before the
search is asked.
With the tracks tab the pill row had grown past what the rail can hold: four
pills sharing one line clipped a word as soon as the panel narrowed. The
filter is the app's own select now, the count riding on the trigger the way
it rode on the active pill, and every option carrying its own in the menu.
Since 4.3.0 the TREK index and OpenStreetMap answer every search first and
Google is only asked when they find nothing, so a list with the wrong place
on it never reached Google, whatever key the instance had, and the provider
choice only decided who fills the empty case.
Two ways to change that, both only where Google holds the key slot. Under a
result list that did not come from Google, one quiet line sends the same
query to Google Places alone (`provider: 'google'` on the search contract,
the MCP search tool takes it too). And beside the key, an admin switch
"Search with Google only" hands every search and every suggestion to Google
and asks nothing of the index or OpenStreetMap; off by default, read by the
search after the keyed provider so an install without a key, or one on Amap
or OpenStreetMap, is untouched. The panel says so beside the switch.
Both shells share the one rule for when the line is offered (placeSource),
and the wiki names both switches.
Booking a night writes the hotel onto its check-in day as a stop, and since
4.3.1 that stop heads its day. The day plan hides it and shows the booking as
the day's accommodation block, but the desktop toolbar hands the export the
store's own assignments, filter and all, so the PDF printed the hotel place
above a flight that left in the morning.
The export now drops that stop before it merges the day, as the list does. The
mobile export already received the filtered set and was right all along.
Closes#2434
A marker click toggled the selection, so the second click on the marker of the
place that was open closed it again, which read as the details refusing to
open: every other click on the same marker showed nothing. A marker click now
selects and never clears; the map background and the close control do that,
and a list row still toggles as before.
Closes#2431
Booking a night writes the hotel onto its check-in day as a stop, for the road
trip's driving chain. The day plan hides that stop and draws the hotel as the
day's bookends instead, but the route builder behind the map line and the
phone's travel connectors still read it from the store. It carries no time of
its own, and a timed booking is seated behind the last timed stop, so on a day
that opened with a flight the hidden stop led the day: the map drove from the
hotel to the departure airport and back to the hotel after landing.
The builder now leaves the booked night's stop out, as the list does, and the
bookend rules put the hotel where it belongs: behind the flight, on the road
from the arrival airport. A hotel the traveller placed as a stop of their own
is untouched.
Closes#2430
The hire desk beside the terminal is a 200 m leg, and the pill under it read
"0 km in 0 min" where the eye wants the next stop. Under 150 m and two
minutes a leg is a hop: the rail keeps the dashed line, the phone chain skips
the row, and neither offers other ways of driving it. A note a plugin
attached keeps the row.
Refs #2428
A hire car is no seam, but its desks are where the drive starts and ends: the
pick-up opens its day when nothing timed comes first, the return closes the
day it is handed back on, the road runs through both and the range budget
starts from a full tank at the pick-up. The return is only placed when the
booking names a return day and a located return desk.
A table, tickets or a tour on the day hang under the stop they are pinned to
or booked at as chips that open the booking; one for no stop is listed under
the day. A ride that leaves and lands on the same day is one block on the
rail and in the phone chain, not two terminal rows and a band. Both shells
read the same helpers (carrierRide, stopBookings, roadtripRowModel).
Server reads car bookings into the plan context; the MCP descriptions say so.
The pt-BR locale had the three ride strings in Breton, fixed alongside.
Refs #2428
The day panel owns the transport detail dialog, and in road trip mode the
day panel is not mounted: a terminal row, a ride pill and a map endpoint all
set the booking to show and nothing showed it. The rail brings the dialog
along now. Also drops a translation key nothing read.
The road trip routed straight from one stop to the next, whatever stood
between them: a flight booked for the afternoon became an eight-hour drive
on the map, with the hotel reached by road from a town the traveller had
flown out of (issue #2428). The day plan has treated such bookings as
carriers for a while, and the drive now reads them the same way.
A flight, train, ferry, cruise or bus with located terminals is a seam. Its
two terminals are seated among the day's stops where the day plan lists the
booking, as stops of their own: no number, no stay, no kind, not draggable,
and an id below every assignment and every automatic night, because they
stand for none. The road ends at the departure terminal and starts again at
the arrival one; the leg between them is the ride, in the booking's own
mode, with the timetable's minutes and no distance, and no router is ever
asked for it. The departure terminal is pinned a check-in ahead of the
timetable and left on the timetable's minute, so a drive that cannot make
the airport in time is late the way a missed pin is. A ride that lands on a
later day seats its arrival at the start of that day; with connected days
the join between the two is the ride, and with daily travel times set the
ride runs on the booking's clock through the night, with no day end placed
on a flight.
The map draws the ride as the booking's own arc, the one the day plan
already draws, beside the roads. The day's distance and driving time count
the roads only, the range budget spends nothing on a ride, and after one the
car cannot take it starts afresh. The corridor search leaves the ride out
and drops a hit under the flight path; a via cannot be filed against a
terminal, and other ways of driving are offered for the road into a
departure terminal only. The server plan reads the same three tables and
seats the same stops, so calculate_roadtrip answers the same as the rail.
Closes#2428
The journey map sets its first view on a rAF after the build, and the active
entry asks for its pan on a 50 ms timer. In a background tab, or on a slow
frame, the timer wins. Leaflet does not refuse a pan on a viewless map, it
takes it as the first view with the zoom still undefined: the tile layer aborts
its own add on that, the load event stops halfway, and every entry marker
queued behind it never reaches the map. The catch that was meant for this
never fired, because nothing threw. The next rebuild, after any save on the
page, then tore down markers that were never added and died in Leaflet's icon
removal, and the error boundary took the whole journey page with it.
Both pans go through whenReady now: at once on a map with a view, otherwise
right after the fit lands.
Plyr's defaults point at its CDN twice: the icon sprite for the controls is
fetched over XHR, and on teardown the element is pointed at a blank clip there
to abort the stream. connect-src and media-src both refuse that host, so the
clip played but every button in the player was blank, and each close of the
lightbox logged a blocked request and an uncaught XHR error.
The sprite ships with the bundle now, served from this origin, which Plyr then
references directly instead of loading it. plyr's exports field hides the file,
so it goes through the same resolver alias the rtl-text plugin already needed.
The teardown source is empty: that aborts the stream just the same, and the
element is unmounted in the same tick.
Refs #2341
A clip picked in the entry editor is previewed through a <video> on an object
URL, and its poster frame is grabbed the same way before anything is uploaded.
The CSP never named media-src, so the browser fell back to default-src, which
does not admit blob: at all. The preview stayed empty and the poster capture
failed silently, so every clip landed without a poster and its thumbnail route
answered 404 (issue #2341, the part that survived 4.3.0). None of it showed in
development, where Vite serves the document and helmet never sees it.
media-src now carries 'self' and blob:. For the clips that still come up
without a poster, because the browser cannot decode them, the entry card and
the editor tile draw the play badge on a black tile instead of asking for a
thumbnail that is 404 by design, the way the gallery already did.
Closes#2341
The runtime stage installed python3 and build-essential for one reason: when
better-sqlite3 12.x could not download its prebuilt binary, node-gyp built the
addon against the headers of whatever Node the base image carried that day.
Since Node 24.19 those headers give node::ObjectWrap a cleanup hook that the
runtime cannot unregister, and the first garbage-collected statement takes the
process down with "Assertion failed: (env) != nullptr". The published image
was spared only because its prebuild download had succeeded (issue #2421).
13.x is built on N-API, so it never touches that path, and it ships the binaries
for amd64 and arm64 inside the npm tarball. Nothing is compiled any more, and
the toolchain leaves the Dockerfile. The install runs with --ignore-scripts:
a lockfile-driven npm ci still synthesises `node-gyp rebuild` for the
binding.gyp it finds and ignores the package's gypfile:false (npm/cli#9837),
which without the toolchain would fail the build.
Closes#2421
The hero's content grid had an auto column, which takes the title's
min-content width. A title that is one long word made the column, the
tools row and the boarding pass as wide as that word, with edit, clone,
archive and delete pushed off the card. The column is capped at the card
now, and the title breaks inside it and stops at two lines.
Every other login method writes one; the SSO callback touched last_login and
login_count and wrote nothing, so an instance moving its users to SSO watched
its audit log go quiet while looking complete. The row is written in the
callback, where the provider has vouched for the user and the client IP is,
next to the role-change row, with method: oidc in the details the way the
passkey login names its method.
Closes#2417
Google writes a range that stays in one half of the day with the meridiem on
the closing time only, "1:00 – 3:00 PM" for one to three in the afternoon.
The 24-hour conversion only saw times carrying their own AM/PM, so the
opener stayed "1:00" beside a converted "15:00". The closing meridiem is
handed back to an opener that has none before converting; a range spelled
on both sides, the 12h display and lines without a meridiem are untouched.
Closes#2412
Two to a row, the title and the host were shrinking together into two
ellipses. The host shrinks first now, the open glyph takes the favicon's
place on hover instead of room of its own, and the actions are a touch
smaller.
A chip takes half the panel now, one to a row where the panel is narrower
than a container query's 420px; the title and the host give way inside it
instead of capping their width.
Unfolding them reflowed the row under the pointer, and a chip that grows as
you reach for it is a chip you cannot catch. They sit faint and come forward
on hover instead.
One chip per link, flowing like tags, instead of a column of rows with four
grey glyphs each. The chip is the link, with the favicon and the host beside
the title; edit, pin and delete are folded into its tail and unfold on hover
or keyboard focus, and stay unfolded where there is no hover. A pinned chip
carries the accent.
A link in the Collab tab could only be deleted and added again once its
title or address needed a correction; the update route has taken both all
along. The add dialog edits a link handed to it now, behind a pencil on
each row. The external-link glyph next to a row that was already a link
read as a button doing nothing; it is a link to the address itself now.
Closes#2414
With the road trip addon on, the desktop panel puts the Days / Road trip
switch above the day list. The list was sized by height alone, so it ran the
switch's height past the panel's clipped edge and the last day could never
be scrolled into view. It is a flex child of the panel now and yields that
height; where nothing sits above it, the height still fills the panel.
Two bookings on one day used to settle by whichever was seated last, because
the other night's stop carries no hour of its own. Its check-in counts now,
in the service and in the migration alike.
A booked night leads its day now, but the stops the bookings put on their
check-in day before this release sit last, and the backfill for older
bookings appended them too. Every trip planned before the change kept that
order until somebody edited the check-in.
A migration seats every night a booking owns by the same rule the service
applies: first, behind only a stop whose own clock is at or before the
check-in. The drawn roads follow the stops they were drawn after, by the
rules the service uses when it moves a night. Stops the traveller placed
themselves are not touched. Re-runnable.
A hotel entered under Days with a check-in at ten was reached at a quarter
past twelve in road trip mode. Two things: the schedule read the check-in
as a floor that held nothing once anything before it set the clock, and
the server seated the night last unless something pinned later pulled it
forward, so two stops without an hour stood ahead of it.
The check-in now pins the night the way its own time would, in the plain
schedule and in the daily window, where a check-in the drive cannot make
is the conflict a missed pin is. The night is seated first on its day;
only a stop with a clock at or before the check-in stays ahead of it, and
the stops without one follow. A new check-in seats the night afresh, a
change of notes leaves a dragged night where it is.
A day that is nothing but its booked night stands among the days now,
with the hotel and its check-in, instead of at the foot of the rail under
"only X so far", which read as the hotel missing from the road trip.
The client puts a stop the server seated at the front where the server
put it, rather than appending it to draw second until the next reload.
On a 1080p screen at 100% the release notice's right column runs out of
height. The column scrolls, but the promise box is a flex item with
overflow hidden, so it may shrink to nothing and was pressed down to its
label while the paragraphs around it kept their height. Everything inside
the two scrolling columns now keeps its size.
Below 1080px the halves stack and the panel scrolls; the single minmax row
that keeps the two-column layout inside the panel starved the release half
to zero height there. The rows go back to auto, and the ask sticks to the
bottom edge once the note is in view.
Paperless-ngx, Papra, Nextcloud, OpenCloud and Synology Drive are a headliner
of this release and were missing from the notice. The card runs across the row
under the three upright ones, picture beside the text, so the panel keeps its
height: the wordmark without its API pill, the two-way arrow, and the five
stores as tiles. The panel's grid row is capped at the panel's height, so a
short viewport scrolls the left column instead of clipping the support buttons
on the right. The headline counts four, the intro and the card texts are
shorter, and the Dawarich card drops its last sentence, in all 23 languages.
The release e2e test asks with the bundle version, as the client does.
After an update the service worker keeps serving the previous bundle until
the new one is installed. That shell announces the release layout too, so
the layout alone let it draw the notice with its own older texts and use it
up for the version now running with one click on X.
The bundle now sends the version it was built as (client/package.json, baked
in by define), and the server compares it with its own before it hands a
notice with a release block over. A shell of another version is told nothing
and gets the notice after the reload, from the bundle that can draw it. This
holds for every update, not only the one that introduced the parameter.
The guard resolved a name to one address and pinned the socket to it. On a
dual-stack name whose AAAA came first that was an IPv6 many hosts cannot
reach on 443, and the request sat until the caller's timeout while the A
record would have answered at once. OIDC discovery, Dawarich, AirTrail, the
photo providers, a self-hosted router or Nominatim and the model endpoints
all ride this path.
The name is now resolved to every address it has, each one is checked, and
one blocked address blocks the name. The whole checked list goes to the
socket, IPv4 ahead of IPv6, where Node walks it itself and moves on after
its attempt timeout. Redirect hops do the same per hop. A caller that still
names one address gets exactly the old behaviour.
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.
Data and credentials
- A document sync connection edited to a new address no longer carries the
stored token along: a blank form under another origin is refused, the same
rule the probe route already had. Dawarich and AirTrail testConnection send
the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
says that the booking and its expense go with it. Deleting a place says the
same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
and its attachments; a real revision carries the attachments over.
Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
hidden hotel stop no longer shifts it one slot early; the store sorts before
it splices. Via re-anchoring, the booked-night guard and the sidebar's day
membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
booking session receives the new stop order too. The Public API and the iCal
feed list a booked hotel once. A plugin can no longer write a stay against
another trip's day.
Road trip and map
- The route overview paces its requests to the public routers and retries a
rate limit, and it says which legs it could not route instead of quietly
reporting a shorter trip. A click on a via handle no longer drops a second
via; handles hide when zooming out. Read-only members cannot edit the driving
limits. The phone shows a way back when no day is picked and prints the night
drive departure in the chosen clock format.
Integrations and search
- Google transit keeps conventional trains when the train chip is on.
TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
accepts the whole boolean family. A caller-supplied description no longer
lands in the instance-wide enrichment cache. Nominatim searches carry a
timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
knows what the Atlas already shows, and a host change with a new key clears
the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
deleting a connection unregisters its subscriptions, Nextcloud under a
sub-path connects, Synology answers a name collision before uploading and
verifies a move into the bin, Paperless titles are capped at 128 characters,
a DNS failure reads as unreachable rather than as a policy verdict, videos
from the store are accepted like uploads, and remote_missing has a way out.
Conflict buttons only for the owner, a refused choice is reported, a refused
credential gets a Reconnect button, disconnecting asks first on the desktop
as it does on the phone, and the folder picker no longer spins forever.
Release
- The server holds the release notice back from a bundle that cannot draw it,
so the previous shell served from the service worker cache after an update
no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
images say when the limit is reached and the new controls are translated.
The transit backend switch reports a failed save. The Helm chart carries the
Amap credentials in its Secret; the shipped templates document the Places
API and Amap variables and the Overpass default. The wiki follows.
"This day" sent the entry's calendar date to the provider as both bounds, and
both providers turned that date into a UTC window — Immich by concatenating a
Z suffix, Synology by relying on a date-only string parsing as UTC midnight.
For a reader at UTC+10 the searched window was 10:00 on the day to 09:59 the
next morning, so every breakfast photo was missing while the next morning's
turned up in its place, and nothing said the day was incomplete. The date
headings had the mirror defect, filing a 07:32 photo under the previous day.
Immich publishes the right anchor already: localDateTime is required on every
search result and is what its own timeline groups by. The window is padded by
a day on each side and the page narrowed again by each photo's own local date,
carried through as localTakenAt; a server that does not send it falls back to
the timestamp used until now, so nothing changes there. Because the padding
means raw rows and answered rows are no longer the same thing, the page is
filled server-side until it holds what the caller asked for, bounded so a
pathological album cannot spin.
Synology has no local stamp, so its anchor comes from the caller as a UTC
offset, resolved for the day being searched rather than for today — a day
looked up across a DST boundary would otherwise start an hour out. The MCP
tool takes the same optional input, defaulting to the behaviour it has now.
The picker's pagination sentinel moved out of the non-empty branch: a page the
new filter empties has to keep paging, and it now says so instead of claiming
there are no photos.
Closes#2336
A trip that models one building as several stops — drop the bags, check in,
the museum inside the hotel — drew every pin on the same pixel, and only the
top one could be clicked, hovered or seen. Clustering is the mechanism for
exactly that: the plugin keeps children no zoom level can separate in one
cluster and fans them apart on click. disableClusteringAtZoom pinned the
group's own maximum zoom to 8, which both removed every cluster above it and
moved the only fan trigger into a zoom band a trip planner never operates in,
so the spiderfyOnMaxZoom set beside it could never fire.
The cutoff is gone. maxClusterRadius is a function of zoom instead: the same
generous radius as before in the overview, a pin's width above it, so stops
that are genuinely apart keep their own pins and a pile becomes a counted
bubble that fans open. Selecting a buried stop from the places rail now opens
the bubble it is hiding in rather than doing nothing visible, and it does that
without moving the camera, which stays the selection's own concern.
The GL renderer has no fan to open, so folding a pin there removes the only
thing that was left rather than providing a way in. It folds at a far tighter
radius — genuine coincidence, where nothing was reachable to begin with — and
draws the selected member of a pile, so the rail reaches it there too. A map
object without a projector now skips folding instead of dropping every marker.
The public share page spreads the same options and has no rail to fall back
on, which made it the worse case; it is fixed by the same change.
Closes#2344
A document the model read correctly could still come back as "No reservations
could be extracted". Every type mapper can answer null when the node carries no
reservationFor, or one with no name, and both call sites pushed the item only
if there was one — with no else. The unknown-@type branch next to them does
warn, so a node TREK did not recognise was reported while a node it recognised
but could not map vanished: no item, no warning, nothing in the log. Because
the file did produce a node, the "no reservations found" fallback was skipped
too, and the preview arrived empty and silent. Every recognised type could
disappear this way, on both the kitinerary and the AI path.
Each of them now warns, naming the file, the index, the type and the reason.
Three smaller silences went with it. The debug line labelled "Raw LLM Response"
printed the already-parsed array, so its inspect formatting — single quotes,
bare keys — read as a provider emitting broken JSON and sent this report at the
JSON parser, which has handled that shape since 4.0.0. A single reservation
answered on its own, with no array and no wrapper, was dropped by the list
unwrapper although the schema downstream would have taken it. And a response
nobody could read returned an empty list, which is the same answer as a voucher
with no booking in it; it now raises, on the Anthropic client too, where a
reply cut off at the token limit produced exactly this report's symptom. The
plugin AI surface keeps its old empty answer, since a value is what a plugin
contracts for.
The retry ladder gained a rung that sends no response_format at all, for
providers that support neither json_schema nor json_object.
Closes#2375
The scoping guard asked whether a referenced id belongs to a different trip.
An id that belongs to no row at all cannot, so it passed, reached the raw
INSERT/UPDATE, and SQLite's own foreign-key enforcement threw. Nothing caught
it, so a stale place_id from a client-side race came back as a bare 500 with no
actionable message — where the same route answers a clean 400 for an id that
does exist somewhere else.
A second, disjoint guard now names the ids that resolve to nothing and answers
400. It deliberately does not cover accommodation_id: that column has no row to
find once a shortened trip cascades its stay away, and refusing the write there
would make the booking permanently uneditable.
The controller guard reads the request body, which is not enough on its own —
an update rebinds whatever the row already held, so a stored id whose row is
gone reaches SQL untouched. day_id, end_day_id, place_id and assignment_id are
all declared ON DELETE SET NULL, so a reference that resolves to nothing is
precisely the state that cascade leaves behind and is now cleared to NULL, the
same way a dangling accommodation_id already heals. day_accommodations' day
columns are NOT NULL and have nothing to heal to, so those are refused.
The plugin surface ran neither guard. It now refuses an unresolvable id, which
turns a crash into an error; it still accepts an id from another trip, because
taking that away belongs to the plugin contract and not to a bug fix. The
reservation MCP tools already validated every referenced id against the trip.
Closes#2355
Role mapping read the configured OIDC_ADMIN_CLAIM out of the userinfo payload
and collapsed two very different answers into one: the claim arrived and did
not name an admin, and the claim was not there at all. The second is not a
verdict about the user — it is what an operator sees when the scope carrying
that claim was never requested, or the claim name has a typo. TREK wrote
role='user' for it anyway, with nothing in the log, nothing in the audit trail
and nothing in the UI, so a misconfiguration read as an unexplained loss of
admin. The only guard was the last-admin rule, which protects an instance with
exactly one admin row and therefore almost nobody.
An absent claim now leaves the stored role untouched. A claim that does arrive
still promotes and demotes exactly as before. To keep the silence from moving
somewhere worse, an absent claim on an account that is already an admin warns
on every login, naming the account: providers that omit a claim rather than
send it empty — Okta's filtered groups, Entra ID — cannot take admin away
through the mapping at all, and that has to be done in TREK's own admin panel.
The ordinary case still warns once per claim name, listing the claim names that
did arrive (never their values) and pointing at OIDC_SCOPE. A real transition
writes an audit row from the controller, with the client IP.
Closes#2364
The image carried its whole start-up sequence as one inline `sh -c` string
containing four single-quoted echo messages. Container management UIs render
Config.Cmd back into a text field and re-split it on submit with a quote-aware,
escape-unaware tokenizer, so editing a container in place — the usual way to
change an environment variable — shredded the quoting and left dash half an
if-block to parse. The container then died before Node started, with only
`TREK: 1: Syntax error: end of file unexpected (expecting "fi")` in the log:
the word TREK is the stranded fragment of the first echo, reported as $0.
The logic moves to server/scripts/entrypoint.sh unchanged, and CMD becomes a
single bare token, which has nothing left to mangle. A unit test pins that
shape — both exec-form lines parse, and every argv element stays one token
free of quotes and spaces — and the Dockerfile joins the test workflow's path
filter so that guard actually runs when someone edits it.
Closes#2374
Two boot migrations. The first appends the missing dated days to a trip
the old 365-day cap clipped, only where the dated days still run unbroken
from the start date, so a trip that was re-dated by hand or lost a day in
the middle is left alone; dateless days that hold content stay behind the
dated ones as generateDays keeps them. The second rebuilds
roadtrip_day_boundaries without the CHECK that held day_number to 366,
which would have refused a boundary on a later day of a longer trip.
The road-trip contracts follow the same limit: the day boundary and
corridor schemas, the set_day_boundary tool and the day window's runaway
guard read MAX_TRIP_DAYS instead of their own 365 or 366, and the
tooLong message carries the number as a placeholder in all 23 locales.
A trip longer than a year stopped at day 365: generateDays capped the day
rows at MAX_TRIP_DAYS while the trip kept its full end date, so the last
days had a date but no day to plan on, in the day list, the transport and
booking dialogs and the phone's date pickers (#2403).
The limit is MAX_TRIP_DAYS in @trek/shared now, 999 days, shared by the
server, both trip forms and the MCP tools, and a range past it is refused
rather than cut short: 400 on REST, an error result on MCP, bad params on
the plugin RPC, and a message in the forms before anything is sent. The
check runs before the trip row and the budget are touched, also catches a
start date moved past the stored end on its own, and holds a day_count
rebuild to the same bound. A trip that already carries a longer range can
still be renamed. The MCP-only 90-day ceiling on create_trip goes with it;
it clipped dated trips the same way.
Closes#2403
Every page was read against the code on dev. What changed:
- Pages that described 4.2 behaviour: satellite view on the vector maps,
the mobile journal timeline, Collab with its fifth tab, the Review badge,
clustered pins on the share page, the Leaflet preset list, the strict
guard on fe80::/10, the invite link tools and their scopes, the Public
API scope rules, the permission count in the plugin SDK.
- Features of this release that no page mentioned: videos in a journal
entry, receipts on an expense, the Google transit backend, routing
counters, the rating on a place, the navigation menu, car stops, the
manual school holiday catalogue and its MCP tools, the Dawarich panel in
the Atlas, the release notes modal, plugin frames going fullscreen, the
corridor call into the search provider hook.
- Statements that sent an admin the wrong way: DEFAULT_LANGUAGE is not in
the Helm ConfigMap, env.PORT cannot be changed on the chart alone, the
rotation script does not cover the document sync credentials, event
handlers cannot broadcast, there is no per user MFA reset in the admin
panel, the demo user can edit, an unparsable APP_URL stops the server.
- Updating gets a section for 4.3.0: the TREK Places API as the new
default, the new variables, the Overpass timeout, SSO sessions on an
OIDC only instance, the migration that puts booked nights on the drive.
The import dialog always made a new list. It now asks where the places go:
a new list, as before and still the default, or a list that is already there.
The picker offers the lists the person may edit, which is their own plus a
shared one where they are an editor or an admin, and starts on the list they
have open.
Adding to a list only ever adds. A place the list already holds is left
exactly as it is, with its status, notes, rating and labels, and counted as
already there in what the import reports; it is recognised the way a single
save recognises one, by provider id, then name, then position. The list keeps
its own name, colour, icon and description whatever the file says, a label
the file brings is created only when the list has none of that name, and new
places are appended after the ones already there so a manual order survives.
The route is POST /api/addons/collections/:id/import, 200 because it adds to
a list rather than making one, and both imports now write their places through
the same helper.
A rootless Podman container reaches its host through 169.254.1.2, and an
identity provider behind that address failed the OIDC login with "Requests
to link-local / cloud-metadata addresses are not allowed", with no setting
to change it.
ALLOW_LINK_LOCAL_IPS names single link-local IPv4 addresses that may be
reached after all. The admin-configured lane (OIDC, AI parsing, plugin
OAuth, routing engines) reaches a listed address directly; the guard on
user-supplied URLs treats it as internal, so it also needs
ALLOW_INTERNAL_NETWORK there. 169.254.169.0/24 and 169.254.170.0/24, where
clouds serve instance metadata and credentials, cannot be listed, and an
entry that is not one link-local IPv4 aborts startup. Unset, nothing
changes.
Closes#2400
The connector menu on the phone listed only the road profiles, so the
transit search could not be opened for a single leg there. It now carries
the Public transit entry whenever the trip has dates, seeded with the leg's
two ends and the departure of the stop it leaves, and the transport sheet
passes that departure on to the search.
The prefill is built by one module the desktop day plan now uses as well.
Closes#2398
The 4.3.0 features had no pages, or pages that described an earlier
state. Three new pages and a rewrite:
- Road-Trip: the addon from enabling it to the phone tab, stays and a
stop's End as the time you leave, pinned times and the backward
schedule, daily travel times, the three driving limits, search along
the route, via points, other ways and what avoiding tolls needs,
following a track, the warning layer, MCP tools and troubleshooting.
- Document-Sync: switching stores on, binding a trip, what each store
asks for and binds, direction and delete rules, conflicts, the
mass-delete guard, renames and restores, what needs a look, limits,
private addresses, and the MCP tools.
- TREK-Places-API: what the index is, where TREK asks it, what leaves
the instance, switching it off or pointing it at a copy of your own,
and what happens when it is down.
- Dawarich: brought up to date with suggestions, the journal fold,
recorded routes, Atlas proposals and the wish list.
Places and search, documents, the addon and admin overviews, MCP
tools, environment variables, internal network access, the sidebar and
home link the new pages, and the in-app help picks them up from the
sidebar.
A binding whose credential owner left the trip is orphaned: the
scheduler, the webhook and the REST "Sync now" route all refuse it,
because a run would use the credential of somebody who is no longer on
the trip. The MCP sync_trip_documents tool only checked the admin
switch, so any member with MCP could still run such a binding on the
ex-member's account.
The tool now skips an orphaned binding before anything is un-shelved,
reports it with the orphaned code, and answers with an error when no
binding of the trip could run at all.
A list could leave the instance as a TREK file, which only another TREK
reads. Export now asks for a format, and GPX is the second one: the
places as waypoints, for OsmAnd, Organic Maps, a Garmin or gpx.studio.
Import takes a GPX in the same dialog as a list file. Asked for in
discussion #2301.
The GPX is written from the very list file the JSON export returns, so
what may leave the instance is still decided in one place. Name,
description and address, notes, website and category go into GPX's own
elements, where every app shows them. Labels, status, phone and the
rest travel in a TREK extension namespace, so a GPX made by TREK comes
back as the same list. A place without coordinates cannot be a
waypoint; it is left out and the export says how many were.
A GPX is read on the server, sent as text, with the XML library the
trip importer already uses. A document with a DOCTYPE is refused before
parsing, which closes both external entities and entity expansion. Size
is capped like the list file, and the element count is capped before a
tree is built. The reader only turns the GPX into a list file: the
browser previews it and imports it through the existing route, so one
contract, one transaction and one skipped counter cover both formats.
Waypoints and named route points become places; tracks and unnamed
route points are counted in the preview and not imported.
The two list-file routes get a body ceiling of twice the file limit.
The global 100 kB cap refused most real favourites files, and it
already refused list files well under the megabyte the contract allows.
A visit's End could be edited and was shown in road trip mode, but the
schedule never read it: the drive always left a stop after its stay, so
a traveller who knew when they had to leave but not how long they would
stand there had no way to say so.
The End is now the time the drive leaves. RoadtripStop.leaveAt is
filled from the visit's End (or its place's) on the client by
useRoadtripRoutes and on the server by roadtrip-plan.service, so
calculate_roadtrip and get_roadtrip_context agree with the planner, and
computeSchedule takes it as the departure it already knew how to hold.
With Start and End both set the stay is the time between them. With
daily travel times on, the stay is the time until then, and an End
before the window start starts the day early the way a Start does.
Before the first fixed time the chain works backwards through an End as
it does through a pinned Start.
An End is read against the drive: the last passing of that clock time
counts as missed when the drive went past it on the road or earlier
the same day, anything else is the next one, so a drive across
midnight neither waits a whole day nor pretends to be on time. A drive
that gets there after the End leaves on arrival and files a
missedLeave finding, shown beside the late badge in the rail and on the
phone.
The rail's STAY badge, the inspector's Stay tile, the phone stop sheet
and the phone chain show the stay the End makes, with an "until 14:00"
hint. It is read off the schedule and never written to
duration_minutes: the stay belongs to the place, the End to one visit,
and a stored copy would go stale as soon as the arrival moved. For such
a stop the stay dialog, desktop and phone, shows that stay read only
and offers to remove the End, through the visit's time route with an
offline path (assignmentRepo.setTimes, setAssignmentTimes). While the
road trip is on, the End field in both place forms says the drive
leaves at that time.
The dead checkoutAt in the plan service is gone. New strings in all 23
locales.
Saving a start on a stop rewrote the whole day as the timed stops in
time order followed by every untimed stop. Its own comment and the
commit that added it promised that untimed stops keep their place, but
a day of two untimed stops and a third one given 14:00 came back with
the third one first. On a road trip the stop order is the drive, so
the day was re-routed, its vias landed on other legs, and collaborators,
who only got the one edited row, saw a third order until they reloaded.
The intended rule, an untimed stop inheriting the time of the timed
stop before it, now lives once as chronoOrder in @trek/shared, used by
both the day plan (dayMerge) and AssignmentsService.updateTime. The
server still reads a booked night's hour from its check-in, and a value
without a colon still sorts after every clock time.
The day is sorted only when the stop's start actually changed, so
taking an End off or saving an unchanged start no longer snaps back a
day somebody dragged out of time order. A sorted day is numbered from 0
like a drag. In the same transaction the day's road trip vias follow
the stop they were drawn behind, the planner's own drag rule, and a
via behind a last stop that stays last is kept, so the drive into the
next day keeps its road.
PUT /assignments/:id/time and the MCP update_assignment_time tool both
broadcast assignment:reordered with the whole day, to the writer as
well, and roadtripVia:changed when vias moved, so every client ends up
with the same order.
ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the
intended one.
The custom image control on a saved place's cover sat in the top left
corner, on top of the category chip, and hid half of it. It also looked
like nothing else on the cover: a square dark button next to a round
translucent close button.
The camera and the remove image button now sit in the top right corner
next to the close button, camera directly left of the X, remove further
left when there is a custom image, all three in the close button's
round translucent look with its hover, phone size and focus ring. The
phone sheet had the same overlap and gets the same row.
The category chip and the controls share one header, so neither can
run under the other: the controls hold the end and never shrink, and
the chip ellipsizes a long category instead of sliding under the
buttons in the narrow docked detail. On the phone the header is in
flow, so a place without a category starts its name below the buttons.
Picker, tooltip, labels, the spinner while an upload runs and the
removal control behave as before. The trip planner inspector keeps its
avatar overlay; it never had the overlap.
SonarCloud flags a bare .sort() on strings (S2871). The comparator keeps
the byte order the default sort used, so every cursor already stored
stays valid.
A run stored the cursor of the listing it started from, whatever it
did afterwards. Paperless, Papra and Synology build that cursor from
what they list, so a copy TREK uploaded and somebody deleted at the
provider before the next run left the scope exactly as the stored
listing had it. The next plain run (scheduler, webhook, Sync now) read
the cursor as unchanged, planned only the TREK side, and neither
flagged the copy as missing nor asked the mass-delete guard until
something else moved in the scope or a full run came along. A copy
TREK binned and somebody restored at the provider read the same way.
WebDAV was not affected: its root ETag never returns to an earlier
value.
The same held for a run that stopped short. What the transfer budget
left over, and a download that failed, was never planned again under
an unchanged cursor, so on a quiet binding those documents did not
arrive at all.
A run now keeps the listing's cursor only when it ended ok and asked
the provider to change nothing: no upload, no rename, no trash. Any
other run stores none, and the next one compares both sides in full
and hands a cursor on once it ends clean. On Paperless, Papra and
Synology that costs nothing, since they walk the scope on every run
anyway; on WebDAV it is one Depth:1 listing instead of the Depth:0
probe.
Both shells keep a binding on screen after an admin switches its
provider off, and name its store through storeName(), which falls back
to a providerName on the link before the raw id. The server never sent
one, so such a binding was labelled with the provider id. publicLink
now carries providerName from document_providers.name whatever its
enabled flag, and every route and the MCP listing that return links go
through it.
The comments on docsync:changed in the realtime schema and the client's
event policy say that the event now also marks a binding created or
removed, and that the Files screen re-asks whether to offer the sync
button when it arrives.
The check that was meant to stand a binding down when its provider is
switched off asked AddonsService.isAddonEnabled(link.provider_id). The
addons table has no rows for paperless, papra and the other document
providers, so it answered "off" for every binding, and every run came
back disabled before it listed anything. The unit test stubbed that
answer and the e2e never runs a sync, so nothing caught it.
isSwitchedOff() now asks the two things an admin actually switches: the
Documents addon, and document_providers.enabled for the binding's
provider. dueLinks leaves such bindings out in SQL, since one that
never runs never gets a last_sync_at and twenty of them would take
every slot of a tick. A run, the webhook on arrival and when its timer
fires, "Sync now" and the MCP sync tool all refuse before anything is
written, so a paused binding keeps its state and its documents' attempt
counters and resumes where it stopped. The REST route answers 409
provider_disabled, the MCP tool reports the same code, status() carries
providerOff per link, and both shells show one line saying the binding
is paused because its provider is switched off, in all 23 languages.
A failed pull_update also left the row stuck. upsertItem wrote the
listing's version, size and time next to the old bytes, so the next run
saw no upstream change, planned touch, which leaves error alone, and
the document stayed in error on stale bytes for good, "Sync now"
included. A failed transfer now leaves the pairing describing the copy
TREK holds; a new row keeps what pairs it but no version, so it is
fetched again.
The lines this branch adds used em and en dashes as sentence
punctuation across comments, JSDoc, CSS comments and test titles. Each
one is rewritten with a comma, a colon, parentheses or a second
sentence, keeping what the sentence said.
Only lines this branch adds are touched; everything that already
exists on dev stays as it is. No logic, error code or logged message
changes. The only string literals affected are describe and it titles,
which now read "X: topic" like the rest of the provider tests.
The Documents addon put a "Document sync" button on every trip's Files
tab, and the seed ships every provider switched off, so after the
upgrade each of those buttons led into "No document providers are
available". The button now shows on a trip that is already bound, to
every member, and otherwise only to somebody who may bind the trip on
an instance that offers a provider. Without it the Files toolbar is
the one from before the feature, trash flush right, on both shells.
/api/addons carries no document providers for a normal user, so the
answer comes from the docsync routes through a new online-only
docSyncRepo and is kept per session in docSyncOfferStore. A revisit
renders the last answer at once and checks it again; offline nothing
is asked and the last answer stands. Creating a binding, removing one
and removing a connection now ping the trip with docsync:changed and
zero counts, so a member already on the Files tab sees the button come
and go without leaving it.
Both shells keep listing a binding whose provider the admin has since
switched off instead of claiming there are no providers, and name its
store through one storeName() helper. A load that answers 403 or 404
clears the bindings as well, so they do not linger once the Documents
addon is gone.
The phone admin screen gets the Documents shelf the desktop one has:
the document providers as sub rows under the addon, toggled through the
same admin route and re-read after Documents itself is toggled, because
the server switches the providers off with it.
isTripOwner and the isOwner prop become canManageSync and canManage,
which is what canManageDocSync answers. Both folder pickers take
loadScopes out of the sync object before their effect, so
exhaustive-deps is satisfied without the effect running on every
render.
The DSM trusted-device token lived in process memory only. After a
restart every token was gone and the stored one-time code long spent,
so every two-factor NAS binding stopped syncing until its owner typed a
fresh code, and nothing on screen said why.
The token is now kept in the connection's encrypted secrets, together
with a digest of the instance and account it was issued for. A saved
connection's ref carries a saveSecret callback that writes through
DocSyncConfigService.saveEarnedSecret, which rereads the row inside a
transaction so a sync run holding an old ref cannot undo a password
the owner changed meanwhile. The key belongs to no form field, so
maskSecrets never reports it and mergeSecrets never touches it: the GET
and PUT payloads do not carry it, an edit keeps it, and a client cannot
plant one. It survives a password edit and a spent or cleared code. A
connection repointed at another NAS or account drops it on its next
call, and a token DSM refuses with 403 or 404 is dropped in memory and
in storage. A probe of form values and an unsaved form may use what is
stored but never write. A refused token no longer shadows a fresh code:
the code in the form gets its turn in the same login call.
In memory the slot is keyed by the connection id together with the
row's created_at. A backup restored in place or the demo reset rolls
the id sequence back without a restart, and a new connection that
reused an id must not log in on the token of the row it replaced.
The Synology adapter receives its client through DI like Paperless and
WebDAV, instead of building a second instance beside the one the module
registers.
Two readings of a failed request are settled as well:
- INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an
untrusted certificate again, so the settings screen offers the
self-signed switch. Papra used to find them through the message text,
and the shared classifier reads codes only.
- A host that never completes the TCP handshake reads as unreachable on
every client. WebDAV said timeout before the shared transport; the
change is now documented and tested.
Where the id is the path, a rename or a move upstream lists the same
copy under a new id. The upstream-only loop re-paired it with a touch,
which went wrong twice. The pairing had already been counted as
vanished, so the same run flagged it missing as well and counted it
towards the mass-delete guard. And touch wrote the provider's new name
into remote_name while the trip file kept the old one, so the next run
read that as a rename made in TREK and renamed the provider's copy
back. The heuristic also matched on a content hash only, which Synology
never sends: on the one provider without stable ids it did not fire at
all, and every rename there ended as a missing flag plus a second
download of the same bytes.
The planner now moves such a pairing first, with a relocate action that
updates remote_id and the version and leaves the agreed name alone. The
pair loop then treats it like any listed document, so the new name
comes into TREK through rename_local exactly as it does where ids are
stable, and nothing counts as vanished. A copy is recognised by its
content hash where the listing has one, otherwise by size and
modification time, which a rename or a move leaves untouched. Only a
one-to-one match is followed, and a copy TREK binned itself never ties
with a live twin of the same bytes. The rows now load remote_size and
remote_modified_at, which were written on pull and never read; touch
keeps them current and a push records them. rename_local and the
provider branch of resolveConflict take the provider name in its
cleaned form, since a raw name with a colon or a slash read as a rename
made in TREK on the next run.
Restores and purges:
- A file taken back out of TREK's trash whose copy somebody else had
deleted meanwhile resumed as synced and was then counted as vanished,
so a few such restores tripped the mass-delete guard on every run
until the circuit opened. It now lands as remote_missing directly,
outside the guard, and the service asks for a full listing while such
a restore is pending.
- A row in error that once held a file now reads as a deletion once the
file is purged, told apart from a first download that never landed by
the agreed hash. "Sync now" used to download the deleted document
again.
- Moves are followed before a restore is judged, so a binned copy
restored at the provider under a new path resumes its pairing instead
of being duplicated on both sides.
- relocate and both renames lift a remote_missing flag the way touch
does.
- A double rename under trek_wins or provider_wins is settled with a
rename instead of a transfer.
A place with an uploaded image showed a plain disc of its category
colour on the map, while a picked suggestion looked fine. The marker
sized its img with width and height attributes, and stylesheet rules
beat those: Tailwind's preflight gives every img height: auto, and
Leaflet's marker pane adds width: auto. Suggested and auto photos reach
the marker as 48px square thumbs, so nothing showed. A phone photo kept
its own proportions on the GL map, a sliver inside the circle, and its
full pixel size on Leaflet, where only the rounded-off corner of a
4000px image lands inside the circle. Both maps now build the img
through one helper that sizes it in its style, so object-fit: cover
crops any photo to the circle.
The thumb loading on both maps was keyed on place ids, so a change of
picture under the same place went unnoticed until a reload:
- Taking an upload off a place left the category dot instead of
bringing its auto photo back.
- Picking another suggested photo kept the thumb of the first one,
which was cached under the provider id. A picked photo is now its own
cache key.
PATCH links validated its body with a schema derived from the create
contract through .partial(). In Zod 4 a .default() still fires inside an
optional field, so a patch naming one setting arrived with every other
one filled in: pausing a pull-only binding switched it to two-way, reset
both policies and blanked its label. The update contract is now written
out without defaults, and an e2e test drives a patch through the real
pipe, which the controller unit tests never see.
Also, the cosmetic leftovers from the review:
- POST connections/:connectionId/scopes no longer wants the connection
in the body as well. It was required, never read, and let one request
name two connections; a client that still sends it has the key
stripped rather than refused.
- createLink loses the scope argument every caller passed as null.
- The controller's owner check is now assertCanManage, because it lets
instance admins through just as canManageDocSync does on the client.
Who may manage a binding is unchanged.
- document_provider_fields drops settings_key and payload_key. Nothing
read them: the connect form takes its fields from /docsync/providers
and the admin shelf sends none for document providers.
- The e2e test imports the provider classes under their new names.
The DSM trusted-device token was cached per NAS and account. A second trip
whose owner knew the account password but never had the authenticator could
bind the same account and log in on the first trip's token, a two-factor
bypass across trip owners. Nothing ever cleared the entry either, so a token
DSM stopped trusting went out ahead of any fresh code on every attempt until
the process restarted.
The token is now filed per connection, with instance and account still in
the key. An edit keeps the connection id, so the token survives a password
change and a stale code in the form. An unsaved form (connection 0) keeps
what its probe earns on the session only, and the saved connection adopts
it from there. A token DSM refuses with 403 or 404 is dropped for that
connection alone.
The four clients each carried their own copy of the transport, and the
copies had drifted. provider-http.ts now holds the SSRF-guarded fetch with
its timeout and certificate switch, the reading of a request that got no
answer, the base status table and the guard on a streamed download.
Timeouts, caps, auth headers and each client's error class and wording are
unchanged. Where the copies differed by accident they now agree:
- Paperless recognises UNABLE_TO_GET_ISSUER_CERT_LOCALLY (a private CA) as
tls_untrusted instead of unreachable.
- ERR_SSL_* handshake failures read as unreachable on WebDAV too, since the
self-signed switch cannot fix them.
- Synology maps HTTP 409, 415 and 507 like the others, Paperless maps 415.
- Every download is refused on an announced length over its ceiling and
counted as it streams. WebDAV and Paperless had no ceiling, Synology only
read the header, and Papra's limiter left the source pinned when it
tripped.
- Papra reported a download without Content-Length as zero bytes, so the
size from the listing was never used.
PapraProvider and SynologyDriveProvider are renamed PapraDocumentProvider
and SynologyDriveDocumentProvider, matching the other adapters.
Both planner branches planned local_deleted for as long as a file sat in
TREK's trash. Under unlink nothing showed, until the binding was switched
to trash: the next run then binned every document ever deleted in TREK,
months back included. A binding on trash from the start asked the provider
to bin the same copy on every run. A local_deleted row is now left alone,
so the policy is applied once, under the policy in force at that moment.
That guard also meant a file taken back out of TREK's trash stayed on
local_deleted for good, which it already did before: no transfer, no
upload, no flag, and a second deletion of the same file would never have
propagated. The row now leaves that state by what became of the provider
copy:
- still there: the pairing counts again and the row is judged like any
synced one, so an upstream edit made meanwhile comes down;
- binned by TREK: the pairing is dropped and the file goes up again as a
new document through the ordinary push, keeping its trek_doc_uid;
- deleted by somebody else: flagged remote_missing, never uploaded over
their deletion.
Telling the last two apart needs a record of what TREK did, so the new
remote_trashed_at column (migration 239) is written when TREK bins a copy
and cleared when the pairing is back, or when the binned copy is listed
again because somebody restored it at the provider.
A file purged from the trash came straight back as well: file_id is ON
DELETE SET NULL, and the row read as a download that never landed. A
tombstone now stays a tombstone, and a purge no run had seen yet is
treated as the deletion it is.
Also drops snapshotHash, which nothing read.
The document-sync flow needed TREK's square app icon next to the
provider glyphs, and the PR got it by rewriting the shared TrekMark
component. TrekMark is the wordmark with the API pill that the admin
Places API card, the mobile admin block and the release notes popup
draw, so all three showed the app icon instead, and
AdminSettingsTab FE-ADMSET-031 failed on the missing "TREK Places API"
image.
TrekMark is back to its dev version. The app icon now lives in
TrekIcon, which DocSyncFlow and MDocSyncSheet use.
AddonManager had also switched Synology Photos to the Synology Drive
glyph, which renders as a block and sat off-line from the Immich row
under Journey. Photos keeps its own icon again. The new Documents
shelf gets tests: its rows and glyphs, staying out of the addon count,
hiding while Documents is off, the re-read after Documents is switched
off, and a provider toggle that saves without re-reading.
The binding asked at setup what should happen when both sides changed, stored
the answer, and nothing ever read it: every conflict was manual whatever the
binding said. The planner now acts on it — provider_wins pulls, trek_wins
pushes, anything else parks the row — and never in the direction the binding
forbids.
Conflicts themselves had no exit in either shell. The server has resolved them
since the first version and the strings have been in the locales just as long,
but neither the panel nor the phone sheet ever listed the rows, so the only way
to answer one was the MCP tool. Both shells now list them through one shared
hook with the same three answers.
Also:
- The desktop connect dialog shows a refused save instead of looking as though
nothing happened.
- An error the server phrases as a sentence no longer reaches the screen as a
raw translation key.
- The scope pickers depend on the stable callback rather than the hook object,
so opening one no longer re-lists the provider's folders on every render
above it.
Papra answers a push of bytes it already holds with the document that has
them. Two trip files with the same content therefore pushed to one document,
and the unique index on (link_id, remote_id) aborted that run and every run
after it with a constraint error. The pairing now stays with the file that
owns it and the second file is recorded as a conflict.
Also in this pass:
- Paperless no longer adopts a same-bytes document that lacks the scope's tag
when a consume outlives the poll budget; that document belongs to someone
else's filing and delete-through would have trashed it.
- The pull path writes the file row, the retirement of the copy it replaces
and the pairing in one transaction, and announces them only once committed.
- PATCH links stops accepting remoteRootId/remoteRootPath, which it read and
silently dropped.
- The MCP sync tool clears shelved items before running, as the REST route
does.
More from the audit, each checked against the code before it was touched.
The webhook obeyed neither the Documents addon nor the app_settings kill
switch, so "switch document sync off" stopped the poll while every provider
holding a webhook carried on driving full runs. Both are now read on arrival
and again when the debounce timer fires, so a switch thrown mid-window still
takes effect.
Nextcloud's subscription registered its secret under `X-TREK-Docsync-Signature`
while the endpoint reads `x-trek-docsync-secret`: the subscription existed, the
calls arrived, and every one of them was dropped for carrying no secret the
controller could find. The two names now agree, and the test pins the one the
endpoint actually reads.
A WebDAV create was an unconditional PUT, so a name already taken upstream was
silently overwritten — two trips bound to one folder, or a `receipt.pdf` a
person put there by hand, is enough. Creates now send `If-None-Match: *`.
The per-item retry bookkeeping was stuck at both ends: the backoff was computed
at step 1 every time, so three of the curve's four steps were unreachable, and
the attempt counter only ever grew, which shelved a row that failed once a
month after six months of otherwise healthy syncing. A successful pass now
clears it, which is what makes the limit mean six failures in a row.
Also: a manual run on an orphaned binding is refused with 409 (its credential
belongs to somebody no longer on the trip, and the scheduler already refuses
it, so the button was a way around that); a document deleted while a run is in
flight is no longer uploaded anyway; a path-shaped WebDAV id containing `..` is
rejected instead of addressing a file outside the bound folder; and Papra's
multipart part header no longer takes an unescaped media type, which a CR could
end early.
Client: three writes in useDocSync swallowed their failure into `void` call
sites, a load failure blanked the panel to "no providers available" whatever
the cause, and overlapping loads let the slowest response win. Writes now
report, only 403/404 blanks the panel, and a generation stamp keeps the newest
answer. The dead lastRun state went with them.
The audit's worst find, reproduced against a live Nextcloud before and after the
fix: the WebDAV adapters answer "nothing changed here" by returning an EMPTY
listing plus `cursorUnchanged`, and nothing read that flag — `grep -rn
cursorUnchanged src/` found it set by four adapters and consulted by none. The
core therefore saw a folder that had lost everything. Run 1 and 2 look fine
because the run's own uploads move the ETag; from run 3 on, every run of an
idle binding ends `partial` with `mass_delete_guard` and six documents reported
missing, forever, until failure_count opens the circuit and the scheduler drops
the binding entirely. That is the ordinary case — a binding nobody is touching.
planReconcile now takes `remoteUnchanged` and plans only what happened in TREK.
Two ids were authorised by trip and never checked against it:
- resolveConflict took an item id on trust, so the owner of any trip could
decide a conflict belonging to somebody else's. Both are plain integers and
nothing compared them.
- The connection test merged the STORED credential into a probe of whatever
baseUrl the caller named — a way to have TREK post another account's API
token at a server of the caller's choosing. Stored secrets are now reused
only for the address they were stored against.
The phone's connect form was wrong in three ways at once, each of which the
desktop had right: it read the address as `baseUrl` while the field is
`base_url` (so every connection it saved carried an empty address), it drew
`allow_insecure_tls` as a text box and always sent false, and it built label
keys as `docsync.provider${cap(label)}` when the label column already holds the
full suffix — `docsync.providerProviderApiKey`, rendered raw on screen. Those
rules are not markup, so they now live in `useConnectForm`, which both shells
use. Its test fixture invented the field shape and so agreed with the broken
sheet; it now mirrors document_provider_fields.
Also: the scheduler read its interval once at bootstrap while this file's own
comment promised a per-tick read, so changing it did nothing until a restart;
and dueLinks ordered by a column that a successful run clears, which left every
binding tied and SQLite answering in insertion order — on a database with 170
bindings the same twenty ran on every tick and nothing created after them was
ever synced automatically.
The attempt counter and the backoff were written on every failure and never
consulted when planning the next run: rows were found in a live database at 46
attempts against a limit of 6, so a document a provider refuses — one it reads
as corrupt, a type it will not take, a full quota — was re-uploaded on every
cron tick forever. planReconcile now skips a row that is shelved or still inside
its window, and a manual run clears the counters, which is what "waits for a
person" has to mean.
The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed,
with a comment explaining it, and nothing read it. Providers fire once per
document, so twenty files dropped into a watched folder were twenty runs — the
in-flight guard answered `busy` to nineteen of them, throwing away nineteen
announcements, and the one run that did start had begun before most of the
changes landed. A burst now folds into one trailing run, and the binding is
re-read when the timer fires so a sync switched off in between does not get a
last run out of a stale row.
Four defects the new suites turned up, all fixed: the sync button lost its
accessible name below sm, where its label is hidden; the stored address was
never shown in the connect dialog although the form kept submitting it; the
allow-self-signed switch only read the connection at mount, so a stored "on"
showed as off and the next save wrote that back; and a refused folder create
escaped as an unhandled rejection with nothing said to anybody.
Also here: the phone shell gets the same feature (one sheet, four views, the
same hook as the desktop), the marks come from the product's own icon files
rather than redrawn approximations, and the 22 non-English locales carry the
new keys.
ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the
test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.
Three decisions shape the rest.
The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.
TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.
Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.
The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.
Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.
Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
A foreign-currency payment gained an exchange_rate in the ledger fixture without
the field on the local Payment type beside it, and the client typecheck has been
red since.
The button carried nothing and the form hard-wired the kind, so the categories
sitting directly above it had no way through and every manual stop started life
as a fuel stop with ten minutes on it. The click now carries what the corridor
is looking for, dwell included. The picker is multi-select, so the first kind
the panel lists wins rather than a fallback nobody switched on; with nothing
selected the form falls back on its own, because a stop without a kind is a
numbered destination that counts in every total.
This also closes the case it was noticed on: an electric car seeds the panel to
charging, so the picker said Charging over a form that said Fuel.
One request per day is thrifty, and the price of it is that a refusal is never
local. With the usual cause gone the shape of the failure is still there, so a
run the router refuses outright now asks for its legs one at a time instead of
leaving the day blank. The pairs are enqueued rather than awaited, so they keep
the spacing of every other request, and a rate limit is never split: answering a
host that asked for less traffic with four more requests is the opposite of
backing off. A refusal the host meant skips the two retries, because the same
coordinates earn the same 400.
Seam legs are merged under the day's own runs now rather than over them.
legsByDay is replaced whole on every routing round while seamLegs only ever
grows, so a pair that used to sit on a day boundary and now sits inside one day
kept an answer nobody re-asks for, shaped by the vias of a stop that has moved.
A day is routed as one request over all of its stops, and OSRM's car profile
forbids a u-turn at an intermediate waypoint unless asked. When it cannot obey
it refuses the whole request rather than the one leg, so a single stop on a dead
end took every leg of the day with it. Gouffre de Padirac snaps onto a cave
access road that goes nowhere else: each of that day's pairs routes fine alone,
all five together come back 400 NoRoute, and the same five with
continue_straight=false come back with their legs.
The permission goes on every request that has a stop in the middle, in the
browser and in the server's planning path, which had the same defect. Two-point
requests keep the url they had.
OSRM refuses an unknown query parameter outright rather than ignoring it, so a
router that has never heard of this one is recognised by its InvalidQuery,
asked again without it, and remembered per origin.
The server freezes the FX rate when an expense or a settle-up transfer is
entered and settles the balances with it, but the client ignored the column
and converted every amount with today's rate. A bill paid in dollars was
therefore worth something different each time it was opened, which a tester
noticed after settling up: the figure moved under him, and the row no longer
matched what had actually changed hands.
convertBooked applies the server's own rule, in one place for both shells and
the transfer rows: an amount already in the trip currency is left to the live
step, a frozen rate converts to the trip currency before the live step, and a
rate of 1 or none at all is a row written before the freeze existed and keeps
converting live.
The Up next card stopped printing a fixed key when the audit replaced the
nonexistent places.navigate with the single navigation target's own name, so
the assertion went looking for text that no longer exists and took the 4.3.0
branch red. The link is found by aria-disabled now, which is what the test is
actually about.
**"Today" was read off UTC while the clock beside it is local.** `localDate.ts` exists for
exactly this and says so in its own comment: between local midnight and the UTC rollover the
two disagree, so east of Greenwich the up-next card hung on yesterday's stage for the first
hours of every night, which is when somebody on a long stage is actually driving. Both the
stage hook and the corridor hook now ask the same helper the rest of the app asks.
**A refused day end left the switch showing a state the trip never reached.** The writer
catches its own failures and shows a toast, so it never throws and the sheet's catch could
not fire, which left the optimistic value standing. It answers whether the day end moved
now, and the switch comes back when it did not. The desktop control ignores that answer, as
it reads its state back from the trip.
**A timed-out corridor search reported more unsearched boxes than it had.** Every worker
still alive ran the deadline branch and each added the whole remainder, so three workers
reported three times the boxes left, which can exceed the number of boxes there ever were.
The remainder is claimed before it is counted, which also ends the other workers' loops.
**A night that fell mid-leg had its morning drive dropped from the stage map.** The join
mark went on the first leg of every day opening on an automatic night, but only a night
standing ON a stop is a connection between two days. `dayWindow` records that difference as
a whole `position` against a fractional one, and where it is fractional the morning drive is
the last stretch to this day's own first stop. It was being filtered off the stage while its
distance stayed in the day's total.
`mobileTrip.rtTruncated` interpolated a count into a sentence naming the stretch once, so at
one truncated area it read "1 stretches". Two keys now, picked by the count, the shape this
codebase already uses where a number changes its noun.
Writing the singular turned up three plurals that were wrong at counts the old one-form key
could never reach. Polish and Czech both had the genitive plural with a singular verb, which
is right at five and wrong at two; Arabic had the 3 to 10 form, wrong at two and again from
eleven. All three now put the count after a colon, where no form has to agree with it.
Russian and Ukrainian were already correct at both, since their phrase leads with a
locative, and were left alone.
The eight languages that keep the singular noun after a numeral differ only in the numeral,
which is what they should do.
**A saved stay left an invisible sheet standing, and the day swipe stayed dead behind it.**
The stay sheet reopened 'rtstop' by spreading its own payload, which carries a place id.
That sheet is located by day and assignment, so it resolved to nothing and drew nothing,
while `shell.sheet` still pointed at it and `menuOpen: !!shell.sheet` kept the day swipe
blocked until something else closed it. The row rides along now, both ways, and with no row
to go back to the sheet closes rather than opening one that resolves to nothing.
**"End the day here" showed off for a day that already ends there, and switching it on
deleted that day end.** Two things close a day: the stop's own flag and a manual boundary
filed against it. The writer has always known about both; the phone switch read only the
flag, so the tap ran through the writer's boundary branch and cleared the boundary. The
question is asked once now, in the planner beside that writer, and the desktop reads the
same answer it always computed inline.
**The road trip chain printed 24-hour clocks at a reader on a twelve-hour one.** The model
hands clocks over as HH:MM, which is what the schedule computes in, and the chain, the head
card and the up-next card printed it straight. The desktop rail, the day timeline and this
tab's own sheets all honour the setting; these go through the same formatter, carried in the
row chrome so no two rows can disagree.
**The up-next card's navigate button read "places.navigate".** No locale has that key. It is
`inspector.navigation`, which is what the stop sheet beside it, the place inspector and the
place sheet all use, with the single-target label when there is only one.
**Offered roads were invisible on a satellite basemap.** The imagery is anchored under the
first layer TREK draws, found by prefix, and the roads offered for a leg are added before
the route source so they sit under the current route. That also puts them before the first
`trip-` layer, so the raster went straight over them. The prefix list knows them now.
**A journal grew a day section for every day Dawarich recorded, all year.** The hook reads
every open stay the account has, and the timeline took all of their dates. A week in May
therefore sprouted sections from September. Bounded by the journal's own span now, its
entries together with the dates of the trips it links, and with no span at all it offers
nothing rather than everything.
SonarCloud's S2871 fires on a bare .sort() over strings and asks for localeCompare, and it
had five of them on new code, which is what put new reliability at D and failed the gate.
All five sort ISO dates or query parameter names, where the default lexicographic order is
exactly the one wanted. Taking the rule's suggestion literally would have been the wrong
fix twice over: a locale-aware comparison reorders dates differently per runtime, and on
the Amap signature it would produce a canonical string Amap itself does not compute, so
every signed request would come back rejected.
So the comparator is written out rather than changed. Byte order, explicitly, with a line
saying why it has to stay byte order. Behaviour is identical: the 43 Amap cases, the 40
track cases and the 30 client cases over these modules all pass unchanged.
Three things the CI would have caught.
The settings dialog's switches are the shared ToggleSwitch now, which is a button with
aria-pressed rather than a role="switch" with aria-checked, and three tests still looked
for the old shape.
The page's wiring test mocks the whole hook, so the day map the timeline reads was
undefined there and every case in the file died on it. The mock carries it now, empty,
because the fold has a suite of its own.
That suite is new: what it pins is the fold rather than the rows inside it, which the
panel's own tests already cover end to end. That it says nothing on a quiet day, that the
line reads shut, that the count takes the singular at one, that it starts closed, that
both actions reach the journal with the stay they belong to, and that the span follows
the traveller's own clock.
The grouping the hook does moved into the model beside the other pure shaping, where it
is four cheap tests instead of a round trip through MSW: by day, in the order they were
lived, only what is still waiting, and no bucket at all for a day whose stays are all
handled.
Capping it was the wrong half of the fix: the stays were reachable but the panel was cut
off at half the rail, which is not what opening something means.
It sits inside the places list's own scroller now, above the rows, so it simply grows to
whatever the day's stays need — 3350px for forty of them — and the rail scrolls past it
to the places. No second scrollbar, no fold. As a band of its own it could never do that:
the list is the flex child that scrolls, so anything unbounded above it squeezed the list
to nothing and took the rail's scrolling with it.
The panel sat in a wrapper that could not shrink and did not scroll, so opening it took
whatever height the day's stays needed — forty of them is over 3000px — and squeezed the
places list, which is the flex child that scrolls, down to nothing. The rail stopped
scrolling and most of the stays sat below the bottom edge with no way to reach them.
The wrapper is capped at half the rail and scrolls on its own. Open, the stays are all
reachable and the list underneath is still a list; shut, nothing changes, because the cap
only bites once the content passes it.
A stay in the trip rail showed both when it happened and how long it lasted. The rail is
narrow and the row does not wrap, and of the two it is the arrival that places a stop; how
long somebody stood there is a second figure competing for the same line.
Off wherever the panel is scoped to a trip, which is the planner rail and the phone's
places browser, and the same test `showTrip` already uses. The journal keeps it: there a
stay becomes an entry, and how long it lasted is part of what the entry says.
Every time Dawarich shows was cut straight out of the timestamp and printed, so on a
12-hour setting the app said "2:05 PM" everywhere and "14:05" here. Both ends of a range
go through the same formatter the rest of the app uses now, and the argument is
required rather than optional so the next caller answers the question instead of
inheriting the default.
That covers the rows in the panel, on the phone and in the planner, and the summary line
a journal day carries, which reads its span through the very same function so the fold
and the stays under it cannot disagree about the clock.
The accept dialog was already right: it fills a CustomTimePicker, which knows the
setting, from the 24-hour value it keeps internally.
A day a pending stay was waiting on now joins the timeline so the stay can be reached,
and the header counted those days as part of the journey: a journal of seven days read
"11 days" because Dawarich had recorded on four more. The figure comes off the entries
again, which is what the word means.
The stays Dawarich recorded used to be a panel above the timeline: every open stay of
every day, stacked over the entries. A fortnight of driving puts forty of them between
the reader and their own first entry, which is the scrolling Roel described on the
16th. They belong where the day is.
Each day of the timeline now ends in one line: the Dawarich mark, how many stays, and
the span they cover. A tap opens them, and what opens is the panel's own rows, imported
rather than copied — one row this detailed in two places would be two things to keep in
step and a straight hit on the duplication budget. Nothing is drawn on a day with
nothing pending.
A stay falls on the day it happened, which is often a day the journal has no entry on
yet, and that is the whole point of offering it. Those days join the timeline so their
stays can be reached at all; without that the only way to a quiet day's stays would be
to write an entry on it first. Not while a search is running, where a day whose entries
the query filtered out is not a day the reader is looking at.
The count takes two keys picked by the number, the shape this codebase already uses for
a noun that changes with it: there is no plural engine in the i18n layer, and one key
with a placeholder in it reads as "1 stays". Russian, Polish, Czech, Ukrainian and
Arabic inflect again at 2-4 and 5+, which two forms cannot express, so their plural puts
the number after a colon, where no form has to agree with it.
The phone is untouched: it opens the same list in a sheet from its own button, which is
where it works on a screen that shows one entry at a time.
Two on the journal.
The phone's Dawarich button floated a hard-coded 128px over the dock, which is short of
the rail underneath it: a day scrubber over a row of covers, and a cover grows from 152
to 180px when it becomes the active one. The button sat on the cards. It now clears the
rail's measured height, observed rather than read once, because that height changes with
every swipe. No rail, as on an empty journal, means no offset.
The settings dialog drew its four switches by hand in emerald and zinc, with a white
knob. In dark mode that is white on near-white: the screenshot is three pills where
neither the state nor the knob can be made out. They are the shared `ToggleSwitch` now,
which is the component every other switch in the app uses and which solved this exact
problem with `--accent-text` on the knob. The row is no longer one big button, since a
button cannot hold another: text left, switch right, the layout the settings screens use.
The last change marked the drive into the next day and kept it off a phone stage, but it
only caught the connection `connectDays` builds through `inboundAt`. A trip with a day
window set never takes that path: the window switches `inboundAt` off and builds the days
itself, so day 2 opens on an automatic night standing where day 1 stopped and its first
leg is the drive on from there. That leg is the same connection, it just arrives as one
of day 2's own legs under day 2's number, and the stage drew the whole way back to
yesterday's last stop on today's map. The setting is not even involved: with a window it
happens either way.
So the leg out of an opening night is marked as a join as well, and the phone drops it
with the rest.
One exception, because the filter must not empty the screen: a day that is nothing but
driving, night to night with no stop of its own, has only the connection to show, and
there the line IS the day. When dropping the joins would leave a stage with no line at
all, it keeps them.
"Connect the days" is a whole-drive setting: with it on, the leg from one day's last
stop to the next day's first is drawn in the colour of the day it leaves, so the desktop
map reads as one continuous drive across every day. The phone has no such view by
default. It shows ONE day, and the stage filter went by day number alone, so that
connection came along: a line running off the day on screen to a place it never visits,
and on this trip the longest thing on the map by far.
The assembler now says which lines are connections (`lineJoins`, parallel to `lines` and
`lineDays`) rather than leaving each surface to guess, because the day number cannot tell
them apart: it reads the leaving day for both a day's own legs and for the drive out of
it. A stage drops them; the all-days view keeps them, which is the view the setting is
for and is one tap away on the same map, next to the day chips.
Routes read back from an older cache carry no marks, so nothing is known to be a join and
nothing is dropped: those days draw exactly as they did before.
The moon pill marking where a travel day ends is back on the phone, filtered to the day
on screen. The markers are built from the whole drive, which is right for the desktop
rail and for the all-days view, but a stage draws one day: unfiltered, day 1 carried day
2's pill as well, sitting in the middle of a map that is otherwise entirely day 1. Two
ends on a map that draws one is a question rather than a label.
Off a stage the list is left alone, because in the all-days view every night does belong
to a line that is drawn. The plan tab is unaffected either way: its vias come from the
route calculation, which has no night pauses in it.
This is what the map lost when the pills were dropped altogether: on a day whose drive
ends somewhere other than its last stop, the pill is the only thing that says where.
`dayWindow.stationary()` puts a leg between the last stop of a day and the automatic
night that closes it: same coordinate at both ends, nothing measured, no texts. It is
bookkeeping, not a drive. Drawn as a pill under the last stop it said "No route" — and
before the template was guarded, a bare " in " — under a place the traveller had simply
arrived at.
Matched on the shape rather than on the next stop being a night, because what makes it
not a drive is that it goes nowhere: a day ended at a boundary dragged down the road HAS
a leg with a distance, and that one keeps its pill. A leg nothing has routed yet keeps
its row too, so it can still say it is pending.
The desktop rail turns a place on the drive into a service stop by its own disc: click
the 3, pick the pump, and the 3 becomes an orange disc while everything below it
renumbers. The phone chain drew the same disc but it did nothing, so the only way to
mark a petrol stop was to open the trip on a computer.
The disc is now the control here too, for the same reason it is there: it is exactly
what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the
desktop's popover under the disc, because a palette aimed at an anchor would land under
the thumb that opened it. The kinds stay discs rather than seven rows of words, each
named for a screen reader. A second tap on the kind a stop already is takes it back to
a destination, and once there is something to undo the sheet says so in words too.
It writes through planner.setRoadtripStopKind, the very call the rail makes, so the
chain redraws off the store with no second code path. Without place_edit there is no
handler and the disc is a plain disc, not a disabled-looking control.
A leg pill with no figures in it is fixed on the way past: a segment that reaches the
chain before its routing round has landed carries metres and seconds but empty texts,
and reading only the texts printed the template with both slots empty, so the pill read
" in " and said nothing. The numbers are formatted when the texts are missing, which is
what the rail does with the same segment, and with neither the leg says it is pending.
It named the day's destination with its picture, its stop count, its arrival and the
stage distance, and it stood there for the whole of the road trip tab. The day and
the distance are in the shell's stage header already, and on a map that fills the
screen a permanent card over the bottom edge costs more than the three facts it
added. Tapping it opened the destination's stop sheet; the chain opens the same sheet
from the row that names it.
The slot stays for the one bar that earns it: while other ways of driving a leg are
on offer, the picker's bar stands there. That wrapper is now mounted with the bar
rather than lying empty over the map's own buttons the rest of the time, and the map
floor lifts only while it is up, so on the stage the round controls stand on the dock
like everywhere else.
The keys come from the server's registry, the copy from the bundle in the browser,
and a deploy parts the two for as long as it takes the page to reload: the service
worker keeps serving the old app while /api/config already reports the new version,
and the release notice for that very version arrives naming keys the old bundle
never had. What is drawn is then system_notice.release_notes.headline where the
headline belongs, on the one notice whose whole job is to be read once per release.
Worse, a dismissal in that window is a real one. The entry is per-version, so
whoever closes the gibberish never sees the notes for that release at all.
So a notice made only of unresolved keys is held back until the reload App.tsx is
already waiting for. Nothing is spent by waiting: a notice is used up by
POST /dismiss and by nothing else. A release notice is judged by the strings its own
two-column layout draws, not by the generic title and body it never shows. Copy
written out in words rather than keyed is left alone, and one resolved string is
enough to show a notice: a notice hidden by mistake is the worse failure.
The credit was a round (i) under the locate button, and on a full-bleed phone map a
lone control there is the first thing the eye lands on. It is off on the phone now:
one rule in mobile.css against both engines' containers, so Leaflet, MapLibre and
Mapbox cannot drift apart. The desktop map is untouched and still shows the full
credit with its links. Owner's call, made knowingly.
With the credit gone the band the round controls floated on had nothing under it to
clear, so the 38px credit row comes out of --bottom-nav-h and out of the camera
padding the offered roads are framed with. The controls now stand on the map floor.
The night pause pills go with it. On the desktop one is a drag handle with a hint;
a phone has neither hover to explain it nor room to drag, and the markers come from
the whole drive rather than the stage on screen, so day 1 drew day 2's pill as well.
Both via lists go through the same filter, because the plan tab carries them too.
A final pass over the feedback commits found two gaps in the phone map.
The planner keeps a point it was asked to show until its next routing
round, and once the fuel offers or the other roads for a leg close it hands
that very array back. MMapArea read it as a new focus, so closing either one
flew the camera to the stop "Show on map" had pointed at, on another day's
stage too. holdFocus now remembers the arrays it already held and only
takes a new one as a focus, with the open picker's own roads as the one
exception.
The all days view still took its pins from the planner's planned list,
which on a phone leaves out booked nights and hidden service stops, so the
drive ended at a hotel with no pin to open it from. It now reads the places
of the stored visits, like the stage does.
The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.
The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.
The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
The credit button sat in the middle of the round controls band, beside the
locate button. On the trip map it now has the bottom right corner to
itself, a gap above the dock (or above the stage bar on the road trip
tab), and the band floats one credit row higher, so the switcher, the
compass and the locate button stay on one line.
MMapArea marks its layer as a credit corner and defines the floor the map
ends at; mobile.css places both GL engines' compact control there, keeps
an opened GL credit's button in the corner, moves the Mapbox wordmark into
the same row instead of under the compass, and unfolds the Leaflet credit
to the left of its button. MapAttributionToggle reads the same two
variables with today's values as the fallback, so the other phone maps and
the desktop keep their placement.
The stage bar over the road trip map joined its figures with middle dots
and led with a bare day colour dot. It now leads with a picture of the
place it names (PlaceAvatar, falling back to the category icon), ringed in
the day colour while day colours are on, and shows the stop count, the
arrival and the distance as badges. The header pill over the tab does the
same with the day and the distance, and leaves out a distance nothing has
measured yet instead of printing 0 m. Both bars carry an accessible name
built from their parts, because pills side by side lose the spaces between
them when a screen reader reads them out.
The badges are a new non interactive MBadge, a span, since every place it
sits in is itself a button. The value rules shared by both bars live in
stageBadges.ts.
The head card at the top of the chain is more compact: the drive and the
stop count ride on the date line as badges, the two clocks are a size
smaller and the separate stats band is gone. The day colour dot is gone
from it: on the list half the line it keys lies hidden under the chain, so
it read as an unexplained status light.
The bar over the road trip map names a place, but a tap on it switched to
the whole day's chain and the place never opened. It now opens that stop's
sheet, the one its row in the chain opens. Its name, clock and tap come off
one row (stageEnd), so the bar no longer pairs the destination with the
automatic day end's clock (Kyoto Station 22:00 while the head card said
12:40). The header's list switch stays the way back to the chain.
A pin on the stage went through the plan tab's marker click, which moves
the place selection and brought up the plan tab's place card. On the road
trip tab a pin now opens its stop on the stage card, or the first routed
visit in the all days view, and only a place no routed day stops at still
gets the place card. The handler keeps one identity for the life of the
map. "Show on map" in the stop sheet and the up next card move the camera
through focusRoadtripPoint instead of the selection, picking the stop's
card first when it is not on screen. A pending focus now lets go once the
day moves, so later stages frame their own drive again.
Stage pins come from the trip store instead of the plan tab's map list.
That list carries the plan tab's day declutter and place filters, so after
the all days switch and back most pins of the next stage were missing, and
the unplanned filter or hidden service stops removed stops the chain draws.
The stop sheet had no way into the place, so a fixed time could not be set
from the road. A pencil in the header now hands over to the place editor
the plan tab uses, with this visit in context, so Start (the time the chain
anchors on) can be set or cleared there. It shows with place_edit, once the
place row resolves from the whole trip store and the visit carries a server
id. No second writer for the time is added.
Opening the editor on a booked night or a service stop the day lists hide
used to miss the visit, prefill Start from the pool place and write that
over the pin on a plain save, dropping the note too. MPlaceEditSheet now
resolves the visit from the stored assignments.
The header row is centred, so a one line name no longer hangs from the top
of the 34px disc.
"Tank runs out here" on the phone was a pink card with a black pill. It now
has the desktop band's design sized for a thumb: the mascot on its
skateboard and not enjoying it, a small uppercase warning, and the reserve
lamp as a 44px button. While a search runs or its offers show, the lamp
steps aside for a close (nothing in the band could clear the offer pins
before), and an empty answer leaves a retry.
Which control shows, which offers and which empty sentence is decided in
refuelBandState in refuelSuggestion.ts, next to the fuel and battery words.
The desktop RefuelBand reads the same helper now, with no visual change, so
the two shells hold markup only.
The head card read LEAVE 11:30 over a chain whose first stop is pinned at
10:00 with an hour and a half there. The figure was the departure from the
first stop, a clock no row prints, while ARRIVE was the last stop's arrival.
So the number was the wrong quantity and "Leave" the wrong word for it.
Both figures now come off the arrival column the rows print: START is the
first timed arrival (or the resume point on a morning after an automatic
night), ARRIVE is the last one. The rule lives in stageClocks in the shared
row model and reaches the tab as rt.clocks. The label is a new key,
mobileTrip.rtStart, because roadtrip.stay.leave keeps meaning a stop's own
departure in the stop and stay sheets. Both clocks carry dir=ltr like the
rows.
Two faults behind one round button on the phone map.
The base layer switcher that both engines draw in the bottom left corner
sat on top of the GL compass, which started 8px to its left and ran on
under the frosted shell, so it read as a second button showing through.
The compass now stands beside the switcher, placed off the switcher's own
exported inset and size so the two cannot drift apart again.
Satellite never drew on GL because applySatellite gave up whenever
isStyleLoaded() was false and only retried on styledata. That flag is false
while any source has tiles or a setData in flight, which on a phone is the
usual state, and styledata does not fire when those finish. On the road
trip stage every tap met a busy style, because the settings write
re-rendered the shell and handed the map fresh stage arrays. The guard is
gone (the pass only runs after load, and addSource/addLayer only refuse a
style document that is not in yet), an existing layer just flips its
visibility, and a source left without its layer still gets the layer.
The stage data in MMapArea is memoised, so a store write no longer refits
the camera or sets the route, access and place sources again.
A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.
The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.
The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.
The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.
Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
An unreleased migration has to survive being replayed on a schema where it
already applied — installs skip releases, and a test rewinds a fully migrated
database to the last released version to prove it. The bare ADD COLUMN threw
"duplicate column name" on that replay, which took every migration test down
with it.
Guard the ALTER on pragma_table_info, and add `source_assignment_id IS NULL`
to the backfill so a replay cannot re-resolve a row that has since followed
its stop to another day.
The trips e2e harness hand-builds a minimal journey_entries table rather than
running migrations, so it needs the column spelled out too.
The mobile shell printed place descriptions and notes as plain text while
the desktop rows and inspector run the same field through react-markdown,
so a description written with the formatting bar read as `# headings` and
`**asterisks**` on the phone.
Put the renderer in one shared component instead of copying it next to
each mobile twin, with a clamped variant for list rows that keeps them one
line high the way the desktop sidebar does. A clamped caption sits inside
the tappable row, so its links keep their text and lose the anchor.
Fixes#2337
Follow-up to #2379.
NOMINATIM_URL arrives from a compose file or a ConfigMap with whatever
whitespace was typed around it. The schema validates the trimmed string and
calls a blank one unset, the derivation did neither, so " " passed startup as
"use the default" and then became the base URL that cannot be fetched, and a
padded value lost every geocoding call the same way. It trims now, which is
also what the places client next door does with its own override.
The chart could not pass the variable at all: the ConfigMap is an allowlist and
an unknown key is dropped without a word, which is exactly the locked-down
cluster the override exists for.
The docs said what to set and not what it costs. They now say that it replaces
the public service with no fallback, that the endpoint has to speak the
Nominatim API, and that the one-per-second spacing stays either way, so nobody
stands up their own instance expecting it to run faster.
Test IDs follow the file again.
The release modal takes the 4.3.0 design: the release and its headline cards
on the left, the note and the support links on the right, at the design's own
size. The cards carry small drawings of the features instead of an icon.
The 4.0.0 entry is retired. In its place sits one entry, release-notes, that
every big release rewrites and keeps. It recurs per version with no upper
bound, so every user sees it once on each update, whatever they did with it
before, and a reload of the same version keeps it closed. An integration test
holds that promise through the real entry and the real dismiss route.
All 23 locales carry the new copy and drop the 4.0.0 strings.
Only the js-yaml entry changes. The Dependabot commit was built on main and rewrote the lockfile with another npm, which dropped the peer flag from 23 rollup entries and carried two wiki commits that dev does not have yet.
iconSize is a PointExpression, which has no x on its tuple form, so the client typecheck refused the size assertion; it now compares against L.point. The test ids follow the FE-PAGE-SHARED-MAP scheme in the order they run, and the comments around the cluster group say in a line what they said in a paragraph.
FE-PAGE-SHARED-040 already names the trip-wide marker colour case in the same file, so the new case moves to 042, and the comment above the clip says what it needs to in two lines.
The Atlas tooltip took both dates from the start dates of every trip with a
place in the country. One trip showed the month it began twice, the month it
ended never appeared, and a trip only booked could become the last visit of a
country already visited.
Both now come from the trips that give the country its status, places and
bookings alike: the start of the earliest and the end of the latest, with a
trip still under way ending today. The optimistic mark on the client clears the
dates the same way the server does.
The second label rendered the raw key atlas.lastVisitLabel since #1908 removed
it from the locales; the strings are back as they were. The month is built
from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and
showed the month before anywhere west of Greenwich.
Closes#1535
The sync engine keyed a skeleton by source_place_id alone, so a stop standing
on two days — the city you land in at dusk and walk through the next morning —
got one entry on the first of them and nothing on the rest.
Key on the day assignment instead. The assignment row rather than the day,
because moving a stop updates day_assignments.day_id in place: keyed this way
an entry follows the move instead of reading as one stop leaving the plan and
another arriving. Existing rows are backfilled to the place's earliest
assignment, and reconciliation adopts an unlinked row rather than annotating
it out from under its author.
Fixes#2329
FE-COMP-INTEGRATIONS-004, -015 and -016 took the first <code> and the first
Copy button on the tab and expected the MCP endpoint. Since the API keys card
shows the /api/v1 address with a copy button of its own (#2371), and it sits
above the MCP section, those queries landed on the API card: the URL read
/api/v1 and the click copied it. The cases now go through the endpoint's own
label.
The Atlas country check timed out. It asked Dawarich for a year of visited
cities in one request, and Dawarich computes that over every point in the
window with nothing cached, so a dense recorder never got under the 15 s the
server allows. The year is now asked for a month at a time, one request after
another, and folded back together. The windows sit a second apart because
Dawarich reads the bounds as inclusive.
The recorded route only appeared after a reload. Every Dawarich call from the
browser carried the shared 8 s timeout, so the browser gave up while the server
carried on and cached the answer for the next page load. Calls that make the
server talk to Dawarich now wait two minutes, the Atlas four. On the GL
renderers the draw also gave up whenever isStyleLoaded() was false, which it is
while any tile is still loading, and only retried on style.load. It now waits
for idle. The hazard overlay had the same guard and gets the same wait. The
pill shows a ring while the route loads, so a slow first fetch no longer looks
like "done, nothing there".
A collapsed day now takes its recorded route off the map, following the same
folds the planner already applies to places, the road trip sidebar's included.
The corridor searches the whole line a card draws, and a card that opens
with a drive from the day before (a night carried over, or the joined road
on connected days) draws that drive ahead of its first stop. The insert
index still came from insertIndexForAlong, which never answers 0, so a
charger found on that stretch was added after the first stop and the day
drove there, turned back to the charger and came the same way again.
insertIndexFor now answers 0 for a hit before the first stop when the card
arrives from an earlier stop, and dropping a hit on the map asks it instead
of computing its own index, so a click and a drop cannot disagree.
With connected days the drive from one day's last stop to the next day's
first is drawn at the head of the arriving card, in the colour of the day
before. anchorFor only knew that stretch as a night spill, so a click on it
anchored to the arriving card's first stop: the via was stored on the leg
after that stop, and the next day drove out to the point, turned and came
back, while the stretch that was clicked kept its shape.
assembleRoadtrip now names the stop that joined drive leaves from, and
anchorFor treats it like the stop a night drive left from. Placing and
dragging both resolve to the previous day's last stop, which is the anchor
the seam request already reads its vias from.
The section stacked a lonely button, a centred line and a small print hint
on top of each other, and the create dialog used bare browser checkboxes
that ignore the accent. The dialog now runs on the shared Modal with
selectable scope tiles, keys render as cards with their scopes as chips,
deletion goes through ConfirmDialog, and the endpoint sits in its own row
with a copy button.
Pressing Enter in the name field with every scope switched off used to
create a key: the empty list was dropped from the request and the server
stored it as a key that reads everything. Creation is now refused until at
least one scope is picked.
A failed list load no longer reads as "no keys yet", the create button
stops at the server's limit of ten keys, a refused clipboard says so, and
SQLite timestamps are parsed so Safari does not show Invalid Date.
The dialog built for this last round was its own thing and showed it: the
suggestion list was barely legible, clipped at the bottom, and never more
than two rows tall. It also lacked most of what the ordinary add-place form
has had for years.
So it is gone, and "Add manually" opens that form instead, in a service-stop
mode. The category control becomes the kind of stop and the time spent
there, the costs section is dropped, and an "Add between" row says where on
the drive it goes. Everything else comes along rather than being rebuilt:
the typed-ahead search with its source marks, the coordinate fields for a
charger no index knows, address, website, phone, notes, times, files and the
duplicate check. With the mode absent the form is the same component it was,
which its own suites still pin.
Where the stop lands is worked out when it is saved rather than when the
form opens, because the coordinate only exists once a place has been chosen.
One function answers both what the select shows and where the stop goes, so
the two cannot disagree, and the write goes through the same assignment path
as every other insert, via re-anchoring included: a stop pushed into a day
shifts every via anchored at or after it, and without re-anchoring first the
drawn road runs forward, doubles back and runs out again.
The select recommends rather than merely offering. Every leg of every routed
day is measured against the place separately and they are listed nearest
first, each with its own distance, so the preselected one visibly is the
closest and overruling it is an informed choice. The word they all shared
moved into the caption.
A stop with no position at all is not filed on a drive by guesswork. It
keeps its kind and goes to the trip's places, and the form says so and says
how to change it.
The case asserts no availability counter is shown for an ambiguous station, and
looked for one with an unanchored /\d+\/\d+/. The attribution line beside it ends
in the source's updatedAt run through toLocaleString(), and tests/setup.ts only
forces en-US on toLocaleDateString, so that timestamp reads "14.9.2026" on a
German machine and "9/14/2026" on the CI runner. The regex matched the second
one. Green where it was written, red on Linux, and red for a reason that has
nothing to do with charging.
Anchored at the start instead. Both places that print a count begin with it, so
the case still says what it meant to say.
"Add manually" did not fit beside "Search" and rendered as "Add manu…",
which is neither the label nor a shape anybody recognises.
The button face now carries one short word, with the full sentence kept as
its accessible name and its tooltip, so nothing is lost to a reader or a
pointer. Every locale got its own short form rather than a clipped version
of the long one: Selbst, A mano, Ručně, Elle, 手動.
Below two hundred pixels the row drops both words instead of shortening
them, and the two actions are their icons. The width is the row's own,
measured, not the window's: this column is resizable, so it can be narrow on
a wide screen and a media query would answer the wrong question.
The search button also stops restating the progress. Its label used to grow
into "Searching 3 of 12" while a run was on, which is the longest string in
the panel and the very sentence the progress row directly underneath already
prints next to the share as a figure. It keeps its word, disabled, with the
spinner in front of it.
Three panel changes and one new MCP tool that the pages did not describe
yet. Also records the two rules the code enforces and a reader would
otherwise have to discover: the charging panel is fetched once per opened
dialog rather than per row of a result list, and the lookup tool carries no
power rating or socket count because those are OpenStreetMap tag data that
travels with the corridor result instead.
The wiki syncs on a push to main, so this rides along until the next
release rather than appearing now.
Three things the first CI run found, all of them in the new tests rather
than in what they cover.
The dialog's search effect depended on the location hint object. Its own
hook memoises, so nothing looped in the app, but the effect sets state on a
query too short to ask about, and a caller whose hook hands back a fresh
literal per render turns that pair into a render loop that never yields. The
suite mocked it exactly that way, and the worker hung before a single test
reported, which is what took the client coverage job past half an hour. The
hint is three numbers, so the effect now turns on those and reads the value
out of a ref when the request goes out. The mock hands back one object, the
way the real hook does.
The suite also installed fake timers in a beforeEach, which deadlocks
against React's scheduler and Testing Library's own waiting. Every wait is
a waitFor now, and the two cases that have to prove something did not
happen sit out a real pause. The debounce boundary is no longer asserted
against a wall clock, because a busy runner stretches any such window.
The charging fixtures are pinned to a fixed instant and the service reads
the real clock against a twenty-minute freshness window, so every answer
through it was stale by construction and available came back null. Date.now
is pinned for those tests, never the timers, since the requests carry an
AbortSignal.timeout that has to keep running.
And the unidentified-station test counted its two "no reliable data" lines
before the answer landed: the price line already says it while the request
is out, so the count settled on one and passed for the wrong reason.
A tester found the charging panel once a station was on the trip and asked
for it while still deciding. The availability and the published tariffs were
reachable only for a saved place: the route takes a places id, and a hit
found along the route has no row yet.
ChargingService.load already needed nothing but a coordinate and a name, so
it now has a public method that says so, behind a trip-scoped route with the
same guard stack, a Zod contract in shared, and the parallel MCP tool in this
same change. Same service, same caches, so a station looked at in the dialog
and then added keys the entry already fetched. Trip-scoped even though the
trip is not part of the question: without :tripId the trip access guard goes
too, and what is left is an open proxy in front of somebody else's public API.
It renders in the add dialog, and only while the selected kind is charging,
so a fuel or food stop costs nothing and one opened dialog is one lookup.
Deliberately not per result row: the corridor search publishes box by box,
every mounted panel owns a refresh timer, a cache miss can cost seven
upstream requests, and a station in a dense charging park is the case the
upstream matcher refuses as ambiguous.
Power and socket counts are not in this answer and were not asked of it.
They are OSM tag data and already ride along on the corridor row for free.
Four things testers asked for in the "Along the route" panel.
Add manually. The corridor search reads OpenStreetMap, and a good share of
the chargers standing at a motorway junction are not in it, Tesla
Superchargers among them. The way round it was to leave road trip mode, add
the place under Days, drag it onto the right day, come back and mark it a
charging stop. The Search button now shares its row with a second one that
asks the two questions those four steps answered: which place, and where on
the drive. The place comes from a debounced typeahead over the place search;
the position from anchorFor, the same projection a via is placed with, and
it is offered as a dropdown of every leg of every routed day so it can be
overridden. Unlike a via, a place far from the drawn line is not refused,
only flagged: that place is the reason this dialog exists. From there it
hands over to the stop popup a corridor hit already goes through, so the
kind and the dwell are decided once for both paths and the write stays
saveStopDraft.
Clear results. A search that turned up the wrong thing left its hits in the
list and its pins on the map, and the only way out was switching to the day
plan and back. The search hook has exposed a clear() since it was written
and nothing ever called it. A control in the results header does now, and
takes the four narrowing states with it, because they were set for a
question that no longer has an answer.
Charging as the default for an electric car. "Looking for" opened on Fuel
whatever the vehicle. It opens on Charging when the trip's vehicle is
electric, and is unchanged for combustion, for an empty value and for a
vehicle nobody set, so nothing moves for anybody who never opened the
dialog. Seeded once, never over a category the traveller picked or a search
in flight.
Clicking a result moves the map to it. The row had no click handler at all;
only the trailing Add button did anything. The badge and the text are one
activatable target with that button as its sibling, which keeps the li a
list item and avoids a button inside a button, and it goes through the same
focusRoadtripPoint the rail uses, which closes the refuel and alternatives
panels first because those outrank the camera.
The ways of driving one leg come from OSRM, except the toll-free or
motorway-free offer beside them: the shipped OSRM hosts are built without
excludable road classes and answer exclude= with HTTP 400, so that one is
asked of the avoidance router instead.
Their figures were then read against each other. The quickest was elected
across both, which is what decides the road drawn as the one being driven,
and every other road was labelled with how far behind that election it sat.
The two engines agree on the road to a tenth of a percent and not at all on
how fast it may be driven: over twelve European legs the per-leg time ran
from 14.7 % under OSRM's to 13.2 % over, with the sign following the region.
On a Spanish motorway leg that gap alone is enough to crown a toll-free
detour and call the motorway somebody is on the slow way round.
An offer now carries the engine that timed it. It takes no part in electing
the quickest, no difference is printed across the two, and the bar says where
the figure came from. Each route still reports its own time; only the
subtraction is refused. An instance running its own OSRM through the MLD
pipeline answers exclude itself, so there both figures are one engine's and
the comparison works exactly as before.
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.
What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
remove and removeMany report what the nights they cancelled took down with
them, so the stubs in the controller and plugin-RPC suites report it too. Plus
a case for the announcement itself, which had none: place:deleted says nothing
about a partner booking or an expense linked by reservation_id.
noUncheckedIndexedAccess makes every index access optional, DriveLimits wants
its two minute ceilings named even when they are null, and the distance unit is
metric/imperial rather than a unit label.
FE-PAGE-JOURNEYDETAIL-105 still looked for accept="image/*" on the editor's
file input after it learned to take video (#2341), which is the one red test on
dev. The camera input beside it stays image-only and the test says why.
deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.
The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
Choosing another road on a leg that already carried a via appended the new
point behind the old one, so the day drove south to the first and north to the
second: a zigzag matching neither the line drawn under the button nor the
distance printed on it. The alternatives are computed for the two bare
endpoints, so the write has to replace the leg, which is what the direct branch
beside it already did.
A day's vias were only loaded while road-trip mode was being looked at, and the
three handlers that re-anchor them after a stop is added, removed or reordered
are reachable from the place inspector in both modes. With the switch off they
computed their correction from an empty list and wrote nothing, so a deleted
stop left the detour on a leg nobody chose, for the traveller and for every
collaborator. They are loaded whenever the addon is on now.
Seam routing paced itself by sleeping after each request, and storing a result
shortened the key its own effect depends on: React tore the effect down
mid-sleep, the cleanup aborted it, and the next request went out immediately.
Thirteen seams left at round-trip speed, the shared routing hosts refused the
tail, and a refused seam changes no state so nothing ever retried it. The wait
now happens before the request, against a clock that outlives the effect.
The "avoidance not honoured" badge could never appear on the rail: the field
behind it was published every round and never written. Only the second engine
reports what it achieved against what was asked, and the rail plans in the
browser, so it never saw the server's own copy — a motorway crossing read as a
motorway-free drive.
Two smaller ones. `trip.toast.loadError` has been called since the first commit
and exists in no locale, so both call sites printed the key itself; it is
written now in all 23. And the phone shell honoured the road-trip session flag
while having no rail, no drive lines and no switch to leave it, so narrowing a
desktop window took the trip overview away and listed every booked night twice.
A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.
Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.
Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.
The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.
The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.
import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.
update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.
The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.
A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.
An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.
Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.
Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.
Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
Places, notes and reservations hang off days, so asking for one of them implies
the day container. That implication ran ahead of the grant: a key minted with
scopes ['trips','places'] and deliberately no 'days' still received every day's
title and the day's own free-text notes, either by naming places or by naming
nothing at all, since an absent include narrows to the granted sections and
lands back in the same branch. Asking for days by name was a 403 the whole
time, so the two routes disagreed about the same fields.
The grant now reaches the service alongside the narrowed include. The container
is still implied, because the places inside it are useless without their date,
but the day's title and notes only go out to a key that was granted days. A key
with full access is unaffected: it is granted days whether or not it asks.
Correcting a booking's date deleted the day stop it had mirrored and inserted
a fresh one. A day stop is not just a day and a place: its participants and any
road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its
hour and its end-of-day flag live in its own columns. All of it went, silently,
for an edit that only meant to move the night one day on. The row is carried
across now, seated by its check-in the same way an insert would be, and the
delete-and-insert is kept only for the case where the target day already holds
that place under a stop of the traveller's own.
Three more holes in the same mirror, found alongside it:
Dropping a night while keeping the place cleared accommodation_id server side
and told nobody. Days hides a stop that carries a booking id, so on every other
screen the place stayed invisible on a day it was standing on.
A night is seated by its check-in, which renumbers the stops around it, and
nothing said so. The day that changed now sends its order along.
Deleting a place cancelled the nights booked there, which takes each night's
partner booking and that booking's expense with it, and announced neither. The
Bookings list kept the reservation and the Costs total kept the money until a
reload, and an expense linked by reservation_id is not one the place-linked
sweep finds.
Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening
a trip past a booking's last night cascaded the booking away and left the stop
pointing at nothing, hidden from Days for good while the route still drove to
it. Migration 234 clears the ones already out there and adds a trigger, which
SQLite fires even when the booking went down with a cascade. And a duplicated
trip now carries the stop's booking id, the booking's check-in window and a
place's fill_percent, none of which the copy took along.
The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.
The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
Dawarich's `duration` is minutes. It writes the column as
`duration_minutes = ((ended_at - started_at) / 60).to_i` in
`app/services/visits/create.rb` and serialises it straight through; the merge
service rounds the same way. TREK read it as seconds and divided by sixty
again, so an eighteen-minute stop reached the suggestion panel as "0 min" and
an afternoon somewhere as "2 min".
Found by connecting a real Dawarich 1.14.5 and comparing: a visit spanning
11:00:00 to 11:18:45 reports `duration: 18`.
The span between the two timestamps is what the panel uses now. It agrees with
the arrival and departure shown beside it and it survives whatever unit a
future Dawarich picks; the reported figure only stands in when `ended_at` could
not be parsed, and is read as minutes there too.
Deleting a place cancels the nights booked at it, and that query hits
`day_accommodations`. This file builds its own minimal schema and did not have
the table, so every delete answered 500 and three cases failed. The assignments
e2e was given it for the same reason; this one was missed.
This is the failure that has been keeping Server Coverage red on dev.
The server works out the reason and names it: a rejected certificate, a name
that does not resolve, a timeout, or the SSRF guard refusing a private address
along with the environment variable that would permit it. All of that reaches
the client as `errorDetail`, and the panel dropped it, leaving one sentence that
reads the same whether the host is off or TREK refused on purpose.
A tester could only report that it did not work. The panel shows the reason
under the message now; the toast stays one line, and the vague codes stay vague
so an English sentence from Dawarich does not land on a German install.
The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.
So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.
The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.
Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
The allow-list learned them and the signature did not, so the only callers that
name them are the tests and only `typecheck:tests` saw it. `typecheck` covers
src alone, which is why this was green locally and red in CI.
Both are white at 28 percent, and they did not look it. The pills carried a
backdrop blur of their own, and a second backdrop-filter inside the panel's own
opens a new backdrop root, so the same colour composited darker one row down.
The panel already blurs everything behind it.
`common.errorOccurred` is in no locale. It sat in the reorder failure toast
already and I copied it into three more places, so a failed dismiss, restore or
reorder printed the key at the reader. They all say `common.errorTitle` now.
The suggestion mark on a phone card was the word spelled out, which took a third
of a 136px card and pushed the date off it; it is the same plus the desktop
suggestion card offers, and the card's italic dimmed title carries the rest.
Dismissing a suggestion from the entry sheet has its own full-width row above
the buttons rather than a two-line label wedged between Delete and Cancel.
And two cards that should have been one design: the dashboard's spotlight trip
wrote its facts in capitals on a panel of dark glass, the journey hero wrote
them in sentence case on a gradient with its badge loose in a corner. The
journey card is built the way the trip card is now, and the trip card's three
pills are set like the DAY x OF y badge beside them, a size down and on the
same ground.
Everything here came out of putting the first commit in front of somebody and
watching them use it.
**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.
**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.
**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.
**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.
Also here:
- Videos upload to an entry (issue #2341). The gallery has taken them since
#823 and an entry could not: everything went through the images-only filter
and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
same absence of transcoding, and the editor now previews a clip in a `<video>`
instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
a column of their own into the feed's padding and appear on hover, and the
day-coloured ring on the active card is a layer over it — an outline tight
enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
than the browser's.
roel-de-vries pitched seventeen changes to Journey. Four were already built or
rest on a misunderstanding, one cannot work as described, and three would take
away more than they give; the rest are here, plus two things the list did not
ask for.
**The entry you write on the move now has a name.** Quick capture hid the title
field outright and offered a note instead, so every entry caught on a phone
arrived nameless and a day's timeline read as a column of identical
placeholders. One line of input; it is the single change in this branch that
users will feel most.
**The map stops yanking.** Stepping through the timeline called flyTo with a
zoom floor of 12, so reading a journey from a country view snapped to street
level on the first scroll and stayed there — every marker filling the screen,
and no sense of where anything was. Both renderers pan now and leave the frame
the reader chose. The initial fitBounds still decides how close a journey opens.
**The phone timeline is photos.** A 64px thumbnail beside three lines of text
spent the card on a story nobody reads at that size and truncated the title
after a few characters. The picture is the card now, with the name and place on
it, the country's flag and the date in the corners, and the day carried as a
colour rather than as a number nobody was reading. Above the carousel, a
segment per day: it says how long the journey is, how far in you are, and takes
you to day nine without swiping past days three to eight.
Everything else, briefly:
- A plus in each day header, starting a draft on that day instead of on today.
- A journey opens on today when today is part of it, and at the beginning when
it is not — a journey that ended last spring is something you read.
- Suggestions can be waved away one at a time. The row survives so the trip sync
does not offer the same place again; journey settings brings them all back.
- Mood, weather and the pros/cons list can be switched off per journey. Nothing
written is erased — the form stops asking, the values stay.
- The weather fills itself in at the desk the way it already did on the phone.
The date picks the source, so writing up last Tuesday gets last Tuesday's.
- Enter opens the next pro or con, below the one you are in.
- The verdict moved behind the story's fold, and the fold itself came in from
nine lines to four.
- The external picker lost the count it was printing twice and some padding it
could not afford inside the editor.
- The phone can reorder entries within a day at last; the desktop arrows finally
have translated labels.
- Marker labels are off where a carousel already names the entry, and on where
they are the only name a marker has.
- Search over a journey's titles, stories, places and tags, accent-blind.
- An entry knows which country it happened in, resolved once from its
coordinates against the bundled boundaries.
Not built, and why: the photos-for-markers idea needs clustering first or thirty
markers become soup, and the active entry's photo is already on screen in the
card below. Removing mood, weather and the verdict outright would take them from
everyone who uses them, so they became switches. `capture` on the file picker
takes the photo library away on a phone, which is why the camera is a second
input; it is hidden on desktop now instead. The suggestion strip in the new-entry
flow would be a third surface over the same data — tapping a suggestion already
opens the editor filled in.
The one gap the list missed and this branch does not close: Journey is the only
domain with no offline path. That belongs in its own change.
The labels tried the cover photo and lost: a blue chip on a blue sky is simply not
there, whatever shadow it carries. They sit beside the rating now, both as badges
on one row — two answers about the place, side by side, each legible.
The note takes the full width on a ground of its own. Loose in the column it read
as another paragraph of the panel rather than as something somebody wrote about
this place.
Asked for in the feedback: a wall of round photos, and no way to tell which is
which. "Name and displaying any custom rating would be very beneficial to quickly
seeing what is around you that is of interest."
The collections map had hover switched off. The trip map has told you what a
marker is all along, so it is the same card, now shared instead of written a
third time — it already existed twice, once per renderer, and the two had drifted
in how they resolved the category icon.
The card gains a rating, and so does the marker itself: an amber disc on the
corner where a trip's day numbers sit. The two never collide, because a numbered
stop is one already planned into a day and this answers the question asked before
that. Nothing is drawn for an unrated place, which is most of them.
Also from this round: the place panel's labels move up beside the name, where a
label belongs — read three rows down they looked like a property of the rating
above them — and the rating gets a ground of its own so a line of stars stops
reading as another row of text.
Getting directions sat up by the address, in a pill of its own, while editing and
copying sat in the footer as square buttons. All three are things you do WITH the
place, so they belong together and should look like one set. Navigation moves
down beside them, and the footer takes the shape the navigation button already
had.
Book a night to check in at ten, pin a stop to three in the afternoon behind it,
and the rail said 13:46. True enough as arithmetic — with no hour of its own the
chain works the first stop backwards out of the first one that has one, so three
minus a fourteen minute drive minus an hour standing there — and nothing anybody
asked for.
A door that opens at ten, with nothing ahead of it deciding otherwise, IS the
hour: the day starts there and runs forwards. Reached later it stays what it was,
a floor, and a floor below the arrival changes nothing — which is what keeps a
hotel behind an afternoon stop at its real arrival instead of dragging it back to
its check-in.
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.
The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.
And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
The stop went to the end of the day, always, on the reasoning that a night ends
the day. It usually does — but a check-in at eleven says the traveller is at the
desk before whatever they pinned to the afternoon, and putting the stop behind it
built a drive that reaches the hotel at seventeen past five after a sixteen
hundred it was booked around. The order looked like the traveller's and was not;
it was this code's default.
A booking with a check-in now takes the place its hour asks for: ahead of the
first stop pinned later, and still last when nothing is. Editing the check-in
re-seats it, in both directions, read off the chain rather than recomputed —
the index a fresh insert would get is not the index a row already holds.
Stops without an hour of their own are passed over in either direction. Their
place in the chain came from the traveller, not from a clock, and a night has no
business pushing past one.
The container refused to boot: Nest will not export a provider that belongs to an
imported module, and the service moved into the domain module in the commit
before. Re-exporting that module gives every importer the same service it always
got, and the wiring test now asserts the shape that actually boots rather than
the one that only typechecks.
Neither tsc nor the unit tests could see this — the module metadata is only read
when a container is built. The e2e harness does build one, and that is what
catches it.
Opening a place sent it from the bottom left of the map to the top, which reads
as the control moving house rather than as room being made. Nothing was in its
way: the inspector is a card centred between the sidebars and at most 800 wide,
so the corner it was clearing is one the card never reaches.
Both renderers, since the switch is not supposed to move when an instance changes
its map.
A stay whose place was deleted kept its row with place_id NULL: still drawn in the
day header, still naming a hotel through its partner booking, and pointing
nowhere. Nothing could reach it any more and nothing cleaned it up.
It goes with the place now, through the accommodations domain rather than a
DELETE in the places path, because a stay takes its partner booking, that
booking's expense and the day stop it wrote with it — none of which is the places
domain's business to know. Both delete paths, single and bulk.
The service moves into an AccommodationsDomainModule, the assignments-domain
precedent: the service never touches PlacesService, only the MCP tool that writes
a place and a stay together does, so it can sit in a module PlacesModule is free
to import without closing a loop. AccommodationsModule keeps exporting it, so
every existing consumer is unchanged.
It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.
The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.
Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.
The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
With daily travel times set, a stay was spent only against the hours the window
is open. Twenty-four hours then took two of them to run down: a single night
ended on the day after next, and every stop behind it moved with it.
Night time is time. The hours between one window closing and the next opening now
count towards the stay, so a night that starts just after eight is over just after
eight the next morning and the day carries on from there. An eleven-hour visit
from eight is over at seven in the evening rather than saving its last hour for
tomorrow — the traveller is still there after the driving stops, they simply do
not drive on until the window opens again.
Three things the day boundary was swallowing, all from the same stay.
The stop after it stayed on yesterday. It is reached a full day later but at a
later reading on the clock, and the only rule watching for a new day was "the
clock went backwards" — which that never does. The schedule's own count knows,
as long as it is read as a difference: a day whose first arrivals came out
negative is lifted whole, and reading the lifted offsets raw once moved an early
stop to tomorrow while leaving the late one behind. It now crosses over properly,
marked as having set off the day before.
The morning after started at the exact minute the stay ended, as if the road to
the first stop took no time. The carried minute is a departure; the drive from
there is added to it.
And that drive was invisible. It was on the map and in the day's total, but the
rail jumped straight to a stop at 08:11 after a stay that ended at 08:00. It gets
the same band any other drive gets, above the stop it arrives at — except where a
stop crossed over, which already draws that road under its own block.
A check-out is the LATEST the room has to be handed back, never the earliest
anybody may drive on, so it says nothing the chain can use. It shaped four things
it had no business shaping: the departure the day was built from, the length the
rail printed for a booked night, a day-selector and a clock field in the stop
dialog, and the branch that sent a night's STAY badge to the booking form because
its length was not editable here. All four go. The booking keeps its check-out;
it is a booking detail and lives under Days, where it is entered.
What replaces it is the thing that was always the honest answer: how long you
stand there. That is the traveller's own statement rather than a deadline, and a
stay running past midnight now carries the clock into the morning after — a stop
pinned before it ends keeps its hour and collects the late finding, as it would
from any leg it cannot make. A stay spanning whole days walks the clock forward
over them. An ordinary evening hands on nothing: the night between two days is
not a wait somebody is serving out.
The stay dialog goes to a full day rather than four hours, since it is now the
only place a night is given its hours, and offers eight and twelve as presets.
Every stop answers the same question the same way.
Overlay panels lose the skeleton placeholder. It was drawn in the page flow, so
the first time each dialog was ever opened a pale block flashed under the planner
and never again once its chunk was cached.
This reverts commit d4677bdea, and keeps the departure chip's icon.
The premise was wrong. A check-out is the LATEST moment the room has to be
handed back, not the earliest one you may leave: whoever wants to set off at
seven checks out before that or leaves the bags at the desk. Reading it as a
floor turned a perfectly good plan into a finding, and no amount of wording
around that would have made it true.
The chip keeps the door-out mark. That part says what it always said, only in a
symbol that is not a second clock face beside the arrival.
Every day was scheduled from nothing, so a booking that runs into the next day
ended at midnight as far as the chain was concerned. Check in at 08:00 and out
at 08:00 the next day, and the rail was happy to put you somewhere else at 07:00
that morning, with no sign that the room was still yours.
The day a check-out falls in now starts at the check-out rather than at nothing.
A floor, never a move: a stop with a clock of its own keeps it and collects the
late finding any leg it cannot make in time would give it, so a plan that says
07:00 still says 07:00 and merely admits it does not add up. A stop with no clock
starts at the check-out instead of having no time at all.
Only that one day. A stay running Monday to Thursday leaves Tuesday and Wednesday
alone, because you sleep there, you do not sit there.
The departure chip takes the door-out mark instead of a clock face. Beside an
arrival time, a second clock read as a second arrival.
The rail prints an hour in ink when a person picked it and in grey when the chain
worked it out. Every scheduler anchors on `time ?? checkInTime`, so a booked night
starts its day on the check-in exactly the way a pinned stop does — but two of the
three then asked only about `time` when labelling the result, and printed that
chosen hour grey.
The answer lived in three places and two of them disagreed with the anchor they
had just used, so it is one function now. Nothing but the rail reads the flag.
Only visible since a booked night became a stop of its own; before that no stop
carried a check-in.
The filter sat on the planner hook's assignments, but the day sidebar is handed
storedAssignments straight from the store, so it never saw it. The hotel was
listed as an ordinary place right under the overnight block naming it, on every
day someone booked a night for.
Filtered in getDayAssignments now, next to the service-stop filter that is there
for the neighbouring reason. That is the list itself, so the reorder and undo
paths keep reading the full day and nothing shifts underneath them. The phone
was never affected: its timeline reads the hook's copy.
The row shapers name every column they pass on, and accommodation_id was in
none of them. So the field the day list hides on never reached the client, and
the booked night was listed as an ordinary place next to the overnight block
that already named it. The stop was correct in the table the whole time; the
three read paths just dropped it on the way out.
The stamp on the freshly written stop had the same hole from the other side: the
INSERT went in without the booking id and an UPDATE added it afterwards, so the
row handed back in the answer, the one the client that booked the night puts
straight into its store, carried none. It goes in with the INSERT now.
The shared link gains the same filter. It has no road trip view to need the stop
and already shows the booking as its own chip on the day, so the row was only
ever the same hotel twice.
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.
It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.
Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
Six lines and four functions the mirror brought in were never entered by a test,
which is what took the domain under its coverage floor.
The keepStop branch of a delete, the plugin surface's own update, and the
transaction that has to roll a place and its stay back together. Plus every
sender a surface hands announceMirror: REST and the three plugin methods build
one each, and a fixture that swallowed the call let all four go unexercised --
the one link between a stay write and the sockets everyone else is watching.
Three things the first pass got wrong.
A stop a booking puts on the day is for road trip mode, which needs the
hotel in the driving chain. The day planner already shows that booking as
its own overnight block, so the row there would be the same hotel twice.
It is hidden in Days whatever the service-stop switch says, and the switch
goes back to meaning what it says: the petrol stations the traveller added.
Cancelling a night from the road trip popup took the stop with it. That
control means "this is a pause now, not a night", and the stop sits
mid-drive where re-adding it would land it at the end of the day. The
booking now hands the stop over instead of deleting it.
Moving a booking whose stop belongs to the traveller put a second one on
the new day and left the first behind, which is the duplicate this change
exists to remove. Only a booking's own stop moves now. Stays booked before
this shipped get theirs from the migration, not from the next edit they
happen to receive.
The day's accommodation is drawn as a leg to the first and from the last
waypoint. Now that a booked night puts its hotel on the check-in day as a stop
of its own, that last waypoint IS the hotel on most days, and the leg drawn to
it is a round trip of zero kilometres: in the map line, in the sidebar's leg
list and in every exported directions link, which carried the hotel twice in a
row. The defect already existed for a night booked in the road trip; the change
above would have made it the normal case.
Both bookend rules now decline a leg whose edge stop sits at the hotel's own
coordinates, which covers the map, the sidebar, the phone route and both export
links from one place. Compared by coordinates rather than ids, because the
bookend comes off the stay row and the waypoint off the assignment.
The server announces the day stop over the socket like any other assignment,
but the socket deliberately skips the session that sent the request, so the one
person who could not see their new stop was the one who had just booked the
night. That looks exactly like the bug it fixes.
The stay writes hand their answer to one applier, which folds the stop into the
store through the same path the socket uses, so the IndexedDB write-through
happens either way and the dedupe by assignment id comes for free. Three
surfaces call it: the day-detail picker, its edit branch and the phone sheet.
The road-trip flow does not, because it writes its own assignment before the
stay and there is never a mirrored stop to apply.
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.
Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.
New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.
The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
Three cases fired their reads through `Promise.all`. The Nest app under
test is never told to listen, so supertest binds an ephemeral port on the
first request; four of those at once race four `listen(0)` calls on the
same server and the losers come back as ECONNRESET. It held here and
failed on a loaded runner. The reads run one after the other now, which
is all they ever needed.
What it reads, what it never writes, what happens to the recorded
positions, and how the connection, the suggestions, the Atlas marks and
the wish list behave from the reader's side.
The trip planner and the journal get a "From Dawarich" list: the stays
recorded for those dates, each one a row that can be dismissed or turned
into a place or an entry after a review step that shows time, name, note
and day. A place that came from a recording carries the Dawarich mark
next to its name so its origin stays visible afterwards.
The recorded route lies over the trip and journal maps as an overlay,
coloured per day and cut along the reader's own timezone rather than
UTC. It is fetched for the window on screen and never stored.
The Atlas gets its own panel beside the map for the countries and cities
the recordings prove, and the wish list is ticked off there, on the day
of the visit, undoably. The phone has its own take on all of it, in the
mobile shell's colours and with native sheets.
Three small things noticed while working nearby, none of them Dawarich:
The map's layer switcher showed the browser's own tooltip while every
control beside it showed TREK's, so hovering along the row produced two
different boxes in two different styles.
The journey entry editor mixed TREK's date picker with a native time
input, and the date, which is the longer of the two, had the narrower
column: "12. Sept. 2026" wrapped while the clock sat half empty. The row
is split in the date's favour now and the picker is disabled together
with the rest of the form instead of staying live behind it.
On the phone's journey, the dock's "+" now turns into an upload button
while the Gallery tab is open, which is what that tab's one big action
actually is.
A new `integrations/dawarich` domain behind its own addon. Each reader
connects their own instance; the API key is encrypted at rest and never
returned. Nothing is written back to Dawarich, and no raw position is
kept in TREK: the recorded route is fetched per request for the window
being looked at and thrown away again.
What it produces are suggestions, never facts. A recorded stay becomes a
proposed place or journal entry, a country or city becomes a proposed
Atlas mark, and a stay near a wish becomes a proposed tick-off; each one
waits for the reader to confirm it. Identity is (user, Dawarich visit),
so a second sync neither duplicates nor overwrites what was accepted,
and deleting the result puts the suggestion back on the list.
The sync runs as a job on the central scheduler, one user at a time,
guarded against overlapping runs.
A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.
Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
Zod schemas for the Dawarich integration: connection settings, the
suggestion list and its accept payloads, the recorded-track window and
the Atlas country/city proposals. Places gain an optional `source` so a
place that came out of a recording can say so.
The tab was built to its own pattern: a bold title where the other four
carry a small uppercase one, a counter instead of the add button, the add
form as a light block sitting on top of the list, and a grey line of text
where every other empty panel shows the mascot. Side by side with Notes it
read as a different app.
It now uses the poll panel's shell: the same header, the same pill button
top right, the same modal for the form, and the mascot holding a chain.
The load effect depended on the toast and translation objects, both of
which are new on every render, so the list refetched constantly and a link
that had just been added was dropped again by the response in flight. It
depends on the trip alone now, and reads the other two through refs.
The two decorative circles in the header are positioned outside it on purpose
(-40 to -60 on each axis) and nothing in the ancestor chain clips them, so the
first one widened the document by its own overhang: a 390px phone reported a
450px scrollWidth and the whole page could be dragged sideways (#2345).
overflow: hidden on the header is what the decoration always meant.
The share page drew every stop as its own pin while the planner drew the same
trip as a handful of clusters, so a shared link opened on three dozen
overlapping markers. On a 300px-tall map that covers a whole city, the pins
underneath can be neither read nor tapped (#2343).
The option block and the icon factory move to components/Map/markerCluster.ts,
which both maps import, so the two cannot drift apart again. The planner's
options are unchanged.
leaflet.markercluster's two stylesheets were imported by MapView.tsx alone.
The share page never mounts MapView, so it imports them itself: without them
the bubbles keep the library's default styling and spiderfy's legs are
invisible.
A bare amap.com/place page carries an id and nothing else, and turning that
id into a location would need a keyed detail lookup the URL resolver has no
user for. It answers 400, which AMAP-084 pins. The wiki offered the link as
paste-able and the parseAmapUrl comment still promised the lookup, so both
now say what the code does.
The provider header also claimed photoRefs/photoBytes were deliberately
left out, naming two methods the places-provider interface no longer has.
The reason is unchanged: an Amap picture comes without a licence statement
we could show, so an Amap place gets its picture from Wikimedia.
With Amap in the keyed slot every reverse lookup went to Amap first,
worldwide. Outside its box that buys a round trip and an empty answer
before Nominatim is asked anyway, which is latency on every right click in
Lisbon. The client side of the same question already filters by the box
(placeNavigation.ts); isOutsideChina is the shared helper both use.
The two fixtures in the file that were cast to any now carry a type: the
key row and the fetch spy whose calls the reverse case reads back.
The place search provider block now renders on a managed install too, and
there the key fields are empty by design: the operator owns the credentials
and user-profile never sends them. An operator key set through the
environment is invisible in the fields either way. The warning behind the
provider select read those fields, so it told every managed admin that
search "falls back to OpenStreetMap" while their key was answering.
app-config already carries the answer as has_maps_key, and it resolves the
same chain the search itself uses, environment included. has_amap_key sits
beside it, so both come into the store at boot and both follow a save.
The index and OpenStreetMap answer every search; the provider choice
only decides who fills the keyed slot next to them. Rows carry a source
mark, a place keeps its Amap id, and the journey map shares the shifted
projection.
The API keys card was hidden wholesale on a managed install, which took
the provider choice with it although the server leaves places_provider
to the admin there. The card stays; the keys, their Save button and the
Google switches fold away, on the phone shell now too. The mobile
warning uses the pending token instead of an undefined one.
The desktop tests find a key input through its labelled toggle rather
than by position, which is where the accessible name sits.
Without a Workbox rule for is.autonavi.com the offline pre-download
fetched the tiles and stored none of them, the hole #2180 closed for the
OpenStreetMap DE and Stadia presets.
The journey map read the same tile setting as the planner but built its
Leaflet map without the shifted CRS, so an Amap basemap put every entry a
few hundred metres off. A change of datum rebuilds the map, since Leaflet
cannot swap a CRS after construction; every other template change still
retiles in place.
An Amap row now carries the same source mark the TREK index, OpenStreetMap
and Google rows get, named per locale because 高德地图 is the name it goes
by where it matters. The "search is on OpenStreetMap" notices and the
key hint in the details column are gone with the keys dev had already
removed, and the client-side provider mirror with them.
amap_poi_id travels with a picked result into the form, the details
column and the shadow log, so a saved place keeps the id it was found
by. A pasted Amap link goes to the resolver like a Google one.
The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
The rebase kept maps.service.ts as dev has it: the TREK index and
OpenStreetMap answer every search first, and a keyed provider only fills
what they leave empty. Amap now sits in that slot next to Google instead
of behind an extracted Google provider, which is dropped.
places_provider (auto | google | amap | openstreetmap) decides which keyed
provider holds the slot; auto keeps whatever the install already used.
An amap: id opens against the Amap key regardless of the setting, takes
the plain details lookup on expand, never reaches Google's photo call,
and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding
asks Amap at the instance key before Nominatim.
The migration carries users.amap_api_key, which the rebase had left at a
stale array position, and places.amap_poi_id for the next commit.
The mobile Costs tab swallowed a failed settlement request, and the final
budget card then filled every traveler's row from the empty fallback: "0.00"
for everyone, which reads as "the trip cost nobody anything". The desktop
panel already keeps a settlementError flag and prints its notice instead of a
number; the mobile tab now does the same in the final budget card.
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.
The settlement response now carries the rows with the figure: `sources` on each
finalBudgets entry lists what the person fronted per expense, the recorded
transfers on their side and the open flows, in display cents. They are filled
by the same two loops that fill the totals, so an expense with no split members
stays out of them as it stays out of the ledger, and each list is spread over its
figure's already allocated cents with the same largest-remainder split, so the
sum is exact in every display currency. The shells print those cents and only
resolve names; the rows carry the sign of the line they sit under.
"Reimbursements received" was written from the receiver's chair: the traveler who sent a payment saw it as a plus under a label claiming they had received something. The line is net of both directions, so its label now says so in every locale.
The settle-up view says who owes whom, but nothing on the Costs screen
answers "what did the trip cost me": the balances move every time a
payment is recorded, and the tiles only describe the signed-in user.
The settlement response now carries finalBudgets, one row per participant
in the ledger: expenses (what they fronted on split expenses), reimbursed
(recorded transfers, received minus sent), pending (their current
balance) and final = expenses - reimbursed - pending.
- Computed in calculateSettlement from the same integer trip cents as the
balances, filled by the same loops, so the breakdown can never
contradict the balance printed next to it. Each component is converted
to the display currency as its own set and the final is subtracted in
display cents, so the three lines add up to the total in any currency.
- A "Final budget" card on desktop, the narrow desktop layout and the
mobile Costs tab: one amount per traveler, and a tap opens the three
lines plus the expenses, transfers and open flows behind them. The
filtering lives in CostsPanel.helpers so both shells share it.
- Contract in shared (budgetParticipantFinalSchema); the MCP
get_settlement_summary description mentions the new field it returns.
- Four new costs.* keys, translated in every locale.
The e2e settlement round trip now sends settled_at on create and update
and reads it back from the column, including a PUT that clears it to
NULL. The service test covers the clear branch for null and the empty
string the date picker sends.
Anchoring the flipped popup to the trigger's top edge dropped the old
vertical clamp: with a short viewport the popup's top edge left the screen
and the month header became unreachable. The popup now flips up only when
there is room above; otherwise it stays below and is clamped so its top
edge never leaves the viewport.
The mobile payment sheet always sent the display currency, while it seeded
the amount raw in the currency the payment was recorded in. Correcting only
the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX
rate for it. The sheet now carries its own currency, seeded from the
payment (or the display currency for a new one) with the same picker the
expense sheet has, and the payment row shows the recorded amount next to
the converted one when they differ, like the desktop row does.
The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
Editing/undoing a settle-up payment was desktop-only — the mobile ledger
row rendered it read-only, with a comment explaining the gap was
deliberate for now. Give it the same edit/undo affordance the expense row
already has: edit reopens AddPaymentSheet pre-filled (now doubling as an
edit form, same as desktop's SettlementModal), undo deletes it outright
with no confirmation, mirroring CostsPanel.tsx's undoSettlement.
CustomDatePicker flipped above a trigger by subtracting a fixed worst-case
height (360) from its top, regardless of the popup's real rendered height.
When that guess overshot — a short calendar view, or a form with little
room, like the settle-up modal — the gap left the popup floating away from
its trigger and over unrelated fields instead of sitting right above it
(#2334, found while testing the settle-up date field).
Anchor the flipped case from the trigger's own top edge via `bottom`
instead, the same technique useAnchoredPosition already uses for other
popovers, so the popup is always flush against its trigger no matter its
actual height.
The unit test mocking db.run for applySettlementUpdate asserted the exact
positional argument list, which the settled_at column added two more
positions to (its CASE-WHEN flag and value, mirroring currency/exchange_rate).
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
The one model the addon offers to pull was Qwen3 8B, which is text-only, so a
scanned page or a photographed document reached it as no text at all and came
back empty. Qwen3.5 4B is multimodal at a third of the size: one download
covers every kind of document instead of leaving the operator to work out that
the recommended model cannot read half of them.
The list stays one entry long. A self-hosted host needs a model, not a menu,
and any other id can still be typed in or picked from what Ollama already has.
Updated instructions to specify 'user' in settings path. Without it, minor confusion to be still operating in main 'Admin' settings where you enabled MCP integration
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.
What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.
What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.
A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.
An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
A public link showed a stop as its name and one line under it, the address
or the description, whichever came first. The owner had written both, and
a note on the place and another on the day, and had set how long they meant
to stay and how to reach it. All of that shows now, laid out the way the
planner lays it out, and every link leaves the page in a new tab with no
opener. A booking shows the note the owner attached and the page they
linked, labelled by where it goes.
What travels to the page is chosen column by column now, where it used to
be the whole row. The whole row of a booking carried its confirmation
number, its import trail and its raw metadata, and the metadata is where an
imported ticket keeps its seat and its record locator; the whole row of a
place carried the owner's booking notes on it and the Google identifiers.
None of that belongs to somebody holding a link. Metadata keeps the parts
that describe the journey, by an allow-list, so a field the import starts
writing tomorrow does not become public by appearing. Links are accepted
only as http or https, on the server and again on the page.
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.
The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
The date range on the photo picker had a calendar that moved one month per
click and no other way to move. A trip three years back was thirty-six
clicks away. The header is a button now, the way the app's other calendar
works: click the month to see the year's months, click the year to see a
page of twelve years, and the arrows step whichever level is showing. A
pick steps back down, and reopening starts on the days again. The same
component serves the entry editor, which keeps its trip-day dots.
A short transport hop is hidden once its two ends sit closer than a few
dozen pixels, so a booking that would draw as a dot does not clutter the
map. That check compared the endpoints, which was the whole line while
every hop was straight. A car booking is routed along the road now, and a
road that leaves a town, rounds a lake and comes back to the next town along
can run across half the screen while its ends project sixty pixels apart.
The whole drive vanished until the map was zoomed far enough in for the
ends to separate, which is the gap in the route the report shows.
The decision now walks the line that will actually be drawn, road route or
arcs, and keeps the hop as soon as that reaches the floor. A straight
two-point hop measures exactly what it measured before, so every existing
threshold holds for the case it was written for. One helper serves both
renderers, which also folds the two hand-copied floor tables into it.
The create_todo and update_todo tools said 1 was low and 3 was high. The
app, its sort and the wiki all say the opposite: 1 is P1, the red one at the
top of the list. An assistant that followed the description filed every
urgent task as the least urgent. The words now match the badges, and the
update tool, which said nothing at all, says the same and that null clears
it. No stored value changes.
Cases appended to four existing suites for the lines this branch left unrun: the
fold state and the map places it hides, the owner-based via anchor, the day seam
and the proportional geometry slicing, and the live roadtripVia:changed events.
With the four new suites in the commits before this one, coverage of the lines
this branch adds goes from 69 % to 96 %, measured the way the gate measures it —
the routed lines against the diff, not a file average.
Every added case was mutation-checked: the rule it claims to pin was broken in
the source and the case had to fail. Two of them found real defects rather than
missing coverage, and both are fixed here.
A via dropped on the incoming night drive went to the wrong leg. The guard for
it asked the insert index whether the point lay before the card's first stop,
and that index cannot answer: insertIndexForAlong clamps to at least 1 for any
card with two stops or more, and the rail publishes no other kind. So the branch
read correctly and never ran, and a via on the night stretch was filed after the
first drawn stop — the route ran forward, doubled back to the point and carried
on, which is exactly what the comment above it said must not happen. Asked of
the distance instead.
The two one-field writes the rail offers, the stop kind and the fill level, went
straight to the API while their neighbour checked place_edit first. The server
refused them, so nothing was ever saved, but a reader got an error toast for
touching a control that should not have acted.
TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.
It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.
A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.
Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.
search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
Two people shaping one drive were each editing their own copy: a via dragged on
one screen reached the other only on the next reload, and the route it changed
looked settled until then. Every write in the domain now announces itself over
the websocket the rest of the trip already uses — the five REST routes and the
matching MCP tools alike, so an assistant moving a stop is as visible as a
person doing it.
Two new trip-scoped events carry it: roadtripVia:changed with the day's whole
via list, and roadtripTrack:changed. The whole list rather than a delta, because
a via is identified by its position in a chain and a delta protocol would need
an ordering guarantee to be worth anything.
The originating socket is excluded through the X-Socket-Id header the other
domains already forward, so nobody watches their own change arrive twice.
A day that runs past midnight used to keep every one of its stops on its own
card, so day one ended at 03:12 with three places nobody reaches on day one and
day two began with a drive that had already happened. The rail now lays the
stops out by the date they are actually reached: when the clock goes backwards
between two arrivals the ones after it move to the following day, into a block
that says where they came from, and the night drive and its kilometres go with
them. It is an arrangement, not a write — the day planner beside it is
untouched, nothing reaches the database, and a shorter stay the evening before
puts everything back on its own.
The map follows the same arrangement, because a boundary drawn in the rail and
not on the map is two answers to one question. Day one now ends where its last
reachable stop is and the line, the legs and the totals are cut to match.
Two switches in the driving settings, off by default. "Connect the days" routes
the gap between one day's last stop and the next day's first, which is real
driving the rail has never drawn, and counts it. "A colour per day" then puts
the days back into a line that has become continuous, and tints each day header
to match.
Collapsing a day from its header takes its route and its places off the map as
well as out of the rail, and the categories in the corridor panel fold the same
way. The refuel band is one full-width red band with the pump as its button,
the results are chips, and the driving dialog is two columns of panels with the
figures on one right edge.
Also fixes a via that would not adjust the route once dragged. Five separate
causes, all measured rather than guessed: leg threading used the card index
instead of the owning day's, the anchor lookup returned chain indices, it
filtered by the card rather than by the anchor's owner, seams were routed
without their vias, and a seam once routed was never re-fetched.
A consumption typed as 500 rather than 5.0 makes the tank arithmetic round to a
range of zero. Zero is a number, so the `??` in effectiveRangeKm took it as the
answer and discarded the range the traveller had typed by hand next to it, and
useVehicleRange then folded that zero to null. What reached the rail was "no
range at all": every fuel and charge warning went quiet, with nothing on screen
naming the field that did it.
A range that rounds below one kilometre is a typo rather than a car, so it is
reported as no answer and the typed figure gets its turn.
The fill level was one figure for the whole trip, and a trip does not have one.
A motorway rapid charger is worth about 80 % because the last fifth costs as
long again; the charger at the hotel is worth all of it because the car stands
there all night. Between those two the difference is a whole leg, and a single
setting has to be wrong about one of them.
So the badge on a refuelling stop is now the way in: it shows what that stop
will actually fill to, its own figure or the traveller's, and opens a panel of
the six answers people actually give. Under them it says what the choice buys in
kilometres, because 80 % is an abstraction and "about 290 km from here" is the
thing being decided. Picking the figure a stop already has hands it back to the
setting, the same gesture the kind picker uses to un-pick a kind.
The value lives in places.fill_percent, beside stop_type, because it is a
property of the stop rather than of the person. Null means "follow the setting",
which is every stop that predates the column and every stop nobody has an
opinion about, so nothing changes for a trip that never touches it.
Two pieces of shared machinery came out of it rather than being copied. The
popover chrome — measure before showing, flip when the window is short, close on
an outside click, Escape or a scroll — is one component now, used by the kind
picker and this one. So is the assembly of the range from seven settings, which
the routes hook and the rail both need and which would have drifted the first
time a field was added.
A range could only be typed as one number, and that is the number fewest
travellers actually know. A tank size is on the filler flap, a consumption is in
the manual, a battery capacity and a kWh per hundred are on the data sheet. All
of them divide into the same figure, so the dialog now takes either and does the
division itself. What the parts say wins over what was typed, because it is the
more specific answer, and the range row goes read-only when they do so there is
never a second answer to the same question.
Of ABRP's list only battery wear is here. Plug type, reference speed, relative
speed and drive style shape a consumption PREDICTION; this addon does not
predict one, it divides a capacity by a consumption the traveller states, so
those fields would add typing without moving a kilometre. Wear is different in
kind: it shrinks the capacity itself, and on an older car by enough to decide
whether the last leg of a day arrives.
Storage stays metric throughout, the same rule the range itself follows. An
imperial traveller types gallons, miles per gallon and kWh per 100 miles and
gets them back unchanged; mpg is the inverse of litres per hundred rather than a
scale factor, which a straight multiplication would have stored as a car four
times as thirsty.
The gauge under the picker is the argument that these two ways of stating a
range are the same thing, so it answers while the figures are still being typed
rather than after the field is left. It draws the tank the day it left the
factory: the filled part is what a stop actually puts back in, the hatched tail
is what age has taken off, and both are things the bare number cannot say.
Also here, from the same round: a fill-up that stops short of full. Nobody
charges to 100 % on the road, and counting every stop as a full tank overstated
everything after it by that last fifth. Stops that refuel now carry a badge
saying how far they fill.
Four things that only showed up once it was on screen.
The range budget treated a petrol station and a charger as the same thing, so an electric
car pausing for coffee at a filling station had its battery refilled on paper: the
warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can
now be named, and then only what actually refills it counts — and the search asks for that
kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the
old behaviour, which is what everybody who never opens the dialog is on.
The band was its own shape in a rail made of drive bands. It is one now: the same rail,
the same height, the same type size, a warning tint instead of a ring, and the figures
where a drive band keeps them. The buttons are icons of the same 18px square the shuffle
mark uses, so the band stays one line beside its neighbours.
The old range warning stood down where the band shows. Both at once said the same thing
twice in the wrong order — the answer above the problem — because the warning marks the
stop somebody finds out at while the band sits on the leg the fuel ends on.
Two measured corrections to the search itself. The server turns a box into a circle of
half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35
and came back flagged as narrowed EVERY time, which read on screen as "we could not
check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before
the dry point rather than on it, because only road already driven is any use — centred on
the point, half the circle was spent on road nobody can reach.
An answer that finds nothing now leaves the button rather than only a close. The place
search is a shared public service that does time out, and "it did not answer" with no way
to ask again reads as broken rather than as busy.
The range budget has always been able to say "you have driven 245 km on this tank" and
never "and here is where to do something about it". The review put it plainly: the value
is in the suggestion, not in the warning.
The warning sits on the stop the car ARRIVES at, which is where somebody finds out. The
fuel runs out earlier, often most of a leg earlier, and a filling station offered at the
warning is one the car cannot reach. So the dry point is computed where it actually is,
a search runs around it on request, and what comes back is filtered to what can be
reached.
Three filters, each one there because its absence gives a wrong answer rather than an
untidy one. Only what lies before the tank empties, because a pump past it is on the far
side of an empty tank. The detour counts double, because 8 km off the road is 16 km of
driving. And a reserve is kept back, because the range figure is a round number from a
manual, not a measurement.
Sorted by the detour rather than by how far it gets. Measured on a real day between
Hamburg and Berlin: the station that got furthest was 9.2 km off the road for one
kilometre more of range, while one 0.9 km off sat almost as late.
Deduplicated by name and half a kilometre, which also came out of that measurement. OSM
maps a charging site as one node per socket: the first three offers were the same
"autostrom plus GmbH" three times over. The four nodes sat in two pairs 6 m and 7 m
apart with 450 m between the pairs, while genuinely different branches of one chain were
18 to 36 km apart — so anything from 500 m upwards separates the two cases.
An empty answer says which kind of empty it is. "Nothing on the reachable stretch" after
a request that failed or was cut short states something that was never checked, and the
server flags both cases itself. Offline it does not offer at all: the place write queues
offline but the day assignment does not, so accepting one would leave an orphan.
Nothing is searched until it is asked for, and one press is one request against a small
circle rather than the sixteen boxes a corridor run costs. Accepting hands the choice to
the same popup a corridor hit goes through, so the stop kind, the dwell time, the
position, the via re-anchoring and the re-route are the paths that already work.
The dry point counts DRIVING metres only. The day's drawn line concatenates every run,
ferries and walks included, while the budget skips those, so walking that line to the
same figure would overshoot by the length of any crossing and put the marker out at sea.
deriveDriveWarnings knew the distance and threw the position away. The warning is filed
against the stop the car ARRIVES at, which can be most of a leg past the point the fuel
actually ran out — fine for a warning, useless for a suggestion, because a filling
station offered at the warning is one the car cannot reach.
emptyAt is a fourth field on the return object rather than a field on each warning, for
two reasons. Five tests assert warnings with toEqual and an extra defined property fails
them all. And a dry point is once per TANK while a warning is once per stop: a long
stretch with nothing on it warns repeatedly on purpose, and one refuel offer per warning
would stack three identical offers for one fill-up down a single day.
drivenMeters counts driving legs only, which is the whole reason it is counted here
rather than derived later. The day's drawn line concatenates every run, ferries and walks
included, while the budget skips those — so walking that line to the same figure
overshoots by the length of any crossing the day contains and puts the marker out at sea.
pointAtMeters walks the driving-only line instead.
Extract the day route builder out of useRouteCalculation into a pure module and
aggregate it over the whole trip: one map with every day's route, each day in its
own colour, the mode per stage and the trip's total distance. Straight lines first,
roads as they answer; a refused leg keeps its line and adds no distance.
Give the floating map controls one shared drop shadow while adding the toggle, so
the new pill and the base-layer switcher stand off the tiles the way the locate
button always has. Document the control in wiki/Map-Features.md.
Addresses #1736
Dragging a via somewhere else on the map changed where the point IS and never where it
sits in the chain. The anchor that decides which two stops it is threaded between was
worked out once, when the point was dropped, and the drag sent only coordinates —
`roadtripViaUpdateRequestSchema` carried a lat and a lng and nothing else.
So a via pulled past the stop it used to precede kept claiming the earlier leg. The
router then drove out to it and turned back before carrying on, which on screen is not a
subtly wrong route: it is a spur that reads as the drag having done nothing at all, with
the original road still sitting there unchanged.
The anchor is not a property of the point but of where the point sits along the drive, so
it is recomputed on a drag the same way it is on a drop. The projection that did that was
inline in addRoadtripVia; it moves to a shared `anchorFor` and both callers use it.
A drag differs from a drop in two ways, both deliberate. It measures against its OWN day:
letting the nearest day win, which is right for a click on open map, would hand the via to
a neighbouring day whose road happens to pass closer and make it disappear from the day it
was dragged in. And it has no 2 km guard: pulling a via well off the current road is
exactly what the gesture is for, and the anchor only says which leg gets bent.
`after_order_index` is optional on the way in, so a drag inside one leg and an older
client still send just the coordinates, and the stored pin is then left alone rather
than cleared.
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
The half of the change that saves the pick from a text search for its own
label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there;
the client test writes the coordinates into its own mock and then asserts them.
Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the
fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎"
and taking whatever comes back first.
So the server test now asserts the coordinates on the wire for both indexes,
and the mobile sheet — which carries a copy of the same fallback and had no
test for it at all — gets one for the fallback and one for the source mark.
Seit die Tipp-Suche beide Schichten fragt, ist ihre Liste gemischt — die
Antwort nennt aber weiterhin EINE Quelle fuer die ganze Liste, und die heisst
'trek-places'. Der Client beschriftet damit jede Zeile, auch die aus
OpenStreetMap. Wer tippt, liest also acht Mal "TREK" und schliesst daraus, die
OSM-Schicht antworte gar nicht. Sie antwortet, sie steht nur unter falschem
Namen da. Auf der Suchstrecke daneben stimmt es laengst: dort traegt jeder Ort
sein eigenes `source`, und genau deshalb liest sich diese Liste richtig.
Also traegt es jetzt auch die Zeile. Das Feld ist optional, weil Google und der
Nominatim-Rueckfall je aus einer Quelle antworten; eine Zeile ohne eigene
Angabe faellt weiter auf den Namen der Liste zurueck.
Dazu die Koordinaten, die der Vorschlag ohnehin schon kennt. Bisher wurden sie
weggeworfen, und wenn der Details-Aufruf nicht antwortete — Kill-Switch, ein
ueberlasteter Overpass-Spiegel, irgendein Fehler oben — baute der Client eine
Textsuche aus Name und zweiter Zeile. Bei einer OSM-Zeile ist die zweite Zeile
aber keine Adresse, sondern der ortsuebliche Name: gesucht wurde dann nach
"Tokio Hauptbahnhof, 東京駅丸の内駅舎", und dessen erstes Ergebnis wurde
kommentarlos als der Ort genommen, den der Nutzer angeklickt hatte. Jetzt
nimmt der Rueckfall den Punkt, den die Zeile mitbringt.
Und das Abzeichen gibt es endlich auch mobil. Die Marke und ihre Tabelle
liegen dafuer in client/src/utils/placeSource.ts, weil beide Oberflaechen
dieselben Listen zeigen und ein Zeichen, das nur auf einer davon steht,
schlimmer ist als keines.
Was hier NICHT drin ist, obwohl es dazugehoert: dass der richtige Bahnhof in
der Liste steht. Das liegt am Dienst, nicht an TREK, und ist dort gemessen und
behoben — Rangfolge nach Art des Ortes plus die Verkehrsknoten, die der
OSM-Schicht ganz fehlten.
The satellite toggle has worked on Leaflet for as long as it has existed and did
nothing at all on a GL map, which is the default renderer since the vector basemap
landed. Not for want of the layer: connect-src never named server.arcgisonline.com.
Leaflet asks for a tile as an <img>, and img-src waves every https: host through,
so the gap could sit there unnoticed for as long as Leaflet was the only renderer
that showed the imagery. A GL map reads the raster through fetch to hand it to
WebGL, and that lands on connect-src instead, where the host is missing. The
browser refuses it in the document, which is the worst shape a failure can take
here: the switch flips, the layer goes on, the source is there, and no tile ever
arrives. Nothing in the app can see it either, because a CSP refusal is not an
error the fetch can catch.
The prefetcher asks for the same tiles the same way, with mode 'no-cors', which
relaxes CORS and not CSP, so the imagery was never cached for offline use either.
Same shape as #2180, which added the other two raster presets for exactly this
reason and did not reach this host.
Typing "Tokio station" from Tokyo suggested a weigh station in Ritzville and a
station in Tuxtla Gutiérrez, and typing "Tokyo Station" put Japan in seventh
place behind Markham, Arlington, Calgary, Milano and Melbourne. The station
itself was never in the list.
The service has had the answer all along. Its OpenStreetMap layer carries 14.6M
travel places with every name form each is known under, including the exonyms a
traveller actually types, and it is not the same data as the index: Overture
holds businesses, so a search for a station finds the shops inside it. The layer
returns "Tokio Hauptbahnhof" for the German spelling and 東京駅 for the Japanese.
It was never asked. `sources` defaults to the index alone and trekPlacesSearch
never sent the parameter, so no caller here has ever seen a layer row.
Sent on the autocomplete path only. The explicit search asks Nominatim in
parallel and would get the same OpenStreetMap places twice; this path asks
nobody else, because Nominatim's usage policy names autocomplete as
unacceptable use. So the layer is not a second opinion here, it is the only
place those names live.
A layer row has a different shape, and that shape is why this is more than one
parameter. It has no `gers` and no address block, and its id reads
`osm:node/9712313` where this file resolves `node:9712313`. Passing it through
untranslated would hand the client an id getPlaceDetails does not know, and the
failure would land after the user had already picked the suggestion. The
secondary line falls back to the local name, which is what is written on the
building and more use under a translated label than an empty line.
Measured against the service before the change, same query, same coordinates:
"Tokio station" index only -> Tokio Weigh Station, Ritzville US
with layer -> Tokio Hauptbahnhof in second place
"東京駅" index only -> six restaurants inside the station
with layer -> 東京駅 itself in the list
The imagery toggle has been there for a while and only ever worked on Leaflet, so
anyone on MapLibre or Mapbox — which is the default since the vector basemap landed —
had a switch they could not see and a view they could not reach.
Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a
style with dozens of layers in it. So the same tokenless ESRI source goes on as a
raster layer of its own, inserted before the first layer TREK draws, which leaves the
route, the pins and the tracks on top and legible. Switching back hides it rather than
tearing it down, so going to and fro does not re-fetch a tile.
Built on demand rather than once at load, because a basemap change rebuilds the style
from scratch and takes every source with it; the styledata event is subscribed for the
same reason. The switch reads the setting the Leaflet map already stores, so the choice
survives an instance changing renderer, and it sits in the same corner with the same
offsets.
The map has narrowed to the selected day on the Planned filter since #2024. The pool
beside it did not, so a trip with 55 planned places read 55 in the tab and drew five
pins on the map, with nothing anywhere to say the two were answering different
questions. Reported twice in the same thread as the map being broken: you pick a day,
open Places, tap Planned, see everything you ever planned, and the map shows a handful
of it.
The list and the tab count now use the same set the map does. Unplanned deliberately
stays trip-wide: a place assigned to some other day is planned, whichever day happens
to be open.
A note under the tabs says so and closes the day when dismissed. It sits there rather
than over the map because a chip on the canvas is out of reach on a phone, which is
where this was reported from.
Every route is calculated in the browser against the public FOSSGIS hosts, so the
server never sees a routing request and nobody has a number: not how many a real trip
costs, not how big they are, not how much already goes to a self-hosted engine.
Deciding whether TREK could run its own router without that number is guessing at the
bill, and the counters have to be collecting before the question comes up.
Counters, not a log. A row is a day, a profile, a request kind and an engine kind with
totals on it: how many requests, how many waypoints, roughly how far, how many were
refused. No query, no coordinate, no route, no user, no trip. Nothing leaves the
instance: its own database holds them and its own admin reads them at
GET /api/route-usage/summary.
The client tallies in memory and flushes in batches — on a timer, past a threshold and
when the page is hidden — because a counter that cost a request of its own would
measure the load by adding to it. Routing all funnels through one fetch in
RouteCalculator, so that is the only place instrumented.
On by default and switchable off with route_usage_enabled, unlike the shadow log next
door: that one stores what people typed, this one stores four integers about a day.
The category pill was switched off whenever road trip mode was on, which left the
corridor search as the only way to find anything. The two answer different questions
though — 'what is along this drive' against 'what is in view right now' — and looking
for a hotel at tonight's stop is exactly the second one. Furkot shows accommodation
at a stop for the same reason (disc #1797 thread).
Both searches now draw together. A place found by both appears once, as the corridor
knows it, because only that copy carries how far along the drive it sits, and the
merged list keeps its reference while nothing changes so the map does not rebuild
every pin on each render.
'Wähle aus, was du brauchst, und suche' is a word-for-word rendering of 'Pick what
you need and search' that no German speaker would write; 'Wieder ein Ziel' is not a
sentence a button can carry; and 'Alle Leistungen' reads as services rather than
charging power in a dropdown of kilowatts.
Three things the rail was quietly getting wrong at a day boundary.
A stop reached later than the time set on it showed a warning only when it carried
a number. The schedule restarts its chain at any anchor, whatever kind of stop holds
it, so the finding was computed for a fuel or charging halt too and then thrown away.
Both branches now draw it, from one component, and it sits in the badge row beside
the stay rather than on a line of its own.
The midnight crossing was a small pill floating in the middle of the rail, which read
as a badge belonging to the stop underneath. It is now a band across the full width
that breaks the dashed line instead of letting it run through, with the mascot asleep
in the gap. Both its animations stop under prefers-reduced-motion.
And the stay dialog said 'leave 01:00' for a departure the chain places on the next
day, because the clock wraps modulo 24 h. It now carries the same day marker the rail
has always shown beside such a time.
The schedule only ever walked forward, so a stop before the first pinned time stayed
blank. That is the wrong way round for how people plan: the museum opens at ten, so
when do we have to leave? Pinning the time on the second stop and reading the first
one's departure off the chain is the whole point of pinning it.
The chain now runs both ways — forward from the first known arrival as before,
backward for everything ahead of it, departure being the next arrival minus the
drive and arrival that minus the stay. It stops at a leg that never routed rather
than inventing a duration, and when working back crosses midnight the whole chain
shifts up a day so the earliest stop stays on day zero and the boundary is drawn
between the two stops instead of under the first one.
The desktop form has offered stops between pick-up and return since the road trip
release (#1797). The phone sheet never knew about them: its car branch rebuilt the
endpoint list from from/to alone, and because the server replaces every endpoint row
of a booking, saving there silently dropped whatever the desktop had set.
Its car branch now reads the stop endpoints back into an editor and writes them out
again, the same shape TransportModal uses: location, time, reorder arrows, remove.
Docker Scout turned the release PR red overnight on advisories published
after the last green run:
multer < 2.3.0 CVE-2026-82333, CVE-2026-77078, CVE-2026-77037
nodemailer < 9.1.0 GHSA-2x7j-588g-ccc2
Both are patch-level resource-exhaustion fixes with no API change — multer
2.3.0 carries the same four dependencies as 2.2.0. multer resolves through
the root override (@nestjs/platform-express asks for 2.1.1), so the range
there has to move with it, and the server's own override list beside it.
`ReturnType<typeof of>` resolves against the zero-argument overload, so the
stub only accepted `Observable<never>` and SPOOL-001 could not hand it the
`of('ok')` it needs. tsc -p tsconfig.tests.json broke on it, which sank the
server preflight job and with it every coverage job that needs it.
The chat images never reached a test: committing them to storage, taking them
back out when a later one fails, and dropping them again when the reply target
turns out to be gone. The interceptor that removes the spool when a request
dies before the handler had none either, and it sits in a domain pinned at 99%
function coverage. Both filters are pinned too, including the disguised
extensions that were the point of changing them.
The three collab:link events were broadcast and landed nowhere: the links
panel held its own list and never listened, so a link somebody else added
showed up on the next reload. It listens now, the same shape the notes panel
uses, and the events are classified in the client's WS policy, which is the
ratchet that caught this.
Four e2e harnesses build their own trip_files and one asserts the collab
feature set, so the new message_id column and the new links flag had to reach
them too. Two MCP unit tests pinned the old four-key feature object.
The URL check used `new URL(...)`, and this package compiles against lib
ES2022 alone so that it carries neither DOM nor Node globals, which is why the
shared typecheck refused it. `z.url()` plus a protocol test does the same job
with names the package actually has; z.url() on its own would take mailto: and
javascript:.
The three collab:link events also needed their fixtures and their place in the
pinned inventory count, the guard that stops the fixture map drifting from the
registry.
The desktop layout rendered notes and links as a pair and dropped whichever of
the two was on its own, so turning the new Links feature off on an otherwise
default install made the Notes panel disappear. Both rows are now split by kind
and rendered only when they hold something, and one function decides what a
panel id renders, because the branch that lost a panel did so by not listing it.
The message and its attachment rows are written in one transaction. The caller
has already committed the image bytes by then, so a message that landed without
them left those bytes with nothing pointing at them and nothing to sweep them.
Deleting one now drops the blobs after the rows are gone rather than before.
A body the Zod pipe rejects used to leave up to four spooled images on disk:
pipes run after interceptors, so multer had already written them and the
handler's own cleanup was never reached. An interceptor removes them on any
failure before the handler, which also covers every other schema mismatch, and
the length check that could never fire is gone.
Adding, pinning and deleting a link now says something when it fails instead of
raising an unhandled rejection, the list has real types, and the rejected-image
toast is translated in all 23 locales rather than shipping English everywhere.
The picking, previewing and clearing of chat images moved into one hook both
shells use: it was copied into two, and both rebuilt every preview on each
change and leaked an object URL per image per pick.
Tests: the four link routes end to end with the validation pipe the harness was
missing, the image filter against the disguised-extension case, and the layout
combinations that drop a panel.
The chat image filter believed the Content-Type the client put on the part.
The stored name keeps the extension of the name the client sent, and the
download route derives what it serves from that extension and sends it inline,
so pwn.html declared as image/png came back as HTML on our own origin. The
filter now checks the extension as well, against the blocked list and against
the types it claims to accept, because a per-route filter fully replaces the
module-level one it looked like it was adding to.
The multipart message route also kept its guard decorators. Guards run before
the interceptor, so a refusal went out while the client was still streaming up
to four images and the socket died as ECONNRESET rather than carrying the
error. The checks moved into the handler, where the other upload route in this
file already does them, and a test pins both refusals.
The three collab:link events were missing from the shared registry, which the
server typecheck refuses, and the admin Links switch could never be saved
because the Zod contract had no key for it. Both are in now. Migrations moved
to the end of the list and the column add uses the pragma guard rather than a
caught duplicate-column error.
The message-required string keeps its old wording for a request without a file
part, so a client from before this route took images sees what it always saw.
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
The User Defaults and Notifications tabs were one narrow column of cards on a
page that has no max width, so the right two thirds stayed empty while the page
scrolled for ages. Both now use the two-column grid the Settings tab already
has, grouped by subject rather than by height.
User Defaults splits into "Default User Settings" (colour mode, units, time
format, currency, booking codes) and a "Map" card holding template, keys,
routing base, preview and engine. The columns are deliberately uneven — the left
one never needs more than its widest option row, the right one holds four fields
whose values are 60-character tile URLs.
Notifications keeps the three channel switches together on the left, because they
all write the same notification_channels list, and puts the operator's own
targets on the right. The admin preference matrix moves in with them instead of
running full width underneath: it decides which of those channels each admin-only
event goes out over. Its label column gets minmax(0,1fr) so it wraps rather than
overflowing in the narrower column.
The transit provider was the one native <select> left on the Settings tab. The
option list of a native select is drawn by the OS, so it followed neither the
colour scheme nor the radius nor the user's text-size setting; it now uses the
CustomSelect the rest of the app uses, and skips the request when the option
already selected is picked again.
In the update banner, "View on GitHub" was a size smaller than "How to Update" on
every axis — 12px against 14px, a 14px icon against 16px, less padding, and 2px
shorter. Two buttons side by side read as one group, so that reads as a mistake
rather than as hierarchy. Same box now; the fill, the colour and the weight still
mark the primary action.
listFiles projects linked_budget_item_ids now, so the harness table it selects
from needs budget_item_id or every list call in that file answers 500. Same
gap the collab harness had.
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.
The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.
The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.
Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.
Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
A solid outline and a whisper of accent instead of the dashed grey placeholder
it replaced, so an empty day reads as an offer rather than as something
switched off, without competing with the day rows around it.
The satellite toggle sat on an offset that included --day-panel-h, so it hopped
every time the day panel was collapsed or expanded. On the desktop map that
panel is a centred card between the sidebars and never reaches the pill at the
left edge, so the lift only applies on a phone now.
In the places rail the two add buttons split the width evenly once a day is
open, and every button drops to its icon when the rail is dragged narrow. The
filter tabs span the rail and keep their own text widths. The count is a badge
across the rail; the search field got the outline the controls around it use.
An empty day offers a button that opens the place form pointed at that day
instead of only stating that nothing is planned. The day is cleared again when
the form is opened to edit, which it was not before.
The trip tab bar no longer carries a native title repeating its own label; the
full name moved to aria-label for the tabs whose label is abbreviated.
Three maps tests drove the index switch through app_settings, which no longer
holds it, and now move TREK_PLACES_ENABLED instead.
The places pool sits one click away from the plan, and a place you already know
belongs to today had to go through the day picker afterwards to get there. With
a day open, the add button splits: the left half still opens an empty form, the
right half opens one that assigns the place to that day on save.
The plumbing was already there. placeFormDayId has assigned a freshly created
place to a day since #1998; nothing in the places rail ever set it.
The main label shortens to "New place" while the split is out, so both buttons
fit side by side in the rail without either one truncating. The second button
stays mounted and collapsed rather than unmounted, so it has something to
animate out of: width, padding and opacity over 180ms, and nothing at all under
prefers-reduced-motion. Collapsed it is also out of the tab order and hidden
from screen readers, because a zero-width button is still focusable otherwise.
Both columns of the place form are 320px now. Two panels of different widths
flanking one form read as a mistake rather than as a hierarchy, and neither of
them is the more important one.
Five states, one shape. EmptyState grows a `fill` option: the stack already
centres what it holds, but with no height of its own there was nothing to centre
inside, so every one of them sat pinned under the header of its column.
Optically centred rather than geometrically. The extra room goes underneath,
which puts the mascot slightly above the middle: in a column you can scroll,
dead centre reads as having fallen to the bottom the moment the list is short.
The details column's own placeholder joins them, with the idle look rather than
the sad one, because nothing being picked yet is a state and not a fault. Its
line is shortened to fit on one: "Pick a result to see more", in all 23
languages.
The corridor panel, the saved-places list and the place details all dropped
`compact`, so the five now read at the same size as the road trip rail.
Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.
The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.
The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.
The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.
Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.
The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
its WebGL context inside the Map constructor. A browser that refuses one throws
from there, and VectorBasemap attaches the layer inside an async effect, so the
throw became an unhandled rejection no boundary could see.
What actually took the page down was the teardown. Leaflet registers a layer
before onAdd runs, so the half-built one stayed on the map, and the bridge's
onRemove calls this._glMap.remove() without asking whether there is one. That
TypeError lands in a React cleanup, which is why the error appears on the way
out of a map rather than on the way in, and why it reads as "Something went
wrong" when a tab changes and "TREK could not start" when the route does.
The browser is now asked first, once per page load, with a one-pixel probe that
hands the context straight back. Without WebGL the basemap is OSM raster tiles
instead of an empty map, and the maplibre chunk, about a megabyte, is never
fetched. The teardown path is safe either way: a half-built GL layer gets a
no-op stand-in so the bridge can finish its own cleanup, and detaching never
throws, because unmounting a map must not fail just because its basemap did.
Reproduced against demo.liketrek.com with WebGL switched off, and verified with
the fix: 14 raster tiles drawn, no GL canvas, no error screen.
Closes#2288
The picker got its photos back in whatever order the provider happened to
return them. It groups by day and appends pages lazily, so an unordered page
lands under the wrong heading and pushes photos out of the fold the reader
is looking at.
Immich now asks for order: desc on both the search and the album path. An
older server that does not know the property drops it silently rather than
failing, so each page is sorted again here; the album paths drain every page
before sorting and are covered outright. getAssetInfo reports mediaType, the
same mapping the listing already used, so the detail route and the picker
agree on what an asset is.
Synology gets no sort parameter upstream on either path. Search.Search
documents none, and _fetchSynologyJson maps every app code except 106/107/119
onto a 400, so a parameter one DSM build rejects would take listing down
entirely for that user. Both paths read every page anyway, so ordering the
mapped result costs nothing and cannot fail.
No media type for Synology yet, deliberately. streamSynologyAsset serves
every asset as a JPEG out of SYNO.Foto.Thumbnail, original included; there is
no video byte path. Labelling an asset a video would make the lightbox mount
a player on a still image, which is worse than the missing badge. The badge,
the byte path and the persisted media_type belong together in their own
change.
Five album cases in the Immich integration suite pinned the old arrival
order. Where the position was incidental they now look the asset up by id;
where the order is the point they state the chronological one.
ScrollTrigger kept onVisible in its effect dependencies, and both callers hand
in a fresh arrow on every render: the picker its loadMorePhotos, the gallery
its setVisibleCount step. So every unrelated re-render, a photo being ticked
included, disconnected the IntersectionObserver and observed again, and each
observe() replays the initial callback.
The callback moves into a ref. loading stays a dependency, because re-observing
once a page has landed is the only thing that still produces a callback when
the sentinel never left the 200px margin, so folding that into a ref too would
leave paging dead after the first page. FE-COMP-JSCROLLTRIG-009 pins that, 007
and 008 pin the rebuild itself; both are red on the old component.
Markup, classes and the signature are untouched.
Sorting inside searchPhotos and loadAlbumPhotos looks equivalent and is not.
contextLocation is live editor state. The place search and "use my location"
write it while the picker stays mounted, and the picker key is only provider
plus date, so nothing remounts it. Open an entry with no location, let the
photos load, then search for Rome: the caption flips to nearest-first and the
grid does not move.
The appending search made it worse than that. It sorted only the page it had
just fetched before appending, so from page two on the grid was a sequence of
sorted blocks rather than a sorted list, and a photo from page two landed under
a day heading page one had already closed.
FE-JRN-PICKER-021 pins it and is red without the memo.
The video badge also gets z-10 and pointer-events-none. It shares a stacking
context with the city gradient that follows it, so the later sibling was
winning on the geotagged tiles; pointer-events-none is what the eight existing
video overlays in the journey code all set.
Clicking Add gave no feedback while the async onAdd call was in
flight, and nothing stopped a second click from firing a duplicate
submit. Adds a local pending flag that disables the button and swaps
its label for a spinner until the call resolves.
Picker tiles gave no visual cue that an asset was a video rather than
a photo. Adds a small play-icon badge (bottom-left corner, matching
the desktop/mobile gallery grid's video treatment) driven by the
asset's existing mediaType field. No video preview -- just the
indicator.
sortProviderPhotos previously passed photos through unchanged whenever
there was no valid journey location, leaving order entirely up to
Immich's search API (not guaranteed chronological). It now falls back
to newest-taken-first.
The picker also re-sorted the entire accumulated photo list on every
render, so loading another page could reorder tiles already on screen
when a location context was set -- newly loaded photos could land
above the fold instead of at the bottom, making it look like nothing
happened. Each fetched batch (search page or album) is now sorted once
at fetch time and appended, so earlier pages never get reshuffled by
later ones.
FE-MOB-MSTOR-013 read migrationBody straight after the save toast. The toast
comes from the PUT, the migration POST is fired by the queue effect one commit
later, so the two are not the same tick. It only passed while that effect
happened to flush first, which on a loaded runner it does not.
The desktop twin FE-ADMIN-STOR-030 has waited here since 8069b8805; the mobile
file was left behind.
Three expectations still described the Vacay/Atlas dock. Two of them
asserted the old arrangement by name, so they were pinning the very thing
that changed rather than catching a regression.
FE-MOB-NAV-006 now watches Atlas take the demoted seat next to
Collections, and picks Atlas out of the popover in FE-MOB-NAV-011 for
the same reason. Two new cases cover what the change is actually about:
Journey sits in the dock when it is enabled, and an instance without the
addon still gets Vacay and Atlas.
Nine files conflicted between the two branches and the merge changes not
one of them: the result is byte-identical to dev. Six were the version
string alone, 4.2.1 against 4.2.0, which the release job rewrites to
4.3.0 on the way to main anyway. The other three were places where dev
already carried main's change plus more, so dev's side wins in each.
Nothing is lost. Thirty of the thirty-four commits on main are
cherry-equivalent to dev already; of the rest, two are version bumps,
one is an empty tag commit, and the RTL text plugin fix is in dev under
its own commit, verified by the files it added rather than by its
message.
Also drops the copy of the LLM-ceiling test that main carried twice under
two names, which the merge would otherwise have reintroduced.
Feedback from Discord: on a phone in the field the two things you reach
for are the itinerary and the journal, but the default dock spends its
two free slots on Vacay and Atlas. Journey sits under More, and most
people never open the navbar customiser to fix that.
DEFAULT_DOCK_IDS becomes a priority list instead of a membership test:
the first MOBILE_NAV_MAX_BAR entries that are actually enabled take the
slots. Journey ships disabled, so an instance that never turned it on
keeps the exact Vacay/Atlas dock it has today. Listing all four known
global addons also fills the dock when one of them is switched off,
where the old membership test left the slot empty.
Accounts that saved their own bar/more split are untouched: a non-empty
config still wins over the default, so nobody's arrangement moves.
A grid item defaults to min-width:auto, so the two date cells never went below
the width of their contents: a long locale date plus the keyboard button beside
it pushed each cell past its track, and on a phone the end date came to sit on
top of the start date with its own button clipped off the edge. Measured on a
412px viewport, the row overflowed its container by 30px.
Both cells get min-w-0 so the track wins, and the picker's own trigger gets it
too, which is what finally lets the ellipsis it already carries do its job
rather than the button simply growing. "12. Sept. 2026" now reads "12. Sept…"
in a narrow column instead of escaping it.
Keeping the locale format: the report suggests ISO 8601, but the date is
formatted from the user's language everywhere in the app, and the overlap is
the layout, not the wording.
Two reports, both about an import that fails without saying why.
#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.
Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.
#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.
Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.
The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).
Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).
App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.
LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.
And a typo in the Russian offline copy: Завершёна → Завершена.
The credential strip and the 303/302 body downgrade are new behaviour in a
security helper, and src/utils sits on a 96% line ratchet that the untested
branches took under.
Two reported advisories, and a third path neither of them covers.
GHSA-7pqc-fj3c-9346 — the ntfy token fell back to the operator's whenever a
request did not carry one, so an authenticated user who named their own server
was handed the operator's decrypted token as `Authorization: Bearer`. The report
covers `test-ntfy`, but the live send path in channels/builtins.ts does the same
thing off a per-user `ntfy_server` setting: no test route needed, just wait for
the next notification. Gating the route on `role === 'admin'` would have closed
one of the two and broken the documented shared-server setup, where the operator
supplies server plus token and users only set a topic. The invariant is the
target, not the role: the operator's token travels to the operator's server and
nowhere else, and that rule fits both paths unchanged. Scheme is part of the
comparison — an http twin of an https server would put it on the wire in clear.
GHSA-8mw6-xphx-886m — both transports checked only the first URL and then let
the platform follow redirects on a dispatcher pinned to that first IP. Pinning
does not help: Node skips the pinned lookup for an IP-literal host, so a 307 to
127.0.0.1 or 169.254.169.254 connected straight through. They go through
safeFetchFollow now, which re-checks and re-pins per hop. safeFetch itself had
the same shape and carried it to sixteen more callers, several with a URL out of
a per-user setting, and one (pipeAsset) streams the response back — which would
have made a redirect into an internal service readable rather than blind. It
now routes through the same follower.
The third: following by hand opts out of the platform's own rules, and both
manual followers replayed the caller's init verbatim. undici drops
`Authorization` across origins itself, so converting ntfy to safeFetchFollow
would have re-opened the first advisory through a redirect. Credential headers
are now dropped on an origin change (same-host http to https stays, self-hosted
setups depend on it), and a 303 — or a 301/302 on a non-idempotent method —
drops the body, so a plugin's client_secret is not re-POSTed at a host the
first one merely pointed at. plugin-oauth pins maxRedirects to 0 like its OIDC
twin, and the Google Places calls get the timeout none of their nine call sites
passed.
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.
Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
The Leaflet mock had no getCenter, so the new guard bailed out of every
draw and FE-COMP-JOURNEYMAP-046/047 went red. Give the mock a loaded map
by default and add FE-COMP-JOURNEYMAP-050, which takes the view away for
exactly the window #2254 hit: render must survive it drawing nothing, and
the moveend listener must still put the thumbnail up once fitBounds lands.
Public journey pages ≥1024px render the sticky sidebar map immediately with photos, before the deferred fitBounds/setView lands, causing latLngToContainerPoint to throw "Set map center and zoom first."
SystemNoticeHost, BackgroundTasksWidget, and save-to-collection were gated only on login/register/password routes, so anonymous visitors to /shared/:token and /public/journey/:token still mounted them and hit system-notices/active with no session, logging a 401 on every visit.
Three reports from the same week, all "the layout is in my way".
#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.
Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.
#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.
#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.
Closes#2247Closes#2248Closes#2249
POST /api/addons/collections answers with the collection itself — the
controller returns the service's `Collection` straight through, and the e2e
suite pins that shape. The client declared a `{ collection }` envelope and
read `.collection` off it, so `createCollection()` resolved `undefined` and
handed back `null` every single time.
The caller needs that id. In the list editor and its phone sheet the id is
what the cover upload and `onCreated` hang off, so creating a list with a
cover picked in the same dialog silently dropped the image, the new list was
never opened or selected afterwards, and the retry guard that exists to stop
a failed cover step from minting a duplicate was dead. The list itself still
appeared, because `loadAll()` runs first — which is why this went unnoticed.
Nothing caught it because both test layers stubbed the shape the client
believed in rather than the one the server sends: the api suite's MSW
handlers wrapped the collection in an envelope, and the store suite's mock
resolved one too. Both now answer the way the controller does, `update()`
included — its declared type was wrong for the same reason, though no caller
reads its result.
The fix is on the client. That wire shape has shipped for as long as
Collections has existed and the server e2e test pins it, so changing that
side would be the breaking one.
The envelope fix routes the created place through the store, which offline
mints a NEGATIVE temp id: reservations have no offline write path, the
mutation queue rewrites temp ids in a URL but never inside another entity's
body, and day_accommodations.place_id carries a foreign key. So a
forced-offline save (the Settings toggle sets the flag while the network is
still live) reached the server with place_id: -1758..., failed the FK inside
the insert transaction and rolled the whole booking back into a 500 — where
dev merely saved it unlinked. Worse, the temp place is in `places` by then,
so every retry matched the same negative id and the booking stayed unsavable
for the rest of the session.
Offline the function now links nothing at all and skips the geocode too: the
booking cannot be written either way, so minting a place would only leave an
orphan behind on the next flush. The name-match arm rejects temp ids for the
same reason. `as never` is gone from the addPlace argument, and
placesApi.create finally declares its `{ place }` return type — an untyped
`r.data` is what let the envelope be misread in the first place.
resolveImportedPlace created the hotel's place through placesApi.create and
read .id off the response, but that endpoint answers { place }, so the id was
undefined: the accommodation was saved without a place_id and the new place
was orphaned. Because the planner's places state never learned about it, the
name match failed on the next edit too, and every save of the same hotel
produced another copy (five in a row on one trip).
Create the place through the store's addPlace instead. It unwraps the
envelope, so the accommodation is linked, and it pushes the place into
`places`, so a later save of the same hotel reuses it. The mobile sheet goes
through the same handler and is fixed with it.
The hook tests mocked placesApi.create with a bare { id } and therefore
never saw the envelope; they now assert on addPlace, and a new case saves the
same hotel twice and expects one place.
Closes#2243
FE-PAGE-LOGIN-014 failed intermittently in CI while passing in isolation:
twice in a row on unrelated PRs, each time costing a full run, and once on
one of two runs of the identical commit.
The takeoff overlay renders LoginWorld, which starts three
requestAnimationFrame loops on mount. Under the load of the full suite that
was enough to push the overlay assertion past waitFor's default timeout.
Nothing in this file asserts on the animation, and LoginWorld has its own
tests, so the file stubs it.
The guard in rtlText.ts had tests; the two lines that call it did not.
setRTLTextPlugin appears at exactly two places in the client and no test
touched either, so deleting a call, swapping an argument or dropping the
deferred flag left the whole suite green. The engines are mocked because
importing them for real builds a worker pool under jsdom, and the plugin
URL is mocked so the payload never loads.
The precache comment in vite.config.js was three releases stale, and it is
the only record in the repo of what a service-worker install costs. A real
build says 463 entries / 23,292 KiB, the plugin among them: it matches the
js glob, so it is fetched at install by every user rather than on first RTL
text as the branch description claims. That trade is the right one, since
the alternative is a CDN the production CSP refuses and an unshaped map for
anyone offline. Only the description of it was wrong.
Measured against a real client build, which no CI workflow performs: none
of them runs vite build or check:gl-split, so nothing here was covered
before.
Vector basemaps rendered Arabic, Hebrew, Persian and Urdu place names as
isolated letters in logical order — "القاهرة" drawn as "ة ر ه ا ق ل ا" —
because neither GL engine loads its optional RTL text plugin by default and
TREK never registered one. Raster basemaps hid the problem, since their
labels are drawn server-side, so it only became visible for everyone when
the default basemap became a vector style.
Both engine entry points now register the plugin lazily, so the payload is
only fetched once a map actually paints RTL text and Latin-only users
download nothing. The call is left with each engine because the two
signatures genuinely differ: MapLibre takes (url, lazy) and returns a
promise, Mapbox takes (url, callback, deferred).
The plugin is bundled rather than loaded from a CDN: TREK is offline-first
and the production CSP is script-src 'self', so a remote plugin URL would be
refused by the worker that imports it. Bundling also puts it in the Workbox
precache, so shaping keeps working offline. It needs a resolve alias because
the package publishes only its ESM/WASM entry point through "exports" while
setRTLTextPlugin requires the classic script in dist/ — the alias is shared
by the build and test configs so the two cannot drift.
The carry-over was conditional on a budget_items row still being linked to
the booking, which drops a real price in two shipped cases. The booking
importer writes metadata.price unconditionally but only creates the linked
cost when the Costs addon is on and the price is above zero, so an instance
with that addon off has bookings carrying a price and no budget row at all;
an MCP booking created with a price of 0 is in the same position. Both lost
the price on the first edit.
The condition is gone: a payload that does not name the key keeps what is
stored, and naming it, null included, is taken at face value so a price can
still be removed on purpose.
What stops a price from outliving its expense is now the other end.
deleteBudgetItem is the single chokepoint for all four delete paths, and it
clears the mirror there, inside the same transaction as the delete. That
also removes an ordering hazard the linked-row check had: the controller
runs update() before it syncs the budget, so on an explicit clear the row
still existed and the price would have come back a moment before it went.
The predicate moves into reservation-metadata.ts so it can be read and
tested on its own, and the update_transport tool description no longer
promises that metadata is replaced wholesale.
A booking's linked expense price lives in reservations.metadata.price
(written by BudgetService.syncReservationPrice). The booking form rebuilds
metadata from its fields on every save and only carries transit /
airtrail_ids over, so PUT /api/trips/:tripId/reservations/:id overwrote the
stored metadata and the price vanished from the card until the expense was
re-saved.
ReservationsService.update() now carries price / priceCurrency from the
stored metadata into an incoming metadata object that does not set them,
as long as a budget item is still linked to the booking. A payload that
sets price itself, a booking without a linked expense, and an explicit
metadata: null behave exactly as before.
Closes#2233
LLM_TIMEOUT_MS was validated as "a positive number", which lets two shapes
through that then degrade into an immediate abort rather than a long one.
A fractional value passes the check and floors to 0 in derive, which makes
the dispatcher fall back to undici's default and setTimeout(abort, 0) kill
every model call on the next tick. Anything past 2^31-1 does the same,
because Node clamps the delay to 1 ms. Both now refuse at boot, which is
what the wiki row already promised.
Also pins the seam the three deleted constants used to occupy: the clients'
abort deadline has to come from the configured ceiling. Nothing else in the
suite would have noticed a literal coming back.
Three shipped sentences became false with LLM_TIMEOUT_MS: two claiming the
addon needs no environment variables (one of them in the very file the
variable was added to), and one naming the old five-minute ceiling that the
deleted constant used to set.
The timeout for a model call was spelled three times and adjustable
nowhere: 120s in the Anthropic client, 300s in the OpenAI-compatible one,
300s in the Ollama one. None of them was the ceiling that actually
applied, either — every one of those calls goes through the pinned undici
dispatcher, and an undici Agent stops waiting for response headers after
five minutes by default, so a client constant set above that never got
the chance to fire.
- LLM_TIMEOUT_MS (default 900000) replaces the three constants and is
passed to the dispatcher as headersTimeout/bodyTimeout, so the abort
signal and undici are governed by the same setting. The default is
deliberately generous: heavier parsing work should fit without a code
change, and an admin who would rather fail fast can lower it.
- The ceiling stays on the model lane. safeFetchAdminConfigured is also
the OIDC lane (discovery, token, userinfo, JWKS) and the plugin OAuth
token exchange, and that one sends no AbortSignal of its own, so it
keeps undici's five minutes rather than inheriting a model setting.
- The derived value is floored: the schema accepts any positive number
and undici rejects a fractional headersTimeout.
Four separate defects sat behind "offline mode is broken".
The app shell. On the first online launch after a version bump, App.tsx
deleted EVERY Cache Storage bucket and unregistered EVERY service worker,
then reloaded. That is exactly wrong for a Workbox autoUpdate build, which
swaps its precache atomically on install and never leaves an empty one.
Between the purge and the completion of a fresh 22 MB precache the origin
had no shell at all; anyone who closed the app or lost signal inside that
window could no longer start the PWA offline. It also threw away the map
tiles and documents the user had downloaded on purpose. It now asks the
worker to fetch the new build and reloads once it has taken over. The
version marker also moves inside the guarded path, because it was written
after the purge and outside its try: a throwing setItem left the caches
gone, the marker unwritten and the reload unreached, repeating on every
launch.
The reported version is validated before it reaches this device's storage and
before it is compared against the stored marker, so a value that is not a
release tag can neither be persisted nor start an update on every launch.
The session. loadUser's offline branch kept the persisted session but
never called onAuthSuccess, the only caller of setAuthed(true), and the
mutation queue's flush, syncAll and prepareForOffline are all gated on it.
A session that launched without network could therefore never sync again
for the rest of its life, even after the signal returned: the offline
settings buttons spun and returned instantly having done nothing, and
queued edits never uploaded. It also left reads pointed at the anonymous
database instead of the user's own.
The date rule. A trip is only cached when it has not ended, and one that
ended over a week ago is actively evicted. The per-trip switch was an
opt-out list, so ticking a finished trip changed nothing at all. For
anyone whose trips are all in the past, "Download for offline use" stored
none of them, reported success, and the cache stayed at zero. Switching a
trip on now pins it, which overrides both the skip and the eviction, and
the switch shows the honest position rather than promising storage that
never happens.
What the screen says. A sync that never started (already running, offline,
signed out) was indistinguishable from one that stored everything: the
progress bar painted full and a green "Ready for offline use" appeared over
a run of nothing. Both syncs now report why they stopped and the screen
says so. A rejected Dexie read no longer renders as "No trips cached yet",
and the clear button stays reachable when the database holds data that no
bookkeeping row points at. A run that rejects outright says so too: the trip
list it starts from is a plain fetch, so a network blip took the whole thing
down and left the last progress frame on screen looking like a finished
download. The 401-with-HTML branch in the API client now
confirms a reachable proxy before tearing the worker down, since several of
TREK's own routes answer 401 with a text/html body.
Both settings shells move onto one shared hook rather than growing the same
logic twice, and the tile prefetch caps its depth when the browser refused
persistent storage. Opaque tiles are billed at about 7 MB each, and an
origin that is not exempt from eviction loses its whole bucket, precached
shell included, which Workbox then never refills because sw.js has not
changed.
Closes#2228
POST /oauth/register refused every single-label private-use scheme, so an
MCP client registering workbuddy:// could not connect at all, while the
error it got back advertised "a private custom scheme" as supported.
The cause was two validators that had drifted apart, both of which run on
registration. The provider required a dot in the scheme name, which RFC
8252 section 7.1 only recommends and which protected nothing
(javascript.evil:// passed it). The service behind it had no
custom-scheme branch whatsoever and no [::1], which is what actually
answered the reporter's second symptom, and its localhost exemption was
not gated on http:. So javascript://localhost/%0aalert(1), blob:, about:,
ftp: and wss: were all accepted through the settings UI, and the consent
flow assigns the stored URI to window.location.href. Only the CSP stood
between that and a navigation. Both now decide through one policy in
oauth.helpers.ts.
Widening the scheme set means the deny-list has to carry its own weight:
it grows by the browser, extension and OS-handler schemes that were
previously blocked only as a side effect of the dot rule, including
ms-msdt (Follina) and the ms- namespace by prefix. intent: is denied,
because Chrome follows its embedded fallback URL, which turns it into an
open redirect the authorization server cannot inspect.
Fixing registration alone would have moved the reporter's [::1]:{port}
failure one step later: TREK matched redirect URIs byte for byte while
RFC 8252 section 7.3 frees the port for loopback, so a native client on
an OS-assigned port got a 302 to consent and then invalid_redirect_uri.
The matcher now relaxes the port, and only the port, for http loopback.
Stored URIs are never canonicalised, so the %3A round-trips untouched.
Note this tightens POST /api/oauth/clients for inputs it accepts today.
That is the security half of the change and applies at create time only,
so no already-registered client changes behaviour.
Closes#2227
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.
Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.
The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.
The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.
Closes#2222
An expense saved with "No one paid yet" was still charging its split
members in calculateSettlement while crediting nobody, so the ledger
drifted off zero by the unpaid total. The greedy simplifier that turns
balances into payments has no memory of which expense produced which
debt, so it handed that phantom amount to whoever happened to be a
creditor from an unrelated expense. In the report, two people each owed
about 80 to someone who never paid that bill. Booking every payment it
offered then left one member permanently short, next to an "everyone
square" line.
Both cost panels already promise the opposite: they label such a row
"Unfinished, total only, not settled yet" and count it into the
Outstanding amount card instead. The server now agrees with them.
The guard deliberately does not also require a non-zero total, unlike the
panels' label predicate. PUT /budget/:id/payers with an empty array zeroes
total_price without re-applying it, which would otherwise leave a custom
split debiting its members against no credit at all.
The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned
the old arithmetic through a deliberately payer-less 9 000 RUB item. The
wiki said the old behaviour was intended, so it is updated too.
Closes#2225
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning
A plugin whose author has not updated its `trek` range for the running TREK can
now be installed, updated and activated when the operator sets
TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker,
post-extraction re-check, sideload, dev-link, activation) warns instead of
refusing; a missing range is tolerated the same way; "install latest" takes the
newest published version. The plugin-API version gate is not lifted and the
Discover compatibility verdict stays truthful.
The admin is warned at every step: each bypass is logged, install/upload/link/
update responses carry a `trekRangeBypassed` marker, and both admin panels show
a "Version checks off" pill, an "Install anyway" confirm dialog before a
registry install, the same notice after a path that could not ask first, and a
persistent chip on the row while the plugin runs outside its range.
Nine new i18n keys in every locale; wiki pages updated (Environment-Variables,
Admin-Plugins, Plugin-Development, Security-Hardening).
* refactor(plugins): one useRangeBypass hook behind both admin shells
SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass
logic had been added to the desktop and phone plugin panels as identical
copies. The marker/warning types, the row chip, the "Install anyway" offer,
the dialog copy and the confirm-or-notice state now live in
useRangeBypass (next to useInstanceSettings, the existing shared-logic
precedent); each panel keeps only its own dialog/sheet markup.
* docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule
The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3%
duplication, A ratings, hotspots reviewed) was only discoverable by waiting
20-25 minutes for the workflow. The root CLAUDE.md now states the conditions,
the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and
how to check a PR with the sonarqube MCP first; client and server CLAUDE.md
point at it. shared/CLAUDE.md says every locale needs a real translation —
parity cannot tell an English placeholder from a translation.
* docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy
Verified every concrete claim in the five CLAUDE.md files against the code
and fixed the stale ones: the websocket server is the Nest realtime gateway,
MCP tools live in <domain>.mcp.ts files, the client "native shell" section
described code that was never written, offline maps also prefetch vector
basemaps, plus several renamed files and wrong table names.
Then trimmed everything that drifts on its own: dates, migration history,
counts, and inventories of stores, slices, domains, sync files and CLI
commands. What stays is the commands, the invariants with the file that
enforces each one, what CI actually runs, the reference implementations to
copy, and the rules for new code. Each file now opens with that rule.
@@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
@@ -90,7 +90,7 @@ These principles come out of a full-repo audit and shape all new code:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips.
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
# node. chmod is explicit because a checkout on Windows carries no exec bit.
RUN chmod 0755 /usr/local/bin/trek-entrypoint
ENTRYPOINT["dumb-init","--"]
# Preflight: if the app code is missing, a volume was almost certainly mounted
# over /app (it hides the image's node_modules + dist). Fail with actionable
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
CMD["sh","-c","if[!-f/app/server/dist/index.js]||[ ! -d /app/node_modules/tsconfig-paths ];thenecho'FATAL: TREK application files are missing from the image.';echo'A volume is likely mounted over /app, which hides the app code.';echo'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads';echo'Do NOT mount a volume at /app. See the Troubleshooting section of the README.';exit 1;fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true;cd /app/server &&exec gosu node node --require tsconfig-paths/register dist/index.js"]
@@ -168,9 +168,9 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
- **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
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), 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
- **27 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, az, et, sk, th
- **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
@@ -49,7 +49,9 @@ See `values.yaml` for more options.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
-`FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
-`COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
| Step picture frame | `guide.ts``frameFor` | 16:10, at least 960 px wide, 64 px padding around the target, centred on it, never larger than the largest 16:10 frame the viewport holds (1728 × 1080). A target wider than that, a full-width row for instance, is therefore cut at the sides, and that is accepted: the narrow frame is the format of the whole library, and a picture that widens to fit one row is at a different magnification from the one beside it. A target taller than the frame is anchored at its top, so a long section shows its heading |
| Ring around the target | `guide.ts``ring` | 3 px in the app's `--accent`, 12 px radius, 6 px outside the element, the rest dimmed 30 %. A white hairline goes outside the ring and the badge only where the surface measured behind them has too little contrast against the accent, which is the lightbox and the other near-black overlays |
| The step's number | `guide.ts``ring` | a 28 px disc in the accent on the ring's top-left corner, outside it. Where that spot is not inside the frame, which is every target wider than the frame, it moves inside far enough to clear the frame's edge. The number is part of the picture; the panel numbers the step again beside it and puts nothing on top of the picture |
| Raster tiles | `guide.ts``settle` → `showLoadedTiles` | loaded tiles are forced visible before every shot. Leaflet fades a tile in over 200 ms and takes the progress from `+new Date()`, and the clock is pinned, so without this the satellite imagery and every raster basemap are a grey rectangle although each tile came back 200 |
| Drag & drop | `guide.ts``ringDrag` (a step with `dropTo`) | source ringed, destination ringed dashed, a bowed arrow from the one to the other, both undimmed |
| Hover | `captureGuide` | the target is hovered before the shot (tooltips, hover-only buttons show); `hover` overrides where the centre is wrong |
| Result picture | `guide.media.result` | the full frame after the last step, pointer parked in the corner |
| Hero (screen overview) | `captureHero` | the full frame of the screen as the reader finds it |
The app's own appearance is the default theme, English, light mode, the seeded
demo data (`e2e/screenshots/seed.ts`) plus the fixtures in `fixtures.ts`. Do not
change the theme, the language or the zoom for a picture.
## How a screen gets its pictures
1. Register the screen and its guides in `src/help/contexts/<screen>.ts` and
`src/help/registry.ts`; write the English texts as a block in
`shared/src/i18n/en/help.ts` (`// ── Screen: <id>`). Guide ids are global: one id,
one picture directory, so never reuse an id from another screen
(`registry.test.ts` refuses duplicates).
2. Write `e2e/help/<screen>.guide.ts`: one `GuideScript` per guide, exactly one
`StepAction` per registered step, `start` that opens the screen, `cleanup` that
puts the seed back. A step's `target` is the element the text tells the reader
to use; `prepare` gets there, `act` does what the text says.
3. Run `npm run help:media -- <screen>.guide.ts` (own ports 5183/3011, fresh
database, the seed, then the file). Fix until every guide passes; a failing
step is a text that no longer matches the UI.
4. Look at the pictures in `e2e/.tmp/help-media/`, then `node e2e/help/promote.mjs`.
5. Translate the block into every other locale (`i18n:parity:strict` is the gate) and
run `src/help/registry.test.ts`, which checks every promised picture is on disk.
## What the fixtures may look like
The documents and pictures the guides upload are drawn in `fixtures.ts`, because
no real person's paperwork and no photograph may live in this repository. They
still have to look like something a traveller would really put into a trip, or
the picture teaches the reader that the feature is a toy.
- **A document is a document.** `drawPdf` takes an issuer band, a title, a
reference line, a table of labelled fields and a footer, which is the shape of
every confirmation, ticket and voucher. Four lines of Helvetica on white is not
a document; it was the first attempt and it was thrown out.
- **A picture is a graphic, not a painting of a photograph.** A hand-drawn
market or landscape reads as clip art at any size, however it is shaded, and it
was tried three times. What works is the kind of image people actually upload:
a map screenshot with a pin and a search field, a booking confirmation in a
browser window, a printed network map. Those are drawings in reality too, so a
drawing of them is honest.
- **Every date comes from `e2e/dates.ts`**, never from a calendar typed into a
fixture. A confirmation whose check-in is two days before the trip's last day
keeps making sense next month.
- The names of the files are part of the seed: guides find their row by name, so
renaming a fixture means renaming it in the guide too.
## Changing the style
`guide.ts` is shared by every picture, so anything changed there is a property of
the whole library, not of the guide in front of you. Before changing it, work out
which pictures actually come out different and re-render exactly those, one
`--grep` away:
```bash
npm run help:media -- --grep "pictures: (files-trash|files-preview)$|hero: trip-files"
```
The staging directory answers most of those questions without a browser, because
the frame is in the file:
```bash
# which pictures were taken at the frame's maximum, i.e. cut at the sides
python -c "import struct,os;[print(r,f,struct.unpack('>II',open(os.path.join(r,f),'rb').read(24)[16:24])) for r,_,fs in os.walk('e2e/.tmp/help-media') for f in fs if f.endswith('.png')]"
```
A change that alters every picture (the frame, the ring's geometry, the viewport)
is a full run of about four hours on one worker. It is not a thing to do twice,
so look at three or four pictures from a subset run before starting it.
## Rules learned the hard way
- Never run `npx prettier` over `client/` sources: `client/.prettierrc` says
`semi: true` and the sources are written without semicolons, so it rewrites
whole files. Nothing in CI formats the client.
- A killed run leaves its server holding 3011 and 5183, and the next run dies
if(!body.connected)thrownewError(`Dawarich is not reachable from the media backend: ${body.error??''}${body.errorDetail??''} (is ALLOW_INTERNAL_NETWORK=true?)`)
body:'Takkyubin from the hotel front desk, **before 11:00**, and the bags are in Kyoto the next afternoon.\n\n- One bag per person\n- About 2000 JPY a bag',
category:'Transport',
website:'https://www.global-yamato.com/en/hc/',
}
/** An .example host never resolves, so the chip shows its own glyph offline too. */
// Plain text on purpose: a bold run in the question would wrap the paragraph
// in one more element and move the card away from `pollCard`'s ancestor depth.
question:'Which evening do we keep for the kaiseki dinner?',
options:[`${short(-5).split(',')[0]}${ordinal(-5)}, after Fushimi Inari`,`${short(-4).split(',')[0]}${ordinal(-4)}, after Arashiyama`,'Either, decide on the day'],
}
/** The seeded poll nobody has voted on yet. */
constSEEDED_POLL='Ryokan or city hotel in Hakone?'
constCHAT={
mine:'Ryokan check-in is 15:00, so let us aim for the 13:10 train out of Kyoto.',
/** Typed before the picker opens, so the picture shows the emoji being added to it. */
emoji:'Two weeks to go ',
flag:'🎌',
theirs:'Drop bags. I want to be at Senso-ji before the crowds.',
answer:'Agreed, bags at the hotel first, then Asakusa.',
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.