Commit Graph

325 Commits

Author SHA1 Message Date
Maurice feef872776 ci(client): lint:pairs keeps a desktop view and its phone twin on one hook
Most features have a desktop view and a phone view, and until now each
carried its own copy of the feature's logic, so a fix on one side drifted
away from the other. With the logic moved into one shared hook per
feature, scripts/feature-pairs.json names every feature with its hooks
and the desktop and phone files that run on them.

lint:pairs fails when a listed view stops importing one of its pair's
hooks (a type only import does not count), when a listed file is gone,
when a hook sits in a view layer rather than under components, hooks,
utils or the folder of a page under pages/, and when a file under
src/mobile/screens is in no pair and not under mobileOnly with the
reason it has no twin. A new phone screen is so either wired to the
desktop feature's hook or marked as phone only on purpose. lint:dup
catches the copies that look alike; this one catches the copies written
anew.

The manifest starts with 120 features and 62 phone only screen files,
each with the reason it stays on the phone alone.

client/CLAUDE.md says how to change a feature, add one with both views
and add a phone screen.
2026-10-09 15:20:10 +02:00
Maurice 88d196bae8 ci(client): lint:dup holds copied code to a baseline that only shrinks
Most features have a desktop view and a phone view, and each carried its
own copy of the feature's logic, so SonarCloud's duplication condition
was the gate a pull request touching both failed most often, and only
after a scan of twenty minutes. lint:dup finds the same copies before
the push.

It runs jscpd's detector (@jscpd/core with @jscpd/tokenizer) over the
source files under src/ at the thresholds the SonarCloud gate applies to
TypeScript, 100 tokens over at least 10 lines, and counts per file the
lines inside a block that repeats elsewhere or in the same file. Tests
and declaration files are left out as Sonar leaves them out, and .ts and
.tsx are read as one language, so a hook that repeats a view's code is a
copy too. The files are read sorted and with readText, so every machine
counts the same. scripts/dup-baseline.json holds the files that still
hold copies with their count; a file may hold no more than its entry, a
file without an entry none, and an entry above its file or for a file
that is gone fails until --update lowers it. --list prints every block
with its other side.

The engine packages are used instead of the jscpd command, which walks
the tree in file system order, skips files past its size limits without
a word and drops every result once it meets a file it has no format for.
2026-10-09 15:20:10 +02:00
Maurice b598b97865 refactor(planner): share the trip sheet wiring between desktop and phone
The phone trip sheet host wrote out by hand three pieces of planner
wiring the desktop planner already had.

useBookingExpenseEditor now holds the expense editor both planners open
over the trip: the booking request state, the receipt fallback, the base
currency, the current user and the save that reloads the budget.
TripPlannerPage and MTripSheets both read it.

applyTripCoverUpdate patches a new trip cover into the store for the
trip form on both sides.

The transport editor and the transit route change moved into
openTransportEditorWith and changeTransitRouteWith, which take the
planner's setters. usePlannerDialogs builds openTransportEditor and
changeTransitRoute on them, and the phone sheet calls the same helpers
on the planner it receives. There are no differences between the two
views to keep as options.
2026-10-09 15:19:51 +02:00
Maurice 973773e003 refactor(files): share the file filter, link list, note save and trash between desktop and phone
fileListRules now holds the type category of a file, the filter tab match
and the list of places and bookings a file is linked to (own column plus
extra links, transport or booking). useFileManager and FileManagerRow on
desktop and filesModel on the phone run on it. fileActions gains the field
save with its reload and assign error, which the desktop assign dialog and
the phone note edit both use, and the trash call with its toasts, used by
the desktop delete and the phone menu sheet. The phone sheet no longer
imports filesApi.

Differences that stay, each as an explicit option:
PHONE_FILE_FILTER_RULES sorts pdf, image and doc by type category (videos
count as images, doc is every file that is no PDF and no media, a pass
with a PDF mime type is a doc) and counts a note id of 0 as collab, while
desktop reads the mime type (images only, doc is Word, Excel and text).
DESKTOP_FILE_LINK_LIST_RULES skips an own place or booking column of 0,
the phone only a null one. PHONE_FILE_UPDATE_RULES waits for the reload
after a save, so a failed reload shows the assign error there; desktop
starts it and moves on. DESKTOP_FILE_TRASH_TOAST_RULES falls back to
English when the trash toast translation is empty.
2026-10-09 15:19:51 +02:00
Maurice d0709c6a37 refactor(vacay): share the calendar logging logic between desktop and phone
The log mode and its half and comp modifiers, the entry, shared and company holiday maps, the trip dots, the day click rules, the default person and the day count format lived in both the desktop calendar and the phone screen. They now live in useVacayCalendarLogic and vacayFormat; the phone keeps reloading trip dots only on a new window shape.
2026-10-09 15:19:50 +02:00
Maurice 8e95b4c7b6 refactor(journey): share the entry editor logic between desktop and phone
useJourneyEntryForm holds the fields, the photo queue that only goes out on
save, the gallery links, the location search and "use my location", the
verdict rows, quick capture and the save for both the editor dialog and the
entry sheet. journeyWeatherCategory moves to components/Journey/journeyWeather
so the dialog no longer reaches into mobile/. Where the two differed they still
do: the dialog starts with a blank verdict row, counts a moved point as a
change, toasts a failed save or location and fills the weather from the
forecast; the sheet edits tags and shows a refused location under the field.
2026-10-09 15:19:49 +02:00
Maurice 52461a6cd8 fix(admin): stop a stale base URL from reaching the Anthropic config on the phone
The phone AI parsing form saved the base URL left over from a Local or OpenAI setup after switching to Anthropic, so Anthropic requests went to the old host. It now saves an empty base URL for Anthropic, as the desktop form does since #1777.
2026-10-09 15:19:46 +02:00
Maurice 2a242f29fb fix(admin): keep a parallel addon toggle when a photo provider toggle fails on the phone
The phone rolled a failed photo provider toggle back by restoring the whole addon list it saw at the click, which also undid any switch flipped while the request was in flight. Photo providers now go through the same toggle as every other addon, with a per row rollback, as on desktop.
2026-10-09 15:19:46 +02:00
Maurice 2acef40c35 fix(admin): keep a later notification toggle when an earlier one fails on the phone
When a toggle on the phone matrix failed after a later toggle had already rendered, its catch put back the preferences from its own click and so also undid the later cell. The live preferences are now mirrored in a ref and a failure reverts only its own cell, as the desktop panel already does. The test hangs the first save, lets the second one through and then fails the first.
2026-10-09 15:19:46 +02:00
Maurice 7298285a43 refactor(collab): share the chat logic between desktop and phone
collabModel moves from the phone tabs into components/Collab so both
shells read it, and the desktop chat drops its own copies of the time,
date separator and emoji only helpers for the model's.

useCollabChatData holds the messages, paging, live updates, the draft with
its images, and sending, deleting and reacting. The desktop hook keeps its
emoji picker, hover and delete animation, the phone tab its long press
popover; where they differed (older page errors, scrolling after a remote
delete, upload progress, the edit permission on send) the hook takes an
option.
2026-10-09 15:19:43 +02:00
Maurice 131d57b755 refactor(budget): share the costs ledger and settlement form between desktop and phone
costsModel moves from the phone tabs to components/Budget.
useCostsLedger holds the settlement, the totals, the filters, the CSV
export and the payments, and useSettlementForm holds the payment form.
CostsPanel and MCostsTab both run on them.

The old differences stay behind flags. The desktop also loads the
settlement on a trip change, leaves expenses nobody paid out of my share
(#2225) and writes each currency as stored in the CSV. The phone sheet
starts blank, refills when it opens and ignores a second save while one
is running.
2026-10-09 15:19:42 +02:00
Maurice 4d4ef17de8 refactor(packing): share the packing list logic between desktop and phone
usePackingItemActions, usePackingTemplates and usePackingImport take the
item, template and bulk import logic out of usePackingListPanel and
MPackingListTab, and the phone listsModel keeps only its priority
tokens.

When adding to a category the desktop looks for the placeholder in the
store and the phone in its rendered items. The desktop toasts on an
empty import, the phone runs one import at a time. The effects run in
the same order as before.
2026-10-09 15:19:41 +02:00
Maurice 6dc8699bd1 refactor(todo): share the to-do filters and the task form between desktop and phone
The to-do filters, counts, sort order and categories move from the phone
listsModel into todoListModel. useTaskForm holds the name, the fields,
the load and the save of a task for the desktop detail and new task
panes and for MTaskSheet. Each side keeps its own guard and error
wording.
2026-10-09 15:19:41 +02:00
Maurice 1b51181930 refactor(planner): share the bookings and transports list filters between desktop and phone
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.
2026-10-09 15:19:40 +02:00
Maurice 9f3e1f8c83 refactor(weather): share the day weather anchor and icon map between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice 9c39a0fc58 test(offline): count the trip member the v2 case stores 2026-10-08 20:22:17 +02:00
Maurice ee921d5c28 test(offline): name the v6 upgrade case for what v6 does
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.
2026-10-08 18:38:29 +02:00
Maurice 5049f3762f test(offline): cover every Dexie upgrade path and guard new versions
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.
2026-10-08 18:38:29 +02:00
Maurice 5bb45bc188 feat(offline): stamp queued writes with their format and skip newer ones on replay
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.
2026-10-08 18:38:29 +02:00
Maurice bd267c67f8 ci(client): fail the count ratchets on an entry above the tree
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.
2026-10-08 14:11:54 +02:00
Maurice df2768d30e fix(client): let a flush called mid-pass resolve with that pass
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.
2026-10-08 14:11:54 +02:00
Maurice 72c284467b fix(client): cache a visit note saved from the place dialog
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.
2026-10-08 14:11:54 +02:00
Maurice 13584d1bf6 style(client): format the i18n key check and its test
The new lint:format ratchet admits no unformatted file outside its
baseline, and these two arrived after it was taken.
2026-10-08 13:27:38 +02:00
Maurice 9a7c17e7cc fix(i18n): a quantity under another name than count is a plural group too
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.
2026-10-08 13:04:42 +02:00
Maurice c0516dd9bf fix(client): check each literal branch of a template key
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.
2026-10-08 13:04:42 +02:00
Maurice 33f004ed04 fix(client): a wide template key needs a reviewed allow-list entry
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.
2026-10-08 13:04:42 +02:00
Maurice bdaddc44ac fix(i18n): every count-bearing string is a plural group
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.
2026-10-08 13:04:41 +02:00
Maurice f00c354d0d fix(i18n): add the two keys the client named without en having them
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.
2026-10-08 13:04:41 +02:00
Maurice 4062b314cf fix(client): lint:skips counts skipIf(true) and runIf(false)
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.
2026-10-08 13:04:41 +02:00
Maurice 1b60de27f4 ci(client): lint:warnings fails an inline rule config comment
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.
2026-10-08 13:04:41 +02:00
Maurice 9d596d9916 ci(client): lint:size measures the Playwright specs and fixtures too
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.
2026-10-08 13:04:41 +02:00
Maurice 3e37dcfb45 fix(client): lint:layers holds src/types.ts to the rules of types
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.
2026-10-08 13:04:40 +02:00
Maurice ead327be73 ci(client): lint:format covers the Playwright specs and the client scripts
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.
2026-10-08 13:04:40 +02:00
Maurice 17b72ff778 ci(client): lint:layers rules help/ and vacay/ as helper layers
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.
2026-10-08 13:04:40 +02:00
Maurice ae84f8859f fix(client): lint:skips counts skips with any title and Playwright's fixme
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.
2026-10-08 13:04:40 +02:00
Maurice 7ac20a0b59 test(client): pin the exit codes of the ratchet commands
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.
2026-10-08 13:04:40 +02:00
Maurice 724cdd1c0e test(client): keep the sign-in background work out of the auth specs
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.
2026-10-08 13:04:40 +02:00
Maurice 0f1020cb80 ci(client): lint:skips forbids .only and ratchets skipped tests
JourneyDetailPage.test.tsx has carried 35 describe.skip blocks since
April, for contributors, trip linking and the map view, features that
were reworked rather than removed. Nothing stops the next skip, and
nothing stops a stray .only from passing CI while the rest of its file
never runs.

lint:skips reads every vitest file under src/ and tests/ and every
Playwright spec under e2e/ from its syntax tree. A .only on any runner
fails the check, always. Skipped and todo tests (it.skip, describe.skip,
test.todo, the x-shorthands, skip.each) are counted per file against
scripts/skip-baseline.json, which only lowers. A skip that decides at
run time is not a declaration and is not counted: skipIf, runIf,
Playwright's test.skip(condition, 'why') and a bare test.skip() inside a
test.
2026-10-08 13:04:40 +02:00
Maurice 07f35d8b81 ci(client): lint:warnings ratchets the ESLint warning counts per rule
eslint.config.mjs keeps most rules at warn so that CI stayed green on a
codebase that was never linted, and CI runs eslint without
--max-warnings. Warnings therefore only ever accumulated: 1218 today,
among them 194 unused variables and 144 missing effect dependencies in
app code, the kind behind the stale closures #2610 chased.

lint:warnings runs ESLint over the client with its own config and counts
the warnings per rule, separately for app code and for tests, so the
headroom from fixing a test cannot be spent in app code. Messages that
an eslint-disable comment silenced are counted as a third group, so an
inline disable is no way past the ratchet either. No count may pass its
entry in scripts/eslint-baseline.json, and --update only lowers. Any
error, a parse failure included, fails the check too, so it can replace
the plain lint:check step in CI instead of running ESLint twice.
2026-10-08 13:04:40 +02:00
Maurice 7a7903a799 ci(client): lint:offline stops new views from calling the API directly
The offline core only works when the views go through it: component,
feature hook, store, repo, then api or Dexie, with writes through the
mutation queue. A view that imports src/api/ skips the cache, the
optimistic write and the idempotent replay. client/CLAUDE.md said so
and noted that no ratchet held the line, and the number of view files
importing api/client grew from 161 to 190 in two months.

lint:offline lists the 213 files under components/, mobile/, pages/
and hooks/ that import anything from src/api/ for more than types
(api/client, the websocket, the per-domain modules) in
scripts/offline-baseline.json. No other view file may, and a listed
file that stops leaves the list through --update, so the next file at
that path does not inherit the exemption. Type-only imports do not
count: they carry no request.

client/CLAUDE.md describes the rule next to the data-flow section,
including that an online-only domain puts its calls in a store or repo.
2026-10-08 13:04:40 +02:00
Maurice 798553b13b ci(client): lint:layers holds the import direction between src/ trees
Components reach up into pages/ for models and helpers (28 imports in
25 files: the journey detail helpers, the collections, atlas and
dashboard models) and sideways into mobile/ for phone hooks and sheets
(36 in 30 files), and a few stores and a repo import from components/.
Nothing stopped the next one, and every such import ties two trees
together that should move and split independently.

lint:layers reads each source file's imports from its syntax tree
(multi-line imports, re-exports, import() and type imports included,
nothing inside comments or strings) and checks them against a small
rule table: a component imports neither pages/ nor mobile/, a shared
hook no view, nothing under the views (store, repo, sync, api, db,
utils and the other helpers) any view, a repo no store, and the Dexie
schema none of the layers above it. mobile/ and pages/ stay free, since
the phone screens reuse the page hooks by design.

The 80 imports that break a rule today are counted per file in
scripts/layers-baseline.json, and a file may hold no more than its
entry. Type imports count: a type the lower layer needs belongs below
it. Tests are not checked.
2026-10-08 13:04:39 +02:00
Maurice 37d386178b ci(client): lint:format keeps new client code Prettier-formatted
client/package.json has had a format:check script, but no workflow runs
it, and 2144 of the 2171 .ts, .tsx and .css files under src/ and tests/
differ from what .prettierrc asks for. Without a formatter gate the line
count in lint:size could also be gamed, and every new file picked its
own layout.

Formatting everything in one commit would bury the history, so lint:format
lists today's unformatted files in scripts/format-baseline.json and only
lets the list shrink: a file outside it, any new file in particular, must
be formatted, and a listed file that has been formatted must leave the
list (--update takes it off and never adds one). A stale entry fails the
check, since it would let the next file at that path in unformatted.

The check runs Prettier through its API with the config that applies to
each file and reads the text with LF line ends, so a Windows checkout
agrees with CI about endOfLine. A file Prettier cannot parse counts as
unformatted, and a missing config stops the run.
2026-10-08 13:04:39 +02:00
Maurice 658807b1ac ci(client): lint:rtl fails closed and counts its disable markers
rtl-lint compared its own path with argv[1] to decide whether it was
the command being run. Started through a symlink or with a path that
differs in its form, it printed nothing and exited 0. The check now
lives in scripts/lib/rtl.mjs and the command file always runs it.

A broken rtl-baseline.json read as empty, so --update would have
written {} over it. It now stops the run with its reason, like
size-baseline.json.

The rtl-lint-disable marker skipped a line wherever it appeared, inside
a string literal too, and nothing bounded how often it was used. It is
now honoured only inside a comment, and the marked lines are counted
per file against scripts/rtl-disable-baseline.json. Today that is the
four map overlay positions in PlanMapStage.tsx. A side that is geometry
on purpose still gets its marker, but its entry is raised by hand, where
review sees it, instead of the marker quietly replacing the logical
forms.
2026-10-08 13:04:39 +02:00
Maurice 2f54f04380 ci(client): lint:size counts wrapped lines and covers tests and stylesheets
lint:size counted newlines, so a file could stay under its entry by
joining lines, and the trip planner page already carried one JSX element
on a single 3760 character line. A line now counts once per 120
characters it spans, the printWidth in .prettierrc, so formatted code
measures what it shows and packed code measures what it holds.

The check also covered only .ts and .tsx sources. Stylesheets now share
the 1000 line limit, and tests get a limit of their own at 2000: they
mirror the component they cover and carry fixtures, but the largest are
past 5000 lines and growing with the god components.

The baseline is measured again with the new rule and keyed by the path
from client/, since it now spans src/ and tests/. Entries are higher in
number than before because the metric changed, not because a file grew;
every file is held at exactly what it holds today. New entries are the
ten largest tests, index.css and studio.css, the generated
countryShapes.ts (305 lines of 2600 characters each) and
JourneyDetailPageEntryEditor.tsx, which was just under the old count.

The scanning, baseline reading and lowering move to scripts/lib/ so the
next ratchets share them. A baseline that is missing or misshapen, a
directory that is not there and a scan that finds nothing now fail the
run instead of reading as empty, and the command file always runs its
check: there is no entry point guard left that could skip it.
2026-10-08 13:04:39 +02:00
Maurice 48b1e879d2 fix(offline): an online edit waits behind an older queued write to the same entity
Holding later queued writes behind a parked one only ordered writes that
were in the queue. Online, the repos send an edit straight to the server, so
an edit made while an older write to the same visit, tour or driving settings
was backing off or parked landed first, and Try again (or the next flush)
then replayed the older write over it. Those writes carry no version token,
so nothing refused the stale replay.

The repos of visits, day clears, tours and driving settings now ask the
queue before an online write. While an older write to the same entity is
pending, in flight, parked or a conflict, the new write is queued too, with
the same optimistic Dexie write as offline, and a flush is started so it
goes out as soon as the one ahead of it does. When the driving settings left
the cache since the older write, they are read in first.

Places and packing items keep their direct online path: they send the base
token, so a stale replay is refused with a 409 and becomes a conflict. What
this does not cover, and the docs now say so: a collaborator's change to a
token-less entity is overwritten by the retried write, as by any replayed
offline write of those resources, and the planner's two time edits that call
the API directly are not held. The wording of the parking commit also named
budget items, which never go through the queue.
2026-10-08 13:02:58 +02:00
Maurice b61a55f037 fix(offline): archived trips on the device follow the eviction rules again
Keeping archived trips stopped the sync from wiping them, but left nothing
that ever took one off the device. The trip sync never refreshes an archived
trip, and the 7-day rule and the per-trip switch only looked at the active
list, so an archived trip stayed until Clear cache however long ago it ended.

The stale and switched-off rules now also run over the archived trips that
are stored here, with the same eviction as an active trip: its rows go, its
parked changes stay. The offline settings list the stored trips the server's
list leaves out, so an archived trip gets a switch the user can turn off;
before, the switches came from the active list only and could not reach it.
2026-10-08 13:02:58 +02:00
Maurice e132d1529f fix(offline): a tab waits for the flush lock instead of skipping its flush
The cross-tab lock was taken with ifAvailable, so a tab that found another
tab replaying returned from flush() at once. Its callers read that as "the
queue is sent": the online trigger then re-seeds every trip from the server
and rehydrates the open ones, and the socket refetches after its pre-reconnect
flush. All tabs see the online event together, so the tabs that lost the race
laid the server's rows from before the replay over the shared Dexie, and over
the optimistic edits still on their way, while the winning tab was mid-flush.

The lock is now requested without ifAvailable. A tab that finds it held waits,
then runs its own pass, which finds nothing or what the holder left behind,
so flush() resolves only after the replay and the re-seed chained onto it
reads the state after it. The holder's requests are bounded by the client
timeout, so the wait is too.
2026-10-08 13:02:58 +02:00
Maurice f5339307b2 fix(offline): only one tab flushes the queue at a time
Every open tab shares the account's queue and runs its own flush
triggers, and the _flushing flag only guards its own tab. Two tabs read
the same pending rows and both replayed them, so correctness rested on
the server's replay cache answering the second request, and each tab ran
the temp id remap and the Dexie write a second time.

The flush now takes a Web Lock named after the account's database, with
ifAvailable, so a tab that finds it taken skips the run the way a busy
flush in the same tab always has; the holder picks the rows up. The lock
is released with the tab, so a tab killed mid-flush cannot strand it.
Where the API is missing or refuses the request, the flush runs as
before under the flag alone.
2026-10-08 13:02:58 +02:00
Maurice b4f705a049 fix(offline): a parked write holds back the later writes to the same entity
Parking a write the server keeps failing on unblocked its trip, so a later
queued edit of the same budget item, visit, tour or driving settings went
through while the older one waited. Try again then replayed the older
write last and it silently overwrote the newer one: only places and
packing items send a token the server could refuse it with. Before the
parking change a 5xx stopped the whole flush, so this order was never at
risk.

The flush now holds back every pending write whose entity has an older
write parked as failed or waiting as a conflict, and a write that parks
during the pass holds back the rest of that pass too. Other entities and
other trips keep syncing. Try again therefore replays the parked write
first and the held ones after it, Keep mine does the same for a conflict,
and Discard or Keep theirs flush right away so the held writes go out
without waiting for the next trigger. Writes queued against an offline
create that got parked now wait for it instead of failing as an
unresolved temp id.
2026-10-08 13:02:58 +02:00
Maurice 237f82c0b6 fix(offline): an archived trip is not a deleted one, and eviction keeps parked changes
The trip sync clears every cached trip the server no longer lists, but it
read only GET /trips, which leaves archived trips out. Every sync (login,
reconnect, the periodic run) therefore wiped every archived trip from the
device, pinned ones included, and with it their parked failed changes. A
trip now only counts as gone when it is missing from both the active and
the archived list, and when the archived list cannot be read nothing is
treated as gone that round.

clearTripData no longer deletes 'failed' rows. Stale eviction, a trip
switched off in the storage settings and a trip that is really gone all
go through it, and none of them is the user deciding to give up a change.
A parked change of a deleted trip, or one the user was removed from, now
stays in the queue: Settings > Offline counts it under failed changes and
offers Try again (the server answers 403/404, so it parks again) or
Discard. Dropping it silently would lose the only copy of an edit the
user may still want to look at or redo elsewhere, and keeping it costs
nothing more than a counter the user can clear in one tap.
2026-10-08 13:02:58 +02:00