useAddPlaceToCollection holds the maps search, the pick and the save
that AddPlaceToCollectionModal and MCollAddSheet each carried, with the
pure helpers in addPlaceModel.
The differences stay as variants. The desktop resets and stays open
after an add, sends links and typed coordinates and shows the error's
own text. The phone closes, takes coordinates only from the picked hit,
ignores a second search or save while one is running and shows the
fallback text.
usePlacesPool holds the category filter, the search, the multi select
and the bulk mark visited of the places pool, for usePlacesSidebar and
for MPlacesBrowser with its filter controls and sheet. When selected
places are deleted, the desktop leaves select mode once none of them is
left and the phone drops only the missing ids. The hook keeps both.
usePlacesFileImport holds the GPX, KML and KMZ import that
FileImportModal and ImpFileStep each carried. The desktop keeps one
toast and one undo label and counts only the summary's skips. The phone
keeps a toast per format, the importFiles undo label for a mix and the
top level skipped fallback.
useListImport holds the Google and Naver shared list import that
usePlacesSidebar and ImpListStep each carried. The desktop closes the
dialog and clears the link before the undo step, the phone calls onDone
after it, and the hook keeps both orders.
useSaveTripPlacesToList holds the logic that saves a selection of trip
places to a list, which SaveTripPlacesToListModal and
MPlacesSaveToCollectionSheet each carried as a copy. The two copies did
the same thing, so the hook has no variants.
The bookings model moves from the phone tabs into components/Planner/bookings, so
both shells read the same sections, traveller predicate and type colours. The
phone bookings and transports tabs had the same traveller filter and folded
sections twice; that state now lives once in useReservationListFilter, which only
those two tabs use. The desktop panel keeps its own filters in useBookingsView
because it remembers them for the session.
The status switch and the delete on a booking card stay per shell. The desktop
hook renders its ConfirmDialog and awaits the delete, the phone asks in an
MConfirmSheet and lets the delete run on its own; sharing them would change
when the question closes.
filesFor moves out of the desktop booking parts into utils, and the phone
transport sheet now reads a booking's files through it as the desktop
transport detail and booking cards do.
The desktop note dialog and the phone note sheet fill their editor with
dayNoteDraft and save through writeDayNote, so both trim the title, store an
empty detail as null and default the icon the same way. Both count the detail
line against one shared limit.
The desktop hotel picker and the phone accommodation sheet take their day
options, range changes, checkout default, places on offer and request body
from stayFormModel, and both save a stay through writeStay.
The desktop day panel and the phone day sheet load the day's forecast with
useDayForecast, rename the day with useDayRename, and read the day's
bookings, the stay labels and the display temperature from dayDetailModel.
The day plan's connector menu and the phone timeline's are built by
legModeMenuItems, and both save a leg's mode through useLegModeActions, which
reports a refused write and reloads the days the same way on both.
The desktop day plan and the phone timeline derive the place order, the note
and transport positions and the per leg positions of a reordered day from
mergedOrder, and write the moved bookings back through the same helpers.
dayRoute moves from the phone's lib into components/Planner. The desktop day
plan builds its Google Maps and CoMaps stops with dayExportStops, and both
optimizers put the reordered stops around the fixed ones with fillAroundLocked.
The day plan, the day panel, the phone timeline and the phone day sheet read
the day local weather anchor from dayWeatherAnchor, and every weather icon
comes from one map in weatherIcons.
The place inspector and the phone place sheet now run on one module for
their participants, file uploads, image picks, ratings and place updates,
and both build the save to collection target with placeToSaveTarget.
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.
The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
A session token was a signed JWT and nothing else. Logout only cleared
the cookie, a 30-day "remember me" token stayed valid wherever a copy of
it had ended up, and nobody could sign out one device short of changing
the password or rotating the JWT secret for everyone.
Every session token now carries an id (jti) naming a row in the new
user_sessions table (id, user_id, created_at, last_seen_at, expires_at,
revoked_at, user_agent cut to 256 characters; no address is kept). The
rows live behind UserSessionsRepository and the new sessions domain,
whose SessionsService is the one issuer: AuthService.generateToken and
the OIDC sign-in both go through it, and sliding renewal re-signs a
tracked token under the same id and moves its expiry. verifyJwtAndLoadUser
takes the repository and refuses a token whose session is revoked,
expired or gone, refreshing last_seen_at at most every five minutes.
A token without a jti was issued before this change; it passes until its
own expiry (or a password change), so the upgrade signs nobody out, and
renewal turns it into a tracked session.
Sessions end on logout (the one the request names, verified even when
expired), on a password change (all, the device gets a new one), a
password reset, an admin setting a password, reset-admin.js, an MFA
disable (all but the current one), an admin clearing a user's MFA, and
with the account. GET /api/auth/sessions lists the active sessions,
DELETE /api/auth/sessions/:id ends one and POST
/api/auth/sessions/revoke-others ends all but the current, with their
contracts in @trek/shared and two new audit actions. There is no UI for
them yet. MCP exposes no account or session tools, so there is no
parallel tool to change; an MCP call made with a session JWT as bearer
goes through the same check.
The token in the login, register, demo-login, MFA and passkey bodies
stays for API clients but is marked deprecated in the API docs and in
the client type, which never read it.
A nightly session-purge job on CronRegistrarService deletes the expired
and revoked rows. The migration is 042500. The sessions domain gets its
own coverage entry at the 80 floor until a full run measures it.
Version 6 only adds the driving settings store; dropping the area cache
is version 7. The case name claimed both, so it now names only the new
store and a comment explains where the empty area cache it still checks
comes from.
The skip of newer-format queued writes only exists from this release on,
so a rollback to an older release still replays them. On a rolled-back
install the skipped rows also stay pending with no retry or discard path
and hold back later writes to their entity, leaving Clear cache as the
only way out. Say both in the wiki and in client/CLAUDE.md instead of
promising that a rollback is always safe.
Each version() of the offline database runs once, on a user's device, when a
new bundle opens what an older one wrote. None of them ran in CI: the v3 blob
cache backfill and the v8 area-fingerprint reset had never been exercised
against an older database.
The new test builds the database as each previous version left it in
fake-indexeddb, seeds a row into every table it had, opens it with the current
schema and checks that the rows survived and that the version did what it is
for. A guard reads the declared versions from offlineDb.ts and fails on a
duplicate, on a version without an upgrade case, or on stores the history
fixture does not reproduce. TrekOfflineDb is exported so the test can open a
named database with it.
A queued write outlives the bundle that wrote it. After a deploy a tab can
still run the previous bundle, and an install can be rolled back, so a row
written by a newer build may be replayed by an older one that does not know
its shape.
Each row enqueue() writes now carries schemaVersion (MUTATION_SCHEMA_VERSION)
and the UI build that queued it. Both fields are optional, so rows queued
before this read as format 1 and replay as before. A flush leaves a row from a
newer format in the queue without sending, marking or dropping it, and holds
back the later writes to the same entity as it does behind a parked write, so
they keep their order until a build that knows the format replays them.
_flushRun holds the running pass or null. Testing it as a boolean is
correct but reads like a forgotten await, and SonarCloud counts it as a
reliability bug (S6544). The explicit comparison says what is meant.
lint:size, lint:layers, lint:rtl (both baselines), lint:skips and
lint:warnings only failed a count that passed its entry. A count that
fell below its entry was logged and nothing more, so a file that shrank
could grow back to its old entry unseen, and the entry of a deleted
file stayed behind for the next file at that path. lint:format and
lint:offline already fail on such a stale entry, and the docs said
these counts may only shrink.
Each of them now fails on an entry that lowerCounts would lower or
drop, naming its count or saying the file is gone, and tells to run
--update, which still only lowers. staleCounts and reportStale in
lib/ratchet.mjs hold the shared part. Every baseline matches the tree
today, so none needed lowering. The client CLAUDE.md says so, and that
CI lints through lint:warnings rather than lint:check.
The planner hook tests spy on the store's actions for the visit times,
and the note now goes through a store action as well. Asserting on that
action instead of on the raw note route keeps these tests about the hook
and keeps the file inside its lint:size entry, which the response stub
the route needed had pushed three lines past.
flush() returned at once when this tab already had a flush going, and
that includes one still waiting for another tab's Web Lock. The socket's
reconnect hook chains a refetch onto flush(), so a reconnect landing in
that window rehydrated the trip while a replay was still on its way, the
race the lock wait was meant to close. sendSoon and the online, periodic
and visibility triggers make such an overlap common.
flush() now keeps the running flush as a promise. A second call still
asks it for one more pass and then gets that same promise back, so it
resolves only once every pass, including the one it asked for, is done.
The pass itself moves to _flushPass, and the repeat runs as a loop
instead of a nested flush() call. The client CLAUDE.md sync bullet now
says so, and loses its dash.
The place dialog stopped reloading the days after a save, so the new
visit note only reached the Zustand store. Nothing wrote it to Dexie,
the tab never sees its own assignment:updated echo, and the time save
before it cached the server row with the old note. A trip reopened
offline then showed the old note until the next full sync.
The note now goes through assignmentRepo.setNotes, which caches the
saved row on its day online and queues the write behind any other
write of the same visit offline, like setTimes does. A new store
action applies it, so the page hook no longer patches the store by
hand. usePlaceEdits drops its direct API import and leaves the
offline baseline.
Each gate came with its npm script but ran nowhere. The server job now
runs lint:size, lint:boundaries and shared's contracts:open after the
shared build; the client job runs lint:warnings in place of lint:check
(it fails on errors the same way and also holds the warning count per
rule), then lint:format, lint:layers, lint:offline, lint:skips and
lint:i18n-keys. All of them are steps of jobs ci-ok already needs.
Both size baselines are taken on the merged branch. Four server files
grew while the other changes landed (the claimed reminder, the email
uniqueness error and the generator rule for an expression index) and
four client files by two or three lines with the plural forms; one
client file shrank and is lowered.
Nothing said which Node TREK runs on. The image is node:24, part of CI
ran 22 under comments saying 22 matched the server runtime, and the dev
guide said "22+". A bare-metal or dev install on an unsupported Node
installed without a word and then failed at runtime: isomorphic-dompurify
needs ^22.22.2 or ^24.15.0, the plugin jail's --permission flag is new
in 22.x.
.nvmrc now holds the major the Docker image runs, for nvm/fnm and for
every workflow to read. engines in the root and each workspace states
the range the runtime dependencies accept, the same one
isomorphic-dompurify declares. Without engine-strict npm only warns
outside it, so no existing install stops installing.
The image scan has been red since 10-06 on five advisories in four
runtime packages: proxy-addr 2.0.7 (critical, IP spoofing through an
IPv4-mapped IPv6 trust subnet), brace-expansion 2.1.4, source-map-js
1.2.1 and @modelcontextprotocol/sdk 1.30.0. brace-expansion had been
fixed once already; the ORM squash regenerated the lockfile and took it
back to 2.1.4 without anything noticing.
The overrides block in server/package.json was meant to hold such floors,
but npm reads overrides from the root package.json only, so none of them
ever applied: @nestjs/platform-ws kept its own ws 8.21.2 next to the
8.21.3 the block asked for. The floors move to the root, together with
new ones for every package this lifts, so the next lockfile regeneration
cannot quietly undo them. picomatch ^4 is dropped instead of moved: at
the root it would force micromatch's 2.x copy across a major. js-yaml is
lifted only inside its 5.x line, the one @nestjs/swagger pins.
The SDK goes to 1.31.0, the first fixed release; it stays hoisted, which
the CJS path aliases in server/tsconfig.json depend on. dompurify,
fast-uri, ip-address and js-yaml come along because npm audit flags the
same lockfile for them and each fix is inside the range already used.
@mikro-orm/postgresql (with pg), marked and topojson-client are dropped:
nothing in src, tests or scripts imports them, and the ORM config names
only the SQLite driver. The Postgres driver comes back with a Postgres
job that exercises it.
npm audit --omit=dev now reports only maplibre-gl, whose fix is the
6.x major tracked separately.
The count-string gate only knew {count} and {n}, the params t() picks a
plural form by. A quantity named anything else kept one form in every
language and passed: Russian read "Все {days} дней" for 2 days and
"{objects} объектов" for 3 objects, Greek "{minutes} λεπτά" for 1 minute.
Twelve such en strings become plural groups with their forms in all 27
locales, the quantity renamed to {count}: the trip length limit, the
chat image limit, the API key limit, the storage move prompt and its
reclaimable line, the Dawarich wish rule and suggestion duration, the
road trip lateness warnings, and the road trip echo and window texts.
Their callers pass the count as a number; the storage panels used to
pass it as a string, which no form is picked by. The reclaimable line
loses its dash for a comma in every locale while it is rewritten.
i18n-parity now treats the quantity names in QUANTITY_PARAMS ({days},
{minutes}, {objects}, {max} and the like) like {count}. The four strings
where the number is followed by nothing but a unit symbol (the Dawarich
durations, the upload size limit) go on NOT_PLURAL with that reason.
A template key was read as a pattern in which every interpolation is a
wildcard, so t(`roadtrip.window.${a ? 'onRoute' : 'onRoutTypo'}`) passed
as long as some other roadtrip.window key existed, and the typo rendered
as raw text.
An interpolation that can only produce string literals (a ternary chain
of them, in parentheses or not) is now expanded, and each resulting key is
checked like a literal one. Thirteen call sites become literal keys that
way. Where data is involved, a literal fallback (`${role ?? 'editor'}`)
is still checked as a key, with the template kept as a pattern under
its own allow-list site. Literals in a condition (`phase === 'start'`)
are never taken for results.
Narrow templates over data still pass without an allow-list entry. The
script header now says plainly what that leaves unchecked: a runtime
value with no key under a narrow prefix, which needs the value's type.
The panel fires a test photos_shared event with count: '5', which reads
like the string counts the plural work removed elsewhere. Notification
params are Record<string, string> from the server through the in-app
store, the real sender passes String(added), and resolveTemplate reads a
numeric string as the plural count, so the test event matches what
production sends.
lint:i18n-keys let any template key through as long as its fixed parts
matched one en key, and each interpolation could stand for any run of
characters, dots included. `settings.${mode}` therefore reached all 574
settings keys and `help.guide.${id}.${part}` nearly 2000, so a wrong
suffix behind such a prefix could never fail, and none of the 109
templates had been looked at.
An interpolation now stands for one key segment, and a template passes
by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30)
en keys, the size of an enum such as the docsync error codes. A wider
one fails unless DYNAMIC_ALLOWED has an entry for that file and template
saying what bounds the value. The twelve wide templates in use today
get one each: the help key builders (checked by the registry test),
literal tuples beside the call, typed unions, the generated plugin
permissions and the seeded photo provider fields. An entry whose file no
longer uses the template, or whose template has become narrow enough to
pass alone, fails, so the list stays short and current.
Listing all 109 templates instead was considered and left out: the
narrow ones would only restate their own prefix, and the bound already
keeps a mistake inside one small namespace.
The plural parity check only knows the groups en already declares, so a
"{count} ..." string added as a plain key kept one form in every
language: Polish read "Usuń 1 spakowanych", Czech "Odstranit 1
hotových" and Greek "1 λεπτά". 44 such en keys become plural groups with their
forms in all 27 locales: the collection, packing, day plan and offline
banner counts, the relative times, the audit log, the tour and transit
minutes, the share and photo hints. Labels like "Notes: {count}" get
their forms too, so a language can still inflect them later. The
dashboard's hand-rolled activeOne/activeMany pair, unused, becomes one
group, dashboard.subtitle.active.
i18n-parity now refuses an en string with {count} or {n} that is no
plural group. NOT_PLURAL lists the eleven where the number is never a
quantity (a day number, a step, a waypoint, the equator multiplier),
each with its reason, and an entry that stops matching fails as well.
Callers that worked around the engine:
- Copy to trip switched between two keys on placeIds.length > 1;
collections.copyN now carries the one-place wording as its `one` form,
where the language's `one` is the number 1 alone.
- The plugin dependency toasts said "addon(s)" and "plugin(s)"; they are
groups now and the panels pass the list length as the count.
- The dismissed suggestions count went in as a string and the comp days
as a formatted string, so no form was picked; both pass numbers.
BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.
Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.
en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
skipIf and runIf were left out as run-time skips, which they are with a
real condition. With the literal true (skipIf) or false (runIf) they
switch a test off for good and gave a free way past the ratchet, while
the same file already counted Playwright's test.skip(true, 'why') for
that reason. Both now count as skips; nothing in the client uses them
today, so the counts stay as they are.
A block comment such as /* eslint @typescript-eslint/no-explicit-any: off */
turns a rule off for its file without leaving a message behind, neither
among the warnings nor among the suppressed ones, so a new file could
switch any warning rule off and the ratchet stayed green. eslint-disable
comments were already counted under "suppressed"; this was the way
around them.
lintClient now reads the block comments of every file ESLint linted from
the TypeScript parser, so the same text in a string, a template or a
regex does not count, and the tally reports each config comment as an
error. Nothing in the client uses one today, so the check is zero
tolerance. The tests baseline drops by one no-unused-vars warning that
is gone.
The size check walked src/ and tests/ only, while lint:format and
lint:skips already cover e2e/. The help center fixtures under
e2e/help/fixtures.ts hold 2291 wrapped lines, past the 2000 a test may
reach, and could grow without bound. e2e/ is now measured with the test
limit; fixtures.ts enters the baseline at its current size, every other
file there is within the limit.
A file's layer was the first path segment under src/, so src/types.ts,
the shared types module every layer imports as '../types', got the
layer "types.ts" and no rule at all, while the imports into it already
resolved to "types". It could import a component or a page unnoticed.
A file directly in src/ is now the layer of its name without the
extension, for the importer and the import alike. No file changes its
count: types.ts imports only @trek/shared today.
OAuthAuthorizePage reloads the user on mount and useDashboard's retry
reloads it too. The real loadUser then registers the background sync
triggers, a 30 s heartbeat and window listeners that nothing in either
spec unregisters, so they outlive the tests the same way they did in the
account specs before 27db5dd5e. Both specs now stub the triggers, which
neither is about.
The check only looked at src/ and tests/, so a new spec under e2e/ or a
new script under scripts/ could land unformatted, and the ratchet scripts
themselves did (fixed in the previous commit). It now walks e2e/ and
scripts/ too and takes .mjs alongside .ts, .tsx and .css. The 58 files
there that were unformatted before this branch (the e2e specs and help
guides, the older build and lint scripts) join the baseline, which keeps
only shrinking; JSON baselines stay out of scope.
The checker scripts were written without semicolons, against the
semi: true in .prettierrc, so every one of them failed the formatting
rule lint:format enforces on src/ and tests/. This is prettier --write
over the scripts this branch added or rewrote, nothing else.
help/ holds the help center's screen registry and vacay/ a few date
helpers; both are plain data and code like utils/, yet without an entry in
RULES they could import any view unseen. Neither does today, so ruling
them costs nothing in the baseline. managed/ stays free and the comment now
says why: it is the slot an operator fills with whole screens at build time.
A skip only counted when its first argument was a string or template
literal; anything else was taken for a Playwright run-time condition. So
it.skip(name, fn) in a table loop, describe.skip(TITLE, fn) or
test.skip(true, 'why') switched tests off without the baseline noticing,
and test.fixme was not matched at all.
The condition form is now recognised in Playwright specs under e2e/ only,
and even there a call counts when it declares a test (a body as second
argument), is called bare inside a test or group, or has the literal true
as its condition. Under vitest every .skip counts. fixme is a skip.
Nothing in the tree uses these shapes today, so the baseline stays at 35.
Every ratchet command hands its check to runCli, which sets the exit
code from the check's result and turns a failure of the check itself
(a missing or broken baseline, a directory that is not there) into a
printed FAIL and exit 1. The test runs runCli in a separate node
process, as CI does, and also pins that an unexpected exception still
ends the run with a non-zero code.
login, completeMfaLogin and register start tripSyncManager.syncAll()
and the system notice fetch without waiting for them, and every
sign-in, loadUser included, registers the sync triggers with their
30 s heartbeat. #2613 stubbed the sync in the push spec after it was
caught logging while the worker shut down ("Closing rpc while
onUserConsoleLog was pending"); the same shape was left in the other
specs that sign in for real.
authStore.test.ts ran the real sync on every successful sign-in and
logged DatabaseClosedError from it, and its notice fetch went past MSW
to the network and logged a failed fetch, both after the tests that
started them. The sync is now stubbed there, and the login, MFA and
register tests assert that it was started. The notice store is reset
before each test, since FE-STORE-AUTH-024 left a stubbed fetch behind.
LoginPage.test.tsx signs in through the form, and AccountTab.test.tsx
and MSettingsAccount.test.tsx reload the user after an MFA change; they
now stub the sync triggers (and the login spec the trip sync), which
none of them is about.
GET /api/system-notices/active gets a default MSW handler returning no
notices, so a login or a mounted notice host anywhere in the suite
settles that fetch in the test instead of on the network. The notice
store's own tests still override it.