Compare commits

..

780 Commits

Author SHA1 Message Date
Maurice bda24ec639 fix(tours): description on its own line, fewer and whole badges in the tour list
The tour list row puts the description between the name and the badges.
The badges keep the three that matter most (time, then a paddle tour's
check bench and distance, or a hike's distance and climbing) and only
those that fit whole, so a narrow sidebar shows two instead of cutting
one off. A check bench without obstacle data shows no pill in the list
or the day plan; the checklist still names the gap.
2026-10-17 15:27:51 +02:00
Maurice 09caf4bc3f fix(tours): sorts that name their order, the note focused without autofocus
The checklist and the obstacle data sorted texts without a compare
function. They now sort by code units through one shared compareCodeUnits,
the order a bare sort gave, the same on every machine; the material data of
a lock's parts no longer orders by the server's locale, so its hash cannot
change with it. The note editor takes the focus through a ref instead of
autoFocus, a direct function reference in a map becomes a call, and the
GPX import's default fields are a named constant.
2026-10-17 03:18:04 +00:00
Maurice 9272151c07 fix(tours): pending checklist saves per tour, a queued checklist waits for its replay
A save answered on one tour no longer clears the pending state of a save
started on another. A checklist changed here offline waits for the queue to
replay before it is read online, as a bench of such a write already did. A
saved tour the list has not loaded yet is no unsaved paddle draft, and Next
walks the rows as the list shows them, a vanished object among them.
2026-10-17 02:31:01 +00:00
Maurice 0105891355 fix(tours): a checklist cut short at its caps says so, also when an earlier decision tips it over 2026-10-17 02:31:01 +00:00
Maurice a7f5a114e6 fix(tours): the checklist keeps to its tour, its state and its focus
A decision answered after another tour was opened no longer replaces that
tour's checklist. A decision made offline on a checklist already older than
the route stays marked as such, and once online a bench from a write made
offline waits for the queue to replay, then takes in the server's. While
the checklist reads again it stays in view, locked, so a panel open on the
map keeps its note. A saved tour turned into a paddle tour asks to be saved
first, a vanished obstacle stays among the rows, Next follows the list
order and keeps the focus in the panel, a note closes only once saved, the
earlier choice in review is named for screen readers too, planning a
portage is offered to editors only, and the map card scrolls within the
screen. A tooltip of several lines breaks at them. A page test waits for
the tours row instead of reading it at once.
2026-10-17 02:07:22 +00:00
Maurice 2a4b71b3d9 fix(tours): checklist entries within their contract, whole locks and a leaner reading
A lock flight chained into one group of more than 30 parts broke the
checklist's contract and made every decision about it fail; an entry now
names its nearest part and the first others, a portage lists at most 30
obstacles and a checklist at most 100 own entries. Reading along a line
takes in the parts of a lock the corridor cut off, so its data basis no
longer depends on how the route runs. A line or an area stands at the
middle of its extent, so adding a node leaves it in place, and an earlier
decision is judged by where the obstacle was, not by the route point of its
passage. The cache keeps its readings without their lines, for one data
version at a time, and the data version is read without the region
boundaries.
2026-10-17 02:07:22 +00:00
Maurice d46b6fb646 refactor(tours): the map stage's track pick and the planner's focus points in hooks of their own
The tour map stage and the trip planner page hook had grown past the
complexity and length the lint ratchet holds them to. Marking a spot on a
recorded paddle track moves into useTrackLinePick, and what the planner map
frames into usePlannerFocusPoints, each with its own tests. The planner's
viewport snapshot no longer reads a ref while rendering, so the count of
that warning goes down by one.
2026-10-17 02:07:22 +00:00
Maurice 4a64fc9d21 docs(wiki): the tours MCP tools name a paddle tour's check bench and checklist 2026-10-17 01:16:41 +00:00
Maurice 5ee8d450a9 refactor(i18n): the checklist's drawing hint goes, nothing in the planner shows it 2026-10-17 01:16:41 +00:00
Maurice de86e6383a feat(tours): the obstacle checklist, the decision panel and the safety note in the planner
PAD-06 and PAD-09 on screen. A paddle tour open in the planner or shown as
recorded carries a checklist card: the obstacles of the data along its
route in route order with the paddler's own portages and locks among them,
each with its status, its paddling rule and the SUP advice at a lock, and
the earlier decisions the route or the data no longer carries behind a
fold. A row moves the camera onto the obstacle and the other parts of its
lock and opens its card on the map, where the decision panel sits: four
choices, the notices that stay after a choice (a ban, the SUP advice, a
portage still to plan), the date of the last check on site, a note and who
decided when. An object the map does not hold opens in a dialog instead.
Deciding waits while the route has unsaved changes, and a stale or dirty
list is dimmed, never hidden.

The tours list, the desktop day plan and the phone day plan show the bench
as one pill, the tour detail shows it with the data's date, and every paddle
tour reaches the safety note from its footer. Nothing reads as safe where
TREK knows nothing, and nothing blocks.
2026-10-17 01:16:41 +00:00
Maurice 14b917ee97 test(tours): a checklist cut short past its cap, and a decision save failing part way stores nothing 2026-10-17 00:31:29 +00:00
Maurice 7fffd4300f feat(tours): the paddle checklist through the offline core on the client
PAD-06 and PAD-09 on the client's data side. A tour's cached row keeps its
checklist beside its provenance, written through one cache helper by the
repository and the queue replay, which also keeps provenance an answer
lacks. The checklist is read for the bench the list shows and served from
the cache while its revision matches. Decisions made offline, or behind a
queued write of the same tour, change the cached checklist and bench at
once under a local revision and replay with their idempotency key; a route
written offline marks the checklist stale until the server judged it.

The obstacle store learns a camera focus: a click in the rail moves the
camera onto an obstacle and the other parts of its lock, the newest of such
a focus and a picked search result wins, and a selected lock lights up
whole on both maps. The checklist hook and its model hold the logic the
cards will show.
2026-10-17 00:29:26 +00:00
Maurice fa8272c522 feat(i18n): the paddle checklist and safety strings in every language
The checklist, its statuses, choices and notices, and the safety note of a
paddle tour, translated into all 27 languages. A spec keeps the checklist
strings in English and German from claiming the water is safe, cleared or
passable. The paddle strings of the last batch that Prettier had not seen
are formatted.
2026-10-17 00:29:18 +00:00
Maurice c40d00b830 feat(tours): a paddle tour's obstacle checklist and check bench on the server
PAD-06 and PAD-09. The tours list carries the check bench of every paddle
tour and the detail its whole checklist, worked out of the obstacle data
along the tour's line and the decisions stored for it. The obstacle data is
read along the line run by run and kept in memory under the line, the boat
and the data version, so a list of a trip reads it once; a hike costs
nothing. While the data cannot be read the checklist says so and keeps every
decision visible.

PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews stores what the
paddlers decided, each against the basis the user saw, and answers the
detail. It needs place_edit, refuses a tour that is no paddle tour with a
400, waits with a 503 while the data cannot be read, and skips an obstacle
no longer on the checklist instead of failing. A copied tour and a copied
trip take their decisions along. The MCP read tools describe the new fields;
there is no write tool, since a decision is a statement of the paddlers.

The obstacle data keeps the dates of checks on site, covers a line by
sampling it, and tells its version by its rows and regions.
2026-10-17 00:29:18 +00:00
Maurice 86976aef10 feat(tours): the contracts and the pure core of the paddle obstacle checklist
PAD-06 and PAD-09 in shared. A paddle tour's checklist lists the obstacles
of the OpenStreetMap data along its route, each bound to a basis: the route
near the obstacle as samples around every passage, and the material data of
the obstacle as a hash and its position. A decision keeps that basis, so it
reads as one to check again once the route or the data changed there, and a
portage decision holds while the paddler's own portage carries round every
passage. The check bench sums the list up for the tours list and the day
plan. Nothing in it is a safety or legal clearance.

The route relation learns the passages of a route by an object and the
distance of a point to a route. The client takes the obstacle key from
shared instead of its own copy.
2026-10-17 00:29:10 +00:00
Maurice c246877f30 refactor(tours): the region repository reads the status enum of its entity, unused exports go
PaddleRegionsRepository types and writes the status through the generated PaddleRegionsStatus instead of a copy of its four values; the paint tokens, a fact key type and a minutes input stay private to their modules, the wind test pins its 502 text, and the planner page's size entry follows it down.
2026-10-16 23:24:20 +00:00
Maurice 1e2ba0f842 feat(i18n): the paddle portage, time, wind, obstacle and region strings in every language
The strings of the paddle plan, the time estimate, the wind card, the obstacle card and notice and the admin's paddle water data, translated into the 25 locales that still lacked them, with the plural forms each language needs. The pinned count of Tours keys follows.
2026-10-16 23:22:09 +00:00
Maurice 64e413330e test(tours): cover the paddle time card, the wind card and the marks on a recorded paddle track
The time card's estimate with water, portages and locks and a lock without times leaving it incomplete, every state of the wind card with day switching and missing values that never read as calm, and the track marks with their tools, minutes, removal and saving through the repo.
2026-10-16 23:20:46 +00:00
Maurice 2e3fd4fc4c test(admin): cover the paddle regions hook: polling while a region is read, the debounced catalogue search, the actions and offline 2026-10-16 23:14:26 +00:00
Maurice 1c38d34699 docs(wiki): Tours in the admin addons, with setting up the paddle water data
How an admin adds a region from Geofabrik's list, what the card shows while it is read, the monthly refresh on the 3rd, what a failed read keeps, and the OpenStreetMap attribution under the ODbL; a record in the data is no check on site.
2026-10-16 23:12:46 +00:00
Maurice ccfb610aab test(tours): cover the paddle obstacle card, map layers, route band, notice, sync, repos and the admin regions panel
Thirteen client suites for the paddle obstacle work and the region admin: the detail model and card, the overlay with its outside press and Escape, the Leaflet and GL layers with the area zoom threshold and the single click handler, the route band marks, every state of the obstacle notice, the sync hook, the tiled obstacle repo, the online only region repo, and the regions panel. The wind hook for the start of a paddle tour is covered too.
2026-10-16 23:12:41 +00:00
Maurice 03d6457bde feat(tours): paddle obstacle card, admin regions panel and obstacle marks on the route band
PAD-05B: a click on an obstacle on the map or on the route band opens its card in the look of the via ferrata card: the kind with its glyph, the name, how it stands to the route (on it at a distance along it, near it with the link marked unclear, or only in the area), the paddling rule its tags give with canoe read before boat, a condition as written, height, grade, hours and fees, the source with the date of the data and the OpenStreetMap link. A missing rule reads as unknown, never as allowed, and a portage way recorded as private says so. Objects only in the area show from zoom 13 on, on Leaflet and GL alike; on GL the lines only near the route are dashed as on Leaflet, one click handler picks the marker over the line, and a picked marker no longer flickers. The route band marks the obstacles on and near the route by their share of the measured line. The obstacle notice also names an answer cut at its limit.

PAD-05A: the admin area gets a Paddle water data card under the addons, on desktop and phone: a search in Geofabrik's list with each region's path, the regions with their state (waiting, reading with its progress, ready with objects and data date, failed with the reason), reading again and removing after a confirmation. The list follows a read without reloading. Region changes write audit rows.

Map colours of paddle objects and portages come from theme tokens read at draw time, as the via ferratas do; the ferrata layer now shares that reader. The rail takes the planner page's parts as one bag, the map hit tests of the tour layers are one helper, and the unused noWaterData string is gone from every locale.
2026-10-16 23:03:09 +00:00
Maurice 89ad4ec56a fix(tours): paddle data reads stream, queue safely and keep their coverage during a refresh
The PBF reader streams the extract and refuses a varint longer than ten bytes; the block offsets are read as the plain int64 the format defines. Region reads queue without loose promises: a region queued while another is read is still picked up, removing a region calls its read off, a shutdown stops the read under way and the next boot queues it again and clears the download folder. The admin list shows how far a read has come.

Geofabrik is asked only on its own servers, redirects included, and an outage of its list answers 502 instead of an unknown region. Nested ids such as us/texas are accepted.

A region being read again or after a failed read still counts with the data it holds, a region without a boundary spans its objects, and an object two overlapping regions hold is answered once. Stored geometry, tags and boundaries go through the JSON column declarations. An obstacle is related to the route both ways, so a long weir passed in its middle and a route inside an area count.

Tests cover the reader with extracts written in the test, the tag rules, the extraction and lock grouping, the geo helpers, the Geofabrik client, the region queue, the read side, both controllers, the job, the repositories and the HTTP routes; the domain gets its coverage entry.
2026-10-16 22:27:29 +00:00
Maurice 2c3b20bb57 feat(tours): paddle portages and locks, paddle time, wind at the start and obstacle data from OpenStreetMap
PAD-03 portages and locks on planned tours and recorded tracks, PAD-07 paddle time with portage and lock stops, PAD-08 wind forecast for the start, PAD-05A regional obstacle import from Geofabrik extracts with admin endpoints and a monthly refresh, PAD-05B first cut of the obstacle layer on Leaflet, GL and 3D. Work in progress: checklist (PAD-06), check bench (PAD-09), admin UI, translations and tests follow.
2026-10-16 23:44:58 +02:00
Maurice d705bd9dd0 test(tours): cover the seamark overlay on GL maps and the tour type fields 2026-10-16 23:14:52 +02:00
Maurice 704621d0c2 refactor(tours): move the detail view's measures into their own component 2026-10-16 23:14:52 +02:00
Maurice 9494521ac7 feat(tours): plan paddle tours, hand-drawn legs, reverse and return to start, maps per tour type
- Paddle as a plannable tour type: one boat (kayak, canoe, SUP, other), river or
  lake, a pace that a boat change never overwrites; time is distance over pace
- Hand-drawn legs beside routed ones, drawn dotted and labelled as not routed,
  kept through save, reopen and edits elsewhere
- Reverse a tour and close it back to its start, each one undo step
- GPX import shows a preview first (line, measures, duplicates, type) and only
  the confirmation stores the tour
- Base maps and overlays per tour type in the planner: hike keeps topo and the
  via ferrata layer, paddle gets the OpenSeaMap seamark overlay
2026-10-16 23:14:52 +02:00
Maurice 33b006014b fix(settings): typing in the wanderer address and key fields no longer gets undone
useToast handed back a new object on every render. useWandererConnection lists
toast in its load effect, so each keystroke reloaded the saved settings and
reset both fields to them.
2026-10-16 11:33:19 +02:00
Maurice 52ed63715c Tours: tests follow the provenance read and the shared day assignment, the tours addon ends MCP sessions, a line without a point is not copied
The repository stubs of the tour service tests answer findProvenance, which getTour and createTour read since P2C-02.
Switching the Tours addon off now ends live MCP sessions like every other addon that gates a tool (MCP-GATE-PARITY-001).
copyTour refuses a line without a point instead of placing the copy at 0/0, and new tests cover the reachable paths
of the copy, the source check and the provenance of a snapshot without its import record, including a trip copy
keeping that record. On the client the shared day assignment hands the plan exactly the tour and the day, the new
visibility store is reset between tests, and the tests of the sidebar, the detail, the phone sheet and the origin
section match the views this PR built.
2026-10-15 22:34:33 +00:00
Maurice 1d7758b819 Tours: the trip copy keeps its copied tours in one map, so trips.service stays at its size baseline; unused TourMapVisibility type removed 2026-10-15 21:40:19 +00:00
Maurice b33727ec75 Tours: no non-null assertion, useless escape or awaited loop in the tour and trip copy paths 2026-10-15 23:25:16 +02:00
Maurice 97b5f36ef0 Tours: the list filter names a tour planned here TREK, as its badge does 2026-10-15 23:11:39 +02:00
Maurice 845979902c Tours: client tests for the new hooks and views, row chips fade at their end, admin user table aligned; strict and lint ratchets green 2026-10-15 23:08:46 +02:00
Maurice a0884685ef Map: tracks drawn from their own modules on both maps; mobile filter chips on the type scale 2026-10-15 22:25:34 +02:00
Maurice 485e360c64 Tours: save a tour as a new, independent tour (P2C-07) 2026-10-15 22:18:41 +02:00
Maurice 356df90095 Tours: read the saved tours of a trip over MCP (P2D-MCP-01) 2026-10-15 22:08:39 +02:00
Maurice cd1b1410bd Tours: filter a tour list by source, routing limit and type where it varies (PR1-13) 2026-10-15 22:02:41 +02:00
Maurice 3d9095ccde Tours: provenance contract, source check, one assignment path and the map overview
- P2C-02: tours record their origin (planned, GPX with file name, wanderer)
  instead of reading it off their shape; the detail answers a provenance
  contract and a trip copy keeps a tour's import record
- MF-03: a wanderer snapshot's source can be checked on purpose, read only,
  with a confirmed reimport as a new snapshot
- PR1-18: every way of putting a tour on a day shares one set of rules
- PR1-07/08: each person can hide tours from the trip map; the list shows
  each tour's line colour and the picked tour stands out on both maps
2026-10-15 21:51:22 +02:00
Maurice 018d2d1425 Tours/wanderer: cover the new service, repository and restore paths
- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
2026-10-15 20:15:17 +02:00
Maurice cab742e641 Tours: bring the pinned counts and test doubles up to the new tables and switch
- tours locale key count, entity count and NOT NULL field count follow
  the wanderer tables and strings
- the shared MSW admin handlers answer /api/admin/tours-wanderer
- the mobile tour detail test checks the dialog, not the dropped heading
- the refused-origin e2e sends one request per case instead of four at once
2026-10-15 19:25:22 +02:00
Maurice 11f23997e8 Tours: copy a planned tour's import link in the repository, and teach the tests the new columns 2026-10-15 18:38:44 +02:00
Maurice f1c437b8b8 Keep api/client.ts and trips.service.ts within their size baselines
The admin switch for wanderer moves to api/wanderer.ts and the recorded
track restore to api/recordedTrack.ts; trips.service.ts keeps dev's
formatting with only the derived-from remap on copy.
2026-10-15 18:16:25 +02:00
Maurice f690065de8 Tours: resolve the distance unit once where tour facts are built 2026-10-15 17:51:19 +02:00
Maurice 782e613506 Tours: day GPX import, wanderer trails and planning over an import
- GPX files drop onto the map or a day; the import lands on that day
- wanderer connection per user in Integrations; the workspace offers the
  user's own trails only, behind an admin switch under the Tours addon
- wanderer difficulty is stored on the SAC scale (T1 to T3), backfilled
- a GPX or wanderer import can be planned as an editable tour, either in
  place (the recorded track is kept and can be restored) or as a new tour
  that remembers the import it came from
- rails, rows and detail dialog follow the plan sidebar's cards and badges
- admin addon shelves with more than one row fold, collapsed by default
- reverse-geocoded waypoint names are cached on the device
2026-10-15 17:44:15 +02:00
Maurice 31ffcf1228 fix(ferratas): sort route tile keys with localeCompare 2026-10-15 12:09:48 +02:00
Maurice 309bef4db7 fix(ferratas): leaving 3D with via ferratas on no longer crashes, the removed map is not asked for its layers 2026-10-14 23:37:58 +02:00
Maurice 6e59240328 test(tours): follow the reworked planner, ferrata and map stage in their suites; keep the map's waypoint list stable between renders 2026-10-14 23:01:52 +02:00
Maurice bdb082d490 test(ferratas): a way without geometry really has none, the tile cache's columns join the NOT NULL count 2026-10-14 22:15:45 +02:00
Maurice 8957503dbe test(i18n): the tours locale count follows the ferrata and planner strings 2026-10-14 21:41:21 +02:00
Maurice 0a50119c52 chore(layout): lower the navbar's theme baseline to what it holds now 2026-10-14 21:24:15 +02:00
Maurice 1d090e0ac9 fix(tours): the 3D relief slider uses the app's range style 2026-10-14 21:14:28 +02:00
Maurice d118c3cee9 chore(tours): drop the planner strings nothing shows any more 2026-10-14 21:09:05 +02:00
Maurice e3f3b0458b style(tours): format the touched planner and map files 2026-10-14 20:54:44 +02:00
Maurice 976a612351 fix(tours): ferrata map tokens in a style module of their own, keep module-private helpers unexported 2026-10-14 20:44:53 +02:00
Maurice d87e276831 fix(tours): translate the ferrata badge everywhere, count the tile cache writes as standalone, settle the new test warnings 2026-10-14 20:34:20 +02:00
Maurice 62b50e3f9b fix(tours): a ferrata waypoint's number keeps the size of the others, its ring sits inside 2026-10-14 20:21:39 +02:00
Maurice 84d637f5fe feat(tours): via ferratas on the tour as fixed legs of their own line, crossing warnings, violet ferrata look, tour list and editor polish 2026-10-14 20:21:39 +02:00
Maurice 77c4785a9d fix(maps): the hover label on the GL maps goes away again when the pointer leaves a pin or the map moves 2026-10-14 20:21:39 +02:00
Maurice 6d43f3a4e6 feat(tours): hiking-rule walking time, source marks in the map search, tour rows and status badges reworked, ferrata card in the map glass 2026-10-14 20:21:39 +02:00
Maurice a805b4b882 fix(maps): hover labels on the GL maps use the app font, not the map's Arial 2026-10-14 20:21:39 +02:00
Maurice e3983ef857 feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map 2026-10-14 20:21:39 +02:00
Maurice 160bc89e78 feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge 2026-10-14 20:21:38 +02:00
Maurice a9882a6415 feat(tours): via ferrata layer mounted on the tours map (2D and 3D) 2026-10-14 20:21:37 +02:00
Maurice 6b7461ee03 feat(planner): a floating variant of the location search in the map controls' frosted glass 2026-10-14 20:16:15 +02:00
Maurice 0eca20f2a4 feat(layout): in a trip the back arrow and the trip name share one pill beside the logo 2026-10-14 20:16:14 +02:00
Maurice 796bbb065d feat(layout): the trip name sits beside the logo as a badge instead of after a slash 2026-10-14 20:15:57 +02:00
Maurice 20d8bae46b test(tours): the wiring test follows the map stage's shared edit gate 2026-10-14 14:02:27 +02:00
Maurice 98cc57942b feat(tours): 3D terrain view with flyover and grade colours, imported tours carry no hiking difficulty
The tours map gets a 3D mode on the same map surface between the side panels:
a "3D" switch in the layer pill drapes the route over open Terrarium elevation
tiles (AWS Open Data), colours it by grade, plays a flyover tied to the
elevation profile in the rail, and keeps planning working by clicking and
dragging waypoints on the terrain. The corridor around the route is preloaded
before the flyover so the landscape never loads in front of the camera.

GPX imports no longer get a made-up hiking difficulty: max_hiking_difficulty
becomes nullable (new migration, waypoints kept), the import writes null and
the client shows no T badge for it. Drawn tours still default to T2.

Info hints in the planner show their tooltip only, and the discard
confirmation reads "Discard changes".
2026-10-14 14:02:27 +02:00
Maurice e7f2e17954 fix(ai-import): give the keeper search a starting flight 2026-10-14 13:24:27 +02:00
Maurice f746c22a83 test: keep the day plan sidebar test within its size baseline 2026-10-14 13:24:27 +02:00
Maurice 723bfa507b test: follow the note row's action wrapper and keep waitFor polling in the users refresh test 2026-10-14 13:24:27 +02:00
Maurice 3c8fa07f44 fix(admin): show when users were last active and keep the list current
The column showed the last sign-in, which a renewing cookie leaves weeks
behind for someone using TREK daily. It now shows the later of the sign-in
and the last request. Saving an edit no longer blanks the row's avatar,
activity and online dot, and the list refreshes every 30 seconds while open.
2026-10-14 13:24:27 +02:00
Maurice 4ceade9d8b fix(ai-import): read a long document's end and charge a shared total once (#2652, #2651)
Cloud models got the first 4,000 characters, so totals and lodging sections
further down never reached them, without a word. They now get 24,000 with the
start and the end kept and a warning when the middle is left out; a server
whose context is too small is asked once more with 8,000. A total the model
repeated on every flight of one booking stays on the first flight only, when
the document prints it once as a total, and the prompt says so too.
2026-10-14 13:24:27 +02:00
Maurice dae20c166a fix(admin): keep an Anthropic Messages gateway as the AI parsing base URL (#2655)
Both admin forms hid the field for Anthropic and the shared hook saved an
empty address, wiping a gateway configured on the server. The field shows for
every provider now and is saved as typed; switching provider empties it, so an
address meant for one never receives another provider's key.
2026-10-14 13:24:27 +02:00
Maurice 0d4ddb2089 fix(booking-import): join flights only on a layover that can be shown (#2653)
A missing arrival time skipped the time check, so a return listed first
merged into the outbound flight. The layover is now measured from the
arrival, or from the departure when there is none, must be forward and under
a day, and a leg landing anywhere the chain has been is a return.
2026-10-14 13:24:27 +02:00
Maurice 775b35d5af fix(dashboard): leave planned public transit out of upcoming reservations
Journeys from the transit search are routing, not bookings, and a day of them
pushed the real bookings out of the six slots. Booked trains and buses keep
their own types and stay. The MCP tool reads the same feed.
2026-10-14 13:24:27 +02:00
Maurice 76b7b305b2 fix(journey): do not count the suggested weather as an unsaved change
The entry dialog fills an empty weather from the forecast, and leaving it
untouched then asked to discard changes. The suggestion is not an edit. In
development StrictMode also dropped the suggestion before it arrived.
2026-10-14 13:24:27 +02:00
Maurice eb4e74b5c3 fix(forms): keep password managers out of a place's contact fields and the admin user forms
1Password filled the signed-in user's login into a place's e-mail field, and
offered the admin's own credentials for an account being created. The fields
now carry each manager's opt-out; autocomplete=off alone is ignored.
2026-10-14 13:24:27 +02:00
Maurice 10a987fec1 fix(files): list a hotel booking's files and files pinned to a stop on the place card
A hotel booking reaches its place through the stay, not its own place_id, so
the card listed the booking but not its confirmation. Links to a stop, which
MCP's link_trip_file and plugins can make, were never returned by the file
list at all; it now carries linked_assignment_ids. Without files the row no
longer opens to nothing.
2026-10-14 13:24:27 +02:00
Maurice 7c6b7622fc fix(planner): open the places filters across the whole sidebar row
Hung off an icon near the end of the row, the category and rating lists ran
out of the sidebar and were cut off.
2026-10-14 13:24:27 +02:00
Maurice 341520f98b fix(planner): give day plan rows their full width until the controls are wanted
The hover controls took their room at rest, so a place's line and a note's
text broke early. A place now gives way only while hovered, a note's controls
fade in over the end of its text in the note's own colour, and a picked place
shows none. All three controls of a place come out together: the add-booking
ticket waited for a React hover state and came a beat late. A click no longer
leaves them showing; keyboard focus and an open menu do.
2026-10-14 13:24:27 +02:00
Maurice 2fe8590c0b fix(planner): show a place photo again once its missing cache file is fetched back
A proxy image_url whose cached file was gone answered 204, and the self-heal
got the same URL back, so the list avatar kept its broken image until a
reload while the inspector showed the photo. The image is now mounted afresh
after the refetch; a second failure still ends on the category icon.
2026-10-14 13:24:27 +02:00
Maurice b3ef13616f fix(nav): put the prerelease badge beside the logo and never let the actions cover the tab pill
Both side columns keep their content width, so the pill gives way: it drops
its labels to icons when they no longer fit (measured, since plugins and
languages change the width) and scrolls only after that. The username is no
longer cut at 96px.
2026-10-14 13:24:27 +02:00
Maurice db531d2bef test(tours): the wiring test reads the map stage, the phone timeline test hands the planner its tours 2026-10-14 12:05:49 +02:00
Maurice f463c44d03 test(i18n): the tours locale count follows the three removed button keys 2026-10-14 11:22:16 +02:00
Maurice 07a82be64e fix(tours): short save and discard labels, info hints level with their label 2026-10-14 11:11:48 +02:00
Maurice 4efc50c012 chore(tours): keep the waypoint drag coordinate type module-private 2026-10-14 10:48:53 +02:00
Maurice 7a2846b3fa style(tours): format the tour service test 2026-10-14 10:41:21 +02:00
Maurice e87c68b811 test(tours): seed and read the tour tests through the row factories instead of raw SQL 2026-10-14 10:33:14 +02:00
Maurice 26833f374c fix(tours): keep the places repository at its size and format baselines 2026-10-14 10:23:22 +02:00
Maurice 7795061630 refactor(tours): move the planner additions out of the map, sidebar and page files that grew past their size baseline 2026-10-14 10:00:08 +02:00
Maurice 09227ec0d1 fix(tours): the map search sits between the panels without a physical side, and the day row facts use the caption tier 2026-10-14 09:22:38 +02:00
Maurice dbee1664af fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev 2026-10-14 09:11:47 +02:00
Maurice 4c487ceefa fix(tours): keep the generated entity byte-identical and the strict baseline flat after the rebase onto dev 2026-10-14 08:49:47 +02:00
elmocito dd43999f1f fix(tours): resolve client lint warnings 2026-10-14 08:39:32 +02:00
elmocito 9a84890142 fix(tours): align generated Tour entity 2026-10-14 08:39:32 +02:00
elmocito eb13d9f785 fix(i18n): add Tours locale keys 2026-10-14 08:39:31 +02:00
elmocito 57ea219a43 feat(tours): complete planner follow-up scope
This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
2026-10-14 08:39:31 +02:00
Maurice af9f013c5e docs(chart): NOTES lists every key the chart reads from existingSecret
SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY and DEMO_ADMIN_PASS were read but missing from the install notes; values.yaml already listed them.
2026-10-13 21:10:41 +02:00
Maurice f870dfc6d1 fix(server): the newer-database refusal also names a development build as the cause
A database a feature-branch build migrated carries migrations this build does not ship too, and was told it came from a newer TREK.
2026-10-13 21:10:41 +02:00
Maurice bf26175724 docs(server): say which builds refuse a migrated database and which do not
The pre-migrate snapshot docstring said an older release refuses a database a newer TREK migrated. Only images from 5.0 on do; 4.3.x and earlier start on it without a warning. server/CLAUDE.md said anything the legacy baseline cannot map refuses the boot, which holds only above step 258.
2026-10-13 21:10:41 +02:00
Maurice 6694a38e65 test(planner): the phone timeline breaks the drive at a place's track with tours off too 2026-10-12 22:18:50 +00:00
Maurice e643ae70ad test(costs): the phone costs cases start without a cached settlement and wait for the queued payment writes 2026-10-12 22:18:50 +00:00
Maurice 5d6dc6129c test(planner): the booking cases read stays through the repo and link a hotel made offline by its temp id 2026-10-12 22:18:50 +00:00
Maurice a9a0478c63 test(llm-parse): an unreachable ollama shows the server's reason in the addon settings, not the status line 2026-10-12 22:18:50 +00:00
Maurice 6fa51c638f test(journey): the journey page cases find the settings delete in its dialog and confirm a gallery delete first 2026-10-12 22:18:50 +00:00
Maurice c7d3dd10f2 test(mcp): update_trip moves the owner's leave days in the plan they are active in, a joined one included 2026-10-12 21:49:40 +00:00
Maurice 9adc87c002 chore(lint): lower the size, strict, hand-built service and knip baselines to what the split files hold now 2026-10-12 21:14:28 +00:00
Maurice dd8d6a0b7d chore(knip): drop the exports nothing outside their module reads 2026-10-12 21:14:28 +00:00
Maurice af3dfd0bad fix(mobile): the new invite, version, gallery and time warning labels use the theme tokens and type tiers 2026-10-12 21:14:28 +00:00
Maurice 00fca4952f test(roadtrip): the road trip suite writes stays through the real repo and seeds the searched day once 2026-10-12 21:14:28 +00:00
Maurice 645b893e92 test(planner): the tours tab cases render the page through one helper 2026-10-12 21:14:28 +00:00
Maurice 18baafe57d refactor(pdf): the remembered preview switches move to utils 2026-10-12 21:14:28 +00:00
Maurice 8832632b4e refactor(planner): each card over the map sets its own left side again, where the reading direction check counts it 2026-10-12 21:14:28 +00:00
Maurice 8f6dab30a2 refactor(planner): the weekday index of a plan day lives with the place opening state 2026-10-12 21:14:20 +00:00
Maurice 7b023c6b54 refactor(map): the road trip via helpers move to utils, so the route hook imports no component 2026-10-12 21:14:05 +00:00
Maurice 64e4c34472 test(costs): the ledger cases wait for the settlement, and the filter and former member cases get files of their own 2026-10-12 21:14:05 +00:00
Maurice b21f9d6739 refactor(costs): the summary card moves into a component of its own 2026-10-12 21:14:05 +00:00
Maurice 191b02b6de test(maps): the place pin and nominatim bias cases get suites of their own 2026-10-12 21:13:58 +00:00
Maurice 0711efcb4b refactor(maps): the country lookup moves to utils, so maps no longer reaches into atlas 2026-10-12 21:13:58 +00:00
Maurice 37b3f0f3da test(trips): the copy case for shared expenses gets a suite of its own 2026-10-12 21:13:57 +00:00
Maurice f470ae029d refactor(trips): the trip span check and the feed token strip move into trips helpers 2026-10-12 21:13:57 +00:00
Maurice 7454c8f95f test(collections): the cases for moving a place out of a list get a suite of their own 2026-10-12 21:13:57 +00:00
Maurice 4984e8f5eb refactor(collections): parsing and writing a list's links moves into collections helpers 2026-10-12 21:13:57 +00:00
Maurice 5b6f76c98a test(calendar): the calendar stop oracle reads the place duration the repository now returns 2026-10-12 21:13:57 +00:00
Maurice 5fcfe8b912 refactor(reservations): grouping endpoints and day slots moves into reservations helpers 2026-10-12 21:13:57 +00:00
Maurice 141ad0acb6 test(budget): the cases for people who left the trip get a suite of their own 2026-10-12 21:13:57 +00:00
Maurice a360e3d391 refactor(budget): the money and rate helpers move out of the budget service 2026-10-12 21:13:57 +00:00
Maurice e6aaeac67e test(mcp): the advanced budget tool cases build their controller in one place 2026-10-12 21:13:57 +00:00
Maurice 15c9503295 refactor(settings): the planner views ask trip membership which trips a user can open, and their tests seed through the factories 2026-10-12 21:13:57 +00:00
Maurice 4a6ee6819c style(client): prettier on the files this branch touched 2026-10-12 20:01:22 +00:00
Maurice 00a19679f5 style(server): prettier on the test files this branch touched 2026-10-12 20:01:22 +00:00
Maurice 3dedfe6b63 chore(lint): lower the client baseline to the warnings the app code holds now 2026-10-12 19:57:48 +00:00
Maurice dbc6cb6b94 test(client): the new cases check a missing result instead of asserting it away 2026-10-12 19:57:48 +00:00
Maurice d4c3b1f4e0 refactor(collections): all saved says why it skips a list it cannot read 2026-10-12 19:57:48 +00:00
Maurice 5eb00d5878 refactor(planner): the day sidebar reads the stop days without a memo the compiler cannot keep 2026-10-12 19:57:48 +00:00
Maurice 97833412dc refactor(collections): the invite cards take the async accept and decline as they are 2026-10-12 19:57:48 +00:00
Maurice d3be55a88f fix(bookings): the transport dialog's submit always returns a promise, as the expense button expects 2026-10-12 19:57:48 +00:00
Maurice 36e8b299f7 chore(lint): lower the server test baseline to the warnings the tests hold now 2026-10-12 19:56:44 +00:00
Maurice d8fef58799 test(server): the new suites check a missing result instead of asserting it away 2026-10-12 19:56:44 +00:00
Maurice e1bee3b562 refactor(settings): a bulk save prepares the planner views once, before the writes 2026-10-12 19:56:44 +00:00
Maurice 43677ec80b refactor(vacay): the leave days a moved trip drops go in one delete 2026-10-12 19:56:44 +00:00
Maurice 5baa1c2c69 fix(oidc): a refused email link is logged through the app logger, so it reaches trek.log 2026-10-12 19:56:44 +00:00
Maurice 8a8c3e4c64 style(i18n): prettier on the translation files this branch touched 2026-10-12 21:22:36 +02:00
Maurice 66e574fcc0 fix(llm-parse): the local model list and pull send the saved api key to an ollama behind an auth proxy
The admin screen's model list (/api/tags) and pull (/api/pull) went out
without the Authorization header that the extraction and /api/show already
send, so an Ollama behind a Bearer proxy answered 401 and the list came back
as a 502. The controller now adds the key saved with the local provider, read
server side through LlmConfigResolver.readLocalServerAccess, and only when the
requested base URL reaches the same server root as the saved one, so another
address in the form never receives it.

A 401 or 403 now says whether the key was rejected or none is saved for that
address, and the admin form shows the server's reason instead of the axios
status line. A server without /api/tags (LM Studio, vLLM) is asked for its
OpenAI-compatible /v1/models instead, and the list body is read with a size
cap and checked before use.

Refs #2649
2026-10-12 21:04:23 +02:00
Maurice accad52ed5 fix(maps): place suggestions name their town and country in the language of the search
The TREK Places service takes an optional lang now and puts localities into
it, and names its OpenStreetMap layer rows in it with the settlement they are
in. TREK never sent one, so a Japan trip in an English UI kept reading
"福岡市博多区" and the layer rows had no place at all, only the local name.

Search, autocomplete, nearby and the details of an index place now ask in the
reader's language (TREK codes through toApiLang, anything that is no language
tag left off). A layer row with a locality reads "Sumida, Japan" like an index
row, the country named from its coordinate; without one it keeps the local
name as before. The offline area download stays without a language. REST and
MCP share the service methods, so both get it.

Refs #2592
2026-10-12 20:53:08 +02:00
Maurice 2777773f7b fix(dayplan): the delete day question says its bookings keep their date
Deleting a day said its bookings stay under Bookings without a day, which
read as if their date went too. They keep their date and stay listed,
upcoming ones included; the hint now says so.

Refs #2604
2026-10-12 20:43:00 +02:00
Maurice d4f4ab3891 fix(bookings): the delete question for a booking with a linked expense says the expense is deleted too
Deleting a booking also deletes the expenses linked to it, but the
question only said the booking would be deleted. A booking with a linked
expense now says so in every place that asks: the bookings list and detail,
the transport and transit dialogs, and the phone's booking cards. The MCP
delete_reservation description names the linked expenses and the stay a
hotel booking created as well.

Refs #2604
2026-10-12 20:42:55 +02:00
Maurice 62a80f420c fix(bookings): an end time before the start time on the same day shows a warning in the booking dialog
A booking saved with 18:00 to 09:00 and no end date was stored as ending
earlier on its start day without a word. The dialog now says that the end
time lies before the start time and that an end date is needed if it ends
the next day, on desktop and on the phone. It only warns and does not block
the save, since time zones can make such times correct.

Refs #2604
2026-10-12 20:42:48 +02:00
Maurice 3b933470d3 fix(planner): a booking pinned to a stop on another day is shown at that stop only, not on two days
A booking linked to a stop has two days to go by, its own date and the
stop's. They part when the stop is moved to another day or the trip is
shifted with "keep bookings on their dates", and the plan then listed the
booking at the stop and again in the timeline of its own day. The stop now
wins wherever both are drawn: the desktop day plan and day detail, the
phone's plan tab and day sheet, and the road trip rail on both. The
booking's date is left as it is; a stop on one of the booking's own days
changes nothing, so a rental picked up at a stop still runs across its
other days.

Refs #2604
2026-10-12 20:42:30 +02:00
Maurice 93cce57306 fix(dashboard): trip counters and upcoming bookings refresh after a trip is created, copied, redated, archived or deleted
The travel stats and the upcoming bookings were only fetched when the
dashboard mounted, so "Trips" and "Days" kept their old values after a new
or duplicated trip and the sidebar still listed booking dates from before a
date change until the page was left. Every successful trip mutation now
reloads both, on desktop and mobile alike since they share the hook.

Refs #2604
2026-10-12 20:29:29 +02:00
Maurice fee41ab394 fix(collab): a note, poll, message or link that cannot be saved offline says it needs a connection
Collab, packing bags, packing templates and imports, the category a place
form creates and the place imports are not kept on the device, so offline
their writes cannot wait in the queue. They failed with a bare "Error" or the
generic save message. While TREK is offline the failure now reads "This needs
a connection. Try again once TREK is back online.", on desktop and on the
phone's Collab notes; online nothing changes.

Refs #2601
2026-10-12 20:18:04 +02:00
Maurice bdd8c08dd4 fix(planner): places put on a day, day names, day notes and trip details changed offline wait in the queue and sync once the connection is back
The day plan wrote straight to the API, so offline a place dropped on a day
snapped back with "Error assigning place", a renamed day or a new day note
failed, and a moved stop, a leg's travel mode or who joins a visit were lost.
Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the
day's name, notes text and travel mode, and the day notes (add, edit, delete,
move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation
queue: the cached day changes at once, a visit or note made offline gets a
temporary id that the edits after it wait for, and an online write held behind
an older queued one joins the queue, since none of these carry a version
token. A new order of a day that names a visit made offline is sent with its
real id, or without it when its create failed. Desktop, the phone timeline and
note sheet all write through the store, so both are covered.

The replay reads each answer for what it names: the day answer keeps the
cached notes and visits, the participants answer lands on its visit, and a
visit the server refuses with 409 (a Tour already on that day) fails instead
of waiting as a conflict no retry can solve.

The trip's name, description, reminder and the archive flag wait in the queue
too, from the planner, the phone and the dashboard. What only the server can
do says so instead of failing: new dates, a day count, a currency or a cover,
a new trip, a copy or a delete, members, guests, share and invite links, and
a new order of the days, which moves the dates of the bookings.

Refs #2601
2026-10-12 20:17:57 +02:00
Maurice 92c48fba22 fix(files): file notes, links, stars and the trash changed offline wait in the queue and sync once the connection is back
Every file write went straight to the API, so offline a star snapped back
without a word and moving a file to the trash failed with "Failed to delete
file". Notes, the place or booking of a file, a further link, the star, the
trash move, restore, delete for good and emptying the trash now go through
fileRepo and the mutation queue like the other trip data: the local copy
changes at once, and an online write to a file that still has an older one
queued joins the queue behind it, since file writes carry no version token.
The desktop file manager, its assign dialog and the phone's files tab,
menu, link and trash sheets all write through it.

The single file answers carry no ids of further links, so a replayed edit,
star or restore keeps the ones the local copy holds, and the link answer
({ success, links }) writes no row.

What only works online says so instead of failing: an upload shows that it
needs a connection, a place saved with newly picked files is stopped with a
hint, and removing a further link (its row id is known only to the server)
asks to try again once online.

Refs #2601
2026-10-12 19:32:16 +02:00
Maurice 64ceecefb3 fix(bookings): bookings, transports and stays saved offline wait in the queue and sync once the connection is back
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.

The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.

What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.

Refs #2601
2026-10-12 19:07:33 +02:00
Maurice 5dbcc4af40 fix(todo): tasks added, ticked, edited, reordered or deleted offline wait in the queue and sync once the connection is back
Every task write went straight to the API, so offline a new task failed with
"Network Error" and was lost, and a tick flipped back. Tasks now go through
todoRepo and the mutation queue like packing items: a temporary row at the
end of the list offline, the edits and the delete of it wait for its real id,
and an online write to a task that still has an older one queued joins the
queue behind it, since task writes carry no version token. A reorder offline
is queued without the tasks that have no server id yet. Desktop and phone
both write through the store, so both are covered.

Refs #2601
2026-10-12 18:24:18 +02:00
Maurice cfdbfb49c0 fix(costs): expenses and payments saved offline wait in the queue and sync once the connection is back
Every Costs write went straight to the API, so offline an expense failed with
"Unknown error" and was lost. Expenses (add, edit, delete, split, paid tick,
reorder) now go through budgetRepo and the mutation queue like places and
packing items: a temporary row offline, held behind an older queued write
online. The replay reads the expense from `item` only, so the split answer
({ members, item }) keeps the local payers and the paid answer ({ member })
never lands in the expense table. An expense linked to a place or booking
created offline is sent with the real id once that one has synced.

Payments are queued without a local row and a hint says they show once
sent; the ledger reads the balances again when the queue is through, and
offline it shows the last balances it read (new Dexie table, v10). Receipts
need an upload, so an offline save with new ones is stopped with a hint.

Refs #2601
2026-10-12 18:14:30 +02:00
Maurice a5bf5e6853 docs(help): road trip mode, the dawarich trail and the route overview are remembered on the account
Help texts in all languages and the wiki said these switches lasted for the
browser session. They now follow the account per trip.

Refs #2639
2026-10-12 17:41:57 +02:00
Maurice fbba8f96b7 fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser
The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
2026-10-12 17:41:56 +02:00
Maurice adb88142ab fix(places): the place search looks where the plan map is looking
Autocomplete and the full search took their location hint only from the
open day or the whole trip, and dropped a trip spread over several cities
as too wide. Zoomed in on one city of such a trip, the search went global.

The plan map now hands its viewport to the trip store on desktop and on
the phone, and useLocationBias tries it first. A viewport wider than the
same 60 km radius, one across the antimeridian or one from another trip
is ignored, and the day and the trip follow as before. The viewport is
cleared when the plan map leaves the screen and on a trip switch.

Refs #2620
2026-10-12 17:18:17 +02:00
Maurice 8c30d64467 docs(wiki): how an SSO login links an existing account and how an admin confirms an address
The OIDC page explains when a login by email links an account, what
email_link_blocked means and how an admin lets a blocked account link by
entering its address in the admin panel. The audit log page lists
user.email_change.
2026-10-12 17:06:59 +02:00
Maurice 9975f9f80f fix(oauth): userinfo reports email_verified only for an address the account did not set itself
The userinfo endpoint answered email_verified: true for every account, although
nobody had confirmed the address. It now follows users.email_self_asserted, the
same mark the SSO login uses before it links an account by email.
2026-10-12 17:06:58 +02:00
Maurice 3d7e8be9aa fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself
An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.

users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.

Refs #2110
2026-10-12 17:06:52 +02:00
Maurice 1b58acc78f fix(trips): deleting a guest keeps the costs adding up instead of dropping their payments
A guest's payer rows and recorded payments hung on the users row by ON DELETE
CASCADE, so deleting the guest wiped money that had really moved: expenses
they paid were left without a payer, settled payments reopened, and a custom
or receipt split lost the guest's share so the balances no longer summed to
zero.

Deleting a guest who paid for an expense or is part of a recorded payment is
now refused with a 409 (REST and the delete_trip_guest MCP tool, same text),
and the share dialog says to change the payer or delete the payment first.
Otherwise the guest's share is handed on: an equal split re-splits as before,
a custom amount goes to the others by largest remainder in the currency's
smallest step (members without an amount get one, so the expense stays a
custom split), and a receipt drops the guest from its lines and is split
again like the cost editor does. A legacy paid_by_user_id is cleared as on
account deletion. Account deletion goes through the same re-split. The
confirmation text in all languages and the wiki say what really happens.

Refs #2604
2026-10-12 16:39:17 +02:00
Maurice 2149fd5315 refactor(budget): the receipt split by line lives in shared so the server can split a receipt the way the cost editor does
calculateTicketShares in the cost editor now converts the hundredths of the
new shared splitTicketLines. Same arithmetic as before: a line nobody is
ticked for is carried by everyone on the receipt and the leftover hundredths
of a line go to the lowest user ids first.

Refs #2604
2026-10-12 16:39:07 +02:00
Maurice 92af437ff6 fix(roadtrip): the stop a booking put on its check-in day keeps the night over a rest later that day
The night went to the last visit of the hotel. On a day without hours the
booking seats its own stop first, and Days does not show that stop, so a rest
the traveller put mid-day was the last visit: it was held to the check-in and
the places behind it followed from there, the way the report describes.

The night is now the visit that ends the day, else the booking's stop, else
the last visit. A visit the traveller adds to the check-in day no longer moves
the night or its check-in. nightVisits in shared holds the rule, and the road
trip, calculate_roadtrip, the stored ride slot and the stop editor all read it.

Refs #2636
2026-10-12 16:05:17 +02:00
Maurice 36ac81322a fix(i18n): a session opened in a fresh browser starts in the browser's language
wiki/Languages.md has the account's language first, then the browser's.
The browser step only ran on the login page, so opening a trip with a
live session in a new browser (or after the site data was cleared) showed
English to anyone whose account had no language of its own.

The app now starts in the browser's language when nothing was chosen in
this browser, and keeps it when the account has none. Nothing is written
to the account; a language set in the account or picked in the app still
wins. The detection moved to its own module so the settings store can use
it at startup.

Refs #2604
2026-10-12 15:46:17 +02:00
Maurice a0331bf53b fix(dashboard): a refused archive, delete or copy says why instead of only that it failed
A member without the right to archive a trip clicked Archive and read
"Failed to archive trip", while the server had answered why. The archive,
restore, delete and copy toasts now show the server's reason, as creating
and editing a trip already did; a failure without one keeps the old line.
The buttons stay where they are.

Refs #2604
2026-10-12 15:46:17 +02:00
Maurice 125649ce1b fix(planner): cards over the map stay clear of the layer pill, and a wide tab bar shows full tab names
The base-layer pill sits in the map's bottom-left corner on purpose. The
place inspector, a tour's detail and the day panel are centred between the
sidebars and were assumed never to reach that corner, but with both
sidebars open in a 1280 window they do, and the pill covered "Remove from
Day" and Edit. These cards now keep their left edge clear of the pill: a
narrow map moves the card right of it, a wide map keeps it centred as
before. The pill itself does not move, and the phone sheets are unchanged.

The Bookings tab showed "Book" at every width, which read as cut off. The
short form now only shows where the bar is tight; from xl up the tab shows
its full name.

Refs #2604
2026-10-12 15:46:09 +02:00
Maurice cb04ebe6d2 fix(trips): the trip form drops its complaint once you edit, and the typed date hint follows your language
"Title is required" stayed on screen after the title was typed, until the
next save. Any edit in the trip form, desktop and phone, now clears the
last save's complaint; the next save checks again.

The keyboard date field always hinted DD.MM.YYYY, while it was filled and
read back in the user's own order: a Korean user saw "2026. 12. 10." under
a German hint. The hint is now the locale's own pattern (YYYY. MM. DD. in
Korean, MM/DD/YYYY in US English), and the field's name is translated.

Refs #2604
2026-10-12 15:46:02 +02:00
Maurice 35eba21128 fix(costs): row buttons, settle-up rows and share toggles say what they are about to a screen reader
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.

- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
  and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
  tooltip; the row now carries them for assistive tech too, and its Settle
  button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
  category) was read out as "..."; it now says Search in the user's
  language.
- The public link toggles on the phone share sheet and the journey share
  toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.

Refs #2604
2026-10-12 15:45:56 +02:00
Maurice dbbbd07b1c fix(costs): amounts follow their currency's decimals and format, and payments count on the local day
Several small things in the costs views, on desktop and phone alike:

- The outstanding card no longer says "You're all settled up" while a
  payment is still open in Settle up.
- The custom split shows its suggested shares as a faint placeholder in
  the currency's own form (30000 for won, not 30000.00), so it no longer
  reads like an amount already typed.
- Amount fields take only the decimals the currency has: won and yen take
  none, KWD and the other three-decimal currencies three. A switch of
  currency cuts what was already typed. 1000.555 won was saved as typed,
  listed as 1,001 and reopened as 1001.
- The expense sheet on the phone prints shares and sums with the same
  formatter as the list, with grouping and the currency's decimals.
- A payment recorded with Settle or Settle all is dated on the viewer's
  own day, and one without a date of its own is grouped by its UTC record
  time read on the local calendar. In Seoul before 09:00 it landed on the
  day before.
- "1 expenses need a payer" now uses the plural forms of each language.

Refs #2604
2026-10-12 15:04:32 +02:00
Maurice 9064dbb2f0 fix(costs): an expense made from a booking is dated on the booking day
Creating the expense from a booking opened the editor on today, and the
cost a booking import writes by itself had no date at all, so it sat under
"No date". Both now take the booking's own date, else a hotel's check-in
day, else the day the booking sits on. The server sets it for every
booking write that creates a cost, so REST, MCP, plugin RPC and the import
agree.

A start time typed on a booking with no day picked no longer borrows
today's date on desktop. Today was mostly outside the trip, the server
then moved the booking onto day 1 while the list kept today. Desktop now
does what the phone already did and keeps the time without a date.

Refs #2604
2026-10-12 15:04:22 +02:00
Maurice 33e606959c fix(budget): settle-up hands out whole won and yen, so balances add up and settle clears them
The ledger nets every currency in hundredths, and the settlement handed those
hundredths out as they were. For a currency shown without decimals the panels
then rounded each balance and flow on its own: 10,000 KRW three ways read
+6,667 / -3,333 / -3,333, two flows of 3,333 sat under "You're owed 6,667",
and Settle booked 3,333.34 won.

The balances, flows and final budget lines are now allocated by largest
remainder in whole steps of the display currency (whole won or yen, unchanged
for two and three decimal ones), so the shown set adds up to zero and the flow
amount Settle sends is the whole amount on the row. The ledger, stored rows
and manual settlements are untouched. REST and the MCP settlement summary
share the same method.

Refs #2604
2026-10-12 14:28:06 +02:00
Maurice ef150439f9 fix(vacay): leave days follow a moved trip as a whole block and never land on blocked days
Moving a trip's start shifted the owner's vacay entries one row at a time,
checking each target against rows that were about to move themselves. A
block shifted by less than its own length kept its first days behind and
pushed the rest onto the weekend, even with weekends blocked.

The shift now decides every day before writing: a day whose target is a
blocked weekend or a whole company holiday is dropped, as is one whose
target already holds leave that is not moving; a half company holiday
leaves half a day. The rest move in the direction of travel inside one
transaction. The plan is the one the owner is active in, so a member of a
fused plan gets their days moved too, and a trip edit no longer creates a
vacay plan for an owner who has none. Open vacay views are notified after
the trip update commits.

The pure vacay helpers moved to vacay.helpers.ts and the trip window tests
to their own suite, so both files stay inside their size budgets.

Refs #2604
2026-10-12 14:12:36 +02:00
Maurice 676f6233ed fix(planner): undoing a place delete brings the place back whole, or is not offered
The undo wrote a new place from nine of its fields and put it on its days as a
bare stop, so notes, website, phone, hours, picture, the visit times, day notes
and travellers were gone while the toast said it was undone. It now writes every
field the place had and plans each visit again with its times, note, end of day,
route flag, travellers and leg modes, and casts the own rating again.

The server takes a booked night, its booking and expense, linked expenses, file
links, other members' ratings and an uploaded picture down with the place, and
writing the place again brings none of that back. Such a delete is no longer
offered for undo. Undo steps that name a deleted place are dropped at the delete
instead of failing later, and adding a place, moving categories and the phone
reorder and optimize now carry their place ids for that.

Taking a stop off a day and clearing a day undo with the visit details too. The
stays are read again when a deleted place had one, here and for collaborators.

Refs #2604
2026-10-12 13:44:44 +02:00
Maurice e70fc334bd fix(costs): people who left the trip are named and keep their balance in the costs lists
Someone removed from a trip stays on the expenses they paid for or shared,
but the costs views only knew the current members. They showed up as "?"
in the expense chips and the settle-up suggestions, and the balances and
final budget lists left them out, so the listed balances no longer added
up to zero.

The names now come from what the server already sends with the expenses
and the settlement, and anyone off the trip who still carries money gets a
row after the members, on desktop and on the phone. The expense form is
unchanged.

Refs #2604
2026-10-12 13:22:32 +02:00
Maurice 64537a0224 fix(trips): a copied trip keeps every expense total without naming people who are not on it
The copy has nobody on it but the copier, yet it took every payer and
split row along. Its balances then had the copier owing people who cannot
see the trip, and the first save of such an expense dropped them and
shrank its total.

An expense anyone else pays for or shares now comes across as a planning
entry with its total and no payers, split or receipt lines; one that is
the copier's alone is copied whole. Assignment participants and the
legacy paid_by_user_id keep only the copier for the same reason.

Refs #2604
2026-10-12 13:15:22 +02:00
Maurice af66bdd910 fix(budget): saving an expense keeps the payers and shares of people who left the trip
Removing someone from a trip leaves their payer and split rows on the
expenses they were part of. Every save ran the ids the form sends back
through the roster filter, so an unchanged save dropped them, total_price
shrank to what the rest had paid and a custom split stopped adding up.

Ids already on the expense now stay allowed, as payer or as member, and
can still be taken off it on purpose. A new expense and an id that never
was on the expense are filtered as before (#1952). The paid ticks of the
split carry over a form save instead of being reset. REST, the plugin RPC
and the MCP split checks all read the same allowed set from the service.

Refs #2604
2026-10-12 13:15:17 +02:00
Maurice 106b748551 fix(maps): marker thumbs from cached place photos load under the same request limit
A place whose image_url already is the place-photo proxy URL skipped the five
slot limiter and loaded its marker picture right away. A map showing a big
list, such as "All saved" in collections, asked for every picture at once.
Those downloads now wait for a slot like the provider lookups do; the entry
itself is still there immediately.

Refs #2559
2026-10-12 12:59:45 +02:00
Maurice 8acc035efa fix(collections): all saved reads its lists four at a time and reloads once per burst of updates
The "All saved" view read every list in full at the same time, and every
collections:* websocket event started that again right away. An agent saving
places one by one over MCP sends one event per place, so an open collections
tab stacked up full reads of every list on the server.

The union now goes through the same batched loader the trip's saved-places
picker uses (four lists at a time), reloads asked for while it runs fold into
one more pass, and a list or union read that lands after the user switched to
something else is dropped instead of painting its places over the new view.
The page waits for a run of events to settle before it reloads and keeps the
selection on "All saved" like it already did on a single list. Desktop and
phone share useCollections, so both get it.

Refs #2559
2026-10-12 12:59:44 +02:00
Maurice 2f0b867685 fix(settings): the integrations tab is always there on desktop so api keys stay reachable
API keys were split off from MCP to work without any addon, but the tab
that holds them still hid unless a photo provider, MCP, AirTrail, AI
Parsing or Dawarich was enabled. On a fresh install all of those are off,
so there was no way to create a key. The desktop tab now always shows;
the addon backed sections inside it keep their own gates. The phone layout
has no API Keys section and keeps its gate.

The wiki now says where keys are created and that no addon is needed.

Refs #2561
2026-10-12 12:46:12 +02:00
Maurice cec5ff1e2e fix(maps): nominatim autocomplete and search fallback keep the location bias
On an install without the TREK Places index and without a Google key the
suggestions come from Nominatim, and autocompleteNominatim asked it without
the box the client had sent, so every suggestion was ranked globally. The
search fallback on the same install dropped the bias the same way. Both now
pass it on as a viewbox with bounded=0, which ranks results near the trip
without hiding anything further away.

Refs #2620
2026-10-12 12:41:55 +02:00
Maurice 5afdbc8633 fix(maps): name the country and drop duplicate places in index suggestions
The suggestion list under the place search showed the index's raw ISO code
("福岡市博多区, JP") and listed a place twice when the index and its
OpenStreetMap layer both carried it, which the search list after Enter
already merges. Suggestions now name the country through Intl in the
language of the search, and a layer row at the spot of an index row with a
matching name is dropped by the same rule mergeSearchResults uses, now
shared as samePlace.

Refs #2592
2026-10-12 12:38:31 +02:00
Maurice 93173b1573 fix(planner): bookings a collaborator moves on a day take their new slots in every open list
reservation:positions was on the ignored list, while the stop order the same
reorder sends was applied, so in other tabs and for other members the bookings
sat between the wrong stops until a reload. The event now writes the slots the
way the mover's own list did, for the day it names (day_id from REST, dayId
from the MCP tool) or as the global slot without one, and caches them offline.

A booking handed back by an edit or a reservation:updated event without day
slots keeps the ones it had, in the store and in the offline cache.

Refs #2631
2026-10-12 12:33:19 +02:00
Maurice 12f08e3646 fix(planner): a timed booking dropped against its time goes back alone instead of taking the stops behind the drop along
The day list draws a row with a time by that time, and an untimed stop takes the
time of the timed row before it. A transit ride dropped behind a later stop was
stored there, drawn back at its time on the next render, and every untimed stop
behind the drop went with it; the next drag then stored that shuffle. A dropped
booking or timed note now lands in the slot nearest the drop the list draws it
in, so nothing else moves and what is stored is what is shown. Rides dropped
where their time allows stay on the drop, and the rule for untimed stops behind
a booking is unchanged.

The chronology check for a timed stop reads a booking's time for the day (the
arrival on the last day of a multi-day ride) instead of its departure, and the
three copies of the remove and insert step share one helper.

Refs #2631
2026-10-12 12:33:12 +02:00
Maurice eaab7dc4e4 fix(reservations): a booking read on its own carries its day slots, so an edit no longer drops them
The create and update answers, the reservation:updated broadcast and the MCP
reservation tools all hand back the single booking read, and that read never
loaded reservation_day_positions. The day list puts a booking moved on a day by
that slot before its time, and the client replaces the booking with the answer,
so a booking moved on a day jumped back to its time after every edit until the
next reload. The read now attaches day_positions in the shape the trip list
already sends.

Refs #2631
2026-10-12 12:32:24 +02:00
Maurice c2dd66986c fix(maps): the day route leaves an imported gpx track to the track and carries on from its end
A GPX or KML import stores the track's first point as the place's position,
so the day route treated it as a single stop there and routed from the
track start to the next stop, a second road running alongside the track.
Any assigned place with a usable track now gets the gap a Tour already had:
the route runs up to the track start and continues from the track end, on
the desktop map and day list, the mobile timeline, the trip overview and the
PDF map, which now draws the track as a line. A place without a usable track
stays a plain stop and never shows as an invalid Tour.

Refs #2600
2026-10-12 11:43:50 +02:00
Maurice 3dbe76945d fix(roadtrip): the day view drives the road picked in the road trip
A chosen alternative or a hand-bent drive is stored as vias, and only the
road trip rail routed through them. The day route on the map, its
connectors in the day list and on the phone, and the overview of all days
asked the router for the bare stops, so they drew and measured the default
road again.

They now thread the same vias between two stops that follow each other in
the stored list, with the anchor counted the way the rail counts it, and
fold the answer back to one leg per stop pair. Only while the road trip
addon is on; avoid settings and the routing engine stay with the rail.

Refs #2580
2026-10-12 11:32:15 +02:00
Maurice 565333a90c fix(roadtrip): a hotel visited again on its check-in day is the night only at its last visit
A stop counted as the booked night whenever a stay began at its place on its
day, so a hotel on its check-in day twice (the booking's own stop and a rest
the traveller planned there) was read as two nights. Both were held to the
check-in: the rest waited for the room or was set back to it, and with a check-in
far from its arrival the rest and every stop behind it moved onto the next day's
card.

oneNightPerStay in shared keeps the night and its check-in on the last visit of
the place and turns the earlier ones into ordinary stops, still a hotel. The
browser and calculate_roadtrip both run it, the stored ride slot times only that
row by the check-in, and opening an earlier visit in road trip mode opens the
stop rather than the night.

Refs #2636
2026-10-12 11:05:41 +02:00
Maurice e3bf18f9a6 fix(collections): a list invite can be accepted where its notification leads, on phone and desktop
The invite notification links to /collections/:id, a list the invitee
cannot read until they accept. The phone screen had no invite UI at all
and showed "Start your first collection", and on desktop the rail with the
invites is hidden below 1080px while the empty state had no way to open it.

useCollections now recognises a route id that is a pending invite, keeps
it out of the route sync and puts it first in the invites it hands out.
The phone screen shows the pending invites as cards above the lists, and
the desktop body shows them when the person has no lists of their own,
or the one invite the route points at. Both reuse the existing invite
strings. A route id that is neither a list nor an invite (declined,
withdrawn, deleted) now drops back to /collections once the lists are in,
instead of leaving the page on a list it cannot show.

Refs #2645
2026-10-12 10:44:57 +02:00
Maurice c94d275cfd fix(journey): gallery photos can be deleted on touch screens and ask before they go
The delete on a gallery tile only showed on mouse hover, so a phone or
tablet never saw it, and the phone screen had no delete at all. Where it
was reachable it deleted at once, although the server removes the photo
for good and takes it out of every entry that uses it.

The desktop tile now shows its delete on hover, on keyboard focus and
always where there is no hover, with a larger target there and an aria
label; viewers no longer get the button. The phone gallery gets the same
corner button for editors. Both ask through ConfirmDialog first, saying
that the photo also leaves the entries it is used in, and both delete
through the store action. The entry editor's photo controls follow the
same hover rule.

Refs #2593
2026-10-12 10:32:32 +02:00
Maurice 90352b8e3a fix(journey): draw the dashed line between entries on fullscreen leaflet maps too
The Leaflet journey map only drew the dashed route between entries when
fullScreen was off. Since the desktop sidebar and the mobile map plus
carousel view both pass fullScreen, the line was missing on the public
journey page (desktop and phone) and in the logged in views with the
Leaflet provider, while the GL renderer always drew it. fullScreen now
only enables wheel zoom, matching JourneyMapGL where it only sets the pitch.

Refs #2606
2026-10-12 10:19:39 +02:00
Maurice 4ee5e3fa96 fix(bookings): booking cards always show the calendar date of their linked day
Phone booking and transport cards printed only the title of the linked day,
so a long title cut off in the field left no date at all. They now print the
date beside the title like the desktop card, as a span that never shrinks
while the title is truncated, and a day range takes the full width with the
time under it. The desktop card gets the same non-shrinking date.

A day without a date of its own (a trip planned without dates) now borrows
the booking's start or end date, on the desktop cards, the list, the detail
dialog, the shared page and the phone alike.

Refs #2590
2026-10-12 10:16:01 +02:00
Maurice 7cb96c3d74 fix(mobile): show the trek version as a footer in the mobile account menu
The avatar menu of the mobile dashboard lost the version pill that the
desktop account menu shows under sign out. It now renders the same
appVersion from the auth store as a small footer, with the wordmark
following the active theme. Discord, help and the prerelease badge stay
desktop only.

Refs #2581
2026-10-12 10:08:08 +02:00
Maurice 8de40db40e fix(calendar): a timed stop without an end time ends after the place duration
A stop with a start time but no end time went out as a VEVENT without
DTEND. Calendar clients fill in their own default length for that, mostly
an hour, so a 20 minute stop showed up as an hour. The event now ends at
start plus the place's duration_minutes when that is set and above zero,
carried over midnight like the stay markers. An explicit end time still
wins, so start and end under an hour stay as they were. The trip download,
both calendar feeds and the MCP export share this builder.

Refs #2641
2026-10-12 10:05:24 +02:00
Maurice 36b8baf259 fix(atlas): removing the last region keeps a country the user marked visited itself
Marking a region wrote its country with source 'manual', the same as ticking
the country off by hand, and removing the last region then took the country
away whatever its origin, Dawarich confirmations included. A country that
only comes in through a region is now stored as 'region', marking it visited
afterwards turns it into the user's own mark, and the cascade in unmarkRegion
only runs for a country without such a mark. Readers still see 'manual' for
those rows, so the country detail, the MCP resource and the plugin RPC answer
as before. Rows written before this change stay 'manual' and keep their
country.

REST, MCP and plugin RPC share the service. The atlas popup on desktop and
phone now asks the server whether the country is still marked before it
drops it from the map instead of guessing.

Refs #2597
2026-10-12 09:55:52 +02:00
Maurice b3fe593a1e fix(share): translate cost categories on the public trip page
The shared Costs tab grouped expenses by the stored category and printed it
as is, so the fixed keys ("accommodation", "flights") showed in English
whatever language the reader picked. It now groups through catMeta and
labels each group with costs.cat.*, the way the Costs tab does, so old
free-text labels also join their key instead of forming groups of their own.

Refs #2596
2026-10-12 09:38:19 +02:00
Maurice 11ede4fbef fix(journey): emptying the location field in the entry editor removes the location
The field showed the stored place as soon as the search query was empty, so
deleting the last character snapped it back and the location could never be
removed. Clearing the field now empties the place and its coordinates, closes
the suggestions and drops any search answer that comes back afterwards. Typed
text without a picked result keeps the stored location as before. The hook is
shared, so the desktop dialog and the phone sheet both get the fix.

Refs #2615
2026-10-12 09:33:00 +02:00
Maurice 1ea72dca97 fix(config): trim whitespace around NODE_ENV, LOG_LEVEL and DEFAULT_LANGUAGE once at boot
NODE_ENV="production " passed the boot check but matched none of the
exact production comparisons, so the web UI was never served and every
page answered {"error":"Cannot GET /"}, while the banner showed a clean
"production". The boot import now trims these three variables in
process.env before anything else reads them, so Express, the plugin
children and readEnv() agree, and reports each change as a process
warning. Casing stays as it was. A padded "test" is only reported, so an
instance cannot switch to an in-memory database after an update. The
banner prints NODE_ENV as set instead of lowercasing it.

Refs #2565
2026-10-12 09:27:19 +02:00
Maurice 7397e33c31 fix(maps): resolve google place links to the place pin instead of the camera
A Google place URL carries two points: @lat,lng is the centre of the map
view, the !3d!4d data param is the place itself. extractCoords took the
camera first, so a pasted share link put the place around 170 m off and
reverse geocoded the wrong street. The data param is now read before @;
the order of @ and ?q=/?ll= is unchanged. REST and the resolve_maps_url
MCP tool share the resolver, so both pick this up.

Refs #2635
2026-10-12 09:21:56 +02:00
Maurice aa1bb41cf5 fix(collections): moving a place out of a list needs the same role as deleting it
Moving a saved place to another list takes it out of the source list, but
updatePlace only asked for edit rights there, so an editor could empty a
shared list they may not delete from. The source now takes owner or admin,
like a delete, and the target still takes edit rights. REST and the
update_collection_place tool share the service path. The Move to list
action on desktop and phone follows the delete permission.
2026-10-12 09:17:08 +02:00
Maurice 6c8321cd63 docs(readme): swap the tour video for the new promo film 2026-10-11 22:19:32 +02:00
Maurice 1157805146 build(docker): build gosu with Go 1.26
Go 1.25.14 carries ten stdlib CVEs that Docker Scout fails the image on;
1.26.9 fixes them.
2026-10-11 21:33:32 +02:00
Maurice aeaf3693ad fix(mascot): typecheck the clip metadata and keep the client ratchets green
The tuple cast on clips.meta.json failed tsc. The clip lookup and the 3D
opt-out move into mascotClips.ts so the component file only exports the
component, the crop offset is allowed as a deliberate physical side, and
MDancingTrek leaves the format baseline.
2026-10-11 21:33:31 +02:00
Maurice ee1b6a837f feat(mascot): 3D mascot as transparent 60 fps clips with a click animation per scene
Every empty-state scene of the TREK mascot now plays a pre-rendered 3D clip
instead of the SVG: a seamless loop per scene and theme plus a one-shot
animation on click that cross-fades in and out.

The clips are plain H.264 MP4 with stacked alpha (colour on top, the mask
below) that a small WebGL shader joins into a transparent image, so the same
file plays in every browser including iOS, decoded in hardware. The videos stay
2 px stand-ins and are drawn onto a canvas, which keeps browser video controls
off the mascot. Each clip is cropped to the area its scene uses and placed back
inside the 88:96 box, so every scene keeps the same scale.

MDancingTrek picks the clip when one exists for the scene and face and falls
back to the SVG under reduced motion, without WebGL, when autoplay is refused,
in tests, or with localStorage trek-mascot-3d set to 0.
2026-10-11 15:08:48 +02:00
Maurice 4a36a60f5a test(roadtrip): a ferry leg draws no car on its band, while the day head keeps its drive pill's car 2026-10-11 15:06:59 +02:00
Maurice 4b1957f1b4 chore(i18n): drop roadtrip.ride.duration, which nothing reads since the ride card lost its duration pill 2026-10-11 14:33:10 +02:00
Maurice c6f45b9be6 refactor(planner): a day head's pill row moves into a module of its own
The stays, hire cars and plugin label under a day's name now live in DayHeadPills, so DayPlanSidebar shrinks back under its size baseline. The stay fields are read through their types instead of an any cast, and the size, eslint and theme-disable baselines are lowered to the new counts.
2026-10-11 14:22:46 +02:00
Maurice 32606d20a6 fix(roadtrip): the rail passes the strict and lint gates, and its tests describe the new markup
The day head falls back to metric when no distance unit is set, VISIT_BOX is no longer exported, the day head is split into smaller parts and the booking icon is created once. The component tests now check the pill badges, the day tile and the ride cards instead of the old boxes, and both baselines are lowered to the new counts.
2026-10-11 14:01:18 +02:00
Maurice e8fd41b9b4 feat(roadtrip): the Google route import and the track dialog use the shared dialog frame, and Search and Manual match the day plan's buttons
Both dialogs now open in the same frame as the stay and stop dialogs: an icon tile and title in the head, the content in a card, and the actions in the footer. Closing stays blocked while an import runs. In the right column, Search and Manual take the size, text and icon of the day plan's toolbar buttons.
2026-10-11 14:01:18 +02:00
Maurice 0c554e319b fix(planner): a day head keeps its stays on one line, each name cut by the same share, under the name and the tools alike 2026-10-11 02:42:21 +02:00
Maurice 856f5b5b08 feat(roadtrip): the rail and its dialogs in the planner's card language
Day heads take the day plan's layout: the number on a raised tile with the date
under it, the name beside it and its facts as pills that never wrap but give way
in steps. Rides, terminals and hire desks are cards in their type's colour with
their icon on the line and their times in the arrival column; a stop's bookings
sit on its own badge row. Every badge on the rail is one pill shape, the drive
bands are round, and the stay, stop and driving settings dialogs use the shared
dialog frame.
2026-10-11 02:36:58 +02:00
Maurice 3ff88062f1 fix(planner): pills on a white row keep a hairline, share one height, and a transport reads its time before its phase 2026-10-11 02:36:58 +02:00
Maurice cd94b010ed build(docker): the server build gets a 4 GB heap, so the image builds on hosts with less memory 2026-10-11 00:44:14 +02:00
Maurice 7c56c74077 test(server): cover the env mapping of every registered setting and the packing write paths the ratchet counts 2026-10-10 23:37:19 +02:00
Maurice 0b8ddd7dcb test(server): day order tests follow the dateless-day rule and the OIDC hop check reads the formatted call
DAY-SVC-048 and DAY-DEL-011 pinned the old reorder, which handed a day
without a date the first slot's date; they now expect it to keep none and
the day it passes to keep its own. OIDC-SSRF-003 reads oidc.service.ts as
text and missed the token call once Prettier put a trailing comma after its
hop budget of 0; the pattern takes the comma.
2026-10-10 22:53:59 +02:00
Maurice cc94bc6953 refactor(planner): the day header's stay order lives in its own module and its tests in their own file
The day badge fix grew DayPlanSidebar.tsx and its test past their size
baselines. The check-out, through, check-in order of the header's stays is
now dayHeadStays() with a test of its own, and the day header tests sit in
DayPlanSidebar.dayHead.test.tsx; both files drop below their old entries.
2026-10-10 22:18:36 +02:00
Maurice 10ffc24395 chore: drop the Place import the removed helper type left unused and lower the client test lint baseline 2026-10-10 22:04:10 +02:00
Maurice 3fdae47041 chore: drop the exports the reworked server modules left without an importer
knip found them once the server lanes and the client ratchets met: re-exports
and helpers that moved into place-import, nest-rpc and auth-core but kept an
export nothing reads, the demo-denied helper the central MCP gate replaced,
and two settings keys only the selector uses. @OptionalAuth stays exported
as the documented opt-out and is marked @public. The baselines for knip and
the unused en keys drop to the merged tree.
2026-10-10 21:52:09 +02:00
Maurice b9a8bfc3e7 refactor(budget): the cost preview splits through the shared cent split the settlement uses 2026-10-10 21:43:08 +02:00
Maurice d2e044869a fix(days): a day without a date keeps no date when it is moved, so it can go to the front of a dated trip
Reordering handed out the dates by position, so moving a day added without a
date to the front gave it the first date and took the date of the last dated
day. The undated row stayed at the end and the move looked like it did
nothing, by drag and by the arrows alike. The dates now go to the dated days
only, in their new order; the rule lives in shared so the server, the
planner's optimistic list and the MCP tool apply the same one.
2026-10-10 21:34:51 +02:00
Maurice d496dd5d30 fix(planner): a day without a stay centres its name on the day badge 2026-10-10 21:33:38 +02:00
Maurice b31c2ff1d2 ci(client): hold the en keys nothing reaches to a baseline that only shrinks
lint:i18n-keys only listed unused en keys as information, and it looked for
references in client/src alone, so every key the server sends (system notice
and notification channel keys in its registries) read as dead. The reference
search now also reads server/src and plugin-sdk/src; those references only
mark keys as reached and are not held to the client's key rules. The unused
keys per en domain file are counted against scripts/i18n-unused-baseline.json,
which only shrinks and fails on an entry above its count until --update
lowers it. Today's 841 unused keys in 44 files are frozen, none deleted.
2026-10-10 21:33:29 +02:00
Maurice c83fa66c67 ci: hold unused files, exports and dependencies to a knip baseline that only shrinks
Nothing looked for dead code: no knip, depcheck or ts-prune, noUnusedLocals
off. knip.jsonc configures knip over the client, server and shared workspaces
with their real entry points (main.tsx, index.ts and bootstrap, the MikroORM
config, the forked plugin host and its SDK surface, migrations and seeders
found by glob, the shared build entries with one per locale, the scripts,
the tests and the Playwright launcher). lint:knip (scripts/ci/knip-ratchet.mjs)
counts its findings per category and file against scripts/ci/knip-baseline.json,
which only shrinks and is built on the client's ratchet helpers. Today's 427
findings are frozen, among them unused server dependencies (nodemon,
@types/uuid, eslint-plugin-prettier, @mikro-orm/cli), the unused root unrun,
and imports of packages no package.json lists (leaflet.markercluster, geojson).
False positives are fixed in knip.jsonc with the reason. knip is a root
devDependency; the check runs in server-quality, the job that installs every
workspace, and knip.jsonc joins the code area of the change classifier.
2026-10-10 21:33:29 +02:00
Maurice 83d807781e ci(client): hold strict null check errors per file to a baseline that only shrinks
The client compiles with strict: false, so nullable contract fields arrive as
if always present and implicit anys stay invisible. tsconfig.strict.json is the
same program with strictNullChecks and noImplicitAny, and lint:strict
(scripts/strict-lint.mjs) counts its errors per file against
scripts/strict-baseline.json: a file may not gain one, a new file has to be
clean, and an entry above its count fails until --update lowers it. Nothing is
converted here; today's 534 errors in 165 files are frozen. allowJs stays: the
tests import the .mjs scripts and read their JSDoc types through it.
The todo MSW handler maps a cleared priority to its default so the test
helper written in this branch starts at zero. The check runs after the
typecheck in client-quality.
2026-10-10 21:32:59 +02:00
Maurice c266a5e1a7 chore(client): freeze typed promise, hook, size, store and global rules in the ESLint baseline
The client lint ran untyped with two react-hooks rules, so nothing saw a
floating promise, an async handler where a void one is expected, a whole-store
Zustand subscription or a window event bus, all of which client/CLAUDE.md
forbids. eslint.config.mjs now lints with the project service and adds, as
warnings: no-floating-promises, no-misused-promises (void returns in JSX
attributes included), the rest of react-hooks v7's recommended set,
complexity 20, max-depth 4, max-params 5 and max-lines-per-function (120 in a
.ts function, 300 in a .tsx one, whose body also holds its markup),
no-console and no-non-null-assertion. Three local rules in
scripts/lib/eslint-rules.mjs cover what no stock rule sees: an empty
.catch() callback, use*Store() without a selector (the message points to
useShallow, since an object selector without it loops on Zustand 5), and
window.__* globals and window.dispatchEvent with an event of our own.
scripts/eslint-baseline.json takes today's count of each new rule, so the
existing code is frozen, not changed, and each new instance fails
lint:warnings. The CI step gets the 8 GB heap typed linting needs.
2026-10-10 21:32:23 +02:00
Maurice 242756708e ci(client): run theme:lint as a per-file ratchet with the missing token rules
theme:lint only reported a total and exited 0, skipped whole files by name and
never ran in CI. It now counts per file under src/ against
scripts/theme-baseline.json, which only shrinks, like lint:rtl: the colour
literals, arbitrary colour classes and numeric font sizes it already saw, plus
palette classes (bg-gray-100, text-indigo-600, bg-white), raw text sizes
(text-sm, text-[13px]) instead of the type tiers, z-index literals above z-50
instead of a step of the layering scale, and reads of the dark_mode setting.
The file exemptions are gone; the map, PDF and brand files carry their count in
the baseline instead. theme-lint-disable markers stay valid in a comment and
are counted against scripts/theme-disable-baseline.json, like rtl-lint-disable
in lint:rtl; a new one raises its file's entry by hand, where review sees it.
The comment handling lint:rtl already had moves into scripts/lib/source.mjs so
both read source the same way.
The check runs in the client-quality job.
2026-10-10 21:32:05 +02:00
Maurice f51a64017d refactor(client): type both trip map renderers against one MapViewProps contract
MapViewAuto took props: any and the Leaflet MapView destructured any, so a prop
one renderer learned and the other never heard of went unnoticed.
components/Map/mapViewContract.ts holds MapViewProps, derived from the GL
renderer's props and what the callers pass; MapView and the GL bindings in
glLazy declare satisfies ComponentType against it, and the GL renderer extends
it with its engine. The file imports types only, so check:gl-split is not
affected. Types only, no runtime change.

The types show two known gaps, recorded in the contract rather than papered
over: onMapReady is called by the GL renderer only, and callers pass pins
without trip_id (assignment and collection places) while both renderers type
their pins as Place; MapViewAuto bridges the latter with one commented cast.
2026-10-10 21:31:34 +02:00
Maurice 5fcb307270 test(client): hold the default MSW handlers to the @trek/shared contracts
contractHandler() parses the request body against the shared request schema,
answering 400 like the server's Zod pipe on a body it would refuse, and the
mock's answer against the response schema, failing on a factory that drifted.
The trips, budget, places, todo, tags and categories, day notes, packing,
reservations and assignments handlers and the vacay plan and colour updates use
it wherever shared has a schema; the offline bundle and the other vacay routes
have none yet and stay plain.
The typed bodies showed where the old handlers echoed request shapes back as
stored rows: payers and members are no longer echoed into a budget item, and
checked and is_private come back as 0/1 the way the server stores them.
2026-10-10 21:31:34 +02:00
Maurice da280f0d2f test(client): reset stores from a registry that a test holds complete
resetAllStores read a hand list of seven stores, and a new store simply never
joined it. tests/helpers/storeRegistry.ts now names every Zustand store in
src/ as either reset or left alone with the reason, and a test fails on a
store that is in neither list or on an entry whose store is gone. The set of
stores that are reset is unchanged.
2026-10-10 21:31:34 +02:00
Maurice 2b86d71d51 test(client): drop the mocks of the deleted server database service
server/src/nest/database/database.service no longer exists, so the five
vi.mock calls that guarded against importing it guarded nothing.
2026-10-10 21:31:33 +02:00
Maurice e971c41d1a chore(client): delete the old budget panel components that only their own tests still imported
BudgetPanel, its summary, category table, inline edit cell, add row, per person
line, pie chart, ring avatar and the useBudgetPanel hook were replaced by the
costs panel and nothing outside their own tests imports them any more.
BudgetPanel.constants, BudgetPanel.helpers and BudgetPanelMemberChips stay in
use. The rtl, format, offline and eslint baselines drop the deleted files.
2026-10-10 21:31:33 +02:00
Maurice 0032a24236 chore: git blame skips the prettier reformat of server and shared
.git-blame-ignore-revs lists the formatting commit, so GitHub's blame view
and a local git blame with blame.ignoreRevsFile set attribute each line to
the change that last meant something.
2026-10-10 21:23:54 +02:00
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00
Maurice f4e518b2df build(server): Prettier leaves the generated entity files to their generator
src/db/entities/ is written by scripts/generate-entities.ts, and
check:entities holds every file byte for byte to its output, which puts the
MikroORM import first where Prettier's import sorting puts it last.
Formatting them would fail that gate, and npm run format would do it on any
run. lint:format no longer scans the directory (its 133 entries leave the
format baseline) and a .prettierignore keeps the format script off it.
2026-10-10 21:21:16 +02:00
Maurice 947d51cef7 build(server): a test-sql-allow marker covers the first call of the statement Prettier broke up below it
Following the method chain was not enough: Prettier also wraps a cast read
in parentheses (const n = ( above db.prepare(), puts a for head above its
body and nests the call in an argument list, each leaving it more than one
line below its marker. When the line under a marker holds no call, the
marker now excuses the first call further down in that statement, up to
the line ending it with a semicolon, never past a blank line and at most
eight lines down. Every file counts the same as before on today's tree and
on the formatted one.
2026-10-10 21:05:16 +02:00
Maurice 97f4784790 refactor(server): keep three suppressed lines whole so Prettier cannot move them past their directive
Prettier wraps the emoji regex onto the line after its declaration and
spreads two any-cast lines in the plugin tests over several lines, which
would leave the casts outside the eslint-disable-next-line above them. The
regex directive now sits on the line right before the literal, the dev-link
controller takes never instead of any for the collaborators it ignores, and
the supervisor test reaches the private kill/onExit through one cast.
2026-10-10 20:52:57 +02:00
Maurice 5c198f2848 build(server): lint:mcp-zod counts a z. call that a line break splits from its dot
Prettier breaks a long schema into z on one line and .object( on the next,
which the count read as nothing: formatting the tool files would have taken
134 hand-written fields out of the tally. Whitespace before the dot counts
now. Four files already held such a split call, so their entries rise to
what they really hold (atlas 23, doc-sync 1, help 4, transit 16).
2026-10-10 20:49:22 +02:00
Maurice 0903dcd5ab build(server): a test-sql-allow marker covers the statement below it when Prettier breaks it into a chain
Prettier splits a long db.prepare('...').all() into db on one line and
.prepare( on the next, which put the call two lines below its marker and
made every parity oracle in the repository tests count as a raw statement.
The marker now also covers the method-chain lines that continue the
statement right below it, up to the first line that does not start with a
dot.
2026-10-10 20:48:33 +02:00
Maurice 517fa21e2f build: prettier leaves side-effect imports where they are
The sort-imports plugin moved bare imports along with the rest, which on
src/index.ts would have put reflect-metadata after the modules that need it
and the env validation ahead of dotenv. With importOrderSideEffects off, a
side-effect import stays in place and the imports between two of them are
sorted on their own. The three test files that were already formatted gain
the blank line the new grouping writes after reflect-metadata.
2026-10-10 20:30:16 +02:00
Maurice 939b13859d test(server): the booted test app stubs the Nominatim reverse lookup instead of rebuilding the photo capture service
bootTestApp replaced JourneyPhotoCaptureService with a factory calling its constructor positionally, only to hand it an offline geocoder, so a dependency the service gained broke the boot of every MCP suite. The container now builds the service itself and the OsmClient's reverse lookup answers that nothing is known, which also keeps every other reverse lookup a booted suite reaches inside the process.
2026-10-10 20:16:14 +02:00
Maurice ac918d362f build(server): the test-mock and hand-built-service ratchets read code, not comments, and see the import() and alias forms
lint:test-mocks missed vi.mock(import('../src/config'), ...), the typed form Vitest takes, and counted mocks quoted in doc comments, which left slack a real mock could take. lint:test-new-service missed a service built under an import alias or as new (Name)(...). Both now strip comments first and recognise those forms; the baselines lose the entries that were only comments.
2026-10-10 20:16:14 +02:00
Maurice ef68c62d80 build(server): the response-contract exempt marker only excuses a handler that writes through @Res()
A response-contract-exempt comment counted any handler as covered, whatever it returned, so one comment line let a JSON handler past the ratchet. The marker now counts only on a handler taking @Res() or @Next(). The five vacay holiday proxies return the provider's JSON, so they lose their markers and are counted again until they declare a schema.
2026-10-10 20:16:14 +02:00
Maurice 8187a423e2 fix(admin): an invite for a trip that does not exist answers 404 instead of an empty 201
createInvite returned { error: 'Trip not found', status: 404 } and the admin controller never looked at it: POST /api/admin/invites with an unknown trip_id answered 201 with an empty body and wrote an admin.invite_create audit row for resource 'undefined'. The service throws the DomainError now, so the route answers 404 { error: 'Trip not found' } and writes neither the invite nor the audit row.
2026-10-10 20:16:14 +02:00
Maurice 8fb888c8a3 build(server): lint:service-http counts envelopes in every domain file and every spelling of a branch on one
The ratchet only read *.service.ts for status literals and one branch shape in controllers and tools, so an envelope in a helper, impl or provider file, a branch written as result.error === 'not_found', and RPC handlers all went uncounted. It now counts the literals in every file under src/nest outside the HTTP layer, and comparisons, negations, optional chains and ternaries on a returned error in controllers, tools and RPC handlers. The baseline takes what the wider count finds.
2026-10-10 20:16:13 +02:00
Maurice ebd8a4f8c9 build(server): lint:boundaries counts the RPC and MCP layers as domains and sees getRepository calls
src/nest-rpc, src/nest-mcp and src/mcp import domains and are imported by them, but the domain map only knew src/nest, so their edges dropped out of domainCycles and their repository injections out of foreignRepositories. Each layer now counts as one domain. The repository rule reads the syntax tree for @InjectRepository(X) and for getRepository(X) on an EntityManager, so a comment no longer counts and a foreign read through the EntityManager no longer passes. The baseline takes the cycles and the repository reaches the wider view finds; settings -> Users goes, it was a comment.
2026-10-10 20:16:13 +02:00
Maurice 0f6ae8b928 refactor(vacay): the invite and share tools ask a kernel user lookup instead of injecting UsersRepository
VacayMcp read the caller's username and email straight from UsersRepository, a table the auth domain owns. UserLookupService in auth-core answers exactly that, so vacay neither imports AuthModule nor injects a foreign repository, and its foreignRepositories entry goes.
2026-10-10 20:16:13 +02:00
Maurice bb6eb1ab89 fix(mcp): the six write tools that never had a demo check stay open to demo accounts under the central gate
sync_trip_documents and the five manual school-holiday tools carried no demo check of their own before the gate moved into the registry, so a demo account could call them. The gate now lets exactly those through, and a test pins the list. upload_trip_file keeps the gate's order: a demo account is refused before its trip access is checked, where the tool used to answer a trip it cannot see with the no-access result first.
2026-10-10 20:16:13 +02:00
Maurice e7e26bf2e6 chore(shared): the format lint script imports URL instead of relying on a global 2026-10-10 20:16:12 +02:00
Maurice 4427c435a8 ci(server): module mocks, hand-built services and test file length in tests/ may only shrink
lint:test-mocks counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db per file under tests/, lint:test-new-service counts new XService(/new XRepository( there with every class whose constructor takes nothing allowed (derived from src/ and tests/helpers/), and lint:size holds files under tests/ to 2000 lines against their own baseline. Both counters share scripts/lib/count-ratchet.mjs, which gains a prepare hook for a check that reads the tree first. The three run as steps of the existing server lint job; the CLAUDE.md files and the module guide describe the test helpers they point to.
2026-10-10 20:16:12 +02:00
Maurice 9bb5854dbb test(server): the suites left in tests/unit/services move next to the code they cover
src/services is gone, so the folder named a layer that no longer exists. The airtrail suites go to tests/unit/nest/integrations, the crypto, cookie, avatar, password policy, timezone and conflict helpers to tests/unit/nest/common, the kitinerary mapper to booking-import, the photo media suite to photos, the atlas bundle guard to tests/unit/nest and the two migration suites to tests/unit/db. Only the relative import paths change, and the format baseline follows the new paths.
2026-10-10 20:16:12 +02:00
Maurice 6ce15157a1 test(server): MCP suites run on the booted container and unit suites take services from their module
createTestModule (tests/helpers/test-module.ts) builds a Nest testing module of the domain modules under test over the suite's test db, with a FakeRealtimeService and provider overrides for the collaborators a suite controls. bootTestApp (tests/helpers/test-app.ts) boots AppModule once per file, with recording realtime, fixture storage and an offline geocoder, and the MCP harness takes McpRegistryService from it inside a request context instead of hand-wiring roughly eighty services. The days, day removal and day notes service suites take their subjects from DaysModule and DayNotesModule.
2026-10-10 20:16:11 +02:00
Maurice fa0edfd714 test(server): suites record broadcasts on a FakeRealtimeService instead of mocking src/websocket
A hand-built service gets a FakeRealtimeService where it took a RealtimeService, a Nest testing module overrides the provider, and a suite over buildApp() spies on the container's instance (spyOnRealtime). The 167 vi.mock factories on src/websocket are gone, RealtimeService reads the socket registry in ws-state.ts directly, and the re-export stub src/websocket.ts is deleted. The facade's own suite now checks what reaches a socket instead of which module function it called.
2026-10-10 20:16:10 +02:00
Maurice 00cbc5aae3 test(server): src/config takes fixed test values from the setup file, so suites stop mocking it
tests/setup.ts mocks src/config once with tests/helpers/test-config.ts. A suite that needs other values calls overrideTestConfig(), and the setup file restores the defaults after every case. The 191 per-file factories are gone; config.test.ts takes the real module through vi.unmock, and the few suites that signed tokens with their own literal secret read it from TEST_CONFIG.
2026-10-10 20:16:10 +02:00
Maurice 93c2bf0740 test(places): the import and image error cases stub the DomainError the service throws now 2026-10-10 20:16:09 +02:00
Maurice 1f78438879 chore(shared): journey.schema.ts leaves the format baseline now that it is formatted 2026-10-10 20:16:09 +02:00
Maurice 848655d593 chore(plugins): the host plugin API file holds its size baseline again
Tightens the two comments the rawBodyBase64 field and the type parity note
added to runtime/plugin-sdk.ts, lowers its lint:size entry, and points the
shared guide at the client files it names by their full path.
2026-10-10 20:16:09 +02:00
Maurice e21f2d97b6 feat(contracts): vacay and journey routes declare their response schemas
The vacay plan, calendars, years, entries, stats and shares, and the
journey list, create, tracks, stats, share link and every success answer
get response schemas in shared and a @ResponseContract on their handler.
The holiday proxies pass the provider's answer through and are marked
exempt. The journey e2e stubs return whole rows, as the service does.
2026-10-10 20:16:08 +02:00
Maurice 8e01dbb92f feat(contracts): todo, settings, collab and file routes declare their response schemas
shared gains the response schemas these routes answer with (rows in their
storage spelling, as the routes have always sent them) plus the common
success and empty answers, all exported for the client to type its calls
with. Every JSON handler in the four controllers carries its
@ResponseContract; the file download, which streams, is marked exempt. The
files e2e fixture table gains the note_id column the real one has.
2026-10-10 20:16:08 +02:00
Maurice 87071bf738 feat(server): @ResponseContract checks a handler's responses against its shared schema in tests
The decorator records the @trek/shared response schema a route answers
with. Under NODE_ENV=test it adds an interceptor that parses every response
and fails the request with a logged 500 naming the handler and the issues,
so the e2e and integration suites catch a response that drifted from its
contract; in production nothing is added to the request path and the body
is never rewritten. lint:response-contracts holds the handlers still without
one to a per-file baseline that only shrinks, and runs in server-quality.
2026-10-10 20:16:08 +02:00
Maurice 81ec7dd28d fix(plugin-sdk): validate checks apiVersion and the manifest rules exactly as an install does
The SDK accepted any number as apiVersion while the host refuses anything
but a positive integer no newer than its plugin API, a false green for an
author. The manifest patterns, sets and caps now live in the host's
protocol/manifest-rules.ts, and gen-plugin-facts writes them and
PLUGIN_API_VERSION into the SDK, so check:plugin-facts covers them. One
manifest corpus is judged by both validators in a server test (the SDK may
only be stricter, case by case), and a typecheck test holds the SDK's plugin
API types to the host child's copy, which gains rawBodyBase64.
2026-10-10 20:16:08 +02:00
Maurice 15db688586 refactor(reservations): one reservation type catalog in shared feeds the server's type lists
RESERVATION_TYPES lists every type with its flags (transport, carrier,
rental, stay, legs, creatable through MCP), and the named subsets derive
from it in picker order. The MCP transport and booking enums, the share
view's travel filter, the road trip carrier query and the shared
CARRIER_TYPES now read it. The reservation response types type and status
as the known values or any stored string, so rows outside the catalog still
pass; requests stay as tolerant as before.
2026-10-10 20:16:08 +02:00
Maurice 5e797463fa refactor(permissions): the permission catalog and its decision rule live in shared
PERMISSION_ACTIONS, PermissionLevel, PermissionKey and evaluatePermission()
move to shared/src/permissions. PermissionsService decides through
evaluatePermission and re-exports the catalog for its importers, and
@RequirePermission takes a PermissionKey, so a misspelt action fails to
compile. Levels, defaults and every verdict stay as they were.
2026-10-10 20:16:07 +02:00
Maurice 8d716aec2f refactor(budget): money arithmetic lives in shared and the settlement uses it
toMinor, sumMinor, splitEqualShares and currencyDecimals move to
shared/src/money. The budget service and its MCP tools call them instead of
private copies, and the share table both sides were pinned to becomes the
spec of the shared function. Stored values and every computed figure stay
the same.
2026-10-10 20:16:07 +02:00
Maurice e5a2af5999 chore(server): size, strict and warning baselines follow the error-envelope work 2026-10-10 20:16:07 +02:00
Maurice e7c1acfb47 chore(server): lint:mcp-zod and lint:service-http hold inline tool zod and error envelopes to a shrinking baseline 2026-10-10 20:16:07 +02:00
Maurice c353302e3f refactor(server): packing templates, road trip, day and booking-import services refuse with DomainError 2026-10-10 20:16:07 +02:00
Maurice 6cff4802bf refactor(mcp): tool ids derive from the shared idSchema instead of an inline zod chain per field 2026-10-10 20:16:06 +02:00
Maurice f9e756391c refactor(server): accommodation and packing item writes run as one use case per write, so REST and MCP send the same events 2026-10-10 20:16:06 +02:00
Maurice aba2ed1ee3 refactor(server): places, unsplash, vacay, collections and file downloads raise DomainError, so REST and MCP stop translating results 2026-10-10 20:16:06 +02:00
Maurice e81efcbc57 refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes 2026-10-10 20:16:05 +02:00
Maurice 66042ae1c7 feat(server): services refuse with a DomainError that REST and MCP answer in their existing shapes 2026-10-10 20:16:05 +02:00
Maurice 064fba5c0c test(server): the provider-orchestrator boundary case gets an id of its own
Both boundary rules arrived as BOUND-012; the provider rule's case is now
BOUND-014.
2026-10-10 20:16:04 +02:00
Maurice 2c3fb15263 style(server): format the files the health lanes added or moved
Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
2026-10-10 20:16:04 +02:00
Maurice a28044dbd4 chore(server): lint:boundaries holds the user-connection repositories and the journey read at their new count
The module comment no longer spells an @InjectRepository call the scanner took
for a foreign Users injection in nest/database, and journey's Users entry is
gone now that it reads the Immich flag through UserImmichRepository.
2026-10-10 20:16:04 +02:00
Maurice 912b8ff96b refactor(place-import): the Naver pager declares its page result, so the moved reader passes the strict ratchet
The reader left places.service with four strictNullChecks errors that a new
file may not carry. Declaring fetchPage's result as a page or a list import
error lets the 'error' check narrow it; the places and maps entries drop off
the strict baseline.
2026-10-10 20:16:04 +02:00
Maurice c859cf3624 test(server): the new demo-gate, timestamp, JSON-column and maps tests add no ESLint warnings
Non-null assertions become explicit guards, the two demo-gate loops collect
their answers with Promise.all, and the MapsService imports the maps tests no
longer use are gone. The src counts the refactors lowered are taken off the
warnings baseline.
2026-10-10 20:16:03 +02:00
Maurice 1a1257ccac docs(maps): the places-provider seam names both keyed providers it now carries 2026-10-10 20:16:03 +02:00
Maurice 04f38f9876 chore(server): eslint keeps fast-xml-parser inside place-import and the WebDAV client 2026-10-10 20:16:03 +02:00
Maurice 1389fe6984 refactor(place-import): file and shared-list readers move into their own domain over one gpx and one kml codec 2026-10-10 20:16:03 +02:00
Maurice dc42fe9a5a chore(server): lint:boundaries refuses a provider that imports the orchestrator above it 2026-10-10 20:16:02 +02:00
Maurice 80e64b4f4d refactor(maps): key selection, place details, marker photos and link resolving leave the maps orchestrator 2026-10-10 20:16:02 +02:00
Maurice 0cedff94a0 refactor(maps): wikimedia lookups and brand logos move into their own client 2026-10-10 20:16:02 +02:00
Maurice 79ba003bfe refactor(maps): nominatim and overpass calls move into an OpenStreetMap client 2026-10-10 20:16:02 +02:00
Maurice 863b02a549 refactor(maps): google place lookups go through the places provider 2026-10-10 20:16:01 +02:00
Maurice 3618a4ab9f refactor(maps): the searchText field mask lives beside the Google places provider 2026-10-10 20:16:01 +02:00
Maurice 526b2c105a fix(mcp): McpSharedModule imports the realtime module its guards inject
McpToolGuardsService injects RealtimeService, and McpSharedModule counted on
some other module of the app having imported the global RealtimeModule.
AuthModule used to do that for every domain that imported it for the demo
check; with those imports gone, a test app assembling ShareModule alone no
longer resolved the guards. The module now names the dependency itself.
2026-10-10 20:16:01 +02:00
Maurice cd0c27921c build(server): lint:boundaries holds foreign repository injections to a baseline
A domain injecting the repository of a table another domain owns couples
the two below the service layer, and nothing counted it. The new
foreignRepositories rule reads every @InjectRepository under src/nest,
looks the entity up in scripts/repository-owners.json (an injected entity
without an owner stops the run) and holds each domain/entity pair to the
baseline, which only shrinks: 209 pairs today.
2026-10-10 20:16:00 +02:00
Maurice fe35e26313 refactor(trip-membership): domains ask TripAccessService who may see a trip
Thirty services, controllers, MCP tools and RPC handlers outside the trips
domain injected TripsRepository for one call, findAccessible. They now
inject TripAccessService from the trip-membership leaf, in the same
constructor slot under the same field name; it answers with the same
access row (or undefined) and binds the id exactly as given, so each
caller keeps its own coercion and its own 404, 403 or tool error.
2026-10-10 20:16:00 +02:00
Maurice 734cbc2a94 refactor(vacay): the invite and share tools read the caller's name from UsersRepository
VacayMcp injected AuthService only to call getCurrentUser for the caller's
username and email before sending an invite or sharing the calendar, which
kept VacayModule importing AuthModule. UsersRepository.findUsernameEmail
answers the same two fields (and undefined for an unknown id, which the
tools already refuse as 'User not found.').
2026-10-10 20:16:00 +02:00
Maurice 806f3c8e62 refactor(addons): the transit-provider setting and photo-provider routes move into the shared kernel
AddonsService read the transit backend through transit/transit-provider.ts
and built photo-provider endpoint paths with memories.helpers, so the addons
domain, which nearly every domain imports for its gates, depended on
transit and memories. Both are free functions over a repository or plain
strings; they move unchanged to common/, where the transit and admin sides
read them too.
2026-10-10 20:15:59 +02:00
Maurice 846d8d4952 refactor(realtime): the gateway asks a journey-access port instead of importing the journey domain
The websocket gateway injected JourneyDomainService to decide who may join
a journey's book room, so the realtime domain, which nearly every domain
imports for its broadcasts, depended on journey and closed a cycle through
all of them. It now injects a JOURNEY_ACCESS port declared in realtime;
JourneyAccessModule (global, imported by AppModule beside the gateway
module) binds it to the same JourneyDomainService method.
2026-10-10 20:15:59 +02:00
Maurice 0c2226c5f0 refactor(realtime): the websocket welcome frame reads the running version from app-config
The gateway imported hostVersion from the plugin install code to put the
version in its welcome frame, which made the realtime domain, imported by
nearly every other, depend on plugins. The running version (image VERSION
or APP_VERSION, else package.json) is app config; runningVersion() holds it
and the plugin host-compat checks call the same function.
2026-10-10 20:15:59 +02:00
Maurice 01fd4e3739 refactor(admin): the admin routes other domains serve keep their DTOs in those domains
NotificationsController, SettingsController, OidcController and the packing
template controller each imported their body DTO from admin/admin.dto.ts,
and AdminController imported the same four without using them. Those
imports tied the notifications, settings, packing and oidc domains to the
admin domain and through it to everything admin depends on. The classes
move unchanged (same names, same @trek/shared schemas) into each domain's
own dto file.
2026-10-10 20:15:59 +02:00
Maurice e40bc71e9f refactor(plugins): the plugin RPC kit moves out of the plugins domain into src/nest-rpc
Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors
and PluginGuards, and every domain module imported PluginGuardsModule, all
of it inside src/nest/plugins, while PluginsRuntimeModule imports those same
domains so their handlers exist at boot. That made the plugins domain part
of every cycle. The kit imports no domain but the protocol tables and
permissions/addons for the guards, so it moves to src/nest-rpc beside
src/nest-mcp, with its tests, its README and its extraction rule; the
runtime module keeps its domain imports, which the boot-time coverage check
of the wire surface needs in any subset app.
2026-10-10 20:15:58 +02:00
Maurice cc79f561e5 refactor(auth): guards, auth decorators and ephemeral tokens move into an auth-core kernel
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global
and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral
token service lived in the auth domain, so every controller that checked a
session depended on the whole account module and its imports. They import
no domain themselves; under src/nest/auth-core they are shared kernel like
common/, every importer points there directly, and AtlasModule and
NotificationsModule stop importing AuthModule, which they only did for the
demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic
domain edges, the largest cycle shrinks from 46 to 40 domains.
2026-10-10 20:15:58 +02:00
Maurice 1a9855cf70 refactor(mcp): the demo-mode write block runs once, in the registry's tool gate
Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
2026-10-10 20:15:57 +02:00
Maurice 8350d04a63 refactor(db): the Immich, Synology and AirTrail columns of users get repositories of their own
UserImmichRepository, UserSynologyRepository and UserAirtrailRepository hold the eighteen connection statements UsersRepository carried, on the same users table and with their statements unchanged, provided through a small module as factories over the EntityManager. ImmichService, SynologyService, AirtrailService and JourneyService inject them instead of the account repository. UserRow drops the dead immich_access_token column (the table keeps it), and a shape test pins the account repository's size.
2026-10-10 20:15:57 +02:00
Maurice 3b58694eb9 refactor(settings): instance settings are named through one typed register
app-settings.registry.ts lists every app_settings key the server reads by name with its shape, default, environment override and flags. The admin form's keys, its masked and encrypted handling and the public config's reads derive from it, isOidcConfigured() replaces the three issuer/client-id checks in AuthService, and OidcService resolves its values through the same rule while still requiring the client secret. Every literal getValue() read goes through readAppSetting()/resolveAppSetting(), and ESLint refuses a new one.
2026-10-10 20:15:56 +02:00
Maurice 42d75a2b7e test(auth): pin how the four OIDC checks read the environment and the settings
The login toggles' legacy fallback, the public config, the lockout guard of the settings save and the provider config each decide on their own whether OIDC is configured. These cases hold what each answers, including that only the provider config also wants the client secret, before the checks are shared.
2026-10-10 20:15:56 +02:00
Maurice e316ccf3bb perf(db): the calendar export, the journey reads and the stop reorders read and write in batches
The ICS export reads the stops and notes of all dated days in two statements instead of two per day, the journey's full read and entry list take the source trip titles in one join instead of one read per entry, and reordering a day's stops or sorting it by time writes every position in one CASE update. Results are unchanged: each day keeps its order (the id settles a tie), a missing trip still names null, and an id listed twice keeps its last position.
2026-10-10 20:15:56 +02:00
Maurice 4a3c97e500 docs(server): name the query API per statement shape and hold QueryBuilder calls to a baseline
server/CLAUDE.md now says which API new repository code uses: Kysely for join, aggregate and set-based statements, the entity methods for single-table CRUD, no new QueryBuilder calls. The TrekRepository docstring no longer calls Kysely an escape hatch. lint:query-api counts this.qb()/createQueryBuilder() per repository file against a baseline that may only shrink and runs in the server CI job.

The same file documents the JSON column and timestamp rules from the two changes before.
2026-10-10 20:15:56 +02:00
Maurice 91deedae8b refactor(db): JSON columns are decoded through one declared schema per column
decodeJson/decodeJsonResult/encodeJson read and write a JSON-in-TEXT column against the schema and fallback declared for it in db/json-columns.ts, and log a row that does not decode. reservations.metadata, the OAuth scope and redirect lists, consents, API token scopes, MFA backup codes, journey entry tags and pros/cons and poll options go through it. Readers that used to throw on a broken row (a hotel update, the ICS export, the price mirror, the OAuth token and session reads, a consent, a journey entry, a poll) now take the tolerant fallback the other readers of the same column already used.
2026-10-10 20:15:55 +02:00
Maurice a926990e4a fix(db): timestamp columns are written and stored in one canonical spelling
A DbTimestamp brand, produced only by dbNow(), types the expiry writes of OAuth tokens and password reset links, and collection edits stamp updated_at the same way. parseDbTimestamp() reads the stored text as UTC and replaces the oauth-local parser; utcSuffix lives once beside it. A data migration rewrites the ISO values those writers left behind, and the OAuth session lists keep answering the expiries in ISO.

Two outputs change on purpose. collections.updated_at used to come back in two spellings: a collection never edited answered the column default (2026-01-02 03:04:05), an edited one the ISO text its writer stored (2026-01-02T03:04:05.678Z). It now answers the canonical text in every case, in the PUT and cover responses and, once the migration has run, in every read; the shared schema types it as a plain string and the client does not read it. The ISO expiries of the OAuth session lists are rebuilt from the stored second-precision text, so they end in .000Z instead of carrying the milliseconds of the write.
2026-10-10 20:15:55 +02:00
Maurice b1da50466d ci(server): strict type errors per file may only shrink
tsconfig.strict.json is the build config with strictNullChecks and
noImplicitAny on. lint:strict runs tsc over it and holds the errors per file
to scripts/strict-baseline.json (204 in 109 files today): a file may only
lose errors, a new file starts at none, and an entry above its count fails
until --update lowers it. An error outside any file or an unreadable tsc run
fails closed. It runs in the server job; the build itself stays on
strict: false.
2026-10-10 20:15:55 +02:00
Maurice 1ea435d730 ci: server and shared lint stop rewriting files, and Prettier is checked per file
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
2026-10-10 20:15:54 +02:00
Maurice c9f66156d3 ci(server): lint:warnings holds the ESLint warnings per rule to a baseline
Server lint ran without --max-warnings or a baseline, so the rules left on
warn could grow without limit. lint:warnings runs ESLint with the server's
own config through the shared tally, fails on any error or inline rule
config, and holds the warnings per rule for src, tests and eslint-disable'd
messages to scripts/eslint-baseline.json, which only falls. It replaces
lint:check in CI. no-await-in-loop, no-non-null-assertion and no-console come
in as warnings held at today's counts. npm run lint no longer rewrites files;
lint:fix does.
2026-10-10 12:08:37 +02:00
Maurice cfef8742f4 docs(server): the Nest README is a blueprint again, and guides may only name paths that exist
server/src/nest/README.md drops from 988 lines to under 200: the module
shape with weather as the reference, the cross-cutting pieces, the guards,
parity and the test layout, without the bridge playbook and the services
recipe that contradicted the code. The migration log moves verbatim to
server/docs/nest-migration-history.md, marked historical.

scripts/ci/doc-paths.mjs resolves every backtick path in the CLAUDE.md files,
the root README, the blueprint and the wiki against the tracked files. The
paths already missing are listed in a baseline that only shrinks, and the
changes job runs it so docs-only changes are checked too. The wiki no longer
says the Playwright flows stay out of CI.
2026-10-10 12:01:07 +02:00
Maurice a4d0fc65f0 chore(i18n): drop the photos, register and stats locale files
None of their keys is read anywhere in client, server, shared or plugin-sdk,
neither literally nor through a key built from a prefix: the photo, sign-up
and stats screens they were written for are gone. All 27 locales lose the
same three files, so parity holds.
2026-10-10 11:54:10 +02:00
Maurice 7ee8333aeb refactor(server): types.ts keeps only imported types and derives Trip from the schema
Thirteen interfaces nobody imported are gone. Trip is now the selectable row
of the generated TripsTable, so currency, is_archived and reminder_days read
as the nullable columns they are and feed_token and reminder_sent_for exist.
2026-10-10 11:52:07 +02:00
Maurice 9a8191d367 chore: remove root scripts nothing runs any more
The two React profiler readers were one-off analysis helpers, and the i18n
migration read client/src/i18n/translations, which has been gone since the
locales moved to shared/.
2026-10-10 11:49:00 +02:00
Maurice db8babffc7 chore(server): remove the MCP config re-export and the dormant row-shape helper
Only their own tests imported them. The MCP knobs are parsed in
app-config/parsers.ts, which tests them directly, and toCamelCase was never
wired into a plugin RPC handler.
2026-10-10 11:48:59 +02:00
Maurice 2050e796eb chore(server): drop nodemon, @vitest/coverage-v8 and @types/uuid
Nothing runs nodemon (dev goes through scripts/dev.mjs), the coverage provider
is istanbul, and uuid 14 ships its own types. uuid itself stays: the OIDC and
Unsplash services import it.
2026-10-10 11:47:45 +02:00
Maurice 889afeae92 build(scripts): server ratchets share one baseline library at the repository root
scripts/lib/ratchet.mjs carries the tree walk, the CRLF-safe reader and the
baseline handling the client ratchets already use, next to a warning tally and
a format comparison that take ESLint and Prettier from the calling workspace.
server lint:size now runs on it with the same messages and exit codes, and the
libraries have node:test suites that CI runs in the server job.
2026-10-10 11:45:27 +02:00
Maurice 5644e3d77e fix(vacay): the desktop calendar no longer requests the trips again on every vacay refresh
Every vacay refresh on desktop (opening the page, a vacay:settings or
vacay:invite update from another member) sent a second GET /trips just to
redraw the same trip dots, even when the leave-year window had not changed.

Cause: useVacayCalendarLogic keyed the trip dot reload on the year settings
object when tripsFollowWindowShape was false (the desktop grid). loadAll()
replaces that object with a deep-equal copy on every refresh, so the effect
ran again. The phone screen already keyed it on the window shape.

Fix: the trip dots always reload on the window shape (year type, start month,
start day, hire date), which covers every field the dot filter reads. The
option only existed to keep the desktop behaviour, so it is removed from the
hook and from both callers.

Red test: FE-COMP-VACAYCAL-010 (useVacayCalendarLogic.test.ts) failed on the
unfixed code with "AssertionError: expected "list" to be called 1 times, but
got 2 times".

Changed assertions:
- useVacayCalendarLogic.test.ts FE-COMP-VACAYCAL-010: was "the desktop mode
  reloads trips on every new settings object" expecting tripsApi.list to be
  called 2 times after a deep-equal settings replacement; now expects 1.
- FE-COMP-VACAYCAL-011 no longer passes tripsFollowWindowShape and no longer
  names the phone mode; its assertions are unchanged.
2026-10-09 19:00:47 +02:00
Maurice 694e11c117 fix(vacay): a failed user load in the desktop invite and share dialogs no longer shows a stale list
When loading the users that can be invited to a fusion or given a calendar
share failed on desktop, the dialog kept showing the list from the last load,
so a user could pick someone who is no longer available and only learn it
from the failed send. The phone sheets already emptied the list and showed
their "no users available" state.

Cause: useVacayUserPicker only emptied the list on a failed load when its
clearOnLoadError option was true; the desktop VacayPersons and
VacaySharedCalendars dialogs passed false, a leftover of the old silent
catch in the desktop dialogs.

Fix: the hook always empties the list when the load fails. The option only
existed to keep the desktop behaviour, so it is removed from the hook and
from all four callers.

Red test: FE-COMP-VACAYPICK-002 (useVacayUserPicker.test.ts) failed on the
unfixed code with "AssertionError: expected [ { id: 2, username: 'bob' },
...(1) ] to deeply equal []".

Changed assertions:
- useVacayUserPicker.test.ts FE-COMP-VACAYPICK-002: was "a failed load keeps
  the last list on desktop" expecting available to equal USERS; now expects
  an empty list.
- FE-COMP-VACAYPICK-003: was "a failed load empties the list on the phone"
  through load() with clearOnLoadError true; now drives the same expectation
  (empty list) through a phone sheet reopening, since the option is gone.
2026-10-09 19:00:47 +02:00
Maurice 33db4ea7cb fix(dashboard): the desktop currency widget always shows the rate for the currency picked last
Switching the from currency of the desktop dashboard currency widget quickly
could show the rate of the previous currency: when the older rate request
answered after the newer one, its answer overwrote the newer rates.

Cause: useCurrencyConverter only aborted a request still in flight in its
phone mode (abortStale true); the desktop dashboard passed false and fetched
without any stale guard.

Fix: the hook now always aborts the previous request when a new one starts
and when the widget goes away, and ignores answers of an aborted request. The
abortStale option only existed to keep the desktop without the guard, so it
is removed and both dashboards call useCurrencyConverter().

Red test: FE-COMP-FXHOOK-008 (useCurrencyConverter.test.ts) failed on the
unfixed code with "AssertionError: expected 1.1 to be 1.3".

Changed assertions:
- useCurrencyConverter.test.ts FE-COMP-FXHOOK-002: was "the desktop mode
  fetches without a signal, the phone mode with one" expecting the desktop
  call to be [url] only; now expects [url, { signal: AbortSignal }].
- FE-COMP-FXHOOK-003 no longer names the phone mode; its assertions are
  unchanged. All tests call the hook without the removed option.
2026-10-09 19:00:47 +02:00
Maurice 1ad0ef6d78 refactor(settings): photo provider cards read their state from the shared hook
With the badge and toast options gone from usePhotoProviderConnections,
the desktop and phone cards called the hook identically and then derived
the same seven per card values (fields, config, values, badge, testing,
whether save and test have a route) line for line, which lint:dup counts
as a copied block of 25 lines in MPhotoProvidersSection.tsx.

The hook now hands each card those values through providerCard, and both
views keep only their markup. Nothing the user sees changes.

New test FE-COMP-PHOTOCONN-011 covers providerCard. No existing assertion
changed.
2026-10-09 19:00:47 +02:00
Maurice 24461ecf76 fix(admin): the default map template offers the Amap presets users can pick
In the admin's default user settings, the map template dropdown listed
seven tile presets, while every user's own map settings listed nine: the
same seven plus Amap and Amap Satellite. An admin running an instance for
people in mainland China could not pick the one basemap that works well
there as the default, short of pasting its URL by hand.

The Amap presets (57eac32f9) were added to the two user preset copies and
not to the admin's separate copy; nothing in that change or later gives a
reason, and the server stores any template as a default. The later
refactor kept the gap as DEFAULT_MAP_PRESETS beside MAP_PRESETS. There is
now one MAP_PRESETS list, read by the user settings and the admin defaults
on both desktop and phone.

Regression test FE-ADMIN-DUS-029 (DefaultUserSettingsTab.test.tsx) was red
on the old code: "Unable to find role="button" and name "高德地图 (Amap)"".

Changed assertions:
- useMapSettingsForm.test.ts FE-COMP-MAPFORM-001: it expected the admin list
  DEFAULT_MAP_PRESETS to hold 7 presets and the user list to add Amap on
  top; it now expects the one list to hold 9 presets including both Amap
  templates.
2026-10-09 19:00:47 +02:00
Maurice d6850756c6 fix(settings): managed installs no longer offer photo providers or AirTrail on the phone
On a managed install the phone's Integrations section still showed the
Immich and Synology Photos cards and, with the addon on, the AirTrail
connection form. Each asks for the address of a server the reader runs at
home, which a managed install cannot reach, so saving or testing could only
time out. The desktop has hidden both since the managed mode landed
(#1946, 3a0604b3f), whose message says these "connect to a server the
reader runs at home, and there is no route from here to one".

That change gated the phone's AI section but missed these two, and the
user facing addon list still carries a photo provider that was enabled
before the switch, so the cards did render. The phone now applies the same
!managed gate as the desktop IntegrationsTab.

Regression test FE-MOB-SETINT-008 (MSettingsIntegrations.test.tsx) was red
on the old code: "expected document not to contain element, found <div
data-testid="photo-providers"> instead".

No existing assertion changed.
2026-10-09 19:00:47 +02:00
Maurice 76b1e67be8 fix(settings): the desktop photo provider badge follows the live status check
On the desktop, the Immich (or Synology) card could show "Connected" for a
server that was down. The settings page loads two answers at once: the
saved settings, whose connected flag for Immich only says that a URL and an
API key are stored, and the status route, which actually reaches the
server. Whichever landed last set the badge.

usePhotoProviderConnections had a statusRouteOwnsBadge option that only the
phone turned on. The option is gone: whenever a provider declares a status
route, only that route sets the badge, on both views. A provider without a
status route still takes the badge from its settings read.

Regression test FE-COMP-PHOTOPROVIDERS-028 (PhotoProvidersSection.test.tsx)
was red on the old code: "Unable to find an element with the text: Not
connected" (the late settings read had turned the badge to Connected).

Changed assertions:
- usePhotoProviderConnections.test.ts FE-COMP-PHOTOCONN-005: it expected the
  later settings read (connected: false) to override the status route's
  connected: true; it now checks that a provider without a status route
  takes the badge from the settings read.
- usePhotoProviderConnections.test.ts: render() no longer takes the option,
  so every call drops its true or false argument.
2026-10-09 19:00:46 +02:00
Maurice 0b90fc8cf5 fix(settings): a failed photo provider test reads "Immich: reason" on the desktop
On the desktop, a failed photo provider connection test toasted "Could not
connect to Immich : Auth failed" with a stray space before the colon, and
"Could not connect to Immich " with a trailing space when the server gave
no reason. The phone already read "Could not connect to Immich: Auth
failed".

usePhotoProviderConnections took a testErrorGap option that put a space
between the sentence and ": reason", and the desktop passed ' '. The option
is gone, so both views join the reason directly.

Regression test FE-COMP-PHOTOPROVIDERS-027 (PhotoProvidersSection.test.tsx)
was red on the old code: "Unable to find an element with the text: Could
not connect to Immich: Auth failed" (the toast read "Could not connect to
Immich : Auth failed").

Changed assertions:
- usePhotoProviderConnections.test.ts FE-COMP-PHOTOCONN-010: the desktop
  toast was 'memories.connectionError(Immich) : bad key', now
  'memories.connectionError(Immich): bad key'; it also checks that a
  failure without a reason toasts the bare sentence.
2026-10-09 19:00:46 +02:00
Maurice 12962480d5 fix(settings): notification settings show an error when they cannot load
When the notification preferences failed to load, the matrix on both the
desktop tab and the phone screen stayed on its loading line for good and
nothing told the user anything went wrong. A failed load of the webhook and
ntfy settings was just as silent and left empty fields that looked like
nothing was saved.

Both loads in useNotificationSettings ended in .catch(() => {}) and had no
cancelled flag. Each failure now shows the common error toast, a failed
preferences load sets loadFailed so both views replace the loading line
with the error text, and both loads ignore a response that arrives after
the screen is gone.

Regression test FE-COMP-NOTIFSETTINGS-010 (useNotificationSettings.test.ts)
was red on the old code: "expected "vi.fn()" to be called 2 times, but got
0 times". FE-COMP-NOTIFSETTINGS-011 covers the cancelled flag and
FE-COMP-NOTIFICATIONS-032 the desktop view.

Changed assertions:
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-002: a failed load used
  to expect the "Loading..." line to stay; it now expects "Error" and no
  loading line.
2026-10-09 19:00:46 +02:00
Maurice aa759ba54a fix(settings): phone notification toggles save only the switch that was tapped
On the phone, every notification toggle sent the whole preference matrix as
it looked when the switch was tapped. With two toggles in flight the later
request could overwrite the earlier one on the server, and when the first
request failed the screen rolled that switch back while the second request
had already saved its value anyway.

The cause was the sendWholeMatrix option of useNotificationSettings, which
only the phone turned on. The server merges whatever cells it receives, so
the desktop already sent just the toggled cell. The option is gone and both
views send only the cell that changed.

Regression test FE-MOB-SETNOTIF-035 (MSettingsNotifications.test.tsx) was
red on the old code: "expected [ { trip_invite: { inapp: false,
webhook: false } }, { trip_invite: { inapp: false, webhook: true } } ] to
deeply equal [ { trip_invite: { inapp: false } }, { trip_invite:
{ webhook: true } } ]".

Changed assertions:
- useNotificationSettings.test.ts FE-COMP-NOTIFSETTINGS-004: the phone
  toggle body was { trip_invite: { inapp: false, email: false } }, now
  { trip_invite: { inapp: false } } (the hook is built with the phone's
  remaining option, skipMaskedToken).
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-026: the PUT body was
  { trip_invite: { inapp: false, webhook: false } }, now
  { trip_invite: { inapp: false } }.
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-032: the mock told the
  in-app write apart by body.trip_invite.webhook === false, which only the
  whole matrix carried; it now checks for the inapp key.
2026-10-09 19:00:46 +02:00
Maurice df7bb9a5e1 fix(storage): Test on a mirrored phone storage row probes the backends on the form
On the phone storage panel, Test on a mirrored backend sent the mirror
itself to POST /api/admin/storage/test. The server expands a mirror by
looking its primary and replicas up by name in the SAVED storage
config, so the phone tested the saved settings instead of what the
operator had just edited, and a replica added but not saved yet made
the whole test fail with "references unknown backend". The desktop
probes the primary and each replica with their draft options and
merges the results.

Cause: the phone called admin.test with the mirror candidate, while the
desktop called admin.testMirror with mirrorProbeTargets. The choice
lived in each shell's markup.

Fix: useStoragePanel now offers loaded.testRow(row), which tests a
plain row as it is and a mirrored row through admin.testMirror with
mirrorProbeTargets from the draft. Both shells call it.

Red test: FE-MOB-MSTOR-018 (new, MAdminStoragePanel.test.tsx) failed
with "AssertionError: expected [ 'mirror' ] to deeply equal
[ 'backups-local', 'off-box' ]". FE-ADMIN-STOR-PANEL-017 (new,
useStoragePanel.test.ts) covers testRow itself.

No existing assertion changed.
2026-10-09 19:00:46 +02:00
Maurice c8e21b9128 fix(admin): the phone addon list wordmark follows an OS theme switch under auto
With the appearance set to auto, switching the OS between light and
dark left the TREK wordmark in the phone admin addon header in the old
colour (dark text on a dark page or the reverse) until something else
made the screen render again. The desktop header switches right away.

Cause: the phone worked the flag out from the dark_mode setting and
window.matchMedia during render, and nothing re-renders the screen
when the media query flips. The desktop uses useIsDark, which watches
the .dark class that applyAppearance() writes.

Fix: MAdminAddonManager uses useIsDark like AddonManager.

Red test: FE-MOB-AADD-037 (new, MAdminAddonManager.test.tsx) failed
with "Expected the element to have attribute: src="/text-light.svg""
after the .dark class was added under auto.

Changed assertions in tests/unit/mobile/admin/MAdminAddonManager.test.tsx:
- FE-MOB-AADD-003 (renamed from "dark mode and auto+prefers-dark swap
  the wordmark" to "the dark palette swaps the wordmark"): it expected
  window.matchMedia to be called with "(prefers-color-scheme: dark)";
  that expectation is gone, since the screen now reads the .dark class.
  The test now sets that class instead of the dark_mode setting and a
  matchMedia stub, and still expects src "/text-light.svg". afterEach
  removes the class again.
2026-10-09 19:00:46 +02:00
Maurice 55ccb903d1 fix(admin): the phone addon list shows the Collab and AI Parsing icons
On the phone admin addon list, the Collab and AI Parsing tiles showed
the generic puzzle piece, while the desktop shows the people and
sparkles icons for them.

Cause: the addons table stores 'Users' for collab and 'Sparkles' for
llm_parsing, and the phone's ICON_MAP had no entry for either, so both
fell back to Puzzle. The desktop map gained them earlier; the phone map
was never updated.

Fix: the phone ICON_MAP now carries Users and Sparkles, like the
desktop one.

Red test: FE-MOB-AADD-036 (new, MAdminAddonManager.test.tsx) failed
with "expect(received).toBeInTheDocument(): received value must be an
HTMLElement or an SVGElement" for svg.lucide-users.

No existing assertion changed.
2026-10-09 19:00:46 +02:00
Maurice 8dd8635b87 fix(plugins): "Update all" on the phone updates the plugins one after another
On the phone plugins panel, "Update all" fired every pending update at
the same time. Only one row can show as busy, so the other rows stayed
clickable while their own install was running, and the list reloaded
once per plugin as each update finished. The desktop runs the updates
one after another, and the hook's own comment calls the parallel way
wrong.

Cause: usePluginsAdmin kept the phone's parallel loop behind the
updateAllInParallel option, which only the phone set.

Fix: the option is gone; updateAll always waits for each update before
starting the next, on both shells.

Red test: FE-ADMIN-PLUGINS-HOOK-009 (usePluginsAdmin.test.ts) failed
with "AssertionError: expected [ [ 'a', undefined ], …(1) ] to deeply
equal [ [ 'a', undefined ] ]".

Changed assertions in src/components/Admin/plugins/usePluginsAdmin.test.ts:
- FE-ADMIN-PLUGINS-HOOK-009 (renamed from "with updateAllInParallel
  every update starts at once, without a version" to "Update all starts
  only the first update, without a version"): pluginUpdate calls were
  [['a', undefined], ['b', undefined]], now [['a', undefined]]. It and
  the mounted() helper no longer pass the removed option.
- FE-ADMIN-PLUGINS-HOOK-008: only its name lost "by default".
2026-10-09 19:00:46 +02:00
Maurice a09d71ed12 fix(storage): a phone migration that fails to start drops the rest of the queue and says so
On the phone storage panel, when the operator accepted several category
moves and the first migration failed to start (for example the server
was busy), only that error was toasted. The other accepted moves stayed
in a hidden queue with no hint, and started on their own later whenever
the panel state happened to refresh. The desktop drops the rest of the
queue, names it in a storage.migrate.queueDropped toast and refreshes
the state, so the operator sees what did not move and saves again.

Cause: useStoragePanel only dropped the queue when the
dropQueueOnFailure option was set, and only the desktop set it.

Fix: the option is gone and useStoragePanel always drops the rest of
the queue on a failed start, toasts it and refreshes, for both shells.

Red test: FE-ADMIN-STOR-PANEL-011 (useStoragePanel.test.ts) failed with
"AssertionError: expected "vi.fn()" to be called 2 times, but got 1
times".

Changed assertions in src/components/Admin/storage/useStoragePanel.test.ts:
- FE-ADMIN-STOR-PANEL-011 (renamed from "by default a failed migration
  start only toasts and keeps the rest queued"): toast.error was called
  once, now twice; migrationQueue had length 1, now 0; refreshState was
  not called, now it is.
- FE-ADMIN-STOR-PANEL-012: no assertion changed; it no longer passes the
  removed dropQueueOnFailure option.
2026-10-09 19:00:46 +02:00
Maurice abbd2a94ce fix(admin): a failed "Load more" in the desktop release history keeps the list and can be retried
On the desktop admin GitHub panel, when "Load more" failed (for example
GitHub rate limiting), the whole release history was replaced by the
error card for the rest of the session: no list, no Load more button,
no way to retry. Had the user found a way to load again, the next load
would also have skipped the page that failed.

Cause: in its one page per load mode useGithubReleases advanced the
page counter whether the request worked or not and never cleared the
error, and GitHubPanel showed the error card whenever an error was set.
The phone (fillPages mode) already held the page, cleared the error on
the next good load and drew the error inline under its timeline.

Fix: the one page mode now moves the page on only after a good load and
clears the error then, like fillPages. GitHubPanel shows the error card
only when nothing has loaded yet; after that it keeps the timeline,
shows the error as a line above Load more and leaves the button there.
The page walk difference behind fillPages stays as it was.

Red tests:
- FE-COMP-GHREL-HOOK-012 (new, useGithubReleases.test.ts) failed with
  "AssertionError: expected [ 1, 2, 3 ] to deeply equal [ 1, 2, 2 ]".
- FE-ADMIN-GH-017 (new, GitHubPanel.test.tsx) failed with "Unable to
  find an element with the text: /v1\.\d\.0/" after the failed load.

Changed assertions in src/components/Admin/useGithubReleases.test.ts:
- FE-COMP-GHREL-HOOK-004 (renamed from "sets the error and still moves
  the page on" to "sets the error and holds the page"): requested pages
  were [1, 2, 3], now [1, 2, 2].
2026-10-09 19:00:46 +02:00
Maurice e4e49381d8 fix(admin): phone GitHub and About links point at the liketrek/TREK repository
On the phone, the admin GitHub panel showed "Latest updates from
mauriceboe/TREK" and its releases, bug report, feature request and wiki
links, as well as the same three links in the settings About section,
all went to the old mauriceboe/TREK repository. The desktop panels
already use liketrek/TREK.

Cause: the phone screens kept the repository name from before the move
to the liketrek organisation.

Fix: MAdminGitHubPanel and MSettingsAbout now use liketrek/TREK, like
GitHubPanel and AboutTab. No other user facing mauriceboe/TREK link
is left in the client (the Ko-fi, Buy Me a Coffee and Docker image
names are unchanged and identical on both sides).

Red test: FE-MOB-SET-ABOUT-001 (new, MSettingsAbout.test.tsx) failed
with "Expected the element to have attribute: href=
https://github.com/liketrek/TREK/issues/new?template=bug_report.yml".

Changed assertions in tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx:
- FE-MOB-AGH-001: wiki href was https://github.com/mauriceboe/TREK/wiki,
  now https://github.com/liketrek/TREK/wiki
- FE-MOB-AGH-004: subtitle was "Latest updates from mauriceboe/TREK",
  now "Latest updates from liketrek/TREK"; GitHub link was
  https://github.com/mauriceboe/TREK/releases, now
  https://github.com/liketrek/TREK/releases
2026-10-09 19:00:46 +02:00
Maurice 6c916cc257 fix(collab): a note category shows the same colour on the phone as on desktop
When the notes of one category carried two colours (a category recolour
that failed halfway, or a note written over the API), the desktop panel
painted the category in the colour of the last such note in the list
while the phone notes tab used the first one, so the same category had
different colours on the two devices.

The phone's buildCategoryColorMap took the first colour on the stated
assumption that every note in a category has the same colour, so first
seen equals all seen; it was meant to match the desktop and only
differed where that assumption breaks. The desktop rule (since v2.6.0)
is the original. buildCategoryColorMap now keeps the last colour seen
and the desktop panel builds its map from the same function, merged
over its empty categories as before.

Red before the fix: tests/unit/mobile/trip/collabModel.test.ts
FE-MOB-CLBM-023 failed with "expected { Ideas: '#ef4444' } to deeply
equal { Ideas: '#10b981' }".

Changed assertion in tests/unit/mobile/trip/collabModel.test.ts
FE-MOB-CLBM-011 (renamed from "keeps the first colour seen per
category" to "keeps the last colour seen per category"): the expected
map was { Ideas: '#ef4444' } and is now { Ideas: '#10b981' }.
2026-10-09 19:00:45 +02:00
Maurice d118423570 fix(journey): the desktop gallery Upload button shows the files counting up again
Uploading photos into a journey gallery on desktop gave no sign that
anything was happening until the toast at the end; the Upload button
stayed clickable the whole time. Up to v4.0.0 the button sat inside the
gallery and showed a spinner with "Uploading done/total" while it was
disabled. When the button moved up into the page header (onRegisterUpload)
that indicator was dropped, and the count kept being tracked with no
reader.

useGalleryUpload now also returns the progress it tracks, the gallery
hands it up through onUploadProgress into useJourneyDetail, and the
page's Upload button is disabled and shows the spinner with
journey.editor.uploadingProgress while the files go, as it did before.

Red before the fix: src/pages/JourneyDetailPage.wiring.test.tsx
FE-JRN-DETWIRE-043 failed with "expected undefined to be [Function
Mock]": the gallery was never given an onUploadProgress callback and
the button never showed a count. FE-JRN-GALLERY-021 and
FE-JRN-GALLERYUPLOAD-007 cover the gallery and the hook. No existing
assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 49e618cbc7 fix(journey): hiding suggestions on desktop no longer throws when the preference save fails
On the desktop journey page, the eye toggle that hides the suggested
entries flipped the view and then saved the preference. When that save
was refused (offline, a server error) the promise rejected into a click
handler nothing awaited, leaving an unhandled rejection behind.

The phone already treats this as a view preference: it keeps the local
flip until the next load and swallows the failure on purpose. The
desktop toggle now does the same.

Red before the fix: src/pages/JourneyDetailPage.wiring.test.tsx
FE-JRN-DETWIRE-042 failed with "expected vi.fn() to not be called at
all, but actually been called 1 times" on the unhandledRejection
listener, called with [Error: offline]. No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 65448ce186 fix(journey): a failed entry save on the phone says so instead of failing silently
Saving a journey entry on the phone sheet while the server refused it
(a locked journey, a dropped connection) left the sheet sitting there
with no message, and the rejection went unhandled. The desktop dialog
toasted journey.settings.saveFailed or the server's reason.

The cause was the toastSaveError option of the shared useJourneyEntryForm
hook: only the desktop set it, and without it handleSave rethrew into a
click handler that nothing awaited. The option only kept the phone on
the broken path, so it is gone and both views toast and keep the form
open.

Red before the fix: src/components/Journey/useJourneyEntryForm.test.ts
FE-JRN-ENTRYFORM-021 failed because handleSave rejected with
"{ response: { data: { error: 'Locked' } } }" instead of toasting.

Changed assertions in FE-JRN-ENTRYFORM-007 (renamed to "a failed save
toasts for the desktop dialog and the sheet alike"): the sheet's save
was expected to reject (rejected true) with one toast in total; it now
resolves (rejected false) and the toast count is 2.
2026-10-09 19:00:45 +02:00
Maurice f19b52fb20 fix(collab): note attachments added or removed on the phone show in the Files tab at once
On the phone, attaching a file to a note, detaching one or deleting a
note with attachments left the trip's Files tab on the old list until a
reload. The desktop panel fires the local collab-files-changed event
that useTripWebSocket listens for, because the server leaves the
writer's own socket out of its broadcast; the phone notes tab never
fired it.

The event now lives in the shared useCollabNotesData hook:
uploadNoteFiles announces it once after a batch, and
announceNoteFilesChanged is what both shells call after deleting a note
or a note file. The desktop no longer fires it by hand after an upload.

Red before the fix: tests/unit/mobile/trip/MCollabNotes.test.tsx
FE-MOB-CNOTE-034 failed with "expected 0 to be 1" on the event count
after detaching a file. FE-COLLAB-NOTESDATA-009 covers the hook.
No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 0246e5c317 fix(map): a distance between near antipodal points is half the globe instead of NaN
haversineKm in src/utils/geo.ts answered NaN for some near antipodal pairs,
for example 47.3812, -10.8827 and -47.3812001, 169.1173. Rounding pushed the
haversine term a hair past 1, and asin of a value above 1 is NaN, which then
spread into route sums and range checks.

Cause: unlike the Studio legKm and the roadtrip haversineKm in
@trek/shared, the shared client helper had no clamp before Math.asin.

Fix: clamp with Math.min(1, ...) before Math.asin. With the clamp the helper
runs exactly the operations of Studio legKm in the same order (2 * 6371 is
12742), so Studio now uses haversineKm and legKm goes, with its cases carried
into the new geo tests. The roadtrip variant multiplies its terms in another
order and gives different last bits, so it stays as it is.

Red test: src/utils/geo.test.ts FE-UTIL-GEO-002 failed on the unfixed code
with "AssertionError: expected NaN to be close to 20015.086796020572,
received difference is NaN, but expected 0.0005".

Removed tests: src/components/Studio/legKm.test.ts went with legKm. Its four
cases (zero for one point, a degree of latitude about 111.19 km, the same
both ways between 8700 and 8900 km, 0,0 to 0,180 equal to 12742 * PI / 2) now
run against haversineKm as FE-UTIL-GEO-001, 003 and 004.
2026-10-09 19:00:45 +02:00
Maurice 7659f2741e refactor(places): drop the unused phone pool counter
poolCounts in the phone places browser model has had no caller since the
phone pool chips stopped showing counts in 8855b5581 (the FilterChip note in
MPlacesBrowser says counts are left out on the phone to save row space). Only
its own tests still ran it, and it had drifted from the desktop counts in
usePlacesSidebar, which count planned places against the day scoped planned
ids. Nothing on the phone should use it, so it goes, with its two tests.

No user facing change, so there is no red test: the helper is dead code.

Removed tests in src/mobile/screens/trip/places/placesBrowserModel.test.ts:
MOBILE-POOL-counts (expected all 3, planned 2, unplanned 1) and
FE-MOB-PMODEL-013 (expected all 2, tracks 1, planned 1, unplanned 1).
2026-10-09 19:00:45 +02:00
Maurice cee496c469 fix(places): coordinates pasted on the phone survive the next search pick
On the phone place form, pasting a "lat, lng" pair into the latitude field
filled both coordinates, but a later search pick whose place came without a
position emptied them again whenever an earlier pick had filled them. On the
desktop the pasted position stays.

Cause: the phone's paste handler wrote lat and lng with setForm directly, so
autoFilledRef still listed them as written by the previous pick, and
mergeResult clears such fields when the new pick says nothing about them. The
desktop goes through handleChange, which marks a field as typed by hand.

Fix: the phone's paste handler goes through handleChange for both halves, the
same as the desktop dialog.

Red test: tests/unit/mobile/trip/MPlaceEditSheet.test.tsx FE-MOB-PLEDIT-047
failed on the unfixed code with "expect(element).toHaveValue(35.7101)
Expected the element to have value: 35.7101 Received: (empty)".

No existing assertions changed.
2026-10-09 19:00:45 +02:00
Maurice 0bdf0fc345 fix(places): tapping a phone search suggestion keeps the last pick's address and position
On the phone place form, tapping a search suggestion emptied the address,
the coordinates, the map ids and the website that the previous pick had filled,
before the suggestion's place was even looked up. When that lookup failed, the
form was left with the new name and no position at all. The desktop form only
puts the name in and leaves the rest alone.

Cause: PlPlaceSearch handed the suggestion's name to the sheet as a search
pick (onPick({ name })), and the sheet folds every pick through mergeResult,
which clears each field the previous pick wrote when the new pick says nothing
about it.

Fix: PlPlaceSearch takes an onSuggestionName callback, the same one the
shared usePlaceSearch hook already offers, and MPlaceEditSheet only sets the
name with it, as the desktop dialog does. The resolved place still arrives
as a normal pick.

Red test: tests/unit/mobile/trip/MPlaceEditSheet.test.tsx FE-MOB-PLEDIT-046
failed on the unfixed code with "expect(element).toHaveValue(Taito)
Expected the element to have value: Taito Received: (empty)".

Changed assertions in tests/unit/mobile/trip/PlPlaceSearch.test.tsx:
FE-MOB-PLSRCH-004 expected onPick to be called first with { name: 'Louvre' }
and twice in all; it now expects onSuggestionName to be called with 'Louvre'
and onPick once. FE-MOB-PLSRCH-005, FE-MOB-PLSRCH-005b and FE-MOB-PLSRCH-024
waited for onPick to be called twice; they now wait for one call.
2026-10-09 19:00:45 +02:00
Maurice 65a657347e fix(budget): a phone ticket split keeps the share of everyone a receipt line names (#1382)
On the phone, a ticket split where a receipt line named someone who was not
ticked as a participant saved only the ticked people as members. Their share of
the line was dropped, the member amounts no longer added up to total_price, and
the settlement kept a difference nobody could clear. The desktop editor had
been fixed for this in #1382.

Cause: useExpenseForm only added the people named on ticket lines when the
caller passed ticketShareMembers, and only the desktop CostsPanel did. The
phone MCostSheet kept the old behaviour.

Fix: the hook always sends everyone a ticket line names, and the
ticketShareMembers option is gone, so both editors save the same members.

Red test: src/components/Budget/useExpenseForm.test.ts
FE-BUDGET-EXPFORM-021 failed on the unfixed code with
"AssertionError: expected { name: 'Lunch', ... } to match object { ... }":
member_ids was [1] and members held only user 1, without user 2's 6.

Changed assertions: useExpenseForm.test.ts FE-BUDGET-EXPFORM-017 used to
expect the phone run (ticketShareMembers false) to send
members [{ user_id: 1, amount: 6 }] and member_ids [1]; that phone half is
removed and the test now expects every editor to send members for users 1 and
2 with 6 each and member_ids [1, 2].
2026-10-09 19:00:45 +02:00
Maurice e870270fb9 fix(planner): the plan view panel tabs say whether their panel is open and show a focus ring
In the plan view the tabs that collapse and reopen the left and right
panels gave a screen reader no open or closed state and showed no focus
ring when reached with the keyboard. The same tabs in the tour planner
had both, because only there LeftPanelTab got the "announced" option,
and the plan view's right tab was a hand copy of the left one.

LeftPanelTab becomes PanelTab with a side prop and always sets
aria-expanded and the focus ring. The plan view's right tab renders it
mirrored, so every panel tab shares one component. The offsets are
written with literal left and right keys so lint:rtl still counts them.
The page shrinks by 11 lines.

Red test: FE-PAGE-PLANNER-016b in client/src/pages/TripPlannerPage.test.tsx
failed on the old page with
expect(element).toHaveAttribute("aria-expanded", "true").
FE-PAGE-PLANNER-LEFTTAB-005 covers the mirrored right tab.

Changed assertions (they asserted the missing state):
- TripPlannerPage.leftPanelTab.test.tsx FE-PAGE-PLANNER-LEFTTAB-003:
  not.toHaveAttribute('aria-expanded') and not.toHaveClass
  ('focus-visible:outline') are now toHaveAttribute('aria-expanded',
  'true') and toHaveClass('focus-visible:outline') (title updated).
- TripPlannerPage.leftPanelTab.test.tsx FE-PAGE-PLANNER-LEFTTAB-004: the
  "announced" prop is dropped from both renders (title updated); the
  expectations are unchanged. The file renders PanelTab instead of
  LeftPanelTab.
2026-10-09 19:00:44 +02:00
Maurice 836a845b61 fix(planner): moving a booking onto another day clears the leftover drag data
Dropping a booking on the body of another day moved it but left the
shared drag data (window.__dragData) of an earlier place or map drag in
place, while dropping it on the end zone of the day cleared both. A later
drop with nothing of its own could then read that leftover place id.

The cross day booking branch of the day body drop now clears
window.__dragData along with dragDataRef, the same as the end zone. The
two clears are one chained assignment, so DayPlanSidebar.tsx stays at
the line count lint:size holds it to.

Red test: FE-PLANNER-DAYPLAN-236 in
client/src/components/Planner/DayPlanSidebar.dragData.test.tsx failed
with "expected { placeId: '77' } to be null".

No existing assertions changed.
2026-10-09 18:58:57 +02:00
Maurice 55e6b55f4d fix(roadtrip): the stop popup shows the answers of the stop it is handed, not the previous one
The desktop road trip stop popup read its kind, length, night and
check-in answers only when it mounted. When the planner handed the open
popup another stop draft, the popup showed the new stop's name with the
previous stop's kind, length, night switch and check-in, and saving wrote
those stale answers onto the new stop.

The phone sheet already seeds the shared useStopDraftForm again for
every draft. The popup now does the same in an effect keyed on the draft,
and seeds the night switch and the check-in time from the same draft.

Red test: FE-ROADTRIP-STOPPOPUP-034 in
client/src/components/Roadtrip/RoadtripStopPopup.test.tsx failed with
expect(element).toHaveAttribute("aria-pressed", "true") on the Charging
chip after the draft was swapped.

No existing assertions changed.
2026-10-09 17:56:27 +02:00
Maurice 8c9a31e5ef fix(planner): switching an edited flight or train to car no longer brings back stops from an earlier booking
The transport form stays mounted between openings. Editing a flight or a
train after a car kept the car's stops (desktop and phone) and, on the
desktop, the from and to picks of that earlier booking as well. Switching
the type to car in the same dialog showed those stale stops and places,
and saving wrote them onto the booking. The desktop also kept stale
flight and train rows when opening a car, bus or other booking.

seedTransportRoute only reset the rows a type does not show when the
phone asked for it (resetHiddenRoutes), and never reset the car stops for
a flight or a train. It now returns every row for every type, so a seed
always replaces the whole route. The option is removed; both views get
the same clean route.

Red test: FE-PLANNER-TRANSPORTFORM-008 in
client/src/components/Planner/useTransportForm.test.ts failed with
"expected [ { location: { ... }, time: '' } ] to deeply equal []".

Changed assertions (they asserted the stale rows were kept):
- useTransportForm.test.ts FE-PLANNER-TRANSPORTFORM-005: desktop
  fromPick toBe(picked) is now toEqual({}) (title updated), and the
  resetHiddenRoutes field is dropped from seedOptions and the phone call.
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-005: desktop route
  keys toEqual(['waypoints']) is now empty fromPick, toPick, carStops and
  trainWaypoints.
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-010: desktop car
  waypoints toBeUndefined() is now two empty rows (plus empty train rows),
  and the bus route now also carries the two empty row sets.
- transportEndpoints.test.ts: resetHiddenRoutes dropped from DESKTOP and
  PHONE.
2026-10-09 17:48:16 +02:00
Maurice 1e674f040a fix(planner): a create expense click that saves nothing no longer opens the editor on the next save
In the desktop booking and transport dialogs, clicking "Create expense"
(or editing a linked expense) before the booking had a title saved
nothing, which is right, but the wish was kept. The next ordinary save
then opened the expense editor out of nowhere. The same happened after a
save that threw.

useBookingExpenseIntent stored the wish in a ref and only cleared it when
the form read it, which the desktop forms do after a successful save. The
hook now clears the wish once the save its click started has settled,
unless a newer click replaced it in the meantime. The phone sheets read
it at the start of the save and are unaffected.

Red tests:
- FE-PLANNER-RESMODAL-107 in ReservationModal.test.tsx: "expected
  "vi.fn()" to not be called at all, but actually been called 1 times".
- FE-PLANNER-BOOKINGEXPENSE-005 in useBookingExpenseIntent.test.ts:
  "expected { create: true } to be null".
- FE-PLANNER-BOOKINGEXPENSE-006: "expected { editItem: { id: 9, ... } }
  to be null".

No existing assertions changed.
2026-10-09 17:43:24 +02:00
Maurice 70f4f7cef3 fix(planner): an imported booking with source files saves and opens its expense without file support
Saving an imported booking in the desktop booking dialog when the dialog
had no upload handler but the import carried source files rejected the
save: the files were handed to the missing handler, the promise rejected
with nothing to catch it, and the expense editor the user asked for never
opened. The transport dialog already skipped the upload in that case, the
booking dialog did not.

The shared uploadBookingFiles helper now takes an optional handler and
does nothing without one, so every form gets the same guard.

Red test: FE-PLANNER-RESMODAL-106 in
client/src/components/Planner/ReservationModal.test.tsx failed with
"expected spy to be called with arguments" for onOpenExpense, plus the
unhandled rejection "TypeError: upload is not a function". The helper
case is covered by FE-PLANNER-BOOKINGFORM-008.

No existing assertions changed.
2026-10-09 17:36:59 +02:00
Maurice 07117949ef fix(mobile): editing a bus, car, taxi or ferry on the phone keeps its price (#2233)
Editing a transport without metadata of its own (bus, car, taxi, ferry
without a transit itinerary) in the phone sheet wiped the expense price
shown on the booking card. The phone built the payload with
emptyMetadataAsNull and sent metadata: null, which the reservations
service reads as "clear the column", so keepMirroredPrice had nothing to
carry price and priceCurrency into. The desktop and both booking forms
send an empty object, which keeps the price.

The option only existed to keep the phone's null, so it is gone and both
views now send the rebuilt metadata object as is.

Red test: FE-MOB-TRFRM-060 in
client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx failed with
"AssertionError: expected null to deeply equal {}".
RESV-SVC-043 in server/tests/unit/nest/reservations.service.test.ts shows
the server side: {} keeps the stored price, null drops it.

Changed assertions (they asserted the null):
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-006: payload metadata null
  is now {}.
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-011: metadata toBeNull is now
  toEqual({}).
- MTransportFormSheet.test.tsx FE-MOB-TRFRM-027: metadata toBeNull is now
  toEqual({}).
- transportEndpoints.test.ts FE-PLANNER-TRANSPORTEP-014: phone metadata
  toBeNull is now toEqual({}) (title updated to match), and the
  emptyMetadataAsNull field is dropped from DESKTOP_SAVE and PHONE_SAVE.
- useTransportForm.test.ts FE-PLANNER-TRANSPORTFORM-007: metadata: null
  is now metadata: {}.
2026-10-09 17:29:22 +02:00
Maurice 62f45e7934 chore(client): lower the ratchet baselines after removing the last copied blocks
The copies inside the map renderers, the location inputs, the day plan
sidebar, the trip planner page, the time picker, studio, the store
slices and a few small components are gone, and so is the markup the
two phone booking sheets, the two phone booking tabs, the desktop
booking dialogs, the vacay sheets and the storage panel drew twice.
lint:dup now holds every file at zero, and lint:size, lint:rtl and the
eslint warning counts go down with it. Nothing goes up.
2026-10-09 16:29:40 +02:00
Maurice 69db17b5dd refactor(vacay): draw the phone user picker list with one component
The invite and the share sheet each carried the same unfolded user list. Both now render MUserPickerList with the users, the picked id and the pick handler from useVacayUserPicker. The markup is unchanged.
2026-10-09 16:29:40 +02:00
Maurice 201de0bd5b refactor(storage): draw the phone backend test result with one component
The phone storage panel rendered the same test result markup for regular and degenerate mirror backends. Both rows now render MStorageTestResult, with the same elements, classes and text as before.
2026-10-09 16:29:40 +02:00
Maurice e0fd2c296b refactor(admin): draw the photo provider marks from one icons file
The desktop and the phone addon manager each carried their own copy of the Immich and Synology SVGs. Both now import them from components/Admin/addons/PhotoProviderIcons, which has its own test. The rendered marks are unchanged.
2026-10-09 16:29:40 +02:00
Maurice c45ac648c9 refactor(bookings): draw the shared parts of the phone reservation cards once
The transports and bookings tabs drew the same title button, edit and
delete buttons, date and time row with the booking code, file chips and
delete confirmation sheet as two copies. Both cards now render them from
MReservationCardParts, and each tab still decides what a tap opens. The
rendered markup stays the same for both tabs.
2026-10-09 16:29:40 +02:00
Maurice a843b66c33 refactor(bookings): draw the notes, files and costs of the booking dialogs with one component
The desktop booking and transport dialogs ended on the same notes field,
link and files row and linked costs block. Both now render
BookingNotesFilesCosts next to them, with the same markup as before.
2026-10-09 16:29:40 +02:00
Maurice 3de3ffcd1c refactor(bookings): draw the shared phone booking fields with one component
The phone booking and transport sheets each carried their own copy of the
booking code and status row, the notes field, the traveler picker with its
avatar, and the transport sheet drew the card of a route stop twice, once
for flights and once for trains and cruises. They now render the same
components from MBookingFields next to the sheets, which take the sheet's
own translate function so every string stays what it was. The rendered
markup is unchanged.
2026-10-09 16:29:40 +02:00
Maurice e86cb6a897 refactor(bookings): let the phone plugin slots use the reservation detail plugin hook
ReservationPluginSlots in tabChrome repeated the plugin store selector
that useReservationDetailPlugins already holds for the desktop panels.
It now calls the hook, which gets its own test.
2026-10-09 16:15:49 +02:00
Maurice 60e8f1cd34 refactor(roadtrip): read the wall clock minutes and today through localDate
useMRoadtrip and useMRtCorridor each kept a nowMinutes helper and their
own check whether the selected day is today. Both now use localMinutes
and isLocalToday from utils/localDate.ts, which hold the same rules.
2026-10-09 16:15:49 +02:00
Maurice 3fcfb12749 refactor(admin): share the fold head of the Google options and the TREK API card
Both folds on the settings page opened with the same summary line and
turning chevron. FoldSummary now draws it and each fold passes its own
label; the Google fold keeps its status pill.
2026-10-09 16:15:49 +02:00
Maurice c8d8ddfd3e refactor(packing): share the template row of the two apply template menus
ApplyTemplateButton and the packing list header drew the same template
row in their menus. PackingTemplateOption now draws it; the header's
faded hover stays a parameter (fade).
2026-10-09 16:15:49 +02:00
Maurice 2a05e6d20d refactor(files): share the preview dialog frame between the PDF and Markdown previews
Both previews built the same dialog head band with the file name, the
open in tab pill and the download pill. FilePreviewDialog now draws it;
the dialog width, the body class and the icon colour stay parameters,
and each preview keeps its own body.
2026-10-09 16:15:49 +02:00
Maurice 76b4b96e16 refactor(budget): share the category reorder between the slice and the remote handler
The optimistic category reorder in budgetSlice and the budget:reordered
handler for another member's reorder grouped the items the same way in
two copies. Both now call orderBudgetByCategories from budgetOrder.ts,
so the local and the remote reorder cannot drift apart.
2026-10-09 16:15:49 +02:00
Maurice 9f1d5c4a63 refactor(atlas): draw the country and place search rows with one row component
The country rows and the geocoded place rows repeated the same button,
hover tint and trailing chevron. A ResultRow inside AtlasCountrySearch
now draws that frame and each list passes its own leading content.
2026-10-09 16:15:49 +02:00
Maurice 71b7b209dc refactor(studio): one great circle distance for the map bows and the road fetch
TravelElements ramped how far a leg bows by a local km helper and
roadRoute refused long legs by a second copy of it. Both now call legKm
from legKm.ts, the same formula with the same clamp.
2026-10-09 16:15:49 +02:00
Maurice e6930e00ac refactor(studio): share the typeface buttons and the weight line between the inspectors
The text inspector and the travel inspector each drew the same row of
typeface buttons and the same weight choice. Both now render FontButtons
and WeightLine from StudioTypeControls.tsx. The travel inspector keeps
casting its patches to Partial<BookElement>, so each side writes exactly
the fields it wrote before.
2026-10-09 16:15:49 +02:00
Maurice d08c949b14 refactor(time-picker): draw the hour, minute and AM/PM columns with one spinner column
The three columns of the time picker each repeated the same chevron up,
value box and chevron down markup. TimeSpinnerColumn draws one, and the
button style it shares with the clear button moves to timeSpinnerStyle.

The AM/PM column keeps its narrower box, smaller type and leading gap as
props, and the hour and minute boxes keep their tabular digits.
2026-10-09 16:15:48 +02:00
Maurice c1a9e98ec4 refactor(planner): share the cross-day move and end-of-day drop between the day body and the end zone
The day body and the drop zone at the end of the list both carried their
own copy of two steps: moving a stop or note dragged in from another day,
and putting a same-day row after the day's last row. Both now live in the
plan hook as moveInFromOtherDay and dropAfterLastItem.

The differences stay where they were: only the end zone passes a
reservation id, so only it moves a booking to the end, and only it clears
the drag state after the move, which it still skips on an empty day.
2026-10-09 16:15:48 +02:00
Maurice 188d867aee refactor(planner): stop echoing the day plan's props back through its hook
useDayPlanSidebar returned about 145 names, and DayPlanSidebar destructured
the same list again, the largest copied block in the client. Most of it
was the component's own props passed straight back, plus values nothing
read any more.

The optional props' fallbacks move into withDayPlanDefaults, which the
hook and the view both read the props through, so they are written once.
The hook now returns only what it computes. State that only the view
reads (the context menu, the permission flags, the hover and expansion
states, the pending note deletion) is declared in the view, and the day
note handlers come back as one notes object. The values no one read are
gone, which also drops their unused variable warnings.

No behaviour changes: every value keeps its source and its fallback.
2026-10-09 16:15:48 +02:00
Maurice da37f3618d refactor(planner): share the left panel tab and the selected place props in the trip planner page
The plan view and the tour planner view each drew the left panel's tab in
full, and the place inspector and the tour dialog each listed the same props
twice, once beside the panels and once in the phone-width sheet.

LeftPanelTab, a small presentational component at the bottom of
TripPlannerPage.tsx, now draws the tab for both views. It stays in the page
file so the physical left and right offsets keep being counted there. The
tour planner's tab passes `announced`, which keeps its aria-expanded and its
focus ring; the plan view's tab still has neither.

The page body now builds selectedPlaceProps (what the inspector and the tour
dialog both take), placeInspectorProps and tourDetailProps once and spreads
them. What differs stays at each call site: the desktop inspector's
onOpenBooking and panel widths, the sheet's edit and delete closing the
sheet, the desktop tour dialog's non-modal focus return and widths, and
canEdit and canAssign on every tour dialog.
2026-10-09 16:15:48 +02:00
Maurice eb271f0d45 refactor(planner): share the suggestion dropdown between the airport, location and address fields
AirportSelect, LocationSelect and AddressInput each carried their own copy
of the open state, the keyboard highlight, the outside click that closes the
list, the floating list with its loading line and the suggestion row.

useSuggestionDropdown now holds the open state, the highlight, the outside
click and the arrow, Enter and Escape handling. SuggestionDropdown.tsx holds
the boxed input with its icon and clear button (airport and location), the
floating list (all three), the row button and the place row content
(location and address).

What still differs between the fields stays with them or is a parameter:
the icon, the placeholder, what typing does (LocationSelect also resets the
highlight), the rows the keys walk (LocationSelect walks the trip's places
below three characters), whether the loading line may show (not while
LocationSelect offers the trip's places) and how a row lines up its content
(centred for airports). Each field keeps its own query, search and pick.
2026-10-09 16:15:48 +02:00
Maurice 03cb9fa439 refactor(map): share the marker thumbnail loading between the Leaflet and GL maps
MapView and MapViewGL each held the same photo loading effect: read the
cached thumbs, fetch the missing ones, listen for their arrival and batch
them into one animation frame. It now lives in useMarkerThumbs, which
also owns the thumb state and the place photos switch, and both maps
call it where the effect used to be.

The effect keeps its keys (the picture sources and the switch). It reads
the places through useEffectEvent, which drops the old
exhaustive-deps suppression in MapViewGL and the warning in MapView.
2026-10-09 16:15:48 +02:00
Maurice 69d2f80fe2 refactor(map): share the transport hop building between the Leaflet and GL maps
ReservationOverlay and reservationsMapbox each carried the booking type
list, the icon and arc table, the time zone aware duration and the loop
that turns a booking into waypoints, arcs and labels. All of that now
lives in transportHops.ts; each renderer keeps only its drawing.

The one difference stays a parameter: Leaflet asks for arcs that cross
the antimeridian to be copied 360 degrees over, the GL map does not
because it repeats features across world copies itself. Leaflet still
adds its transit segments on top of the shared hop.
2026-10-09 16:15:48 +02:00
Maurice 369697650f refactor(map): build OSRM leg connectors in one place
calculateSegments and the cached legs call turned each OSRM leg into the
same connector record line by line. That record now comes from
osrmLegSegment; the legs call keeps adding its durationText on top, so
both answers stay exactly as they were.
2026-10-09 15:26:27 +02:00
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 2ccdba78d6 chore(client): lower the ratchet baselines after sharing the feature hooks
The size, layers, format and eslint baselines only go down: files that
shrank, moved or lost a copied warning leave or lower their entries.

The offline baseline drops from 212 to 170 entries. 102 desktop and phone
views no longer import src/api/ and leave the list. 60 shared hooks take
their place, because the calls those views made moved into the one hook
both of them now import. Each of the 60 is imported only by views that
were on the list before, and every api module it imports was imported by
those views before. No call is new, and none moved behind the repo or the
mutation queue, so the offline behaviour is the same as before.
2026-10-09 15:20:09 +02:00
Maurice cf5b5c1245 refactor(collections): share the save to list picker logic between desktop and phone
useSaveToCollection holds the store driven picker that
SaveToCollectionModal and MSaveToCollectionSheet each carried: loading
the lists and the membership for a new target, toggling the place in or
out of a list, the per list status, marking it visited everywhere and
opening the collections page. Both views keep their own markup.

The two copies did the same thing, so no option was needed. The load
effect now lists the membership query beside the target instead of
silencing the exhaustive deps rule; the query is derived from the
target alone, so it runs exactly as before.
2026-10-09 15:20:09 +02:00
Maurice a0a4a24395 refactor(admin): share the invite link status between desktop and phone
Whether an invite is expired, used up or active, and which status label it shows, was worked out in both user views. It now lives in inviteStatus in the admin model; each view keeps its own row markup.
2026-10-09 15:19:51 +02:00
Maurice 6d2490a3ba refactor(tours): share the planned tours filter between desktop and phone
The All, Unplanned and Planned filter of the tours list was copied into the phone selection list. It now lives in filterTours next to the other tour presentation helpers, and both lists call it.
2026-10-09 15:19:51 +02:00
Maurice f84924c56a refactor(trips): share the trip loading splash beats between desktop and phone
Both splashes carried their own copy of the four beats, the step time, the still beat and the interval that cycles them. These now live in useTripSplashBeats; each splash keeps its markup and styling. The phone follows the reduced motion setting live while the splash is up, the desktop reads it on each render; that difference stays as the liveReducedMotion option.
2026-10-09 15:19:51 +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 88a2479627 refactor(vacay): share the settings logic between desktop and phone
The holiday countries, the calendars split by type, the weekend day toggle, dissolving a fusion, the leave year save and window label, the region options and the add check now live in useVacaySettings. The phone sheet still loads countries only while open.
2026-10-09 15:19:50 +02:00
Maurice e4179dbc11 refactor(vacay): share the calendar sharing picker and row actions between desktop and phone
The read only share reuses useVacayUserPicker for picking and sending, and useVacayShareActions holds the hide toggle and the removal with their error toasts for the sidebar card, the phone sheet and the phone chips.
2026-10-09 15:19:50 +02:00
Maurice fd7f6f3cf9 refactor(vacay): share the fusion invite picker between desktop and phone
Loading the users that can be invited, the picked user and sending the invite with its toasts now live in useVacayUserPicker. The dialog still resets and closes on success, the sheet still closes and resets on the next open and empties the list when loading fails.
2026-10-09 15:19:50 +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 18eeb16aa0 refactor(dashboard): share the widget logic and plugin hooks between desktop and phone
The currency converter, the world clocks, the collection lists, the plugin widgets and card badges and the card date format each existed once per dashboard. They now live in useCurrencyConverter, useDashboardTimezones, useCollectionLists, useDashboardPlugins and dashboardModel; the phone keeps aborting a pending rate request through an option.
2026-10-09 15:19:50 +02:00
Maurice 080afa9cff fix(trips): keep the trip length across a DST change when the phone moves the start
The phone sheet added the old length with a local setDate on a UTC midnight date, so in zones east of UTC a range crossing the start of summer time came out one day short. It now counts UTC calendar days like the desktop dialog.
2026-10-09 15:19:50 +02:00
Maurice e964a98138 refactor(trips): share the trip form cover picker and checks between desktop and phone
The dialog and the phone sheet each carried the cover upload, the Unsplash search, the staged cover for a new trip and the title and range checks. They now live in useTripCoverPicker and tripFormModel; the blob url release keeps each view's timing behind an option.
2026-10-09 15:19:50 +02:00
Maurice 106830609e refactor(nav): share the context aware create action between the bottom navs
The centre button resolved its target in two copies of the same hook. useCreateAction now holds it once, with the phone only branches (gallery upload, atlas search, collections) behind a flag.
2026-10-09 15:19:50 +02:00
Maurice e1219a166a refactor(toast): share the toast queue between desktop and phone
Both presenters ran their own queue behind the window toast bus. The queue, the auto dismiss and the exit delay now live in useToastQueue; each presenter keeps its markup, its exit timing and its unmount handover.
2026-10-09 15:19:49 +02:00
Maurice 7261520659 refactor(journey): share the card carousel and the gallery upload between desktop and phone
useJourneyCarouselSync keeps the map on the card nearest the centre once
scrolling settles and brings a card to the centre for a marker, a day or a
tap, for both the phone journey screen and the narrow timeline of the desktop
and public pages. useGalleryUpload normalises and uploads gallery files for the
desktop gallery and the phone screen. The phone still moves the map on a tap
of another card, the desktop gallery still counts the files up while they go.
2026-10-09 15:19:49 +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 78ddb1b0b8 refactor(journey): share the journey settings logic between desktop and phone
useJourneySettings holds the name, cover, archive, the switches saved on the
spot, delete, unlinking a trip and removing a contributor for both the settings
dialog and the settings sheet. useJourneyTripLinking loads the linkable trips
and links one for the add trip dialog and the sheet, and useJourneyShareLink
manages the public share link for the share section and the sheet. The dialog
still closes after a new cover or an unlinked trip, the sheet still stays open.
2026-10-09 15:19:49 +02:00
Maurice e40bc98a33 refactor(atlas): share the country popup actions between desktop and phone
useAtlasCountryActions marks a country or a region, unmarks a region, adds the
country or region to the bucket list and takes the country off it again for
both the desktop dialog and the phone sheet. The desktop dialog moves into its
own component in AtlasPage so the page body keeps calling useAtlas only. The
phone sheet keeps storing a marked region as visited and its own month input;
the desktop keeps its month and year picker in useAtlas.
2026-10-09 15:19:49 +02:00
Maurice df6a8ba003 refactor(collections): share the saved place detail logic between desktop and phone
useCollectionPlaceForm reseeds the edit form when a different place opens,
fetches a cover photo for a place without one, uploads and removes the custom
cover and saves the edits for both the detail sheet and the phone sheet. The
detail keeps editing the coordinates and keeps a late photo across an update
of the same place; the sheet holds the last place through its exit and drops
a late photo once the place object changes, as before.
2026-10-09 15:19:49 +02:00
Maurice 771d4bbf07 refactor(collections): share the list editor logic between desktop and phone
useListEditor seeds the form from the target, handles the cover upload and the
Unsplash search, cleans the links and creates or updates the list for both the
dialog and the sheet. The sheet keeps holding the last list through its exit
and still hands over a list whose cover upload failed when it closes.
2026-10-09 15:19:49 +02:00
Maurice 634dd411c5 refactor(collections): share the copy to trip picker between desktop and phone
useTripCopyPicker loads the trips on every opening, filters them by the search,
formats the date range and turns the copy result into toasts for both the
dialog and the sheet. The dialog keeps its guard against an empty selection.
2026-10-09 15:19:49 +02:00
Maurice 1a4f986cc3 refactor(collections): share the list sharing logic between desktop and phone
useCollectionSharing holds the invitable users lookup, the roster order, invite,
role, cancel, remove and leave for both the share dialog and the share sheet.
The dialog keeps one busy member per kind of row action, the sheet one for all.
2026-10-09 15:19:48 +02:00
Maurice cf73779070 refactor(notifications): share the notification row actions between desktop and phone
Answering a question once, following a link after marking it read and the compact timestamp live in useNotificationItemActions. The desktop item keeps closing its panel after a navigation through the optional onClose.
2026-10-09 15:19:48 +02:00
Maurice a7bd26722a refactor(settings): share the general settings save between desktop and phone
Saving one preference with the error toast on failure lives in one useSettingSaver hook; the desktop tab drops its eleven inline copies of it.
2026-10-09 15:19:48 +02:00
Maurice 3b4f17643c refactor(settings): share the appearance editor between desktop and phone
The live preview, the debounced save with its flush on leave, the colour mode save and the widget toggles live in one useAppearanceEditor hook. The contrast helpers and widget groups move to appearanceModel.
2026-10-09 15:19:48 +02:00
Maurice 05394644b9 refactor(admin): share the default user settings logic between desktop and phone
Loading, saving and resetting the defaults for users and the map field handling live in one useDefaultUserSettings hook that reads the presets and style rules from mapSettingsModel. Each shell passes its own error text and preview stamp.
2026-10-09 15:19:48 +02:00
Maurice 0722a3b6ad refactor(settings): share the map settings form between desktop and phone
The tile presets, the provider and style rules and the preview place move to mapSettingsModel, the form state and save to one useMapSettingsForm hook. The phone keeps showing the normalized style before the save lands, through an option.
2026-10-09 15:19:48 +02:00
Maurice d042a18d0a refactor(settings): share the notification settings logic between desktop and phone
The preference matrix, the webhook and ntfy credentials and the channel test sends now live in one useNotificationSettings hook. The phone keeps sending the whole matrix on a toggle and skipping a token typed as the mask, through two options.
2026-10-09 15:19:48 +02:00
Maurice a8dc000e7e refactor(settings): share the account settings logic between desktop and phone
Profile and avatar, the password change, two factor setup with its backup codes and the account deletion now live in one useAccountSettings hook. Two existing differences stay as options: the toast key for a failed avatar removal and whether the backup code actions skip an empty code list.
2026-10-09 15:19:48 +02:00
Maurice 231acced7e refactor(settings): share the passkey logic between desktop and phone
Listing, adding with the step up and the WebAuthn ceremony, renaming and
deleting passkeys now live in one usePasskeys hook together with the
date and abort helpers. The desktop section and the phone card keep
their own markup.
2026-10-09 15:19:48 +02:00
Maurice 3b4539f600 refactor(settings): share the integration and plugin tab gating between desktop and phone
The addon flags, the managed install flag and the addon reload of the integrations tab now come from one useIntegrationGates hook, and useSettings hands both shells the plugins tab flag next to the integrations one.
2026-10-09 15:19:47 +02:00
Maurice deff306755 refactor(mcp): share the MCP configuration logic between desktop and phone
The OAuth presets, the endpoint snippets, the OAuth clients, sessions and API tokens with their handlers and the copy tick now live in one useMcpIntegration hook. Two existing differences stay as options: the desktop loads only while the addon is on, and the phone reads the client list back after a create or rotate where the desktop appends locally.
2026-10-09 15:19:47 +02:00
Maurice 5ee7cc052c refactor(plugins): share the plugin user settings, OAuth and activity logic between desktop and phone
The declared field form with its save, the actions and their confirm state, the OAuth connect and disconnect and the activity log now live in usePluginUserSettings, usePluginOAuth and usePluginActivity. Each shell keeps its own form, confirm dialog or sheet and picker.
2026-10-09 15:19:47 +02:00
Maurice 2acfc9cb43 refactor(memories): share the photo provider connection cards between desktop and phone
The provider field model, the seeding and hydration, save and test now live in one usePhotoProviderConnections hook. Two existing differences stay as options: on the phone the status route alone sets the badge, and the desktop keeps its space before the server reason in a failed test toast.
2026-10-09 15:19:47 +02:00
Maurice 82070c72ec refactor(oauth): share the scope group picker logic between desktop and phone
The grouped scopes, the folded groups and the select all, group and single scope toggles now live in one useScopeSelection hook. Both pickers keep their own rows.
2026-10-09 15:19:47 +02:00
Maurice 309753b799 refactor(settings): share the AI parsing form between desktop and phone
The provider, model, key and multimodal state, the hydration from the settings store and the save with its reload on failure now live in one useLlmConnection hook. The phone keeps its picker sheet state.
2026-10-09 15:19:47 +02:00
Maurice 68e2e0639b refactor(airtrail): share the AirTrail connection form between desktop and phone
Both settings shells now read their state, the save and test handlers and the canSave rule from one useAirTrailSettings hook and keep only their own markup.
2026-10-09 15:19:47 +02:00
Maurice 6c5ab5414a refactor(admin): share the JWT rotation and passkey reset between desktop and phone
useAdminUserActions now rotates the JWT secret and resets a user's passkeys for the desktop dialogs and the phone sheets. Each shell keeps its own confirm step, and the phone sheet still stays busy until the reset settles.
2026-10-09 15:19:47 +02:00
Maurice feb99ce398 refactor(admin): share the admin settings saves and the TREK API lists between desktop and phone
useAdminSettingsActions now holds the OIDC and file type saves and the five place switches, writing through the admin bag both shells already receive. trekApiModel keeps the TREK Places API fields, facts and sources the desktop card and the phone block list.
2026-10-09 15:19:46 +02:00
Maurice 8c8069e983 refactor(admin): share the admin shell actions between desktop and phone
Saving the demo baseline and the bag tracking and collab feature switches now live in useAdmin. AdminPage and the phone admin screen pass them on instead of carrying their own inline copies.
2026-10-09 15:19:46 +02:00
Maurice 7738334fd6 refactor(admin): share the addon manager and the AI parsing config between desktop and phone
useAddonManager now loads and toggles the addons and builds the provider shelves, useLlmParsingConfig holds the AI parsing form with the Ollama model list and pull, and addonModel keeps the shared types, collab sub features, labels and recommended models. Both shells keep their own markup.
2026-10-09 15:19:46 +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 163b4c54e0 refactor(admin): share the notification settings saves and tests between desktop and phone
useAdminNotificationSettings now holds the SMTP save and test, the trip reminder switch and the admin webhook and ntfy save, test and token clear. The desktop tab drops its inline handlers and both views keep their markup.
2026-10-09 15:19:46 +02:00
Maurice ab3a69e18c refactor(admin): share the admin notification matrix between desktop and phone
useAdminNotificationMatrix now loads the matrix, picks the visible built in channels and saves each toggle with a per cell revert. The desktop panel and the phone card keep their own markup.
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 78fa42a0e6 refactor(admin): share the backup panel logic between desktop and phone
useBackupAdmin now loads the backups and the auto backup schedule and runs create, download, delete, restore and the schedule save. The phone keeps its form when the server answers without settings through the keepSettingsWhenMissing option.
2026-10-09 15:19:45 +02:00
Maurice f1719c3e35 refactor(admin): share the packing template manager logic between desktop and phone
usePackingTemplates now owns the template list, the expanded template with its categories and items, and every create, rename and delete. The phone keeps its generic delete error toasts through the genericDeleteErrors option.
2026-10-09 15:19:45 +02:00
Maurice 7da3a1724c refactor(admin): share the MCP token and OAuth session admin between desktop and phone
useMcpTokensAdmin now loads both lists, revokes sessions, deletes tokens and keeps the expanded scope chips. Both panels keep their own cards and confirm dialogs.
2026-10-09 15:19:45 +02:00
Maurice 7c006b6445 refactor(admin): share the category manager logic between desktop and phone
useCategoryAdmin now holds the category list, the create and edit form, the colour presets and the delete. The phone sheet still shows a busy delete and closes itself on success through the trackDelete option.
2026-10-09 15:19:45 +02:00
Maurice 0de89b86fa refactor(admin): share the permission levels editor between desktop and phone
Loading, editing, resetting and saving the permission levels now live in one usePermissionsAdmin hook next to the category and level tables. Both panels keep their own markup.
2026-10-09 15:19:45 +02:00
Maurice a52179f685 refactor(admin): share the audit log loading and paging between desktop and phone
Both audit log panels now read one useAuditLog hook for the first page, refresh, load more and the row labels. The desktop table and the phone cards keep their own markup.
2026-10-09 15:19:45 +02:00
Maurice 40fa8c6fe6 refactor(plugins): share the plugin admin logic between desktop and phone
Both panels carried the same types, helpers and a four hundred line block of state and handlers. pluginModel now holds the types and pure helpers, usePluginsAdmin the state, loading and every action, and usePluginDetail the registry detail fetch. The phone keeps starting all updates at once from Update all and reads the detail manifest under its stricter shape; both panels keep their own markup, dialogs and sheets.
2026-10-09 15:19:45 +02:00
Maurice a99b0c5b32 refactor(storage): share the storage panel and backend editor logic between desktop and phone
useStoragePanel holds the editor, removal and sync prompts, the save with its migrate prompt and the sequential migration queue; useBackendForm holds the backend draft, its checks and the backend it builds. The desktop keeps dropping the rest of the queue when a migration fails to start, and keeps recomputing the candidates at confirm time; the phone keeps moving the candidates its prompt opened with.
2026-10-09 15:19:45 +02:00
Maurice e92b233c3a fix(admin): the phone release history no longer skips a page after a long prerelease run
When five full pages in a row held only prereleases, the walk stopped with its page counter already one past the last page it read, so the next Load more started a page later and that page of releases never showed. The counter now ends on the last page read.
2026-10-09 15:19:44 +02:00
Maurice 81decb843d refactor(admin): share the GitHub release history between desktop and phone
useGithubReleases holds the paged fetch, the prerelease filter, the expanded rows and the date format, and parseReleaseNotes splits a release body into the blocks both panels draw. The phone keeps its page walk past hidden prereleases through the fillPages option, its repository link and its own inline link scan; the desktop keeps one page per load.
2026-10-09 15:19:44 +02:00
Maurice 67f20829f7 refactor(admin): share the dev notification tester between desktop and phone
Both panels loaded the same trips and users, built the same twelve test payloads and sent them the same way. That now lives in useDevNotifications; each panel keeps its own markup and the phone keeps its own wording of the failure toast.
2026-10-09 15:19:44 +02:00
Maurice 5abe5f6519 refactor(docsync): move the sync direction type into the docsync model
The pure model imported its direction type from the flow view. The type
now lives in the model, which takes it as the direction parameter, and
the flow view re-exports it for its existing importers.
2026-10-09 15:19:44 +02:00
Maurice f190aa5a01 refactor(collab): format the moved collab model and drop its stale comments
The model left the format baseline when it moved into components/Collab,
so it is now Prettier formatted. Its header no longer points at the
deleted services layer, and the colour palette no longer cites a rule
that stopped applying once the file sat beside the desktop panel. The
chat data hook says why it still returns setters nobody writes.
2026-10-09 15:19:44 +02:00
Maurice c9c3cc6175 refactor(files): keep each view's id rules in the shared link toggle
The shared toggle had mixed the two views. Desktop again treats any empty
column as free and finds a link record by comparing ids as they come. The
phone link sheet again treats only a null column as free and compares the
numeric value of a record id.

Starring stays per view. It is a single filesApi call with different
error handling and refresh on each side, so a shared wrapper would add a
layer without removing any logic.
2026-10-09 15:19:44 +02:00
Maurice a809de1b7a refactor(collab): keep the phone notes tab loading without a trip id guard
The shared notes hook skipped loading and listening without a trip id,
which only the desktop panel did. That guard is now a desktop option, so
the phone tab loads and listens as before.
2026-10-09 15:19:44 +02:00
Maurice 1c88a42890 refactor(collab): keep the phone's own poll reconciliation in the shared hook
The shared polls hook had taken the desktop rules for both views. It now
keeps each view's logic as it was before the move. The phone again
replaces a poll with the one a live closed event carries, puts a vote
answer on the poll it voted on, and stops the countdown tick once a
deadline passed. The desktop panel again ticks until a poll is closed.
2026-10-09 15:19:44 +02:00
Maurice 74e1853c39 refactor(docsync): share the scope picker, lane switch and folder name between desktop and phone
useScopePicker holds the provider's containers, binding one, and making
and binding a new one under the suggested name; the desktop dialog keeps
its search filter. docSyncModel holds the direction a lane switch leads to
and the folder name made from the trip title, which the panel and the
phone sheet each carried a copy of.
2026-10-09 15:19:43 +02:00
Maurice dd7cfca0b2 refactor(collab): share the notes list and attachment upload between desktop and phone
useCollabNotesData holds the note list with its first load and the live
note events, and the attachment upload both shells run after saving a
note. Writing notes, their colours and categories stays in each shell,
since the desktop panel keeps empty categories and colour batches the
phone tab does not have. A failed load still empties the desktop list only.
2026-10-09 15:19:43 +02:00
Maurice 428b4e6006 refactor(collab): share the polls logic between desktop and phone
useCollabPolls holds the poll list with its live updates, the countdown
tick, and creating, voting, closing and deleting. The desktop panel reads
the vote totals, winner and closed state from collabModel like the phone
tab does and keeps its own countdown label. A failed load still empties the
desktop list only, and closing still takes the server poll on the phone
only.
2026-10-09 15:19:43 +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 676de4c1d9 refactor(files): share the media lightbox logic between desktop and phone
useMediaLightbox holds the signed image address of the file on screen,
paging by button, arrow keys and swipe, and Escape to close. The desktop
lightbox keeps its own index and thumbnail strip, the phone lightbox stays
controlled by the files tab; both draw their own overlay over the hook.
2026-10-09 15:19:43 +02:00
Maurice 076ed897e7 refactor(files): share the file trash, paste and link rules between desktop and phone
useFileTrash holds the trashed files with their restore, permanent delete
and empty actions; the desktop trash view loads it on demand and the phone
sheet while it is open. fileActions holds the clipboard file picking and the
two tier link toggle (own column first, then link records) that the desktop
assign dialog and the phone link sheet both ran on their own.
2026-10-09 15:19:43 +02:00
Maurice 000c9dfa30 refactor(roadtrip): share the add stop draft between desktop and phone
The stop popup and the phone draft sheet take the kind and length a found stop opens with, the kind pick that brings its usual length along and the six length choices from useStopDraftForm and stopKinds. The desktop keeps its night booking and still starts from the draft it mounts with; the phone still seeds the answers for every draft it is handed.
2026-10-09 15:19:43 +02:00
Maurice 745cd5f65d refactor(roadtrip): share the stay length draft between desktop and phone
The stay dialog and the phone stay sheet take their presets, the five minute step, the full day limit, the draft and the arrival and departure preview from useStayDraft. Each still seeds the draft when it opens and saves it its own way; the phone keeps whole minutes when a step moves a stored fraction, the desktop does not.
2026-10-09 15:19:43 +02:00
Maurice 97d3512aa7 refactor(planner): share the trip export between desktop and phone
The export dialog and the phone export sheet keep their rows and run the PDF, the calendar file and the GPX download through useTripExport, which also reads the print settings and whether my plan is offered. The desktop still runs one export at a time and closes after each one; the phone still lets a calendar or GPX file run beside a PDF and stays open after the PDF.
2026-10-09 15:19:43 +02:00
Maurice a4e3e83d3f refactor(planner): share the transport detail day and segment labels between desktop and phone
The transport detail dialog and the phone transport sheet name the booking's day and each segment's own booking code through transportDetailModel, next to the files filter they already share. The rest of the two views reads the booking differently on purpose and stays as it is.
2026-10-09 15:19:42 +02:00
Maurice fc3cc1bbbe refactor(planner): share the booking form and its hotel stay between desktop and phone
The booking dialog and the phone booking sheet keep their markup and run on useReservationForm: how a saved booking or an import fills the form, the end before start check, the start date and time, the place and hotel picks, the stay range and the request body with the stay a hotel booking creates come from reservationFormModel. The desktop's check-in window, its link to a stop inside the form and its imported price stay its own, behind options where the code is shared.
2026-10-09 15:19:42 +02:00
Maurice 203e425e7e refactor(planner): share the transport booking form between desktop and phone
The transport dialog and the phone transport sheet keep their markup and run on useTransportForm: the route rows, how a saved booking or an import fills them, the day options and the request body come from transportEndpoints. Where the two shells differ today (unknown type fallback, stopover days, which rows are reset, train anchors, empty metadata, the link field) the shared code takes an option. The phone's upload helper moves to bookingFormModel next to the traveller checks both booking forms use.
2026-10-09 15:19:42 +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 80c66d3f83 refactor(budget): share the expense editor between desktop and phone
useExpenseForm holds the payers, the splits, the ticket items and the
receipts of the expense editor in CostsPanel and MCostSheet.

Flags keep the old differences. Only the desktop sends the people named
on a ticket line as members (#1382). The phone ignores a second save
while one is running and stays busy after a successful save. The
rebalance effect uses useEffectEvent instead of an eslint-disable.
2026-10-09 15:19:42 +02:00
Maurice ad220d9608 refactor(packing): share the bag editor and the bag weight totals between desktop and phone
useBagCardEditor holds the editing of a bag's name and weight limit for
the desktop bag card and MBagsSheet, and bagLoadSummary computes the
weight totals for the bag sidebar, the bag modal and the phone sheet.
The desktop card follows the bag when it changes elsewhere, the phone
row resets a name it did not save.
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 5a0e03929d refactor(lists): share the remembered lists sub-tab between desktop and phone
useListsSubTab remembers whether the packing or the to-do sub tab was
open, for ListsContainer and MTripShell. The desktop restores any stored
value, the phone restores todo and otherwise falls back to packing.
2026-10-09 15:19:41 +02:00
Maurice 71289ba43e refactor(places): share the place form and the maps search between desktop and phone
usePlaceForm and usePlaceSearch take the form state, the prefill, the
opening hours selection, the duplicate name check, the payload and the
maps search out of PlaceFormModal. MPlaceEditSheet, PlPlaceSearch and
PlCategoryPicker run on the same hooks, and PlTimeFields uses the shared
timeCollisions.

Where the two shells behaved differently the hooks take a dialog or a
sheet variant: the coordinate pair rule, abort and console handling,
when the billing session ends, whether a pick clears the suggestions,
the empty search cue, whether a map link clears the results, and how a
new category is trimmed and guarded. The open effect and the submit flow
stay per shell, because service stops, expense intents and delete arming
differ too much to share.
2026-10-09 15:19:41 +02:00
Maurice b2fd0ae5aa refactor(collections): share the add place to list logic between desktop and phone
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.
2026-10-09 15:19:41 +02:00
Maurice bbe3da7722 refactor(places): share the places pool filters and selection between desktop and phone
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.
2026-10-09 15:19:41 +02:00
Maurice 4dc446424f refactor(places): share the GPX, KML and KMZ file import between desktop and phone
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.
2026-10-09 15:19:41 +02:00
Maurice f2b6a14e9f refactor(places): share the Google and Naver list import between desktop and phone
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.
2026-10-09 15:19:41 +02:00
Maurice 8a28f5e22f refactor(collections): share the bulk save to list logic between desktop and phone
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.
2026-10-09 15:19:40 +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 9529b55bd7 refactor(planner): share the booking files filter between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice d01d9df93a refactor(planner): share the day note draft and save between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice 70c96be193 refactor(planner): share the stay editor rules between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice f6d51ba68e refactor(planner): share the day detail logic between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice 38c7d76c5d refactor(planner): share the per leg travel mode between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice 20d7e62afc refactor(planner): share the day plan reorder writes between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice ae31b55441 refactor(planner): share the day route export and optimize slots between desktop and phone
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.
2026-10-09 07:16:40 +00: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 34127b99bb refactor(planner): share the place detail actions between desktop and phone
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.
2026-10-09 07:16:40 +00:00
Maurice 8bd831238d test(server): cover the lease failures, sink errors and store passthroughs
The lease heartbeat and settle failures, the log sink and rotation
fallbacks, the runtime env getters, the public API summary edges, the
admin-only permission level and the WebSocket handler rejection had no
spec, which took six domains under their coverage floor.
2026-10-08 21:15:17 +02:00
Maurice 2b218a5528 refactor(server): drop the state helpers the injected stores replaced
setPermissionsCache and the free leaveRoom, leaveAllRooms and
leaveAllBooks lost their last caller when the services moved onto the
injected stores.
2026-10-08 21:15:17 +02:00
Maurice 9c39a0fc58 test(offline): count the trip member the v2 case stores 2026-10-08 20:22:17 +02:00
Maurice da97d7535f test(server): follow the signatures the session and port work changed
The session gate passes a session lookup to verifyJwtAndLoadUser, the
Immich save takes auto_upload, getAppUrl stays synchronous, the demo
session list marks the current session by the id it is handed, and the
lease no longer needs a transaction of its own, so its spec is gone.
2026-10-08 20:22:16 +02:00
Maurice 37af6f4ff9 test(server): seed bookings and places the way the services store them
The converted specs wrote accommodation_id as a plain id, while the
services store the legacy '<id>.0' text, and wrote read-only foreign key
twins instead of their relations. The files e2e schema gains the trip
columns the entity fills, and the idempotency spec seeds a real user.
2026-10-08 20:22:16 +02:00
Maurice ca0138dc45 test(server): make the row factories return what they promise
findRow and the default category lookup asked findOne with an empty
where, which MikroORM refuses. insertRows returned a single id because
insertMany on plain data reports only the last insert id; it now inserts
row by row and returns every id. insertRow and updateRows reject instead
of throwing before the caller holds a promise.
2026-10-08 20:22:16 +02:00
Maurice 7ae4df1ca2 test(trips): expect a failed day rebuild to roll the new dates back
TRIP-SVC-072 still pinned the old order, where the trip row committed
before the days were rebuilt. Both now run in one transaction, as
TRIP-SVC-018b already asserts.
2026-10-08 19:21:52 +02:00
Maurice 0172c5d12a ci(server): record the first Postgres probe measurement
All 100 dialect helper cases pass on both engines. Of 1402 repository
methods, 11 send one statement Postgres refuses as SQL and 4 send none
the probe can see; both lists may only shrink from here.
2026-10-08 19:19:15 +02:00
Maurice ce56efefc6 docs(wiki): list the dialect, Kysely type and Postgres probe commands 2026-10-08 19:18:42 +02:00
Maurice 0fa47e4297 test(demo): give the session reset spec a baseline to reset to
resetDemoUser returns early when no baseline file exists and asks the
port whether it can snapshot, so the spec needs both to reach the swap.
2026-10-08 19:18:42 +02:00
Maurice be016f180e test(server): seed the session, lease and rollback specs through the factories
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
2026-10-08 19:05:04 +02:00
Maurice ccf8c4309a ci(server): check the generated Kysely types and the SQL dialect baseline
check:db-types and lint:dialect were documented as gates but not run.
2026-10-08 19:05:04 +02:00
Maurice 8c66964961 test(server): fit the session, transaction and seeding specs together
The session carry-over specs were written against the old close and
reinitialize calls; they now drive the database backup port. The rollback
specs read through the factories and await the helpers that became async
when the seeding moved to the ORM. Two readers that lost their await and a
duplicated import are fixed as well.
2026-10-08 19:05:04 +02:00
Maurice 68504b337f refactor(scheduling): take a cron lease in one conditional upsert
acquire() made sure a row existed and then raced on a conditional UPDATE,
two statements the registrar wrapped in a transaction of its own. lint:tx
cannot follow a repository reached through a local variable, so it
reported the method. One INSERT ... ON CONFLICT DO UPDATE ... WHERE now
takes a free lease, renews the holder's and leaves anyone else's alone,
atomically on SQLite and on Postgres, and needs no transaction.
2026-10-08 19:05:03 +02:00
Maurice fac139723e build(db): generate the Kysely types for the lease and session tables
Both tables arrived in migrations written before the generator existed,
so src/db/kysely/ did not describe them and check:db-types would fail.
Regenerated with gen:db-types.
2026-10-08 19:05:03 +02:00
Maurice 115f76ffae style(server): re-indent the user deletion assertions in the admin spec
The conversion left 23 assertions of the user deletion case at column 0 in the middle of its indented body.
2026-10-08 18:47:25 +02:00
Maurice e90ed065e5 test(server): clear and age fixture rows through the ORM in converted specs
These specs left the raw SQL baseline but still emptied tables with
`db.exec('DELETE FROM ...')` and aged a Dawarich connection with a raw
UPDATE, which the ratchet cannot see because it only counts `.prepare(`.
They now go through deleteRows and updateRows like the rest of the file.

packing_item_recipients and packing_bag_members have no entity; both
cascade from their item and bag, so the packing spec relies on that with
foreign keys on in the snapshot database.
2026-10-08 18:47:25 +02:00
Maurice df10dc6ec2 test(server): assert the row exists before checking its stamped columns
findRow returns null for a missing row, and `row?.deleted_at` is then
undefined, which `.not.toBeNull()` accepts. The converted soft delete,
album sync and passkey counter checks would therefore pass if the row were
removed outright. They now assert the row is there first, as the raw
`.get()` reads they replace did by throwing on a missing row.
2026-10-08 18:47:25 +02:00
Maurice f579f03692 test(server): drop the dashes from two reworded spec comments
The permissions and plugin RPC fallback comments were reworded when
their raw SQL went away; this keeps the new wording in line with the
comment style used elsewhere in the converted specs.
2026-10-08 18:47:24 +02:00
Maurice 4c69804c81 test(server): lower the raw SQL baseline to what the converted specs hold
The specs converted to the factories no longer carry raw prepare()
calls, or keep only ones marked with test-sql-allow, so their baseline
entries are dropped. Leaving them in place would let raw SQL grow back
into those files unnoticed.
2026-10-08 18:47:24 +02:00
Maurice 6a7b55b13b test(server): seed the reservations service spec through the factories
Converts the raw prepare() fixtures and reads in the reservations service
spec to the ORM factories: expense links, stays, stops, endpoints, day
positions and guest flags are written through insertRow and updateRows,
and the narrow SELECT post-conditions read whole rows through findRow and
findRows, compared with toMatchObject so they still assert exactly the
columns the projection named. stopsOn keeps returning only the three
columns its toEqual assertions compare, and the helpers that now await
become async.

The one write left raw carries a test-sql-allow marker: it plants
dangling references inside the synchronous foreign_keys = OFF window,
which an async ORM write would outlive. The unused canAccessTrip and
isOwner entries are dropped from the database mock.
2026-10-08 18:47:21 +02:00
Maurice 24a2624488 test(server): seed the users repository spec through the factories
Converts the raw prepare() fixtures and post-condition reads in the users
repository spec to updateRows, findRow and readUser. The Immich read
helper becomes async and still returns only the three columns its toEqual
assertions compare, and the guest insert check compares its columns with
toMatchObject now that the read returns the whole row.

The raw statements that remain carry a test-sql-allow marker: the legacy
statements run as parity oracles for the repository reads, the
NOT NULL DEFAULT 0 probe on is_guest, which has to bypass the entity
default, and the unflushed-entity check, which must read the table
without an EntityManager that could flush first.
2026-10-08 18:47:13 +02:00
Maurice a0a584e1df test(server): seed the places service spec through the factories
Converts the raw prepare() fixtures and reads in the places service spec
to the ORM factories: tours, waypoints, files, links, expenses, collection
places and ratings are written through insertRow and updateRows, and what
the service left behind is read back through findRow, findRows and
countRows. The track colour cases insert their seeded tracks with
insertRows in place of INSERT ... RETURNING, tracksOf and the file attach
helper become async, and the stale updated_at is computed with dbNow
instead of datetime('now', '-1 hour'). The rating reads keep comparing
only the rating column, as the narrow SELECT did.
2026-10-08 18:47:12 +02:00
Maurice 40c5eb5c54 test(server): seed the calendar service spec through the factories
Converts the raw prepare() fixtures in the calendar service spec to the
ORM factories: reservation times, metadata and endpoints, stays, stops and
place addresses are written through insertRow and updateRows. The stay
helpers become async, so their callers await them. The timezone cache
case seeds its 1010 bulk bookings and their endpoints with two insertRows
batches instead of a raw transaction. The unused canAccessTrip and
isOwner entries are dropped from the database mock.
2026-10-08 18:47:12 +02:00
Maurice 6c3bb58d59 test(server): seed the auth service spec through the factories
Converts the raw prepare() fixtures and reads in the auth service spec to
the ORM factories: instance settings go through setAppSetting and
deleteRows, MFA, OIDC and invite state through updateRows, and the
password version, push device and MCP token checks read back through
readUser and countRows. The push device helpers become async, so every
caller now awaits them. The unused canAccessTrip and isOwner entries are
dropped from the database mock, since nothing under test imports them.
2026-10-08 18:47:12 +02:00
Maurice 61598e070a test(server): seed the admin integration suite through the factories
Converts the raw prepare() fixtures and reads in the admin integration
suite to the ORM factories and readers. The user-deletion case that seeds
one row per foreign key pointing at the departing user now writes each of
them through insertRow and checks what survived through findRow; the
expiry timestamps that used datetime('now', ...) are computed with dbNow.
The OAuth client in that case uses the same string for its row id and its
public client_id, so the token and consent rows reference it the same way
whichever column the relation keys on.
2026-10-08 18:47:12 +02:00
Maurice cfa3d10dfa test(server): seed the trips integration suite through the factories
Converts the raw prepare() fixtures and reads in the trips integration
suite to the ORM factories and readers: archived trips, permission rows,
memberships, the copy's source rows (tagged places, assignments, to-dos,
the budget category order, private packing items) and every read of what a
request left behind go through insertRow, updateRows, findRow and findRows.
The copied place tags are counted per place through the place_tags
relation instead of a raw join.
2026-10-08 18:47:12 +02:00
Maurice 732b951439 test(server): seed the document sync service spec through the factories
Converts the raw prepare() fixtures and reads in the document sync service
spec to the ORM factories and readers. The row builders (bindings, sync
items, files, the connection) and the files service double write through
insertRow and updateRows and are async now, the shape the real service has;
the row readers go through findRow and findRows. Seeding that ran inside
Array.from callbacks is a plain loop, so every row is written before the
run it feeds starts.

Three reads stay raw behind allow markers: the helper that takes a time
from SQLite's own clock and the two checks that compare a stored timestamp
with CURRENT_TIMESTAMP and julianday('now'), since the clock is what they
are about.
2026-10-08 18:47:12 +02:00
Maurice e88e28b79c test(server): seed the user cleanup service spec through the factories
Converts the raw prepare() fixtures and reads in the user cleanup service
spec to the ORM factories and readers: plugins and their per-user config,
journeys with their entries, photos, contributors and share links, trip
share tokens, settlements and memberships are written through insertRow and
makeShareToken, and what the deletion leaves behind is checked through
findRow and countRows. The slim-schema case keeps its single raw users
insert behind an allow marker: that database is hand-rolled and carries no
schema an entity could write to. The trek photo the journey case seeds now
takes its created_at from the column default instead of a literal 0 no
assertion reads.
2026-10-08 18:47:11 +02:00
Maurice 56060188a6 test(server): seed the atlas service spec through the factories
Converts the raw prepare() fixtures and reads in the atlas service spec to
the ORM factories and readers: places, booking endpoints, travellers,
visited countries and regions, cached place regions and bucket list rows
go through makePlace, makeReservationEndpoint, insertRow and upsertRow, and
the region cache is read back through findRow.

The two #2527 cases keep their raw move and delete, with an allow marker:
they land inside the repository call the test intercepts, synchronously on
the call's own connection, which is the race they pin. The database mock
loses its raw canAccessTrip and isOwner, which nothing reads any more.
2026-10-08 18:46:52 +02:00
Maurice efce09f3a2 test(server): seed the memories access service spec through the factories
Converts the raw prepare() fixtures and reads in the memories access
service spec to the ORM factories and readers: shared trip photos, journey
photos and contributors, album links and the trip memberships the access
checks walk are written through insertRow. The legacy MA5 statement stays
raw behind an allow marker as the oracle its parity cases compare with, and
the database mock loses the raw canAccessTrip and isOwner nothing reads.
2026-10-08 18:46:52 +02:00
Maurice 234400b2fe test(server): seed the reservation visibility parity spec through the factories
The reservation visibility parity harness now stages bookings, links stays
and reads the trip's days through the ORM factories and readers. The
legacy string fragments it holds the typed predicates to keep running raw
behind allow markers, since they are the fixed side of the comparison.
2026-10-08 18:46:52 +02:00
Maurice 7427a47eea test(server): seed the collab service spec through the factories
Converts the raw prepare() fixtures and reads in the collab service spec to
the ORM factories and readers: reactions, note attachments and the stored
note, message and vote rows go through insertRow, findRow and countRows.
The database mock loses its raw canAccessTrip and isOwner, which nothing
reads any more, and the repository parity cases keep the legacy joins they
are held to behind allow markers.
2026-10-08 18:46:51 +02:00
Maurice 3b1d30b587 test(server): seed the places integration and trip members repository specs through the factories
Converts the raw prepare() fixtures and reads in the places integration
suite and the trip members repository spec to the ORM factories and
readers. Tags, categories and linked expenses are seeded through makeTag,
makeCategory and makeBudgetItem; the member rows the roster cases backdate
or point at an inviter are written through insertRow and updateRows. The
legacy UNION and join statements the roster reads are held to stay raw
behind allow markers.
2026-10-08 18:46:51 +02:00
Maurice fecaa82ad2 test(server): seed the unified memories and admin service specs through the factories
Converts the raw prepare() fixtures and reads in the unified memories
service spec and the admin service spec to the ORM factories and readers.

The memories spec drops the raw canAccessTrip and isOwner on its database
mock, which nothing reads any more; its two parity cases keep the legacy
joins they are held to behind allow markers and build the provider list
they bind through the reader. The admin spec's password reset helpers
(password version, token and push device counts, the stored hash) now read
through the ORM, and the audit log parity case keeps its legacy statement
as an allowed oracle.
2026-10-08 18:46:51 +02:00
Maurice 345d459e5a test(server): seed the files and dawarich sync service specs through the factories
Converts the raw prepare() fixtures and reads in the files service spec and
the dawarich sync service spec to the ORM factories and readers.

The dawarich service stub the sync spec hands in now reads and writes its
connection rows through the ORM and is async, the shape the real service
has. Writes that set accepted_hash from the row's own source_hash read the
row first and write the value back, since a native update cannot reference
another column. The files spec drops the raw canAccessTrip and isOwner on
its database mock, which nothing reads any more, and keeps the legacy
FILE_SELECT statements it is held to behind allow markers.
2026-10-08 18:46:50 +02:00
Maurice 63bcf61a2b test(server): seed the system notice, packing e2e and synology specs through the factories
Converts the raw prepare() fixtures and reads in the system notices
integration suite, the packing e2e and the synology memories integration
suite to the ORM factories and readers.

The packing e2e moves from its hand-rolled tables to the migrated snapshot,
seeds its three users with the ids their session cookies are signed for,
and drops the raw canAccessTrip on its database mock, which nothing reads
any more. The synology suite now shares one ORM handle for the photo
registration helpers and the readers instead of declaring a second one
halfway down the file.
2026-10-08 18:46:50 +02:00
Maurice 2a9730d7c5 test(server): seed the demo repository, transports MCP and immich specs through the factories
Converts the raw prepare() fixtures and reads in the demo repository spec,
the transports MCP spec and the immich integration suite to the ORM
factories and readers.

Where a case compared a narrow SELECT with toEqual, it now picks the same
columns off the row the reader returns, so the comparison stays exact
rather than loosening to a partial match. The immich album link cases seed
their photos through addTripPhoto and addAlbumLink, which register the
provider asset the same way the raw INSERT OR IGNORE did.
2026-10-08 18:46:50 +02:00
Maurice e9ff28d84a test(server): seed the files MCP and roadtrip e2e specs through the factories
Converts the raw prepare() fixtures and reads in the files MCP spec and the
roadtrip e2e to the ORM factories and readers.

The files spec seeds its trip files through makeTripFile and its links
through linkFile, keeping the helper's explicit-null handling for size and
type. The roadtrip e2e keeps its pinned trip, day and place ids, which its
routes address directly, now written through insertRow and insertRows.
2026-10-08 18:46:50 +02:00
Maurice 635170a14f test(server): seed the share integration and passkey specs through the factories
Converts the raw prepare() fixtures and reads in the share integration
suite and the passkey service spec to the ORM factories and readers.

The passkey credential helper takes a typed set of overrides instead of a
loose record, so a fixture can no longer hand the insert a column the
entity does not map. The two credentials the listing case backdates are
written with their created_at through insertRows, blobs as Buffers.
2026-10-08 18:46:49 +02:00
Maurice 203eb6a216 test(server): seed the collections MCP and in-app notification specs through the factories
Converts the raw prepare() fixtures and reads in the collections MCP spec
and the in-app notification preference spec to the ORM factories and
readers. The collection, member and saved place helpers and the boolean
notification helper keep their signatures and simply resolve to the new
row's id, so each case reads as it did; the four copies of the boolean
notification INSERT collapse into one helper over makeNotification.
2026-10-08 18:46:49 +02:00
Maurice 9104dab183 test(server): seed the photo cache and dawarich specs through the factories
Converts the raw prepare() fixtures and reads in the trek photo cache and
google place photo cache services and the dawarich e2e to the ORM factories
and readers.

The place photo cache suite moves from its hand-rolled three-table fixture
to the migrated snapshot: the references it checks are now real places on a
trip and real saved places in a collection, seeded through makePlace and
makeCollectionPlace, which a table without names or owners could not hold.
The dawarich e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more.
2026-10-08 18:46:49 +02:00
Maurice 2860148df3 test(server): seed the plugin discovery, permission, feed and stay specs through the factories
Converts the raw prepare() fixtures and reads in the plugin discovery spec,
the permissions service spec, the feeds and invite MCP spec and the day
accommodations repository spec to the ORM factories and readers.

The permission cache cases still write their override rows past
savePermissions, now through setAppSetting, so the stale-cache behaviour
they pin is unchanged. The day accommodations spec keeps the legacy
statements it holds the repository to behind allow markers.
2026-10-08 18:46:48 +02:00
Maurice 855c4e3ba3 test(server): seed the collections, storage registry, transit, token and immich specs through the factories
Converts the raw prepare() fixtures and reads in the collections e2e, the
storage registry service spec, the transit MCP spec, the MCP token
repository spec and the immich memories integration suite to the ORM
factories and readers.

The collections e2e drops the raw canAccessTrip on its database mock, which
nothing reads any more. Reads that used to join trip_photos to trek_photos
in SQL resolve the photo row through the readers instead, and the orphan
trip photo of IMMICH-057 is still written with foreign keys switched off,
now through the ORM on the same connection.
2026-10-08 18:46:48 +02:00
Maurice 8b2e7baacd test(server): seed the trip read model, share token and audit log specs through the factories
Converts the raw prepare() fixtures and reads in the trip read model
service spec and the share token and audit log repository specs to the ORM
factories and readers.

The trip read model spec drops the raw canAccessTrip and isOwner it still
put on the database mock; nothing has read them since trip access moved
onto the repositories. The share token spec now inserts through
makeShareToken and reads what a write left behind through findRow, keeping
the SELECT * row and the legacy statements it is held to as allowed
oracles. The audit log spec keeps the raw row and the typeof() storage
class probe behind allow markers, since both are about what SQLite stores.
2026-10-08 18:46:48 +02:00
Maurice a7fe990c6e test(server): seed the plugin settings, tag, rating, day and receipt specs through the factories
Converts the raw prepare() fixtures and reads in the plugin settings
isolation spec, the tags MCP spec, the place ratings and days repository
specs and the receipt scan e2e to the ORM factories and readers.

The receipt scan e2e writes the AI parsing addon config as the object the
entity's JSON column maps, not as a pre-serialised string, and seeds its
users and trips through makeUser and makeTrip with the ids its session
cookie is signed for. The days spec keeps the SELECT * row it compares the
ORM's rows with key for key, and its legacy statements, behind allow
markers; the place ratings spec backdates updated_at with dbNow instead of
datetime('now').
2026-10-08 18:46:48 +02:00
Maurice 2529255559 test(server): seed the websocket, push, api key and day specs through the factories
Converts the raw prepare() writes and reads in the websocket connection
suite, the web push and instance API key services, the days MCP spec and
the packing visibility and place details cache repository specs to the ORM
factories and readers.

The websocket suite reads the app's own ORM; the password version bump that
was a single relative UPDATE now reads the user and writes the next
version. The packing visibility parity keeps the legacy fragment it is
checked against behind an allow marker.
2026-10-08 18:46:48 +02:00
Maurice 7349ee95c2 test(server): seed the atlas repository and storage admin specs through the factories
The visited countries and place regions repository specs seed their rows
through the ORM; the legacy statements they hold the repositories to stay
raw behind an allow marker, since those are the oracles the parity is
checked against.

The storage admin e2e moves from hand-rolled tables to the migrated
snapshot, seeds its two users through the factories with the ids their
session cookies are signed for, and reads the stored backends and the audit
rows through the readers instead of raw SELECTs.
2026-10-08 18:46:47 +02:00
Maurice 9de9719a02 test(server): seed the share, quota, photo and stats specs through the factories
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.

The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
2026-10-08 18:46:47 +02:00
Maurice e3982a8e2a test(server): seed the repository, MCP and e2e specs through the factories
Converts the raw prepare() fixtures and reads in the next set of specs to
the ORM factories and readers: the password reset, webauthn, dawarich,
budget, to-do and vacay repository specs, the journey, settings, addons and
share MCP specs, the plugin POI and operator egress specs, the feeds,
storage stats and query helper services, and the packing, collections,
day reorder, leg mode, upload context and trip access integration suites.

The notifications and google quota e2e suites now seed their users through
makeUser with pinned ids, and the notifications suite moves from hand-rolled
tables to the migrated snapshot. Legacy statements that serve as a parity
oracle and the raw rows the primary-relation spike holds toObject() against
stay raw, each with an allow marker naming why.
2026-10-08 18:46:47 +02:00
Maurice 894a5ee781 test(server): seed the small leftover specs through the repositories
Converts the raw prepare() reads and writes in the share, airtrail, files,
todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard
specs to the ORM factories and readers, so they go through the entities the
app uses instead of hand-written SQLite.

The vacay e2e moves from hand-rolled tables to the migrated snapshot, with
its user pinned to the id its session cookie is signed for. Statements that
are raw by nature keep the allow marker with the reason: the legacy oracle
of the bucket list parity spec, the PRAGMA probe of the trek_photos
migration, the unmapped probe table of the route import rollback, the
behind-the-ORM reads of the entity insert probes, and seedUser, which also
serves the hand-rolled users table of createTempDb. Comments that quoted a
prepare() call are reworded so the ratchet no longer counts them.
2026-10-08 18:46:47 +02:00
Maurice ad1111858b test(oauth): read stored revoked_at raw in OAUTHTOKREPO-003
The case checks the text format revokeAllForClient and revokeById write.
Reading it through the entity runs DbTimestampType, which turns an epoch
integer or a Date into formatted text, so an integer write would still
pass. The two reads go back to raw statements under an allow marker and
assert the stored value is a string before matching its format.
2026-10-08 18:46:47 +02:00
Maurice 9561a2efc7 test(server): lower the raw statement baseline to what the seeded specs hold
The specs seeded through the repositories in the commits before this one
no longer carry unmarked raw statements, so their entries leave the
baseline. What stays raw in them is marked with a reason.
2026-10-08 18:46:47 +02:00
Maurice aec925cffb test(server): seed the journey domain spec through the factories
The journey domain suite now writes its photos, entries, contributors,
trip links, plan edits and place changes and reads back what a call left
through the ORM factories. Three small readers keep the shape a
statement's .get()/.all() handed back: one() answers undefined when no
row matches, pickOne()/pickAll() return just the named columns, and a
count stays { n }, so every toBeDefined, toBeUndefined and toEqual keeps
its meaning. The helpers that wrote or read rows (photoOnEntry, photoOn,
insertEntry, orderOf, daysOf, skeletonFor, skeletonsFor, skeletonsOf,
withGeometry) are async and awaited where they are used, and the M5d
parity seed binds its values in the old column order through columnsOf.

Kept raw and marked: the legacy statements of both repository parity
blocks (JG8, JG14, JG17, JG18, JG34 and the LEG set) and the unguarded
delete the JG119 mutation proof measures the guard against.
2026-10-08 18:46:39 +02:00
Maurice 2a51abaeb8 test(server): seed the budget service db spec through the factories
The DB-backed budget service suite now sets trip currencies, hangs
expenses on bookings, writes places, bookings, trip files, file links
and category order, and reads back items, members, payers, settlements
and links through the ORM factories. Its row readers (paidFlag,
personsOf, memberCount, itemRow, placeRow, settlementRow, totalOf,
rateOf, metadataOf, linksOf) and seeders (tripWithBooking, seedAudTrip)
are async now and awaited where they are read; projected comparisons map
the same keys off the found row.

The database mock drops its dead trip access stand-ins. Kept raw and
marked: the legacy BG10 to BG13 and settlement statements the repository
reads are held to, and the currency switch BUDGET-SVC-DB-066 makes from
inside the stubbed rate fetch.
2026-10-08 18:46:39 +02:00
Maurice 980b12aff2 test(server): seed the places repository spec through the factories
The places repository suite now edits its fixture places, writes tags,
ratings, tours, regions, stops and tracks, and reads back what a write
left through the ORM factories. The datetime('now', ...) offsets the old
statements computed in SQL are the same UTC second-resolution strings,
computed by sqliteNow, and the full-column fixture of PLACEREPO-050 goes
through insertRow with every column it named.

Kept raw and marked: the legacy getPlaceWithTags composition, the listForTrip
statement builder and every other legacy projection the typed reads are
compared against, plus the UPDATE in PLACEREPO-013 that must land behind
the ORM's back.
2026-10-08 18:46:38 +02:00
Maurice 847d98e8fd test(server): seed the reservations repository spec through the factories
The reservations repository suite now shapes its bookings, trips, stops,
stays, legs and day positions through the ORM factories, takes its day
ids from findRows and reads back the AirTrail and review columns a write
left with findRow.

Every legacy statement the typed reads are compared against stays raw
and marked as the oracle, the calendar, upcoming, share and public API
projections among them.
2026-10-08 18:46:38 +02:00
Maurice 55ccf3e399 test(server): seed the trips repository spec through the factories
The trips repository suite now edits its fixture trips and reads back
what a write left through the ORM factories (setTrip, tripRow,
findRow), and promotes its admin through updateRows.

The parity statements stay raw and marked: canAccessTrip, the owner
check, the viewer, active trip, feed token, travellers, sync and share
projections are the oracles the repository reads are held to. Also kept
raw: the SELECT * snapshot toObject has to reproduce, and the UPDATE in
TRIPREPO-022 that must land behind the ORM's back.
2026-10-08 18:46:38 +02:00
Maurice 9755c51c35 test(server): seed the days service spec through the factories
The days service suite writes bookings, legs, stays, vias, boundaries
and day edits through the ORM factories and reads them back with
findRow/findRows/countRows. The day, booking and stay readers the cases
compare against (dayRows, reservationRow, staySpan, stopsOn, viasOn,
boundaries) are async now and awaited where they are read; a reader of a
row that is gone fails the case as the old cast did.

The database mock drops its getPlaceWithTags, canAccessTrip and isOwner
stand-ins, which the module stopped exporting when those reads moved
onto the repositories.
2026-10-08 18:46:38 +02:00
Maurice b09f48c4d7 test(server): seed the packing service spec through the factories
The packing service suite writes its items, bags and templates and reads
back what a call left through the ORM factories. Template items are read
category by category instead of through a join, and the trip access
stand-ins the database mock still carried go, as the module no longer
exports them.

The repository parity block keeps its legacy statements raw, marked: the
VISIBLE_TO_ACTOR fragment, the assignee join, the correlated item count,
the category assignees and the export read are the oracles the converted
reads are held to.
2026-10-08 18:46:38 +02:00
Maurice 58cdaf828c test(server): seed the days integration, OIDC and share service specs through the factories
The days integration suite, the OIDC service suite and the share service
suite write their fixtures and read back their effects through the ORM
factories: invite tokens through makeInviteToken, user column edits
through updateRows, bookings, stays, packing items and messages through
insertRow on their entities. Two more database mocks drop their dead
canAccessTrip/isOwner stand-ins.

The OIDC identity-linking cases keep their raw SELECT * of the user row,
marked: they assert that no column of the table moved, including any the
entity might not map.
2026-10-08 18:46:38 +02:00
Maurice 5947631db6 test(server): seed the days, tours, assignments and journey share specs through the factories
The days and tours e2e suites, the assignments service suite and the
journey share suite build their fixtures through the ORM factories and
read back through findRow/findRows/countRows. Fixtures that pin ids (the
trip, day and places a session cookie or a route targets) keep them.
Readers that the old suites called synchronously (order, dayOrder,
viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where
a case compares several at once.

Left raw and marked: the legacy statements the parity cases hold the
repository reads to, the COLLATE NOCASE lookups that prove the token
match is exact, and total_changes(), which counts writes on the
connection itself.
2026-10-08 18:46:37 +02:00
Maurice a3781ed27b test(server): seed the collab, budget, MCP and OAuth token specs through the factories
The collab and budget e2e suites, the MCP integration suite, the budget
MCP tools and the OAuth token repository suite seed and read through the
ORM factories. The collab e2e suite moves off its hand-rolled CREATE
TABLE set onto the migrated snapshot, the way the factories guide asks of
a suite that converts: an entity reads and writes every mapped column,
which a trimmed table does not have. Its users are seeded with the ids
the session cookies are signed for.

The recursive chain statement in the token suite stays raw, marked as the
legacy oracle collectChainIds is held to.
2026-10-08 18:46:37 +02:00
Maurice 2cf853e504 test(server): seed the atlas and notes tools, day removal, doc sync config and plugin runtime specs through the factories
The atlas and notes MCP tool suites, day removal, the doc sync config
service and the plugin runtime conversion suite build their fixtures and
read back what a call left through the ORM factories. Helpers that used
to answer synchronously off the handle (dayRows, range, the reservation
and boundary readers) are async now and every caller awaits them; a
reader that answered undefined for a gone row still does, so the
toBeUndefined/toBeDefined checks keep their meaning.

The one statement left raw rewinds sqlite_sequence, the engine's own
autoincrement table, which no entity maps; it is marked.
2026-10-08 18:46:37 +02:00
Maurice 2b41a55022 test(server): seed the reminder, roster, Dawarich and plugin specs through the factories
Reminder jobs, user profile, trip members, Dawarich suggestions, the doc
sync controller, the plugin registry and the plugin repository parity
suite now write and read their rows through the ORM factories. The doc
sync controller's itemsForTrip stand-in answers through the two
repository reads DocSyncService makes instead of a copy of their SQL, and
one more database mock drops its dead canAccessTrip/isOwner stand-ins.

What stays raw is marked: the legacy statements the parity cases are
held to, and the places stub in the Dawarich suggestions suite, which
runs inside the service's open transaction and has to write on the
connection that transaction holds.
2026-10-08 18:46:26 +02:00
Maurice 49ba73fcae test(server): seed the atlas, settings, budget and notification specs through the factories
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
2026-10-08 18:46:26 +02:00
Maurice e2ce2ad05c test(server): seed the service, MCP and route specs through the ORM factories
Service, MCP, integration and e2e suites with up to a dozen raw
statements now seed and read through the factories. A buildApp() suite
uses the app's own ORM, a partial-module e2e or a unit suite a
createTestOrm() or sharedTestOrm() handle on its database. Reads that
used to come back undefined now come back null, and the assertions say
so; where a projected SELECT was compared whole, the same columns are
picked off the row.

Two reads inside the scheduler's delivery callback stay synchronous raw
statements, marked, so they keep seeing the row at the moment of
delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in
the module no longer exports.
2026-10-08 18:46:25 +02:00
Maurice d28e91d056 test(server): seed the repository specs through the ORM factories
The repository suites now write their fixtures and read back what a call
left behind through the factories and readers, not hand-written SQLite on
the better-sqlite3 handle. What stays raw is marked with test-sql-allow:
the legacy statements the parity cases hold a repository read to, rows
read as the table stores them (a BLOB, an integer the entity maps to a
boolean), and a write that has to land on the connection an open
transaction holds.

Cases that pinned a "raw UPDATE" between two repository reads now make
that write through a context of their own, which the repository's
identity map knows as little about, so they still prove the same thing.
2026-10-08 18:46:13 +02:00
Maurice b2f2aef016 test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their
rows through the ORM factories instead of hand-written SQLite on the
better-sqlite3 handle. A legacy statement that serves as a parity oracle,
the schema introspection and the bare backup file keep their raw SQL and
say why with a test-sql-allow marker. Two database mocks still answered a
getPlaceWithTags the module no longer exports; that dead branch goes too,
and the budget seam suite stubs the reservations repository directly
instead of a fake prepare().
2026-10-08 18:46:13 +02:00
Maurice feda6ffdbc test(trips): seed the whole-copy parity reservation through insertRow
The TRIP-SVC-069 reservation seed was a raw INSERT carrying a
test-sql-allow marker, justified as reproducing the legacy raw binding of
a number into the TEXT accommodation_id. legacyBoundIntegerText already
writes that exact "<id>.0" shape and the sibling copy tests seed the same
column with it through insertRow, so nothing about this statement needs
to stay raw. The seed now goes through the factory with the same column
values, and the assertion still pins the legacy shape.
2026-10-08 18:46:06 +02:00
Maurice f5b29ddb50 test(server): lower the raw test SQL baseline to what the specs hold now
The seeding rounds above took the converted specs down to few or no
raw statements, so their entries in the ratchet drop to the new counts,
and the files that reached zero leave the baseline. A file that is
converted can no longer grow its raw SQL back unnoticed.

Two specs move to the exemptions instead, because raw SQL is their
subject: the migration step that reseats booked nights is tested on rows
seeded over the raw connection before the step runs, and the place
region trigger is pinned by raw updates on databases the migrations
alone built.
2026-10-08 18:46:06 +02:00
Maurice e4d0b40404 test(server): seed the trip and vacay service specs through the repositories
The two largest service suites seeded and inspected rows with raw
statements. They now write through the ORM factories and read through
small local readers that return a row the way the SELECT did (only the
selected columns, undefined when there is none), so every assertion keeps
its exact meaning. The trips database mock loses its dead canAccessTrip,
isOwner and getPlaceWithTags stubs.

Raw statements stay, marked, where they are the subject: the legacy day
grid rebuild that the fuzz case runs as its oracle inside a synchronous
transaction it rolls back, the reservation seed that reproduces the
legacy REAL binding into the TEXT stay link, and the legacy entry read
model the vacay suite compares against.
2026-10-08 18:46:06 +02:00
Maurice ba28b5538d test(server): keep the legacy TEXT shape of a booking's stay link
reservations.accommodation_id is TEXT, and every writer stores a stay id
in the shape the legacy statement bound it (`14.0`, see
legacyBoundIntegerText). The converted specs wrote and matched the bare
`14` instead, so a lookup of a booking the service had linked would miss
it and a seeded link would not look like one the app writes. They now go
through legacyBoundIntegerText, the same helper the services use.
2026-10-08 18:45:57 +02:00
Maurice 01f0bf3ef9 test(server): seed the larger service specs through the repositories
The settings, notification, road trip plan, collection and accommodation
service suites wrote their fixtures and read back their results with raw
statements against the shared test handle. They now seed and inspect
through the ORM factories and readers on the suite's shared test ORM,
with small local helpers where a suite repeats the same read.

The collection and accommodation database mocks drop their dead
canAccessTrip, isOwner and getPlaceWithTags stubs: db/database exports
none of them since the repositories took them over.

Raw statements stay, marked, where they are the subject: the legacy
read model oracle in the collections suite, the SELECT * that proves a
saved place has no itinerary columns, and the member count taken
synchronously while the delete broadcast fires.
2026-10-08 18:45:57 +02:00
Maurice 8eaeed07a8 test(server): seed the smaller service specs through the repositories
The service suites for mail, notification channels and transports,
idempotency, invites, tokens, OAuth, push subscriptions, the version
check, budget settlement maths, addons, place shadow picks, tour GPX
import, the place region repair, journey stats, day notes and storage
jobs seeded and inspected their rows with raw statements. They now go
through the ORM factories and readers over the suite's shared test ORM.

The database mocks of day notes and OAuth also lose their dead
canAccessTrip and isOwner stubs: db/database exports neither since the
repositories took them over.

Raw statements stay, marked, where they are the subject: the backup
file the boot restore reads before any ORM exists, the full raw row a
day note is checked against, sqlite_master, the synchronous probe that
counts tours while the GPX import may still hold its transaction, and
the edits the region repair case lands mid-pass, past the ORM's
connection queue.
2026-10-08 18:45:56 +02:00
Maurice d8052c49b4 test(server): read the db, demo and helper specs through the ORM
The demo seed parity case, the transaction yield probe and the test
UnitOfWork check read their row counts and rows back with raw
statements; they now use the ORM readers.

The statements these files are about stay raw and are marked: the
dirty read and write the transaction yield probe measures on the shared
handle, and the reset-admin.js case, whose database file has no ORM
bound to it.
2026-10-08 18:45:56 +02:00
Maurice 514d5159ce test(server): seed the repository specs through the ORM factories
The repository suites for assignments, notifications, document sync,
tags, photo metadata, vacay, day notes, notice dismissals, idempotency
keys, settings, travellers, invites, providers, hidden regions, road
trip tracks and the collection role check seeded their fixtures and
read back what a write left behind with raw statements. Fixtures now go
through the ORM factories and insertRow/updateRows, and the state reads
through findRow/findRows/countRows.

What stays raw is raw on purpose and carries a test-sql-allow marker:
the legacy statements every parity case compares a repository against,
the full raw rows used as such an oracle, PRAGMA reads, typeof() and
date() storage checks, and the out-of-band writes the identity map
freshness cases are about, which have to bypass every EntityManager.
2026-10-08 18:45:32 +02:00
Maurice 57404dee17 test(server): seed the plugin specs through the repositories
The plugin service, instance and user settings, OAuth, audit, route
profile, host state and request context suites seeded plugin rows,
settings fields, actions, tokens and audit entries with raw statements
and read them back the same way. They now go through the ORM factories
and readers over the suite's own test ORM.

Two raw statements stay, marked: the PRAGMA table_info read that proves
the actions scope column exists, and the audit insert that reproduces
the pre-conversion appendAudit byte for byte for the hash chain parity
check. The instance settings suite's stubbed isActive now follows the
status the test flips through markActive instead of a synchronous read.
2026-10-08 18:45:32 +02:00
Maurice f73f395817 test(server): seed the integration specs through the repositories
The buildApp() suites for auth, bookings, budget, journeys, assignments,
passkeys, profiles, OAuth, the AirTrail tick and the plugin boot order,
plus the storage restore suite, seeded settings, addons and fixture rows
and read back what a request left behind through raw better-sqlite3
statements. They now write and read through the ORM factories and
readers, against the app's own MikroORM (or the suite's test ORM where
one already existed).

The account deletion case keeps every one of its foreign key checks; a
row that used to read back as undefined now reads back as null.
2026-10-08 18:45:32 +02:00
Maurice 583ba17606 test(server): seed the e2e specs through the repositories
The e2e suites for assignments, places, bookings, trips, the public API,
doc sync, addons, transit, to-dos, admin, invite links, school holidays,
categories, feeds and plugin search seeded and inspected their database
with raw better-sqlite3 statements. They now go through the ORM
factories and readers, the way the repositories write and read.

Six of them (public API, transit, to-dos, admin, invite links,
categories) still built a handful of hand-rolled tables. They move to
the migrated snapshot, since an entity reads and writes every mapped
column; where the real schema keeps usernames unique, the seeded users
get distinct names and keep the ones the assertions read back. The dead
canAccessTrip stubs left in two database mocks are dropped: nothing in
src imports it from db/database any more.
2026-10-08 18:45:20 +02:00
Maurice ddf97c5a21 test(server): seed the MCP tool specs through the repositories
The MCP tool suites seeded trips, places, bookings, packing, vacay and
settings rows and read back what a tool call left behind through raw
better-sqlite3 statements on the sync handle. They now go through the
ORM factories and readers in tests/helpers/factories, so the rows are
written and read the way the repositories do it.

Every assertion keeps its meaning: a missing row that read back as
undefined now reads back as null, and a partial SELECT is replaced by
the same fields picked off the full row.
2026-10-08 18:45:20 +02:00
Maurice 1bba65634c test(server): seed and read test data through ORM factories
The server suite seeds and reads its database through some 5,500 raw
better-sqlite3 statements on the sync handle. They restate the schema by
hand, skip the repositories the app goes through and run on SQLite only,
so nothing in the suite could ever exercise the dialect layer on another
engine.

tests/helpers/factories/ adds typed factories per domain (users, trips and
days, places, assignments, bookings, budget, packing, to-dos, files,
journeys, tours, collections, notifications, settings, plugins, tokens,
atlas, vacay, photos, collab) and generic readers (findRow, findRows,
countRows, updateRows, deleteRows) that go through MikroORM in a request
context of their own, against the app's ORM or a createTestOrm handle.
They refuse a write to a read-only foreign-key twin, which MikroORM would
otherwise drop without a word. The README there is the conversion guide.

The categories integration suite and the tags and calendar-feed e2e suites
are converted in full as worked examples; the tags suite also moves from
hand-rolled tables to the migrated snapshot.

lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left
to a per-file baseline: growth and stale entries fail, files whose subject
is raw SQL are exempt with a reason, and a single statement can be allowed
with a test-sql-allow comment. CI runs it next to lint:boundaries.
2026-10-08 18:44:39 +02:00
Maurice be751ad521 fix(auth): start no new session for a password change over a Bearer token
A password change ends every session of the user and re-issues one for
the device it was made on, handed out as the session cookie. A caller
that authenticated with a Bearer token never reads that cookie, so the
new session sat in the session list as a live device nobody held until
it expired.

The controller now asks for the replacement session only when the
request came with the session cookie, the same check the sliding
renewal makes. The response body is unchanged.
2026-10-08 18:44:08 +02:00
Maurice 648f63c626 fix(sessions): keep an ended session's row until it expires
A session renewed from a token issued before sessions were tracked has
an id derived from that token, and the renewal inserts it only when no
row by that id exists. The nightly purge deleted revoked rows straight
away, so the next request with the still valid old token inserted the
session again as an active one and got a fresh tracked token with a new
lifetime. A logout, an ended session or a sign out of the others held
only until the purge ran.

The purge now deletes expired rows only, and a revoked row stays until
its own expiry. A derived row is recorded with an expiry no earlier
than the old token's, so by the time it goes the old token is refused
by its signature check anyway.
2026-10-08 18:44:08 +02:00
Maurice e94525afd3 test(sessions): cover every path that ends a session end to end
The session e2e suite drove login, logout, the session routes, a
password change and renewal, but nothing showed end to end that a
password reset, an MFA disable, account deletion or an admin action
makes the old cookie stop working, the purge job had only a unit test
with a stubbed service, and reset-admin.js had no test of its session
handling at all.

sessions.e2e.test.ts now also resets a password through the mailed
token (both devices get 401, every row is ended, the new password signs
in), turns two-factor off from one device (the other gets 401, this one
stays), deletes the account (the rows go with it) and runs
SessionPurgeJob.tick against the real table (ended and expired rows go,
the live session keeps working). The admin paths need AdminModule and
with it most of the app, so session-revocation.e2e.test.ts boots
buildApp(): an admin setting a password, clearing two-factor and
deleting a member each sign both of the member's devices out and leave
the admin signed in, while an edit that leaves the password alone signs
nobody out. reset-admin-sessions.test.ts runs the script against a
database with session rows (only the reset account's live ones end) and
against one without the table.
2026-10-08 18:44:00 +02:00
Maurice 0dde01baa2 fix(oidc): sign in after SSO on a success flag, not the deprecated token
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.
2026-10-08 18:44:00 +02:00
Maurice b872bb48a6 refactor(auth): check sessions through SessionsService, not its repository
The sessions domain owns user_sessions, yet AuthService injected
UserSessionsRepository next to SessionsService and AuthModule registered
the entity in its own forFeature, only so verifyJwtToken could hand the
repository to verifyJwtAndLoadUser.

verifyJwtAndLoadUser now takes a SessionLookup, the two repository
methods the session check uses (findActive, touchLastSeen).
SessionsService answers both, so AuthService passes itself through the
sessions domain's API and drops the repository and the forFeature entry.
The guards keep handing in the repository from their own entity
manager, which satisfies the same type. auth.service.ts lost lines, so
its size baseline goes down with it.
2026-10-08 18:43:50 +02:00
Maurice ac89895978 fix(sessions): renew a token from before tracking into one session
A token without a jti past half its life is renewed on every request
its cookie authenticates, and each renewal minted a session with a
random id. A first page load sends many requests at once, so one browser
ended up with several active sessions; only the last cookie survived,
and the rest showed up in GET /api/auth/sessions as separate devices
until they expired, up to 30 days for remember me.

The interceptor now hands the token to SessionsService.renew, which
derives the session id from it (a SHA-256 of the token, laid out as a
version 8 UUID so it passes the same id check) and inserts the row only
if it is not there yet. Every renewal of the same token lands on the
same session; if that session was ended in the meantime nothing is
renewed, just as for a tracked token. A caller that passes no token gets
the old behaviour.
2026-10-08 18:43:42 +02:00
Maurice 1ad6ae0ebc fix(auth): keep demo visitors out of each other's sessions
On a demo instance every visitor signs in as the one shared demo
account, so its sessions are other people's browsers. The session
routes had no demo check, unlike the password, MFA and account
changes, so any visitor could read the User-Agent of everyone else
signed in and sign them all out with revoke-others.

For the shared demo account in demo mode, GET /api/auth/sessions now
lists only the session the request came with, and DELETE
/api/auth/sessions/:id and POST /api/auth/sessions/revoke-others answer
403 "Sessions cannot be ended in demo mode." without ending anything.
Logout still ends the visitor's own session. Other accounts on a demo
instance keep the full routes. No MCP tool covers sessions, so there is
no parallel tool to change.

The demo sign-in now records the device's User-Agent on its session
like every other sign-in, which the session list needs to tell the
visitor's own entry apart.
2026-10-08 18:43:42 +02:00
Maurice 474beb0dfb fix(sessions): carry the signed-in sessions across a demo reset and a restore
Both swap the whole database file. The demo reset copies a baseline
saved at first seed, before anyone logged in, so it brings back an empty
user_sessions table; a backup restore brings the backup's own rows, or
none for a backup from before sessions were tracked. A session token
whose row is missing is refused, so the hourly reset signed every demo
visitor and the demo admin out, and the admin who ran a restore got a
401 on the next request. Before session tracking only the password
version applied, and those tokens survived.

Both paths now read the active rows with their owner's email before the
swap and put them back after the reopen, in a fresh fork, for every
account the new file still holds under the same id and email. A row the
new file already has is left as it is, so a session that ended there
stays ended, and a session whose id now names somebody else (a backup
from another install) is dropped instead of handed to them. The password
version check still refuses a token the restored account no longer
matches. Reading or writing them is best effort and never fails the
reset or the restore.
2026-10-08 18:43:42 +02:00
Maurice 9aab9e7513 feat(auth): track sign-in sessions so a session can end before it expires
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.
2026-10-08 18:43:06 +02:00
Maurice 094e0b28f8 fix(booking-import): write an imported venue with its booking in one transaction
confirm() created the venue's place row in its own transaction and
broadcast place:created before the booking and its cost were written in
a second one. When that second write failed, the item was skipped but
the trip kept a place no booking pointed at, and clients had already
been told about it.

Per item, the venue and endpoint geocoding, the day lookups and the
cost's frozen rate still run first, outside any transaction. The place
row, the booking and its linked cost then go into one transaction
through the new ReservationsService.createWithCostInTx, the core that
createWithCost itself now wraps. place:created, reservation:created,
accommodation:created, the stay mirror and the cost events are sent
only after the commit. Each item keeps its own transaction, so a
failing item still leaves the others imported; confirm() is tagged
@txIndependent for that and leaves the lint:tx baseline.
2026-10-08 18:42:10 +02:00
Maurice 9cebadf67c fix(memories): save the Immich connection and auto_upload in one transaction
PUT /api/integrations/memories/immich/settings saved the connection through
one service call and then wrote auto_upload through a second one from
the controller, each committed on its own. A failure between them left
a new connection with the old upload choice.

saveImmichSettings takes the auto_upload flag and writes it with the
connection (or the disconnect) inside one transaction, after the SSRF
check that does the DNS lookup. An absent or non-boolean flag still
keeps the stored choice, a refused URL still writes nothing, and the
response is unchanged. The controller's write sequence leaves the
lint:tx baseline, and the MemoriesService facade loses the
immichSetAutoUpload pass-through it no longer needs. No MCP tool writes
these settings.
2026-10-08 18:42:10 +02:00
Maurice f40d92a69e fix(trips): write the budget currency rebase with the trip row
TripsService.update rebased the budget onto the new currency in its own
transaction and only then wrote the trip row in a second one. When the
day grid rebuild, the booking resync or the leave shift threw after the
rebase had committed, budget items and settlements stayed pinned and
re-frozen against a currency the trip never took (#1543).

rebaseTripCurrency is split into prepareCurrencyRebase, which fetches
the rates before any transaction opens, and applyCurrencyRebase, which
reads the outgoing currency and writes the rebase. update() fetches
first and hands the plan to updateTrip, which applies it inside the
transaction that writes the row, the leave shift and the day grid.
updateTrip called without a plan (the plugin RPC host) still never
rebases, and rebaseTripCurrency keeps working as before.

The updateTrip docstring no longer claims the row is written outside
the day regeneration transaction. TripsService.update leaves the lint:tx
baseline, and the size baselines shrink with the files.
2026-10-08 18:42:10 +02:00
Maurice 615a0b14f5 fix(reservations): roll a booking write back when its linked cost fails
createWithCost and updateWithCost run the booking and its cost in one
transaction, but the cost sync still caught and logged its own errors
inside it. A price that could not be written therefore committed the
booking edit and left the linked expense on its old figure; only the
category re-file path actually rolled back.

The sync no longer swallows a failing cost write. Both WithCost methods
are its only callers, so the error now reaches their transaction and
the booking and its cost commit or fail together. A booking import
item whose cost cannot be written is skipped like any other failing
item instead of landing without its cost.
2026-10-08 18:42:10 +02:00
Maurice 2fb699189e test(journey): add gallery and entry photos concurrently
uploadGalleryPhotos and addPhoto read MAX(sort_order) inside the transaction
that inserts, but the only gallery test ran its uploads one after another and
would pass without that. These cases run several uploads, and several photos
added to one entry, at once and expect consecutive positions in the gallery
and on the entry, as JOURNEY-SVC-030b does for entries.
2026-10-08 18:42:09 +02:00
Maurice c7feaf0a65 fix(reservations): link a cost created on a booking update on its insert
syncBudgetOnUpdate created the expense and then set its reservation_id in a
second statement, inside a try that logs and swallows the failure. Running
inside updateWithCost, a failed back-link committed an expense that was not
linked to its booking. The expense is now created through
linkBudgetItemToReservation, which writes the link on the insert, the same
way syncBudgetOnCreate does.
2026-10-08 18:42:09 +02:00
Maurice b84867b4c2 fix(mcp): update bookings through updateWithCost like REST and the plugin RPC
update_reservation and update_transport accept a type but still called the
bare ReservationsService.update. Only updateWithCost re-files a linked expense
that still carries the category derived from the old type, so changing a
booking from flight to train over MCP left its expense filed under flights
while the same change over REST or RPC moved it. Both tools now call
updateWithCost without a price entry, and send the cost events and the
accommodation change after the commit, as REST does.
2026-10-08 18:42:09 +02:00
Maurice 36a0d1eaba fix(auth): erase plugin user data in the transaction that deletes the user
erasePluginUserData deleted the host-side plugin rows and then queued the
plugin erasures as separate commits, and deleteGuest ran it before its own
transaction. When the guest delete failed, the guest kept its account but had
lost its plugin rows, with erasures queued for a user that still exists. The
erasure is now one transaction, and deleteGuest runs it inside the same
transaction as the budget re-split and the user delete, so it becomes a
savepoint of that write. Only DB writes and a synchronous readdir run inside.
2026-10-08 18:42:09 +02:00
Maurice b7e13e4af6 fix(booking-import): write an imported booking with its cost in one transaction
Confirming an import created the booking, then froze the FX rate and created
the linked cost in a second commit, so a failed cost write left the booking
without its expense. REST, MCP and the plugin RPC already go through
ReservationsService.createWithCost. The import now builds the cost entry and
freezes its rate first, outside the write since that can fetch rates, then
calls createWithCost and announces the cost after the commit. A rate that
cannot be frozen keeps the booking without its cost, as before. The place a
venue becomes still commits on its own, with endpoint geocoding between it
and the booking, so confirm stays in the lint:tx baseline at 4.
2026-10-08 18:41:57 +02:00
Maurice 60a42cfac1 fix(airtrail): import a flight with its AirTrail link in one transaction
The import created the booking and wrote its external_* link as two commits.
A failed link left the booking in the trip while the import reported the
flight as skipped. The flights are fetched before the loop, so nothing between
the two writes waits on the network: each flight or joined connection now goes
in with its link in one transaction, and the method is marked as one
transaction per imported booking instead of being held in the lint:tx baseline.
2026-10-08 18:41:57 +02:00
Maurice 8018f10273 feat(server): gate writes outside a transaction with lint:tx
The rule that a multi-statement write runs in uow.transactional was
written down but nothing held it. scripts/tx-writes.mjs reads every class
under src/ with the TypeScript parser and counts, per method, the writes
it issues outside a transactional callback: repository calls that build
an INSERT, UPDATE or DELETE, calls to methods that write (on the class
itself or on a constructor-injected one), and a transaction holding
writes as one. A write in a loop counts twice, of two branches only the
larger counts, local helpers count where they are called, and a helper
whose every call sits inside a transaction is not reported. --explain
lists the line of every call that counted.

Today's 102 reported methods sit in scripts/tx-writes-baseline.json and
may only shrink; most are syncs with network I/O between their writes,
plugin lifecycle steps and adapters calling two service writes. A new
method fails CI, and an entry above what its method holds fails until
--update lowers it, the same ratchet as lint:size and lint:boundaries.

Two JSDoc tags mark the deliberate cases with their reason in the tag:
@txStandalone for a write that stands on its own whatever the caller does
(the audit row, notification sends, the booking price mirror, the
assignment journey refresh, the quota retention purge), @txIndependent
for writes complete one by one on purpose (the OAuth token retention
sweep, the notification claim and its release). Runs in CI after
lint:boundaries; documented in server/CLAUDE.md, the root CLAUDE.md and
the wiki's dev setup page.
2026-10-08 18:41:57 +02:00
Maurice 9dd2e1b983 fix(server): take MAX+1 positions inside the transaction that inserts
Days, todo items, packing items and bags, packing template categories and
items, collections, collection labels and road-trip vias took their next
position as MAX(...) + 1 in one statement and inserted in the next, with
any other request free to run in between: two adds at once read the same
MAX and landed on the same position (the road-trip test pinned exactly
that duplicate). The read now sits in the transaction that inserts, and
the transaction holds the connection, so the second add waits and takes
the next position. A collection label's limit and name check move into
the same transaction, so two labels of one name added at once end in one
label and a 409.

Each site gets a test that adds several rows at once and reads back one
position each.
2026-10-08 18:41:32 +02:00
Maurice e015e5e5e8 fix(server): run the remaining multi-statement service writes in one transaction
A sweep of the services for write sequences that committed step by step,
each one leaving half a change behind when the next statement failed:

- accommodations: the linked bookings' times moved after the stay's own
  transaction had committed
- airports: the boot backfill wrote a flight's two endpoints one at a
  time, and a flight left with only the first was never picked up again;
  each flight is its own transaction now
- auth: the admin settings form and a password reset (retire the old
  link, issue the new one) each land whole
- oauth: a refresh revoked the old pair before issuing the new one, so a
  failure in between logged the client out; rotating a client secret and
  revoking its tokens go together too
- oidc: the identity link, the role from the claims and the avatar of a
  returning user are one write
- addons, notification preferences: a settings save lands whole
- places: create with its tags, update, and the GPX/KML imports with
  their track colours
- trips: the trip row, the shifted leave entries and the rebuilt day grid
- vacay: a first visit provisions the plan in one transaction with a
  second look against a concurrent first visit; updatePlan fetches the
  public holidays between two transactions instead of inside its writes
- memories: a photo and its trip link, and the deletes with the orphan
  sweep behind them

AddonsService, AirportsService and OauthService take the UnitOfWork for
this. Network I/O stays outside every transaction. Comments that only
narrated the migration plan make room under the lint:size entries, which
drop with them.
2026-10-08 18:41:32 +02:00
Maurice f76538b2b2 fix(plugins): uninstall a plugin and record its install in one transaction
Uninstall deleted the plugin row and then up to fourteen tables of rows
about it one by one. A failure halfway left grants, scheduled tasks or
per-user settings behind for a later plugin that reuses the id to
inherit. The rows now go in one transaction, and the data directory is
removed after the commit instead of before the rows that describe it.

A registry install records the discovered row, its provenance, the
pinned author key and the cleared update block together, so a failure
never leaves a registered plugin without its pinned key. Starting an
OAuth connect drops the stale state and stores the fresh one together,
and a disconnect drops tokens and state together; PluginOAuthService
takes the UnitOfWork for that. The scheduler's per-task claims stay
separate on purpose and say so for the transaction gate.
2026-10-08 18:41:06 +02:00
Maurice f6076328fe fix(journey): create a journey with its owner, trips and photos in one transaction
createJourney inserted the journey, then its owner row, then linked each
trip with its skeleton entries and set the cover, every step committed on
its own: a failure halfway left a journey nobody owned. It now runs as
one transaction and broadcasts the linked trips after the commit.
addTripToJourney links inside a transaction the same way.

The photo paths wrapped only the gallery insert and left the entry link
and the skeleton promotion outside; the photo row, the gallery row, the
link and the promotion are one write now. removeTripFromJourney,
updateEntry, updatePhoto, setPhotoProvider, the photo deletes and the
place hooks write in one transaction each.

createEntry and uploadGalleryPhotos read MAX(sort_order) inside the
transaction that inserts, so two entries or uploads at once take one
position each. reconcileTripSkeletons and the photo capture helpers are
tagged as deliberately split for the transaction gate that follows.
2026-10-08 18:41:06 +02:00
Maurice 5cbd9e9d36 fix(reservations): write a booking and its linked cost in one transaction
A booking's create and update ran their own transaction, and the linked
cost sync ran afterwards from the REST controller and the plugin RPC
handler, so a failing cost write left the booking edited and its expense
on the old category or price. MCP linked the cost in a second call of its
own the same way.

ReservationsService.createWithCost and updateWithCost now run the
booking write and the cost sync in one transaction and hand the cost
events back, so REST, MCP and the plugin RPC call the same method and
broadcast only after the commit, in the order they did before. The cost
sync keeps swallowing its own failures the way the REST route always
has, so a cost that cannot be written still leaves the booking; MCP now
behaves like REST there instead of failing the tool after the booking
was already saved.

The service sheds its inventory markers (RS1..RS54, plan history) to
stay under its lint:size entry.
2026-10-08 18:41:06 +02:00
Maurice 465480630b fix(plugins): report a plugin SQL call done once its last statement has run
The time budget for an exec script was read after every statement, the
last one included. A single statement without args, or a script whose
closing COMMIT crossed the budget, had already run and committed, yet the
call threw a time budget error. A plugin that retries on error then wrote
the same rows a second time.

The script now reads the clock before each statement after the first, so
it still stops before the next statement once the time is up and rolls
back a transaction it opened, but a script whose statements have all run
is reported as done. query() likewise reads the clock only once another
row has come back, so a result read to its end is returned.

The ctx.db doc in the SDK said a single statement was not timed, which
contradicted the query budget. It now names what is really untimed: an
exec with bound args and migrate. README and wiki describe the script
stopping before its next statement.
2026-10-08 18:41:02 +02:00
Maurice d3be6fb2b8 fix(plugins): hold exec scripts to the time budget between statements
The time budget covered query() and tx() only. exec() without bound args
handed the plugin's script to Database#exec, which runs any number of
statements in one call with nothing checked between them, so a long
script held the host loop as long as it liked.

An exec script now runs one statement at a time. sql-script.ts cuts it
after every semicolon outside a string, a quoted identifier or a
comment; a piece SQLite reports as incomplete input (a trigger body cut
at its inner semicolon) is joined with the next until it prepares. The
clock is read after every statement, and past the budget the call
throws. As with Database#exec, the statements before that point stay
applied, and an error is SQLite's own; a transaction the script opened
itself is rolled back so the connection is not left inside it.

exec() with bound args is one statement and stays as it was. A single
statement cannot be bounded at all on the host thread, a write such as
INSERT ... SELECT over a cross join included, and the SDK README and the
wiki now say so next to the cross-join read. migrate() keeps running
unbudgeted on purpose: a migration stopped part of the way is rolled back
and fails the same way on every later start, leaving the plugin unable to
update its schema.

The ctx.db rows of the SDK README and the plugin wiki pages describe the
budget for scripts, and the lines touched there and in plugin-sdk's db
comment lose their em dashes.
2026-10-08 18:41:02 +02:00
Maurice 9dec36995e feat(plugins): extend the output allowlist to child rows and whole-row read models
The output contract cut only the top level of an entity result. A derived
key's value went out untouched, so the children that are whole rows still
carried every column: a day's notes_items are full day_notes entities and
a reservation's endpoints are SELECT e.* on reservation_endpoints. About
fifty methods were also left as readModel and passed through whole, many
of them backed by SELECT * reads (day_accommodations, day_notes, todos,
tags, packing bags, journal entries, collab, collections, vacay plans).
A new column on any of those tables reached every plugin by default,
which is what the contract was meant to stop and what the SDK README and
the wiki already claimed it did.

PLUGIN_ENTITY_NESTED now names, per entity, the derived keys that hold
rows of another entity, and the shaper cuts those rows down to their own
field list, recursively. dayNote and reservationEndpoint are the child
tables that were whole rows; day.assignments and place.tags are held to
the assignment and tag lists too, although their reads already name
their columns. The remaining derived children (travelers, participants,
ratings, budget members, payers and receipts, packing recipients,
attachments, voters, bag members) are built field by field, which the
contract comment now states as the rule for a derived key without a
nested entity.

The whole-row read models get entity contracts: accommodation, dayNote,
todo, tag, category, packingBag, fileLink, collabNote, collabPoll,
collabMessage, journey, journalEntry, bucketItem and collection, and
vacayPlan with its holiday calendars. collections.listMine,
collections.get and vacay.mine return envelopes around rows; an entity
with a null table describes those, so their rows are reached through the
same nesting. Every list holds exactly the keys each read returned
before, so no plugin loses a field. What stays readModel is built field
by field from named columns, and each such entry says what it returns:
setBagMembers, atlas.visited, collections.savePlace and copyToTrip, the
two vacay toggles and journal.addEntryPhoto.

The schema test now covers every child and new table, and calls each
newly shaped method on the real services, comparing nested rows key for
key so the only field allowed to disappear is a withheld one. A further
case proves the nested rows it compares are not empty. gen:plugin-facts
publishes PLUGIN_ENTITY_NESTED to the SDK, and the SDK README, the plugin
wiki pages and server/CLAUDE.md describe the nested rows and list the new
entities.
2026-10-08 18:41:02 +02:00
Maurice 0c9e1cd639 fix(plugins): hold plugin SQL to a time budget between rows
A plugin's own database runs on the host's event loop, and the only bounds
were a regex denylist, the 256 MB quota and a 100,000 row cap. A select
whose every row is expensive, or a long scan under the cap, could hold the
loop for as long as it liked.

query() and tx() now get a wall-clock budget of 2 s, read between the rows
a statement yields and between the statements of a batch. Past it the call
throws, and a tx rolls back whole like any other failure inside it. The
row cap and the budget are overridable per PluginDataDb for tests; the
defaults are what production runs.

better-sqlite3 has no interrupt or progress handler, so a statement that
yields nothing until it is done, such as an aggregate over a cross join,
still runs to its first row. Only moving plugin SQL off the host thread can
bound that; the SDK README and the plugin wiki state the budget and this
limit.
2026-10-08 18:41:01 +02:00
Maurice 95f9f031c4 feat(plugins): cut entity results down to a published field list
The entity reads of the plugin RPC surface handed plugins whatever their
query produced, mostly SELECT t.*, with credentials stripped by a denylist
(withoutFeedToken). Every column a migration added reached every plugin
holding the read grant, and renaming one was an unversioned break of a
published API.

protocol/output-contract.ts now states, for every wire method, what its
result is: rows of one of the nine entities the SDK types (trip, place,
day, reservation, packingItem, tripFile, budgetItem, assignment, user), a
value the host builds itself, or a read model passed on as the REST route
returns it. The router applies it to each result on the way out, so an
entity row keeps exactly the fields plugins received when the list was
drawn, in the same order and with the same values, minus the trip's
feed_token (listMine, create and update used to carry it as null). The
table is keyed by every method, so a new one does not compile without an
entry.

output-contract.schema.test.ts holds the lists to the migrated schema: a
new column on a whole-row table fails until it is published or withheld,
and each entity method is called on the real services to prove the
contract drops nothing but the withheld fields.

gen:plugin-facts publishes the lists to the SDK as PLUGIN_ENTITY_FIELDS
and PLUGIN_METHOD_RESULT, and the SDK's entity interfaces are type-checked
against them. Place.day_id, which no read ever delivered, is deprecated
rather than removed. KNOWN_ADDONS is generated from ADDON_IDS as well,
which adds the dawarich, roadtrip and tours ids the hand-kept copy lacked.
The SDK goes to 1.9.0, and its README and the plugin wiki pages describe
what a ctx call returns.
2026-10-08 18:41:01 +02:00
Maurice 8c6157aa46 test(server): pin the request id through the body parsers
A handler behind express.json() is reached from the request stream's 'end'
event, long after the correlation middleware returned, so it looked as if
every write lost its id. It does not: raw-body and multer bind their
callbacks with AsyncResource at the point they are called, which is inside
the request's correlation. Nothing tested that, though, because every
REQID case was a GET. REQID-007 posts a JSON body and REQID-008 one that
arrives in several chunks, and both expect the handler to see the id the
response carries. The middleware comment now says why this holds.
2026-10-08 18:41:01 +02:00
Maurice aa8d9b4037 refactor(server): inject the transit base URL and the kitinerary paths as config tokens
The ownership rule says a boot-stable value that only Nest providers read
belongs to a registerAs token, yet TransitService froze TRANSIT_API_URL in a
module-top readEnv() const and KitineraryExtractorService read
KITINERARY_EXTRACTOR_PATH and PATH through readEnv() in its probe. Both now
come from tokens: transitConfig and kitineraryConfig, derived in
boot-derive.ts and dropped from deriveIntegrations, so readEnv() no longer
reads those variables at all. TransitModule and KitineraryExtractorModule
import AppConfigModule, which keeps them buildable on their own in tests.

Behaviour is unchanged: the same defaults, the same trailing-slash strip and
PATH split, now frozen per built app instead of at import. The app-config
README names each boot-stable variable that stays on readEnv() together with
the reader the container cannot inject into.
2026-10-08 18:41:01 +02:00
Maurice a6f635f3e0 docs(server): keep the managed-hosting switches out of the env reference
The env reference parity test forced TREK_MANAGED, MAPBOX_ACCESS_TOKEN,
CARTO_API_KEY and PLACES_API_BASE into server/.env.example with how-to text.
They are managed hosting only and stay out of the public docs, the same way
TREK_DB_FILE and APP_VERSION do, so they move to NOT_OPERATOR_SETTINGS with
that reason and their lines leave the self-hoster reference again. The
variables themselves and their behaviour are unchanged.
2026-10-08 18:41:01 +02:00
Maurice 558203213c fix(server): keep idle connections open longer than Traefik's upstream timeout
The 65 s default sat above nginx's 60 s but below the 90 s Traefik keeps an
idle upstream connection, so a default Traefik deployment could still reuse a
socket Node had just closed and answer that request with a 502. 95 s clears
both, and the headers timeout one second above it stays well under Node's
300 s request timeout and the 290000 cap the schema accepts.

Also moves the stray RawEnv import in boot-derive.ts up to the other import.
2026-10-08 18:41:01 +02:00
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00
Maurice fa0e6a3eae refactor(server): take the storage roots from DataPathsService
The data-directory change said Nest classes inject DataPathsService, but
only AdminService did. StorageRegistryService still read the built-in
uploads and backups roots and the scratch dir from module constants
frozen at import, and StorageJobsService read the scratch dir the same
way.

The registry now takes DataPathsService through its constructor (a
hand-built one resolves the same layout), and the jobs service asks the
registry for its temp dir. The plain helper modules keep calling
resolveDataPaths() because they run without a container or are shared
with code that does: config.ts, the database path, index.ts, the logger,
the backup functions and the plugin trees, which the out-of-process
supervisor reads too. The app-config README now lists both sides, and
the nest README, server/CLAUDE.md and the provider's own comment say the
same.
2026-10-08 18:40:52 +02:00
Maurice d1b461a37f docs(server): name HTTP_KEEP_ALIVE_TIMEOUT_MS as an httpConfig variable
The keep-alive change moved HTTP_KEEP_ALIVE_TIMEOUT_MS onto the httpConfig
token, but the app-config README still listed only TRUST_PROXY and
HSTS_INCLUDE_SUBDOMAINS for it and left the variable out of the
boot-stable list. Also drops the dashes from two lines the recent
scheduling and entity changes rewrote.
2026-10-08 18:40:37 +02:00
Maurice a72497b3c4 fix(server): trace a cron tick lost to another process as skipped
A tick whose lease another process held returned from runLeased without
running, and traceEntry still wrote `cron <name> ok`, so both processes
logged the tick as run and the per-tick line could not tell which
replica did the work. runLeased now says whether the tick ran, and
traceEntry takes a `skipped` option that logs such a call at debug as
`cron <name> skipped <ms>: lease held by another process`.

Taking the lease is two statements (make sure the row exists, then the
conditional UPDATE that decides the race), so it now runs inside
UnitOfWork.transactional like every other multi-statement write.
2026-10-08 18:40:37 +02:00
Maurice 06dfdc6943 refactor(server): put process-local state behind injectable stores
Rate-limit counters, OAuth pending codes, OIDC login states and codes,
the permissions cache and the WebSocket rooms were Maps on module or
instance scope. That is right while TREK runs as one process, but nothing
could be swapped without editing every consumer.

Each now sits behind a port with an in-memory implementation that keeps
the old behaviour exactly: RateLimitStore, PendingCodeStore,
OidcFlowStore, PermissionsCacheStore and RoomRegistry. The owning module
provides the implementation with the same lifetime the state had
(process-wide for the pending codes, the permissions cache and the rooms,
per instance for the rate limits and the OIDC flows), the consumers take
it through their constructor, and a hand-built consumer falls back to the
same in-memory store, so tests and the free-function paths (the backup
restore's cache flush, the broadcasts) see what they saw before. A store
shared between processes can replace a provider later without touching
the services.
2026-10-08 18:40:37 +02:00
Maurice 5cb87a4df5 feat(server): add last-resort error handlers, a draining readiness probe and proxy keep-alive
Three small gaps in how the process behaves at its edges:

- An unhandled rejection or uncaught exception crashed the process with
  the stack on stderr only. index.ts now logs it to trek.log and runs the
  same orderly shutdown a SIGTERM gets, exiting with 1; a second fatal
  error during that shutdown exits at once.
- /api/health/ready kept answering 200 while a SIGTERM was draining the
  process, so an orchestrator sent traffic for another probe period. A
  shutdown now marks the new ReadinessService first (and Nest's own
  shutdown hook marks it too), and the probe answers 503 from then on.
- Node closes an idle keep-alive connection after 5 seconds while reverse
  proxies keep upstream connections for about a minute, which shows up as
  intermittent 502s. The server now keeps them for 65 seconds, configurable
  through HTTP_KEEP_ALIVE_TIMEOUT_MS (owned by the httpConfig token), with
  the headers timeout one second above.
2026-10-08 18:40:37 +02:00
Maurice c5bc6798a9 feat(server): take a database lease before a cron tick runs
waitForCompletion only kept a tick from overlapping itself inside one
process. Two processes on the same database (a rolling update's overlap,
a second replica, a dev container pointed at the same file) both ran
every job: two auto-backups, double syncs and retention sweeps, and
reminder runs racing each other.

A new migration adds scheduler_leases, and CronRegistrarService now runs
every registered tick through SchedulerLeasesRepository.acquire: one
conditional UPDATE moves the job's row to this process when it is free
(no holder, a lapsed holder, or this process already), so exactly one
process wins. A running tick renews its lease every 20 seconds, a crashed
holder frees the job after a minute, and a finished tick keeps the lease
for 30 seconds so a peer whose timer fires late does not run the same
tick again. The per-row reminder claims stay as they are; jobs need no
change of their own.
2026-10-08 18:40:15 +02:00
Maurice 8632f93c37 feat(server): correlate log lines with a request id per entry point
A 5xx stack and its access-log line were two unrelated lines, the
exception filter printed the stack with console.error and no request
context, and the MCP, WebSocket, plugin RPC and cron entry points logged
nothing per call. A self-hosted bug report could not be traced from a
failure back to the call that caused it.

Every HTTP request now runs under an id held in AsyncLocalStorage: a
well-formed incoming X-Request-Id is kept, anything else is replaced by a
UUID, and the response carries it in X-Request-Id. The logger tags every
line written inside a unit of work with `[http <id>]`. The exception
filter leaves a server-side failure on res.locals and the access log
writes the request line and the stack as one entry under that id; without
an access log watching the response it still prints the error itself.

MCP tool, resource and prompt calls (through a new `around` seam on the
registry), WebSocket messages, plugin RPC dispatches and cron ticks each
run through traceEntry(), which gives the call its own id (remembering the
outer one as its parent) and logs one line for it: debug on success, like
the HTTP access log, and warn or error with the reason on failure. The
call's result and timing are unchanged, a failed cron tick is logged once
rather than twice, and a WebSocket handler rejection no longer reaches
rxjs's asynchronous rethrow.
2026-10-08 18:40:15 +02:00
Maurice a82cb3adbb perf(server): write the file log in batches off the event loop
Every log line ran existsSync, statSync and appendFileSync on the event
loop before it returned. On a slow data volume (an SMB or NFS bind mount
on a NAS) that stalled every request that logged, and rotation renamed
files without checking whether another process sharing data/logs had
rotated already.

The file half of the logger is now BufferedLogFile: a line is queued in
memory and the queue goes out with one appendFile per batch, through a
single serial chain, so no append runs while files are being renamed. The
files and the rotation are the same as before (trek.log rotates to .1 at
10 MB, five files in total), but the size is re-read before a rotation
and a file another process already rotated is left alone. A stalled disk
costs dropped lines with a count instead of unbounded memory, a failed
write is reported instead of thrown, and index.ts writes whatever is
still queued synchronously when the process exits.
2026-10-08 18:40:15 +02:00
Maurice ba1504d6e9 refactor(server): give every environment variable one config owner
Eight registerAs tokens existed while every consumer read the same values
through readEnv() instead, so the boot-stable layer the repo rules describe
was unused and nothing said which path owned a variable. FORCE_HTTPS, for
instance, was frozen by the middleware through the http token and read live
by the cookie code at the same time.

A variable is now owned by exactly one side. TRUST_PROXY and
HSTS_INCLUDE_SUBDOMAINS belong to httpConfig, which buildApp() already
injects into the pre-init middleware, and TREK_PLACE_PHOTO_DIR to the new
storageConfig, which the storage registry now injects instead of reading it
through RuntimeEnvService. Their derive functions moved to boot-derive.ts,
which deriveAll() never calls. The six tokens nothing injected are gone:
their values are needed before the container exists (config.ts, the
database, src/mcp, the SSRF guard) and stay on readEnv().

config-ownership.test.ts records the keys each side reads through a Proxy
and fails on a variable read through both, on a token nothing injects and
on a variable the schema does not validate. env-reference.test.ts holds
server/.env.example to the schema, which had drifted by 34 variables (SMTP,
WebAuthn, plugins, MCP session tuning, TREK_MANAGED and others); they are
documented now. jwt-verify.ts no longer cites a middleware file that is
gone.
2026-10-08 18:40:15 +02:00
Maurice 5c054863b9 refactor(server): resolve the data directory in one place
The data and uploads directories were derived independently in about ten
files, each counting `..` hops from its own `__dirname`, and the file
logger used `process.cwd()` instead. Moving a file or starting the server
from another directory quietly moved where secrets, backups or logs went,
and the backup code named `data/travel.db` directly, so with TREK_DB_FILE
set a backup archived and a restore replaced a file the connection never
opened.

`resolveDataPaths()` in app-config now anchors data/, uploads/, backups,
tmp, logs, the JWT secret and encryption key files and the database file
(TREK_DB_FILE when set, else data/travel.db) on the server package root.
Code that runs before the container calls it directly; Nest classes inject
the new `DataPathsService`. Every previous anchor uses it, and the resolved
paths are unchanged for the image and for `npm run dev`.
2026-10-08 18:40:15 +02:00
Maurice c84d0efb1a ci(server): hold unmeasured methods and only SQL errors to the probe baseline
The Postgres probe only ratcheted the methods it managed to call. A method
it could not build arguments for, one that threw before its first
statement and one that timed out were counted and logged but held to
nothing, so a new repository method with a callback parameter could land
SQLite-only SQL unseen, and the part of the code the probe reaches could
shrink without anyone noticing.

pg-probe-baseline.json gains an `uncovered` list of those methods. A
method outside it that goes unmeasured fails the run, and so does an
entry whose method is measured now or gone, so the list only shrinks.
It is seeded from the same first measurement as `failing`; a baseline
without the key reads as unmeasured.

The failing count also counted every refusal, including data errors the
sample values cause (a "probe" string in a date column, SQLSTATE 22007),
so renaming a parameter could fail the gate on a change that never
touched the SQL. Only classes 42 and 0A count now; the rest is reported
in the log and the step summary without being held.

The docs said the probe measures every repository statement. They now
say it measures what each method reaches with sample arguments against
empty tables, and that the methods it cannot reach are held to the list.
2026-10-08 18:39:34 +02:00
Maurice 12bf6c0da3 ci(server): fail the Postgres probe on an unmeasured baseline
The probe shipped with scripts/pg-probe-baseline.json at `failing: null`,
and an unmeasured baseline only reported. Every repository statement
Postgres refused, new SQLite-only SQL included, still passed the job;
only the dialect helper cases could fail it, so the ratchet was off until
some later commit seeded the file.

An unmeasured baseline now fails the run outside --update, and the log,
step summary and annotation say which artifact file to commit. The job
stays red until the first measurement lands, so the gate cannot reach dev
holding nothing. A run that called no repository method (an unreadable
tsconfig, a moved repositories directory) fails as well, also under
--update, so a broken plan can neither pass nor seed an empty baseline.
2026-10-08 18:39:34 +02:00
Maurice b8144bb92a ci: probe the server's SQL against a Postgres service
Nothing in CI ran a statement against anything but SQLite, so the claim
that the dialect layer keeps the repositories portable was never measured
and new SQLite-only SQL could land unseen. The new postgres-probe job
(server/scripts/pg-probe.ts, npm run probe:pg) does that against a
postgres:17 service container:

- it derives a Postgres schema from the entities with MikroORM's schema
  generator, keeping TEXT timestamps, mapping the SQLite-only defaults and
  the NOCASE collation, and leaving foreign keys out;
- it runs every dialect helper on an in-memory SQLite and on Postgres
  against the same expectation, and every case has to pass;
- it calls every public repository method once with arguments the
  TypeScript checker builds from the parameter types, through a Postgres
  driver that runs each statement alone in a rolled-back transaction with
  a statement timeout and records whether Postgres accepted it.

The statements Postgres refuses are held per method to
scripts/pg-probe-baseline.json, which may only shrink. The baseline starts
unmeasured (failing: null): the job reports, passes and uploads the first
measurement as the pg-probe-baseline artifact, together with a JSON report,
so the commit that seeds it and every later commit that lowers it needs no
local Postgres. The probe refuses a database holding tables it did not
create.

@mikro-orm/postgresql joins the server's devDependencies at the 7.2.1 the
other MikroORM packages resolve to; the image's production install leaves
it out. The job needs the preflight gates like every test job, runs only
for the code area, stops after ten minutes and is part of ci-ok.
2026-10-08 18:39:34 +02:00
Maurice 4fa2f3dc8b feat(db): give every dialect helper a Postgres branch
The dialect layer promised that a second engine would be a branch in
src/db/dialect/ and no sweep through the repositories, but every helper
only knew SQLite and threw for anything else. Each one now has a Postgres
branch, and the helpers whose text both engines read alike (COALESCE,
LOWER, TRIM, substr, ||, the aggregates, the CASE shapes) share one
spelling behind isKnownPlatform.

The Postgres branches keep the storage model SQLite has: the clock helpers
render UTC as the same YYYY-MM-DD HH:MM:SS text (to_char over CURRENT_TIMESTAMP
AT TIME ZONE 'UTC'), GLOB becomes an anchored regular expression, the integer
cast truncates through numeric so '14.0' still reads as 14, JULIANDAY
distance comes from the epoch difference, SQLite's null-safe IS becomes IS
NOT DISTINCT FROM, and NOCASE names a nondeterministic ICU collation a
Postgres schema creates. Bound values on the Kysely side are cast to text
where pg would otherwise send an untyped parameter the planner cannot
resolve.

The SQLite branches are untouched, so nothing changes for a running
install. The Postgres text is pinned in sql-functions.postgres.test.ts.
2026-10-08 18:39:34 +02:00
Maurice 53d9ec5678 refactor(db): move the Kysely dialect helpers into kysely-functions.ts
sql-functions.ts sat at 1007 lines, held there by the size baseline, so it
had no room for the Postgres branch every helper is meant to grow. The
Kysely-expression twins are a separate family already (a Kysely Expression
and a MikroORM RawQueryFragment do not convert into each other), so they
move to src/db/dialect/kysely-functions.ts, and the platform check plus the
fail-closed unsupported() move to platform.ts, which both files share.

No helper changes behaviour or spelling. lint:dialect now skips the whole
src/db/dialect/ directory instead of the one file, and the size baseline
drops its sql-functions.ts entry.
2026-10-08 18:39:34 +02:00
Maurice 1d6719347b build(server): count the bare datetime name the query builder takes
The datetime rule only matched a literal holding a call such as
datetime('now'). The query builder spells the same function as
eb.fn('datetime', [...]), where the literal is just the name, so that form
slipped through while strftime, json_extract and glob are caught bare. A
literal that is exactly datetime, julianday or unixepoch now counts as well,
the same way the glob rule treats a bare glob. The only such call lives in the
dialect layer, which is not scanned, so the baseline does not change.
2026-10-08 18:39:34 +02:00
Maurice 6ef6a7efd2 refactor(db): drop the unused raw document provider seeder
seedDocumentProviders() had no caller left: the migration that creates the
tables and DocumentProviderSeeder both import only the DOCUMENT_PROVIDERS and
DOCUMENT_PROVIDER_FIELDS constants. Its two INSERT OR IGNORE statements were
the only SQLite-only spellings in the file and had been baselined rather than
removed. Deleting it lets the file leave the dialect baseline (13 hits down to
11) and the better-sqlite3 import allowlist in eslint.config.mjs, since nothing
in it touches the driver any more. DOCUMENT_PROVIDER_SEED_IDS goes with it, it
had no importer either.
2026-10-08 18:39:34 +02:00
Maurice 6d45a26e46 docs(server): write down the SQL dialect rules
The repositories already lean on the query builder, the generated table
types and sql-functions.ts, but the rules behind that were only implied.
server/CLAUDE.md now states them: which builder call replaces each SQLite
spelling, which helper covers time, strings and casts, where PRAGMA may live,
how a new migration is treated, and that the generated types describe the
SQLite storage model.

It also says plainly that MySQL and MariaDB are out of scope. The
repositories rely on INSERT ... ON CONFLICT and RETURNING, which MySQL lacks,
so Postgres is the second engine the code is prepared for.
2026-10-08 18:39:34 +02:00
Maurice 39bb8f9619 build(server): hold SQLite-only SQL spellings to a shrinking baseline
ESLint keeps raw SQL out of the repositories, but nothing looked at the rest
of src/: the entities, the domain services, the src/db lifecycle files or a
migration added from now on. scripts/dialect-lint.mjs (npm run lint:dialect)
reads every string and template literal under src/, skipping comments, and
counts INSERT OR IGNORE/REPLACE, datetime(), strftime, json_extract, GLOB,
last_insert_rowid, AUTOINCREMENT, PRAGMA outside the lifecycle files, ||
concatenation in SQL and .insertId reads. scripts/dialect-baseline.json
holds today's thirteen hits; the counts may only go down, like lint:size.
The dialect layer is not scanned, and neither are the migrations that have
shipped.

The four seeders that still spelled INSERT OR IGNORE (addons, photo and
document providers, the schema_version marker) are Kysely inserts that do
nothing on conflict now, so a re-run still leaves an operator's rows alone.
They leave the SQL-string exemption list in eslint.config.mjs and their pins
in the selector test.
2026-10-08 18:39:33 +02:00
Maurice c524454f47 refactor(db): read new ids with RETURNING instead of insertId
Kysely fills InsertResult.insertId from SQLite's last_insert_rowid. On
Postgres it is undefined, so the sixteen repository inserts that returned
Number(result.insertId) would hand NaN ids to their services without an
error. They now add .returning('id') and read the id from the row, which
SQLite (3.35 and later, bundled by better-sqlite3) and Postgres both answer
with the same value.

DemoRepository ran its example-trip seed as nine SQL strings through
connection.execute, including INSERT OR IGNORE and a bare CURRENT_TIMESTAMP.
Those are Kysely inserts on the generated table types now: ON CONFLICT
(trip_id, user_id) DO NOTHING for the membership row, the dialect helper for
the timestamp, RETURNING id where the caller needs it. With no SQL text left
it drops off the ESLint exemption list and its pin.

ESLint now refuses any read of insertId in src/db/repositories and
src/db/seeders, so the pattern cannot come back.
2026-10-08 18:39:33 +02:00
Maurice 5f17a5df50 refactor(db): generate the Kysely table types from the migrated schema
The repositories typed their Kysely statements against 159 table interfaces
written by hand, one or more per repository. Nothing compared them with the
schema: a migration that renamed a column or changed its type left them
stale while tsc stayed green, and several had already drifted
(reservations.accommodation_id is TEXT, not INTEGER).

scripts/generate-db-types.ts migrates a throwaway database with the real
migrations, reads every table back and writes one interface per table into
src/db/kysely/ plus the DB map in db.ts. Every hand-written interface is now
Pick<DB, ...> of that type, and npm run check:db-types fails when the
committed files no longer match the migrations.

A column an insert may leave out (rowid, DEFAULT, nullable) is
InsertOptional<T>, declared as ColumnType<T, T | void, T>. The server
compiles without strictNullChecks, where Kysely's own Generated<T> still
demands the column; that is why the old code kept separate insert-only
interfaces. The ids callers pass as number | string are cast to the column
type at the bind, so every statement binds the same value as before.
2026-10-08 18:39:33 +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 2d2d17619d docs(offline): narrow the rollback promise for newer-format writes
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.
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 3e1234112d fix(demo): save the first baseline after a reopen that seeded too
The demo seed runs in the schema bootstrap, at boot and again after every
reopen. Since the baseline save moved out of the seed into DemoResetJob,
only the boot read the seed's mark. A restore onto a demo instance with no
baseline, whose archive held no admin trips, seeded the example trips
during the reopen and then saved nothing, so the hourly reset kept logging
"No baseline found"; the mark also stayed set for the rest of the process.
Before the move, that path saved the baseline from inside the seed.

DatabaseLifecycle now runs listeners after a reopen's schema bootstrap,
and DemoResetJob registers one in demo mode that does the same check as at
boot: save through the backup port when the seed just ran and no baseline
exists. Each check clears the mark. A failed save is logged, never turned
into a failed reopen. AdminModule imports DatabaseLifecycleModule from the
shared database kernel for it.
2026-10-08 18:38:29 +02:00
Maurice 1dfc5dd8ea docs(server): say who constructs MaintenanceRepository
The DB paragraph said MaintenanceRepository is never built with `new` in
src/, but MaintenanceModule's factory does exactly that. Say what is
actually true: the module's factory is the one place that builds it, and
every consumer injects it.
2026-10-08 18:38:28 +02:00
Maurice b658b5e68d docs(server): say why the admin domain does not import DatabaseBackupModule
DatabaseBackupModule stays @Global, but its comment gave a vague reason.
The real one is concrete: the backup domain already reaches admin through
auth and settings, so importing the module from AdminModule would add a
domain cycle that lint:boundaries rejects. The module comment now says
that and points at the e2e harness that imports it by hand, and
AdminModule notes where its DATABASE_BACKUP comes from.
2026-10-08 18:38:28 +02:00
Maurice 175f5d96da fix(backup): report a lost connection when the swap and its reopen both fail
SqliteDatabaseBackup.replace reopens the connection in a finally block, so
it runs even when the swap throws. A reopen that failed there was stored
and then dropped: the swap's exception propagated past the return that
would have carried it. The demo reset caught that swap error, logged
"Failed to restore baseline" and returned normally, leaving the instance
without a database connection and nothing in the log to say so. Before
the port, the same branch awaited reinitialize() and its rejection reached
the tick's log line.

replace now throws a DatabaseConnectionLostError when both steps fail. It
carries the swap error and has the reopen failure as its cause, and its
message tells the operator to restart. The demo reset lets that error
through to DemoResetJob's tick, which logs it, and still logs and skips a
failed swap whose reopen succeeded. A restore that hits it answers the
same 500 as any other thrown restore error, with both failures in the
server log.
2026-10-08 18:38:28 +02:00
Maurice fd28ba7fd3 fix(demo): save the first baseline only after a real seed again
Moving the first baseline save out of the boot-time seed into DemoResetJob
also changed when it happened. The seed saved it only on the boot that put
the example trips in; a later boot returned early and saved nothing, so an
instance with data but no baseline logged "No baseline found, skipping" on
every hourly reset. The job instead saved one on any demo boot that found
no travel-baseline.db, freezing whatever the database held at that moment,
user edits included, as the target the hourly reset rolls back to.

The seed now leaves a mark when it seeds the example trips, and the job
saves the first baseline only when that mark is set and no baseline
exists, which is the old trigger. As before, the save does not depend on
the cron registrar being enabled. The demo mode wiki page says so.
2026-10-08 18:38:28 +02:00
Maurice 0aa5b36661 fix(backup): back up and restore through an injected database port
Backup, restore and the demo reset each handled the SQLite file
themselves: backup.impl closed and reinitialized the connection, built
MaintenanceRepository with new, and assumed the database was
data/travel.db. That tied the whole chain to one local file and hid two
bugs.

They now go through DATABASE_BACKUP, a port declared in the shared
database kernel (snapshot, verify, keep a copy before a restore, replace).
SqliteDatabaseBackup implements it with what the code did before:
VACUUM INTO for a consistent copy, and a close, copy, sidecar drop,
rename and reopen through DatabaseLifecycle for a restore. BackupService
injects it and hands it to backup.impl. The demo reset and the baseline
save use the same port; the first baseline is saved by DemoResetJob once
the app is up, because the boot-time seed runs before anything can be
injected. Another engine can plug in a logical dump behind the port.

Fixes on the way:

- The backup and the restore ignored TREK_DB_FILE. The backup looked for
  data/travel.db and, with the database elsewhere, silently left it out
  of the archive; the restore wrote data/travel.db and never replaced
  the live database. Both now use DatabaseLifecycle.file, the path the
  connection runs on, and the pre-restore copy sits next to it.
- A failed VACUUM INTO fell back to archiving the live WAL-mode file
  without a word, the torn copy the snapshot exists to prevent. The
  backup now fails: nothing is archived or committed, the existing
  backups are untouched, a partial snapshot is removed, and the log says
  why. A missing database file and a failed checkpoint are logged too.
2026-10-08 18:38:28 +02:00
Maurice 8ede0dab9e refactor(server): provide MaintenanceRepository through DI
MaintenanceRepository was built with new over an injected EntityManager
in the readiness probe and in UserCleanupService, outside the container.
MaintenanceModule now provides it, as a factory over the same
context-resolving EntityManager Nest injects everywhere, and both inject
it. The class itself stays free of Nest decorators, like the rest of
src/db, so tests keep building one over their own EntityManager.

No behaviour changes: the probe still pings and the erasure still runs
inside the account deletion's transaction.
2026-10-08 18:38:28 +02:00
Maurice 0a4c6fbace refactor(db): open the database through a DatabaseLifecycle provider
db/database.ts opened the connection as a side effect of being imported,
so the moment anything reached it the database was open. index.ts had to
defer loading the app until a first-boot restore was done, and nothing
in the container decided when the connection opened or closed.

The module now opens the connection on first use and opens nothing at
import. A connection closed on purpose (a restore, the shutdown) stays
closed until it is reopened, as before. The new DatabaseLifecycle
provider owns the decisions: buildApp() opens through it, which also
binds the ORM to later swaps (the hook attachOrm used to register) and
runs the schema bootstrap in the same order as before, and the shutdown
closes through it. Its file property is the one resolved database path.

The module keeps every export, so the test suites that replace it with
vi.mock keep working; buildDbMock gains the new openDb.
2026-10-08 18:38:28 +02:00
Maurice 5aa9ac8529 fix(client): compare the running flush with null instead of testing a promise
_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.
2026-10-08 14:53:13 +02:00
Maurice 67aff332a7 fix(i18n): translate photos.uploadN as the upload action in every locale
en now reads "Upload {n} photos", but ru, uk, fr and sv still said the
photos had already been uploaded, and the new ru and uk plural forms
copied that wording. ca, es and it used a status noun ("upload of",
"loading of") instead of the action, and ko put the counter in front of
the noun. All locales now name the same action, with each plural form
matching its Intl.PluralRules category.
2026-10-08 14:11:55 +02:00
Maurice e4f39bc8e7 test(server): pin what a send reports while push is unavailable
NotificationsService.send() now reports how many deliveries it attempted
and how many went out, so the reminder jobs can tell a failed send from a
sent one. WPDISP-008 and WPDISP-011 asserted that the send resolved to
undefined, which only meant "no error" and broke on the new result. They
now pin the counts: the in-app row and the webhook go out, and a push the
broken key pair cannot send is skipped rather than counted as failed.
2026-10-08 14:11:55 +02:00
Maurice 65ed033f5d docs(wiki): list the quality gates CI now runs in the dev setup page
CI lints the client with lint:warnings and runs the size, boundary, layering,
offline, skip, format, i18n key and open contract checks, but the dev setup
page still called the client lint:check the CI name and its pre-push list
stopped at typecheck and lint. Add the new scripts to the command tables and
spell out the server, client and shared quality steps from test.yml, so a
contributor who follows the page passes the same gates CI does.
2026-10-08 14:11:54 +02:00
Maurice c8b31b3f57 ci: skip the secret-dependent jobs on Dependabot runs
A run Dependabot triggers only sees Dependabot secrets, and its PRs come
from a branch in this repo, so the fork guard let s3-contract start with an
empty MINIO_LICENSE_KEY. AIStor then never became healthy and ci-ok went red
on every grouped bump PR. s3-contract and scan now also skip when Dependabot
is the actor; ci-ok already counts a skip as passed.
2026-10-08 14:11:54 +02:00
Maurice 306391c7d1 docs(server): count all five rules in the import boundary header
The header said four rules while listing and enforcing five, which also
disagreed with server/CLAUDE.md.
2026-10-08 14:11:54 +02:00
Maurice 3643696a11 ci: treat markdown under test and fixture directories as code
changed-areas skipped every gate for a change that touched only Markdown
outside wiki/. server/tests/fixtures/wiki/ is Markdown too, but wiki.test.ts
parses it, so a PR that only edited those fixtures passed ci-ok without the
test that reads them ever running.
2026-10-08 14:11:54 +02:00
Maurice 6e78342053 docs(wiki): name 5.0 as the release that ships the upgrade safety changes
The image tag rows already say the next release is 5.0.0 and that 4.x gets
no minor tag, while the rollback, email case and APP_VERSION notes still
pointed at a 4.4 that will never be published. Use 5.0 throughout so the
pages agree with each other and with docker.yml.
2026-10-08 14:11:54 +02:00
Maurice 809ec386fc ci(server): fail lint:size and lint:boundaries on stale baseline entries
Both server ratchets only logged an entry the tree had moved past. A
file that shrank could grow back to its old size entry unseen, a
deleted file left its allowance to the next file at its path, and a
boundary violation that was fixed stayed allowed until someone ran
--update. The client ratchets now fail on such entries, so the server
ones do the same.

lint:size fails on an entry above its file's size or for a file that is
gone, and lint:boundaries on an entry that no longer occurs; both tell
to run --update, which still only lowers or drops. Both baselines match
the tree today.
2026-10-08 14:11:54 +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 4344dbe923 test(client): check the planner's note save through its store action
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.
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 9d30a33e78 ci(client): lower the test warning baseline to what the merged branch has 2026-10-08 13:28:34 +02:00
Maurice 5f7d76fd5a ci: run the new size, boundary, layering, offline and i18n gates in CI
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.
2026-10-08 13:27:38 +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 d7563e721d docs(wiki): use a minor tag that will exist as the example
The image tag tables showed mauriceboe/trek:4.3 as the minor tag example,
"published for releases after 4.3.3". The next release is 5.0.0, so no
4.3 tag will ever be pushed, and a self-hoster copying the example into
compose gets "manifest unknown". The example is now 5.0, and the row says
4.x releases have no minor tag and are pinned by major or full version.
2026-10-08 13:04:43 +02:00
Maurice c5ba70f67e ci(release): let a later green ci-ok supersede a red run on the same commit
Both release workflows refused a commit if any ci-ok check run on it was
not green. Every workflow run is its own check suite, so a red run stays
on the commit after a later one passed: a flaky job on the dev push, or a
push run cancelled by a manual run on the same ref (ci-ok runs with
always() and fails on the cancelled jobs). Running Tests & SonarQube
again, as the error told the maintainer to do, added a green suite but
never cleared the red one, and the message did not say which run was
red.

The gate now lives in scripts/ci/ci-ok-verdict.mjs. It fetches every
attempt (filter=all), keeps the newest attempt per check suite, refuses
while one is still going and requires the run that finished last to be
green. It prints the link of every run it counted, and an older red run
it overrode shows up as a notice. A response it cannot read, one cut off
by pagination or a run without a suite, conclusion or completion time
fails the release instead of passing it. Tests run with test:ci-scripts.
2026-10-08 13:04:43 +02:00
Maurice 3f2b60b00b ci: list moved files under their old path, run the tests for chart, compose and wiki changes
The changes job listed the diff with rename detection on, so a file moved
out of an area showed up only under its new path. Moving server code or a
chart template under docs/ turned every area off and ci-ok passed on a
change that removed source from the build. The diff now runs with
--no-renames, and a test reads the exact command out of test.yml and runs
it against a scratch repository with a move in it.

The code area also missed files the test suites read from disk outside
the packages. config-templates.test.ts pins docker-compose.yml and the
Helm chart to the code defaults, help.test.ts serves wiki pages and
images, and the client's help registry test checks its links against the
wiki headings. A chart, compose or wiki change now runs the tests, and a
wiki change also builds the image, which ships wiki/ as the in-app help.
Markdown is still ignored everywhere except under wiki/.
2026-10-08 13:04:43 +02:00
Maurice d88dc17e8e ci(release): build the bump commit, add a minor tag, make the bump an explicit choice
docker.yml built and published whatever `main` pointed to when the build
and merge jobs started, not the commit it had just bumped and tagged, so
anything that landed in between shipped under a version that did not
contain it. build, merge and release-helm now check out the bump commit,
the way docker-dev.yml already pinned its prerelease builds. The push of
that commit fast-forwards main from the commit the run started on, so a
race fails the release instead of skipping over it.

Nothing tied a release to a green CI run. Both release workflows now
refuse a commit unless every ci-ok check run on it has finished green,
and docker.yml refuses to run on anything but main.

bump=auto fell back to a patch whenever no prerelease was in flight,
which would have published the ORM release, the largest change the
project has made, as 4.3.4. auto now only finalizes a prerelease above
the last stable tag and otherwise stops, asking for patch, minor or
major. The inputs are unchanged. A patch that adds migrations gets a
warning, not a refusal, since patch releases have carried them before.

Stable releases also publish :MAJOR.MINOR, so an install can take the
fixes of one minor line without being moved onto the next on day one.
The tag tables in the wiki list it and the prerelease tag, and the root
CLAUDE.md describes the release and prerelease flow.
2026-10-08 13:04:43 +02:00
Maurice 4cfe6b59ba ci: move every workflow off Node 20 actions, pin the third-party ones, add Dependabot
security.yml, lint-prettier.yml and the release workflows still ran
actions built for Node 20 (checkout and setup-node v4, github-script v7,
the docker actions v3/v5, create-github-app-token v1), which GitHub now
forces onto Node 24 with a deprecation warning on every run. They move
to the Node 24 majors test.yml already uses (download-artifact to v7,
since v6 is still Node 20). Third-party actions, several of which hold
the Docker Hub and GitHub App secrets, are pinned by commit with the tag
in a comment. ubuntu-latest moves to a new Ubuntu release on 10-19, so
every job names ubuntu-24.04 and the switch happens in a PR. Every
setup-node reads .nvmrc; publish-plugin-sdk leaves 22, which it ran
under a comment claiming it matched the server.

security.yml gains an npm audit job over the lockfile, on PRs and pushes
into dev as well as main. It covers what the image installs and the
plugin SDK, takes seconds, and would have flagged the brace-expansion
regression the ORM squash brought in the day it landed. Scout keeps its
image scan on main. Neither becomes a required check: a CVE published
overnight would turn every open PR red, including the ones that cannot
fix it. The scout job now also skips PRs into dev, which the trigger
change would otherwise hand it.

.github/dependabot.yml adds weekly version updates into dev for the root
workspace, plugin-sdk, the actions and the Docker base images, minor and
patch grouped per ecosystem, majors one PR each. The Node major in the
Dockerfile is left to a hand-made change together with .nvmrc.
2026-10-08 13:04:43 +02:00
Maurice 8e695cc267 ci: gate merges on one ci-ok check that covers the image and the chart
The ruleset required only `lint` (shared's eslint and prettier) and
`check-target`. Types, lint, coverage, end-to-end and the S3 contract
were advisory, and the three health PRs merged with red checks. Nothing
on a PR into dev built the Dockerfile or rendered the Helm chart either,
which is how #2610 shipped a Dockerfile no build could parse.

ci-ok needs every gate, test, image and chart job and is meant to be the
one check the ruleset requires. It runs with always(), fails when a job
it needs failed or was cancelled, and passes when they passed or were
skipped. The Sonar scan stays outside: its quality gate reports on the
PR by itself, and an outage there should not block merges.

A required check cannot sit behind a workflow-level path filter: on a
docs-only PR the workflow never starts and the check never reports. The
filter moves into a `changes` job. It diffs the PR's merge commit against
its base (or a push against the previous head) and scripts/ci/
changed-areas.mjs maps the files to code, image and deploy areas. It
fails closed: no usable diff, an empty one or a path it cannot read
cleanly runs everything. Its tests run in the same job.

docker-image builds the Dockerfile with buildx (layer cache written by
pushes only, read by PRs), starts it with the same hardening as
docker-compose.yml, waits up to three minutes for /api/health/ready,
checks the VERSION file and that / serves the client, and prints the
container log. deploy-manifests lints the chart, renders it with the
default values and with each secret, persistence and ingress mode,
validates the output with kubeconform, and runs `docker compose config`
on every compose file. helm and kubeconform are pinned by version and
sha256.

While the file is open: every job runs on ubuntu-24.04 instead of the
moving ubuntu-latest, reads its Node version from .nvmrc (the plugin-sdk
jobs ran 22 under a comment saying it matched the server, which runs
24), download-artifact moves from v6, still on Node 20, to v7, and the
Sonar action is pinned by commit.
2026-10-08 13:04:43 +02:00
Maurice 2f5d16ac74 build: pin the Node major in .nvmrc and declare the supported range
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.
2026-10-08 13:04:43 +02:00
Maurice 43ed960f30 fix(deps): lift the vulnerable runtime packages and move the overrides to the root
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.
2026-10-08 13:04:42 +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 5ca6afa789 fix(i18n): the untranslated ratchet sees plural copies and copied phrases
Two holes let English through the ratchet unseen.

A plural form en has no key for (Russian .few and .many, Arabic .two)
was compared with en's value for that same key, which never exists, so a
locale could copy en's text into exactly the forms a new plural group
needs and the count stayed put. Such a form is now compared with every
form en spells out for the group. A form en does declare keeps the exact
comparison: Italian "{count} file" is the Italian plural, not a copy of
en's "{count} file".

The // same-as-en marker excused any value in a Latin-script locale, so
one comment waved a whole copied sentence through. It is honoured only
on a value holding a single plain word, the case shared/CLAUDE.md
already describes. No locale carries the marker today, so nothing that
passed before fails now; the counts are unchanged.
2026-10-08 13:04:42 +02:00
Maurice d4673ca4dd docs(client): say why the dev photos_shared notification sends count as text
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.
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 5f0865a476 fix(i18n): a one-word English copy counts as untranslated
The ratchet excused any value equal to en that held at most one plain
word, so "Settings", "Appearance", "Readability" or "Users" copied into
another locale were never counted, Japanese and Arabic included. Buttons,
tabs and section titles are mostly one word, so a feature could still add
26 English copies of its labels and stay green.

An identical value is now excused only when nothing ordinary is left
after dropping placeholders, markup, addresses, a listed set of product
names (BRAND_NAMES, which now holds one-word names such as Atlas, Immich
and Mapbox) and unit symbols (UNITS). A word that really is the locale's
own, German "Status" or French "Transport", carries a `// same-as-en`
marker on its declaration, which the catalogue reader now reports. The
marker is honoured only in Latin-script locales: a Latin word left in
Japanese or Russian is English whatever it says. Every locale folder
must be on LATIN_LOCALES or NON_LATIN_LOCALES, so a new language cannot
pass the rule unclassified.

The baseline is re-recorded from the narrower rule: the unmarked copies
go from 1252 to 3238, the marked fallbacks stay at 3157, and no entry
went down. This is the measured state under the new rule, not a raised
gate; translating, or marking a word that is genuinely the same, lowers
it with --update as before.
2026-10-08 13:04:42 +02:00
Maurice 8f691bff5a feat(i18n): untranslated strings per locale may only shrink
Parity proves a locale has every key, not that the key holds the
locale's language: copying the en string passes it, so a feature could
add 26 English copies and stay green. Nothing counted them either. Today
the locales hold 3157 strings marked `// en-fallback` and 1252 unmarked
values identical to en.

scripts/i18n-untranslated.mjs counts both per locale and file and holds
them at scripts/i18n-untranslated-baseline.json, recorded from today's
tree. i18n-parity --strict runs it, so the existing CI step enforces it.
The two counts are separate on purpose: dropping a marker without
translating moves the string from one count to the other and still
fails. --update lowers the baseline to the current counts and never
raises an entry. A missing or malformed baseline, a locale lacking one
of en's files or a value the reader cannot parse is an error, not a
pass.

An identical value is not counted when it reads the same in every
language: after dropping placeholders, markup, URLs, e-mail addresses,
paths and a short list of multi-word names (Google Maps, Home
Assistant), at most one plain word may be left, where acronyms, single
letters and names with an inner capital are not plain words. That
excuses "{count} km", "GPX", "OAuth" and "Budget" and keeps "Source
code" counted.
2026-10-08 13:04:41 +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 6a6f3f6cf6 test(client): stub the sync triggers in the consent and dashboard specs
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.
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 0344a86aec style(client): format the ratchet scripts with Prettier
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.
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 9f3802ce2d docs(client): one punctuation fix in the data path rule
The sentence lint:offline extended still joined its clauses with a dash; it reads as one clause now.
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 71196b3dad docs: fix the open-shapes spec path and drop two leftover dashes
The open-request-schemas header pointed at scripts/open-request-schemas.spec.ts,
which does not exist; the spec lives in src/. The bracket-notation comment in
the server ESLint config kept a dash that the earlier cleanup on this branch
missed.
2026-10-08 13:04:39 +02:00
Maurice a198dde5d2 test(server): stop counting a filtered partial index as covering a foreign key
The foreign key index check took every index from PRAGMA index_list as a
usable prefix, partial ones included. An index like
CREATE INDEX ix ON c(p_id) WHERE state = 'open' would satisfy FKIDX-001,
yet the cascade lookup WHERE p_id = ? still scans the table, which is the
regression the check exists to catch. A partial index now counts only when
its predicate is IS NOT NULL on the key's own columns, the one form SQLite
can prove from p_id = ?. Nothing in today's schema depends on a partial
index alone, so the result on the migrated snapshot is unchanged.
2026-10-08 13:04:39 +02:00
Maurice 5a5349b817 docs: drop two dashes from the contract and fetch rules touched on this branch
Both lines were reworded here for the new gates and still carried a dash from before. They read the same with a colon and a comma.
2026-10-08 13:04:39 +02:00
Maurice a0e1a39acc ci(server): ratchet the open shapes in every schema a DTO binds
contracts:open in shared only counts exports named *RequestSchema, but
the name is a convention: 37 body DTOs wrap a schema called *BodySchema,
*InputSchema or plain *Schema, and tags.dto.ts declares its schemas
locally. pluginInstanceConfigUpdateSchema (a record of unknown behind
PluginConfigDto), createAlbumLinkSchema with five open shapes and a dozen
more were never counted, and a new open body under one of those names
passed both checks.

tests/unit/contracts/dto-open-shapes.test.ts walks the other way round.
It finds every class that extends createZodDto under src/, imports it,
and counts the open shapes in the schema the Zod pipe validates with
shared's walk. Each class is held to scripts/dto-open-shapes-baseline.json,
61 DTOs with 101 open shapes today, and a new DTO may hold none. It fails
closed: a createZodDto call that is not an exported class, two DTO
classes with one name, an empty tree or an unreadable baseline stop it.
It runs with the unit tests; npm run contracts:dto-open runs it on its
own and --update only lowers the baseline.

The five DTO classes declared inside roadtrip controllers are exported so
the walk can reach their schemas. The selector test picks up prettier's
wrapping of the cases added before.
2026-10-08 13:04:39 +02:00
Maurice 9d6debc591 fix(shared): count an open shape once per path, and see maps, sets and custom
openShapes() skipped every schema node it had already visited, so one
z.unknown() instance reused across four fields counted once, and a
baselined schema could gain any number of open fields that share a node
without its count moving. z.map(), z.set() and z.custom() were not walked
at all, so an open value inside them counted nothing.

An open spot now counts once per path it sits at, and the visited set is
only a guard on the ancestor chain, so a recursive z.lazy() still
terminates. The same kind at the same path is listed once, which keeps the
two sides of an intersection from counting a shared field twice. Map keys
and values and set values are walked, and z.custom()/z.instanceof() count
as a custom open shape. The request schemas hold the same 75 open shapes
under the new walk, so the baseline does not change; the spec covers
reuse, recursion and the new types.
2026-10-08 13:04:39 +02:00
Maurice f357c01c8c test(server): pin the SQL strings in the files exempt from the SQL text rule
MaintenanceRepository, DemoRepository and six seeders are exempt from the
SQL string selectors as whole files, so new SQL text added to any of them
passed lint unseen, and nothing noticed when a file no longer needed its
exemption. The comment promised a list that can only shrink, but nothing
held it to that.

The selector test now works out which files under src/db/repositories and
src/db/seeders the config really exempts and compares that set with a
pinned table, then runs the SQL selectors over each exempt file's source
and compares the hits with the count pinned for it (23 today). One more
statement fails, one fewer asks for the pin to come down, and a file at
zero has to leave the ignores. Per-line disables would have done the same
job but add suppressions the codebase is meant to lose.
2026-10-08 13:04:39 +02:00
Maurice 8487dbb160 fix(server): accept timeoutMs as a fetch deadline and close the null init gap
The outbound-timeout selectors flagged safeFetch(url, undefined,
{ timeoutMs }) and safeFetch(url, { headers }, { timeoutMs }), although
timeoutMs is the deadline over the whole exchange that server/CLAUDE.md
tells callers to pass. At the same time an init spelled null or void 0
passed unseen, so a guarded call could still go without any timeout.

A call whose third argument is an object literal with a timeoutMs key is
now exempt, and undefined, null and void 0 (also behind an as cast) all
count as no init. The cast forms pin the inner node type, because esquery
compares String(value) and a missing name would otherwise read as
'undefined' and flag every cast init. The selector test covers both
sides.
2026-10-08 13:04:39 +02:00
Maurice 30bdcf061b chore(server): spell out the coverage pin and drop a dash from the redirect note
Wording only: the comment over the new coverage entries and the safeFetch doc comment it shares a change with.
2026-10-08 13:04:39 +02:00
Maurice a86aa4e819 ci(server): refuse SQL text in repositories and seeders, move the gallery oracle to tests
The repository rule that keeps dialect SQL in sql-functions.ts only
matched raw() and the sql tag, so a SQL string handed straight to the
connection, em.getConnection().execute('INSERT OR IGNORE ...'), passed
unseen, and the seeders sat outside every repository rule. A new block
refuses a string or template whose text starts like a statement as the
first argument of execute, run, all or get in src/db/repositories and
src/db/seeders; a result mode such as qb.execute('run') stays legal.
The files that already hold such SQL are named one by one so the list
can only shrink: MaintenanceRepository (PRAGMA, VACUUM INTO),
DemoRepository and six of the seven seeders.

The selector groups are named once in eslint.config.mjs, and the
selector test now also checks the new block per path, its exceptions,
and that raw() stays forbidden in repositories.

journey-gallery-order.ts exported raw SQLite ORDER BY text from
src/nest that no production code read any more since the gallery reads
moved onto JourneyPhotosRepository. Two parity tests still compare the
Kysely rebuild against it, so it moves to tests/helpers as their oracle.
2026-10-08 13:04:38 +02:00
Maurice 3fbe0418eb fix(server): bound every SSRF-guarded fetch in time and size
The ESLint rule from #2610 that requires a signal on outbound fetches
only matched the platform fetch. The guarded wrappers hand it a spread
init, which the rule trusts, so safeFetch(url, undefined, options) passed
lint with no timeout, and the dispatcher left undici's five-minute
default in place. Nothing capped the body either. Two callers did exactly
that on hosts TREK does not control: the Synology thumbnail read (a
user-configured NAS) and the place enrichment photo download (a URL out
of third-party place data), both buffering the whole answer.

The wrappers now carry defaults. A call without a signal waits 30 s for
the response headers; the body stays under undici's idle timeout, so a
proxied video that pauses with its player is not cut off. timeoutMs adds
a deadline over the whole exchange and joins the caller's signal, once
for the whole redirect chain. Every body is capped at 64 MB, refused up
front when the declared length is larger and failed mid-read otherwise;
callers pass a tighter maxBytes, or null where the body is streamed on
(pipeAsset, the doc-sync transfers that count their own bytes, the
Ollama pull).

The two call sites get a signal and an 8 MB cap. The lint selectors now
cover safeFetch, safeFetchFollow and safeFetchAdminConfigured as well,
including an explicit undefined init, and are named once so the
repository block restates the same list. A new test asks ESLint for the
options it applies to a path and runs them over snippets, so a selector
that stops matching fails a test instead of passing silently.
2026-10-08 13:04:38 +02:00
Maurice 8e6c30f883 test(server): keep the new gate tests inside the tests' type check
coverage-thresholds.test.ts imported vitest.config.ts, which pulled the
config into tsconfig.tests.json's program; that program compiles as
CommonJS and rejects the config's import.meta, so typecheck:tests
failed. The test now loads the config at runtime. The other new gate
tests get prettier's formatting.
2026-10-08 13:04:38 +02:00
Maurice 5a842c386a test(server): pin the eleven unlisted domains on their own coverage floor
google-quota, managed, mcp-shared, mcp-transport, public-api,
receipt-scan, route-usage, scheduling, school-holidays, tokens and tours
had no entry in the per-domain ratchet, so they sat in the pooled
src/nest/** catch-all, where a domain can lose twenty points behind the
headroom of the files beside it. That includes the cron registrar, the
MCP HTTP transport, the public API and the new tours domain.

Each is pinned at floor(measured) minus 1 as scripts/coverage-thresholds.mjs
prints it, measured over the unit, integration and e2e suites that load
the domain. A full run can only measure higher. mcp-shared (59 branches)
and route-usage (66 branches, 77 functions) measure below the 80 floor
and are pinned where they are; managed holds an empty module.

A new unit test fails when a folder under src/nest has no entry, or an
entry names a folder that is gone, so the next domain cannot land in the
catch-all again.
2026-10-08 13:04:38 +02:00
Maurice 98a446a7c0 test(db): every foreign key has to lead an index
Migration20200101042200 wrote 86 indexes by hand so that deleting a trip
or a user stops scanning every child table while it holds the single
connection. Nothing kept it that way: the next table with an unindexed
trip_id would bring the scans back, and the entity parity tests compare
index names, not whether a key is covered.

The test reads PRAGMA foreign_key_list off the migrated test database
and requires the columns of each foreign key to lead some index or the
primary key. Today every one does, so the allow-list for deliberate
exceptions starts empty, and a second case keeps that list honest. A
third builds a small schema to prove the check fails on an unindexed
key, a composite one, and an index that holds the column second.
2026-10-08 13:02:59 +02:00
Maurice 0b4000af81 ci(shared): ratchet the open shapes in request schemas
The server's boot gate only checks that a write route has a Zod DTO, not
that the schema constrains anything, and 47 of the 213 exported request
schemas hold an open shape: a loose object, a record of unknown, or an
unknown field. shared/CLAUDE.md already says to narrow those rather than
add new ones, but nothing counted them.

scripts/open-request-schemas.mjs walks every exported *RequestSchema and
counts its open shapes against a baseline of today's 75. A new request
schema may hold none and an existing one no more than its entry;
--update only lowers. The CLI checks the built dist/ for CI, and
src/open-request-schemas.spec.ts runs the same walk over src/ so the
shared test run fails as well. A missing or malformed baseline, or a
dist/ without a single request schema, is an error.
2026-10-08 13:02:59 +02:00
Maurice 22b398ad8c ci(server): gate import cycles and cross-domain reach-ins with a baseline
Module boundaries were held by review alone. storage.module.ts documents
a cycle that only surfaced at runtime as "imports[1] is undefined", three
modules exist just to break cycles, and nothing stopped the next feature
from reaching into another domain's helpers.

scripts/import-boundaries.mjs parses every import under src/ with the
TypeScript parser and checks five rules against a baseline of today's
violations: load-time file cycles (none today once type-only and lazy
imports and the two-way MikroORM entity relations are left out), new
dependencies between domains that already depend on each other (262
pairs, one tangle around plugins and auth), shared-kernel files importing
a domain (8), imports of another domain's internals (69) and src/db
importing src/nest (5). A domain's public surface is what the code
already treats that way: modules, services, guards, decorators, types,
constants, the audit logger, the plugin RPC kit and the addon gates.

New violations fail, --update only drops entries, and an unreadable
baseline, a missing src/ or an import it cannot resolve stops the run.
2026-10-08 13:02:59 +02:00
Maurice e5a5d167af ci(server): hold source files to 1000 lines with a size baseline
The client has had lint:size since #2612, the server had nothing, and
the server is where the biggest files live: maps.service.ts holds 3231
lines and grew by 200 in a month, and nine more services are past 1100.
scripts/size-lint.mjs carries the client's semantics over to src/ and
scripts/: a file may hold 1000 lines, the 23 that already hold more are
pinned at today's length in scripts/size-baseline.json, and --update
only ever lowers an entry.

A line past prettier's 120 columns counts once per 120 columns it spans,
so joining lines does not make room under the limit. The check fails
closed: a missing or malformed baseline or a missing src/ stops it
instead of passing with nothing checked.
2026-10-08 13:02:59 +02:00
Maurice 789a95de92 fix(planner): the place dialog and the reorder time removal go through the visit repo
The planner's two time edits of a visit called the time route straight:
saving the place dialog opened from a visit, and removing a visit's time
before a reorder. They skipped the visit repo's check for an older write
to the same visit still in the queue, so with such a write parked or
backing off the new time landed first and Try again (or the next flush)
replayed the older time over it, with no version token to refuse it.

Both now use the store's setAssignmentTimes, which writes through
assignmentRepo.setTimes, queues behind the older write when there is one
and keeps the optimistic update and rollback the other time edits have.
The dialog no longer reloads the days afterwards, since the store already
holds the new time and a reload would show the server's older one while
the new one waits in the queue; a changed day note is set on the visit in
place instead. The retry docs drop the exception they made for these two
paths.
2026-10-08 13:02:58 +02:00
Maurice c63ec5e285 fix(offline): a flush asked for during a running pass gets its own pass
An online edit that has to queue behind an older write to the same entity
starts a flush so it goes out right after that write. When the older write
was already in flight, flush() returned at once on the in-tab flag, and the
running pass had read its pending rows before the new one existed. The edit
then sat in the queue until the next trigger (the 30 second tick, a
visibility change or a reconnect), so collaborators saw it late and a
refresh in that window showed the older server value.

A flush called while a pass runs now asks that pass for one more round,
which it starts once it is done, next to the existing "mine wins" re-run.
Each pass clears the request when it starts, so the extra rounds stay
bounded by the calls made during a pass.
2026-10-08 13:02:58 +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
Maurice 7cb4a09837 docs(wiki): say which images refuse a newer database on rollback
The rollback section promised that an older image refuses a migrated
database and names the pre-migrate copy. Only images from 4.4 on carry
that check. Going back to 4.3.x, the most common rollback today, gives
neither the refusal nor the pointer, so the copy has to be restored by
hand before the old image starts.
2026-10-08 13:02:58 +02:00
Maurice 28f6d30368 docs(notifications): why the reminder backfill counts days in UTC
The backfill in Migration20200101042100 marks reminders as sent by UTC
date('now'), while the reworked job counts days in the app TZ, which read
like a seam that sends twice or skips on upgrade day. It is not: the
backfill stands in for the job before it, which matched start_date
against UTC date('now') at 9 AM app time, so UTC is the clock those
reminders went out by, and with one run per UTC day nothing it marks
was left unsent. The only effect is its strict "<": the upgrade day's
own reminders stay unmarked because the boot may precede that day's
run, so a boot after it reminds such a trip once more. One extra
reminder at most and never a lost one, so the shipped migration stays
untouched and the reasoning now sits on the job.

The todo claim's docstring writes its dedup bound as SQL.
2026-10-08 13:02:58 +02:00
Maurice bb5cc8f795 fix(db): one account per email address, whatever its case
Login, password reset and the sign-up checks all match emails
case-insensitively, but the only constraint was the column's own
UNIQUE, which SQLite compares case-sensitively. Two accounts whose
addresses differ only in case (legacy rows, two sign-ups at once) left a
login to resolve to whichever row came first, and LOWER(email) lookups
had no index to use.

A migration adds a unique index on lower(email). An install that
already holds such a pair must still start, so the migration then
leaves the index out, logs how many addresses are shared and by which
user ids (not the addresses), and records email_case_index_pending.
Since a migration runs once, db/email-case-index.ts runs on every boot
while that flag is set: it repeats the warning until the accounts are
told apart, then creates the index and clears the flag. The app-level
checks keep guarding meanwhile.

A write that passes the up-front check and still loses the race is now
refused by the database. The users repository turns that unique
violation into UserIdentityTakenError, and sign-up, admin create and
edit, and the profile update answer it with the same 409 their checks
give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned
the old behaviour and now pins the 409).

The entity generator could not see an expression index (PRAGMA
index_info names its column NULL), so PARITY-009c found a db index with
no entity declaration. Rule 14 carries such an index into the entity as
a named entry with its own statement; Users.entity.ts is regenerated.
2026-10-08 13:02:58 +02:00
Maurice c04fca81ee fix(notifications): claim a reminder before sending it, give it back when sending fails
The trip and todo reminder ticks sent with `.catch(() => {})` and then
marked the reminder sent whatever happened. A send that threw (a DB error
resolving the recipients) or whose every channel failed (SMTP down, no
in-app) was recorded as delivered and never retried, without a log line.
And because the mark came after the send, two processes running the same
tick (a rolling update overlap, a stray container on the same volume)
both sent.

Each reminder is now claimed first with one conditional UPDATE through
its repository: reminder_sent_for is set only while it differs from the
start date (and the start date is still the one read), reminded_at only
while it is empty or older than the 20 hour dedup window. Only the
process whose update lands sends. If the send throws, or it attempted
in-app and channels and none went out, the claim is given back, the
failure is logged, and the next tick tries again until the trip starts
or the todo falls out of its window. A send nobody wanted (everyone
opted out) stays marked, since a retry would reach no one.

For that, NotificationsService.send() now reports what it attempted and
what was delivered; a channel that rejects or answers false counts as
not delivered. Existing callers ignore the result.
2026-10-08 13:02:57 +02:00
Maurice f527b933fb test(db): boot a v4.3.3 install with real rows on this release
The legacy-upgrade suites stopped at legacy-v242, while v4.3.3, the
release every 4.x install will come from, left schema_version 244. The
most common upgrade path only ever ran against synthetic step maps.

legacy-v244.sql is that release's own runner booted once on an empty
file, plus synthetic rows across the main tables: users (one with a
mixed-case email), trips with a member, days, places, assignments with
the canary times, a booked stay, a flight with endpoints, budget items
with splits, packing, todos, notes, file metadata, tags, a journey with
an entry and a notification. scripts/build-legacy-fixture-v244.mjs
rebuilds it byte for byte from the tag: it archives v4.3.3 into a temp
dir outside the repo, boots that runner, seeds, pins every timestamp,
VACUUMs and dumps.

The suite runs the shared upgrade assertions and checks that every row
of those tables comes through with its values, that the boot copied the
database first and the copy still is the 4.3.3 schema, and that the
reminder backfill marks only the reminders whose day has passed.
2026-10-08 13:02:57 +02:00
Maurice ff3615fdf0 feat(db): copy the database before a boot migrates it
Migrations only go forward, and since #2610 an older image refuses a
database a newer one migrated. Going back after a bad update therefore
needs a copy from before it, and the only copies were backups the admin
takes by hand or auto-backup, which is off by default. The first boot
on 4.4 migrates every 4.3.x install through the legacy baseline and the
new migrations without either.

Before applying pending migrations to a file database that holds
anything, the boot now writes a VACUUM INTO copy next to it, named
pre-migrate-<schema>-<time>.db (legacy-<schema_version> for an install
the positional runner left, otherwise the last recorded migration), and
keeps the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP (3). The copy is
written under a temporary name and renamed, so a crash never leaves a
truncated file that looks good. If it cannot be written the boot refuses
before anything is migrated; TREK_DB_PRE_MIGRATE_SNAPSHOT=false is the
documented way past that. The newer-database refusal names the copy
taken right before the newer release migrated past this one.

A fresh install, a restart with nothing pending, an in-memory database
and a restore's re-bootstrap (the archive is the copy) take none, and
NODE_ENV=test leaves it off unless a suite sets the switch.
2026-10-08 13:02:57 +02:00
Maurice 395a7f41fe test(client): keep the login's background trip sync out of the push spec
A successful login starts tripSyncManager.syncAll() without waiting for
it. In authStore.push.test.ts that sync was real and could still be
logging when the worker shut down, which vitest reports as an unhandled
"Closing rpc while onUserConsoleLog was pending" and fails the coverage
job although every test passed. The spec is about push, so the sync is
stubbed.
2026-10-08 11:04:33 +02:00
Maurice 8997640ac9 ci(sonar): give the JS/TS analyser 8 GB on full scans
Since the ORM migration the scan of every push to dev stops with "the
Node.js process running out of memory (heap size limit 4288 MB)" and
fails the job, while PR scans, which only analyse changed files, stay
under it. The hosted runner has 16 GB.
2026-10-08 11:04:33 +02:00
Maurice 7de50c5d57 fix(docker): put the escaped newline back into the VERSION printf
c6b6b83d3 wrote a real line break where `printf '%s\n'` belongs, so the
RUN instruction ended inside the quote and Docker read the next line as an
instruction of its own: "dockerfile parse error on line 135: unknown
instruction: '". Every image build from dev fails on it. PR CI builds no
image, and the scout job on the release PR was already red for another
reason, so nothing showed it.
2026-10-08 11:04:33 +02:00
Maurice 39fa796503 ci(client): the split files leave the size baseline
useTripPlanner.ts (3095 lines, now 718) and RoadtripSidebar.tsx (2287, now
694) are back under the limit and drop out of the baseline, and
TripPlannerPage.tsx is held at its new 1081 lines.
2026-10-08 05:02:48 +00:00
Maurice 9b9ec44b5e refactor(client): booking saves and the import review move into useBookingEdits
Saving a reservation or a transport, deleting a booking, finding or
creating the place an imported stay belongs to, the review that walks
imported items through their editors with its state, and the bridge
that picks up an import or a scanned receipt from the background tasks
move out of useTripPlanner into useBookingEdits.

useTripPlanner calls the hook where the save handlers used to sit,
after the day add, delete and clear wiring and before the booking
detail, so the bridge still runs between the rail snapshot and the
splash and the planner returns the same object. The review state only
moves later in the render, which no effect observes. The two existing
loose record types in the save handlers move along unchanged.
2026-10-08 05:02:48 +00:00
Maurice f9d0f960bc refactor(client): the day plan's assignment edits move into useDayPlanEdits
Putting a place on a day, moving a stop to another day, taking one off,
reordering a day with the vias it carries along, renaming a day and
reordering the days move out of useTripPlanner into useDayPlanEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
day plan's edits used to sit, ahead of the day add, delete and clear
wiring, so the planner returns the same object and behaves exactly as
before.
2026-10-08 05:02:48 +00:00
Maurice 7e6af9c8fc refactor(client): the ways into the place form and the stop popup move into usePlaceFormOpeners
The right click on the map, the POI marker and the hit on the drive with
the check-out days a night can end on, the service stop added by hand
with the legs it can go on, the popup handing over to the full form with
its duplicate check, and the place editor move out of useTripPlanner into
usePlaceFormOpeners.

None of it runs an effect, and useTripPlanner calls the hook right after
the place edits, where the place editor used to sit, so the planner
returns the same object and behaves exactly as before. The note on adding
a POI with its day now sits directly above the handler it describes.
2026-10-08 05:02:48 +00:00
Maurice a3fed9e392 refactor(client): saving, deleting and recategorising places move into usePlaceEdits
The place delete question with the booked nights it warns about, telling a
tour from a place, saving the place form with the day it was added from
and the vias that stop pushes along, deleting one place, a tour or several
places, and moving places to another category move out of useTripPlanner
into usePlaceEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
place form's save used to sit, ahead of everything that asks whether a
place is a tour, so the planner returns the same object and behaves
exactly as before.
2026-10-08 05:02:47 +00:00
Maurice b771eae61b refactor(client): the via anchoring, refuel stops and dropped hits move into useRoadtripDriveShaping
Working out which stop a point on the drive belongs behind, where a place
chosen by hand goes, adding, dragging and removing vias, asking for and
accepting a refuel stop, and a corridor hit dropped onto the route move
out of useTripPlanner into useRoadtripDriveShaping.

None of it runs an effect, and useTripPlanner calls the hook where the via
anchoring used to sit, so the planner returns the same object and behaves
exactly as before. The note on placing a via now sits directly above the
function it describes.
2026-10-08 05:02:47 +00:00
Maurice d2706d3d1e refactor(client): the route alternatives picker and the map framing move into useRoadtripAlternatives
The other ways of driving one leg, the road the pointer is on, the
overlays and the stretch of map they cover, the automatic day points and
their markers, the point the map is asked to bring into view, the rail
snapshot a choice is checked against, and asking for and choosing an
alternative move out of useTripPlanner into useRoadtripAlternatives.

useTripPlanner calls the hook where the picker used to sit, after the
road trip feed and the stop edits, so the highlight reset, the close gate
and the rail snapshot still run in the same order and the planner returns
the same object. The note on what the map frames now sits directly above
the value it describes.
2026-10-08 05:02:47 +00:00
Maurice 9681fdf321 refactor(client): the edits to one stop on the drive move into useRoadtripStopEdits
Saving the stop popup as a pause or as a night, the stay release that
asks before a booked night is dropped, the kind, fill and stay of a stop,
moving a stop within its day or onto another one from the rail, ending
the day at a stop, and the stay dialog with the call that opens it move
out of useTripPlanner into useRoadtripStopEdits.

None of it runs an effect, and useTripPlanner calls the hook where the
stop edits used to sit, so the planner returns the same object and
behaves exactly as before.
2026-10-08 05:02:47 +00:00
Maurice bde734c50f refactor(client): the road trip feed and the layers it draws move into useRoadtripFeed
The road trip mode and feed flags, the driving preferences and the limit
save, the day boundaries with their reset and drag controls, the vias,
the refuel search, the routes, the corridor search, the followed track,
the via counts and the via index helpers, the days folded in the rail and
the map layers that follow those folds move out of useTripPlanner into
useRoadtripFeed.

useTripPlanner calls the hook where the feed used to sit, right after the
trip overview, so the preference load, the boundary and via hooks, the
routing round and the corridor search still run in the same order and the
planner returns the same object. The helpers, the folds and the boundary
controls are plain memos and callbacks, so computing them earlier changes
nothing.
2026-10-08 05:02:47 +00:00
Maurice 1db227cb5b refactor(client): the map marker set and the day order badges move into useMapPlaces
The list filters the map shares with the sidebars, the declutter of
collapsed days, the compact and pending marker flags, the Tracks filter
fallback, and the selected day's order badges and fit list move out of
useTripPlanner into useMapPlaces.

useTripPlanner calls the hook where the marker set used to sit, right
after the trip's websocket, so the store subscriptions and the Tracks
fallback still run in the same order and the planner returns the same
object. The order badges and the fit list are plain memos, so computing
them earlier changes nothing.
2026-10-08 05:02:47 +00:00
Maurice 41dabbda6e refactor(client): the planner map view toggles and first fit move into usePlannerMapView
The day route toggle and its travel profile, the whole-trip overview, the
map lock, the recorded Dawarich trail, the fit key and the effect that
fits each trip once move out of useTripPlanner into usePlannerMapView.
A new test calls every toggle and the automatic route, which no test
reached before.

useTripPlanner calls the hook where that code used to sit, so the
Dawarich fetch and the first fit still run in the same order and the
planner returns the same object. handleSelectDay now lists the map lock
ref, the phone ref and setFitKey it receives from the hook; they never
change identity, so nothing behaves differently.
2026-10-08 05:02:47 +00:00
Maurice 87ca3df781 refactor(client): the planner dialogs and their URL intents get their own hook
The place form, the service stop form, the stop popup, the stay release
question, the trip and member dialogs, the booking, transport and transit
editors, the importers and the booking detail move out of useTripPlanner
into usePlannerDialogs, together with the ?create= and ?tab= effects and
the AirTrail sync. plannerTypes.ts holds the option types the planner's
sub-hooks share.

useTripPlanner calls the new hook where that code used to sit, so every
effect still runs in the same order and the planner returns the same
object. The state setters it now receives from the hook are added to the
dependency lists that use them; they never change identity, so nothing
behaves differently.
2026-10-08 05:02:47 +00:00
Maurice e2dab1aebb refactor(client): the trip summary and the day header move to RoadtripSidebarHeaders
TripSummary leaves RoadtripSidebar.tsx for its own module and is
imported back by name; Unit and splitValue go with it unexported.

The day card's header becomes DayHeader in the same module. Its body is
the header element the card drew inline, together with the reads only
the header used: the language, the distance unit, the clock format,
the missed ride and the day colour. The card keeps its translation and
passes the day, the fold state and the track details straight through.
DayHeader returns the bare header with nothing around it, so the markup
is unchanged, and it sits at module scope so it never remounts.

The code moves as it was otherwise, with the dashes in the moved
comments rewritten as colons and commas, and the imports only these
two used leave the sidebar, which is down to about 700 lines. Nothing
renders differently.

New tests render both on their own: the summary's figures split from
their units, narrow and still loading; the day's facts and its fold
control; a day over the driving limit, roads it could not avoid, a
missed flight, the track badge with and without its name, and the
wash in the day's own colour.
2026-10-08 05:02:47 +00:00
Maurice 8e2a2725f0 refactor(client): the stop rows move to RoadtripSidebarStopRows and share one badge row
ServiceStop, RouteViaStop and Stop leave RoadtripSidebar.tsx for their
own module and are imported back by name; effectiveFill goes with them
unexported, and the imports only they used leave the sidebar.

The badge row under a stop's name was written out twice, once in each
stop shape. It is now the local StopBadgeRow, which both render in its
place. It returns the same span with nothing around it, so the markup
is unchanged, and it reads the fill setting and the vehicle the two
shapes used to read themselves. Those are plain store reads, so moving
them one component down changes nothing either.

The code moves as it was otherwise. The note on the stop kind table now
sits at the lookup it describes, the comment on the two settings says
where they are read now, and the dashes in the moved comments become
colons, commas and full stops; each theme-lint-disable stays on the
line of its style. Nothing renders differently.

New tests render the rows on their own: a plugin halt with and without
its name and stay, the kind picker on a pause and on a numbered stop by
click, Enter and Space, the drive findings and the walk from the road
on both shapes, including the range and unknown findings the rail never
draws, and the fill badge with the traveller's default.
2026-10-08 05:02:47 +00:00
Maurice 5cfc546630 refactor(client): the rows a booking stands for move to RoadtripSidebarBookingRows
TerminalStop, BookendStop, RideBlock, BookingChips and UndatedRides
leave RoadtripSidebar.tsx for their own module and are imported back by
name. RIDE_DASH, DiscRow, CodeChip, TimetableClock and CheckInBadge go
with them unexported, since only these rows use them, and the imports
only they used leave the sidebar. The code moves as it was; the one
change is that UndatedRides spells out the type of its booking callback
instead of reading it off the sidebar's props, so the new module never
imports from the sidebar. Nothing renders differently.

New tests render each row on its own: a departure with its clock and
check-in, a hire desk, a booked night that is left after check-out, a
ride that is caught, missed or has nothing on its timetable, the chips
a reader may and may not open, and the rides on no day.
2026-10-08 05:02:47 +00:00
Maurice 2552b37e90 refactor(client): the drive band and the night drive block move to RoadtripSidebarDriveBands
DriveBand and SpillBlock leave RoadtripSidebar.tsx for their own module
and are imported back by name. MODE_ICON goes with them, unexported,
since only the drive band reads it, and the imports only these two used
leave the sidebar. The code moves as it was, with the dashes in its
comments rewritten as colons, commas and full stops. Nothing renders
differently.

New tests render both on their own: every travel mode's sign, the
alternatives button and its pressed state, a hop, a ride with and
without its booking and timetable, and a night drive that is automatic,
has no leg yet or misses its figures.
2026-10-08 05:02:47 +00:00
Maurice 8e4874841f refactor(client): the fill-up band moves to RoadtripSidebarRefuelBand
RefuelBand leaves RoadtripSidebar.tsx for its own module and is
imported back by name; the imports only it used go with it. The code
moves as it was, with the dashes in its comments rewritten as colons
and parentheses. DriveBand's note, which the band used to separate from
its function, now sits directly above DriveBand. Nothing renders
differently.

New tests render the band on its own: idle, searching, offers with and
without the right to add one, each empty answer, and the battery words
for an electric car, which no existing test reached.
2026-10-08 05:02:47 +00:00
Maurice 97a93bb719 refactor(client): the stop badges and the arrival clock move to RoadtripSidebarBadges
OffRoadBadge, FillBadge, NightCheckIn, StayBadge, DriveFindingBadge,
DayCarry, Arrival and LateBadge leave RoadtripSidebar.tsx for their own
module and are imported back by name. The code moves as it was. The doc
blocks that sat stranded above other functions now stand above the
badge they describe, StayBadge's two notes as one, and the dashes in
the moved comments become colons and commas. Nothing renders
differently.

New tests render each badge on its own, including the walk from the
road and the range and unknown arms of the drive finding, which the
rail never reaches.
2026-10-08 05:02:47 +00:00
Maurice 7bd351f9b7 refactor(client): the rail's shared styles move to RoadtripSidebar.constants
RAIL_GRID, RAIL_DASH, DISC, STAT_LABEL and DAY_BADGE leave
RoadtripSidebar.tsx for a plain module beside it, so the parts the
sidebar is split into next can share them without importing from the
sidebar itself. Values and comments move as they were, except that the
dash in the DISC note becomes a colon; the two style objects are typed
as CSSProperties from a type import. Nothing renders differently.
2026-10-08 05:02:47 +00:00
Maurice b8c1fbf192 refactor(client): the plan map and its floating controls move out of the planner page
The plan tab's MapView with the route alternatives bar, the overview and
trail stack, the POI and compass cluster, the phone compass and the phone
POI portal move to pages/tripPlanner/PlanMapStage.tsx. It returns the same
children as a fragment in the same place, so the DOM and the portal order
stay as they were. Its props are picked from the planner hook's own types.

The four physical sides that moved are map geometry and carry an
rtl-lint-disable marker on their line, so the page's rtl baseline drops
from 17 to 13. Two moved comments lose a dash.
2026-10-08 05:02:47 +00:00
Maurice 7d8dcb02c8 refactor(client): the lists tab moves out of the planner page
ListsContainer, the packing and to-do subtabs of the lists tab, moves
unchanged to pages/tripPlanner/ListsContainer.tsx, taking along the
imports only it used. The page renders it exactly as before.
2026-10-08 05:02:47 +00:00
Maurice 3b9e5606ce refactor(client): the planner's lazy panels and their boundary get modules of their own
The lazyWithRetry declarations of the planner page move to
pages/tripPlanner/plannerLazy.ts, a plain module so Fast Refresh keeps
treating the page as a component boundary, and LazyPanel moves to
pages/tripPlanner/LazyPanel.tsx. The page imports both. Comments travel
with their code; two of them lose a dash. Nothing renders or loads
differently.
2026-10-08 05:02:47 +00:00
Maurice 5189d7fcca ci(client): source files may not grow past their size baseline
lint:size counts the lines of every source file under client/src, tests
aside. A file may hold 1000 lines; the 24 files that are longer today are
listed in scripts/size-baseline.json with their current length and fail
the check when they grow. --update lowers an entry once a file shrinks
and never raises one, so a long file is split by concern instead of
growing. A baseline that cannot be read or parsed stops the check with
its reason, and --update leaves it as it is. It runs in the client job
next to the reading direction check.
2026-10-08 05:02:47 +00:00
Maurice bee500880e fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a
count string's form by the language's own Intl.PluralRules, and every
locale now spells out the forms its language reaches with whole
numbers, about 160 count strings in all: one, few and many in Russian,
Ukrainian and Polish, one and few in Czech and Slovak, five forms in
Arabic, one in the others, none in Japanese, Korean, Chinese, Thai,
Vietnamese and Indonesian. Each language was translated and then
reviewed on its own. English no longer reads "1 files", "1 updates
available" or "photo(s)".

Count labels shown next to a number now take that number too: the
atlas and admin statistics, the trip cards, journey and shared trip
pills, packing templates, receipts, storage usage, the Studio export
note. A heading that shared its key with a counted label keeps a key of
its own (the boarding pass heading, the Bookings tab), so languages that
put a noun after a number in the singular keep a plural heading. The
plugin hosts chip passed its count through .replace() after t() and
never got a form; three toasts sent the count as text. Mail and push
texts for shared photos and suppressed storage failures pick their form
in code through pluralForm.

A whole number sent as text counts too, which is how in-app
notification params arrive, and the plural rule follows the language of
the strings on screen while another one still loads.

The parity check knows which forms each language needs and refuses
missing and impossible ones. It also reads keys that contain a colon
now: it skipped them, which hid 58 missing plugin permission texts in
Catalan and 35 in Vietnamese, both translated. The client parity test
covers every language of the registry instead of a hand-kept list of 19,
and the placeholder spec lets a form spell its number out only where
its category is that one number. Brazilian Portuguese error screens
were in Breton, and an Arabic dashboard label held Hebrew text.
2026-10-08 00:01:59 +00:00
Maurice 55697b75c1 test(db): the legacy step map knows the tour type migration
Migration20200101042300_every_tour_type_the_contract_names runs after
the legacy steps like the other post-legacy migrations, but was missing
from the test's list of migrations without a step, so LEGACYMAP-001
failed.
2026-10-08 00:01:43 +00:00
Maurice 960520c6d9 test(e2e): the iPad case checks what broke #1432, the guides read today's texts
The iPad case could no longer fail on its own regression: a draggable
row behind the long-press bridge is what tablets get by design since
#1616, and nothing in the case swiped. What turned swipes into drags in
#1432 was the drag-drop-touch polyfill, so the case now asserts that a
tablet never loads it.

Two help-media guides waited for "1 files" and "2 flight(s) imported",
which now read "1 file" and "2 flights imported".
2026-10-08 00:01:43 +00:00
Maurice d4eccb39a3 fix(client): three more places follow the reading direction in Arabic
On a phone in Arabic every system notice, the release notes included,
opened as an empty sheet: its three-slot strip is shifted to show the
middle slot, and in a right-to-left row that shift moved all three out
of view. The shifts point the other way in a right-to-left page now,
which the swipe handling already assumed.

The budget's total amount kept its parts in order but sat on the
opposite edge from its label, because the whole row was made left to
right. Only the amount itself is now. The phone chat's bubble tails
were still physical corners while the desktop ones follow the row; both
use logical corners now.

lint:rtl takes comments from the TypeScript parser instead of scanning
lines, so a "/*" inside a string (accept="image/*") no longer hides the
lines after it.
2026-10-08 00:01:43 +00:00
Maurice 0126ef3401 fix(client): the version notice asks about a change during the session only
Three gaps in the notice from the previous commit, found in review.

A page served from the network and claimed later by its own build's
worker counted as already taken over, so Reload went to that worker,
got the old build again and spent the session's reload: no notice and
no new build until the next start. Only a worker that replaced the one
that served the page skips the handover now, as at launch.

A reload that came before the new worker could take over (a slow
precache, a failed update, an install gone redundant) also spent that
guard, so the launch behind it could no longer finish the handover with
its own reload. That reload leaves the guard alone now.

An install whose server never reports the bundle's version (a hand-set
APP_VERSION) showed the notice in every session, and Reload could not
clear it. The store keeps the first version a page hears of, from the
app config at launch, and the notice only asks when a later report
differs: a different build at launch is the launch handover's to fix.

The server's wire-envelope test pins the welcome frame's fields and now
expects the version beside the socket id. The wiki's update page says
what an open tab shows after an update.
2026-10-08 00:01:33 +00:00
Maurice 254a214b7d fix(shared): the tour metrics pass the strict shared typecheck
computeTourMetrics indexed points[i - 1] and points[i], which the shared
package's noUncheckedIndexedAccess reads as possibly undefined, so the
shared typecheck in CI failed since the metrics moved there. It walks the
points pairwise from the first one now; the numbers are the same.
2026-10-07 23:09:42 +00:00
Maurice 8dca372c47 ci: run the browser flows and the map bundle check on every change
Both only ran on a developer's machine, and both had stopped telling the
truth. The end-to-end flows now run in CI in Chromium and in WebKit (the
iPad and iPhone cases need the engine every iOS browser uses), with the
browsers cached on the lockfile and the traces uploaded when a flow
fails. A second job builds the client and fails when one chunk carries
both map engines.

Making them pass meant fixing what had rotted. The setup closed every
first-run notice but the release notes, whose X is called Close, so the
notes covered the dashboard and every flow after login timed out on a
click. The iPad case still expected undraggable place rows; tablets drag
on purpose since #1616, behind the long-press bridge, so it now checks
what #1432 is about, a swipe that scrolls and raises no drop overlay,
with locators for the scroller and the Plan tab that match today's
markup. The iPhone case read the body scroll lock through
window.scrollBy, which moves an overflow: hidden viewport in every
engine; it now reads the lock where it takes effect and checks the page
keeps its place.
2026-10-07 22:49:36 +00:00
Maurice a094f79efa feat(client): offer the new version when a release lands during a session
A tab kept open across a deploy ran the previous build until its next
start. The server now names its version in the realtime welcome frame,
which every open client reads again when it reconnects after the
restart a deploy brings. When that version is not the build the page
runs, a notice offers to reload. The reload goes through the same
service worker handover a launch uses: it waits for the new worker to
take over, or for a short timeout, and records the version only when
the takeover happened.

The notice reads the error boundary's texts for the same situation, so
every language has it already. It is one component for both shells,
sits above the offline pill, stays away offline and after a reload for
that version that did not bring it, and can be closed for the session.
2026-10-07 22:40:12 +00:00
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00
Maurice de128cc4ae wip(i18n): plural forms through Intl.PluralRules
Unfinished, continued in the next session: t() resolves key.one/.few/...
by the language's own plural rule, the parity check knows which forms a
language needs, 16 singular/plural key pairs are merged in every locale
and every count === 1 branch in the client uses the plural form. Still
open: the forms each locale needs for the remaining count strings, the
two new labels in every locale, and 35 tests that expect the old English
wording.
2026-10-07 22:57:51 +02:00
Maurice 588e483205 fix(calendar): VTIMEZONE spells out the offset changes of its span
Each zone's VTIMEZONE carried one fixed offset, so a client that reads it
literally showed every event after a change to or from summer time an hour
off. It now lists every offset change in the calendar years its events
fall in, each as its own observance after the offset in force before the
first one. A zone without changes, or a span with no readable date, keeps
the single fixed offset.
2026-10-07 22:56:35 +02:00
Maurice ab343b0a7b fix(client): a phone turned sideways stays in the phone layout
The phone shell was chosen by width alone, and most phones are wider than
767px in landscape, so turning one swapped the whole route tree for the
desktop planner and an open sheet, its input and the map position were
lost. The phone query now also covers a touch screen under 500px tall,
which no tablet is. It lives in one place: the shell, the planner and todo
hooks, the warnings banner, dialog focus and the stylesheets read it, and
six components that measured the window once on mount now follow it.
2026-10-07 22:06:40 +02:00
Maurice e5ceba00f3 fix(tours): wait for the public Valhalla's turn before every request
The public instance allows about one request a second per client, and the
Tours elevation questions went out right behind the route request with no
pause, so they could be refused with a 429 and leave the tour without the
elevation it needs to be saved. Every request to that host now waits for
its turn, across callers; an operator's own Valhalla is not paced.
2026-10-07 21:39:57 +02:00
Maurice e0efa0314a feat(tours): tours read and write through the offline core
A tourRepo replaces the direct toursApi calls: reads fall back to a new
Dexie tours table, and a tour saved or edited offline is written there and
queued like a place, under one temporary id for the place and its facet.
The queue moves both to the real id on replay and drops both when the
server refuses the create. Trip sync caches each trip's tours while the
addon is on, so a tour no longer reads as a plain place offline.

The tour metrics move to shared so the offline copy shows the server's
numbers, and useTourPlaceIds keeps one Set per change as intended.
2026-10-07 21:30:45 +02:00
Maurice f7edd849ab fix(tours): one tour type catalogue, and no column nothing writes
tour_types now holds every key the shared contract accepts, all but hike
disabled, and create and edit turn a disabled type away with a 400 instead
of failing on the foreign key. tours.tour_group_id is dropped: nothing ever
wrote or read it. difficulty and wanderer_ref stay for the Wanderer import.
2026-10-07 20:54:10 +02:00
Maurice f64f9e58de fix(client): give the context menu a menu role, wrap the tour filter callback 2026-10-07 20:35:15 +02:00
Maurice bd8e1a287b test(oauth): the module now provides the token retention job 2026-10-07 17:55:25 +02:00
Maurice e6e270610c fix(places): type the tour mark on the single-place row 2026-10-07 17:37:26 +02:00
Maurice 57df945318 style: plain punctuation in the new comments and test names 2026-10-07 17:12:26 +02:00
Maurice 40bce5554d docs: the key check, the restore guard, the new probes, failed offline changes and reminders
Updates the wiki for the changes in this branch: TREK refuses an
ENCRYPTION_KEY that disagrees with the key file, the rotation script
covers the AI Parsing key and moves the file along, a restore refuses a
newer backup and keeps the replaced database, the chart's startup and
readiness probes, PORT and extraEnv, retrying or discarding failed
offline changes, trip reminders catching up, and why a Portainer
recreate used to keep reporting the old version.
2026-10-07 17:11:56 +02:00
Maurice 6b037e0fa0 refactor(tours): measure distances with the shared haversine
The Tours metrics carried their own copy of the great-circle formula
next to nest/common/geo.ts, which exists so there is one.
2026-10-07 17:10:59 +02:00
Maurice e4ed4bfc7b fix(mobile): the booking, transport and place sheets ask before dropping what was typed
On the phone a flick of the sheet's top edge, a tap on the scrim or
Escape closed the booking, transport and place forms and threw away
whatever had been typed, without a word; the desktop dialogs have asked
since #2253. The question moves into a shared hook that DialogShell and
MSheet both use, and the three sheets pass their form state to it.
2026-10-07 17:10:14 +02:00
Maurice c3c21fb0b4 fix(client): five loads that catch their own error recover from a deploy too
The PDF export on desktop and phone, the language switch, the holiday
regions and the PDF's map image load a chunk on demand and catch the
failure themselves. After a deploy the old page asks for chunk names the
new build no longer has, so they showed their own error on every retry
(or, for the language, silently stayed in the old one) instead of
reloading onto the new build the way an uncaught chunk error does.
importChunk reloads once for a missing chunk and still hands the caller
its error.
2026-10-07 17:05:36 +02:00
Maurice 0a1a5092f7 fix(ui): the context menu answers the keyboard and closes when the page moves
The shared context menu closed only on a click elsewhere: Escape did
nothing, the arrow keys did nothing, and a scroll left the fixed menu
hanging over content that had moved on. It now takes focus on its first
item, moves with the arrow keys, and closes on Escape, scroll and
resize. rules-of-hooks is an error again; its one hit was a test helper
named like a hook.
2026-10-07 17:03:47 +02:00
Maurice 094a46a3cf fix(vacay): stop swallowing a failed delete as a holiday API error
One try around the holiday fetch and the deletes that follow it caught
both, so a database error while removing vacation days that fall on a
public holiday was logged as nothing and left the days standing. Only
the fetch is tolerated now. Also drops two files long gone from the
Sonar coverage exclusions.
2026-10-07 16:59:52 +02:00
Maurice 48f9e7d132 perf(db): index every foreign-key column
86 foreign-key columns had no index. SQLite does not add one by itself,
so every cascading delete of a trip or a user scanned each such child
table in full, inside the transaction, while every other request waited
on the one connection. One migration adds them all, and the entities are
regenerated to match.
2026-10-07 16:56:58 +02:00
Maurice 1a4df59bf3 ci: give every job a timeout
No job had timeout-minutes, so a hung step held a runner for GitHub's
six-hour default. Test and housekeeping jobs stop after 45 minutes, the
image builds after two hours.
2026-10-07 16:52:26 +02:00
Maurice c60277c4d2 fix(tours): a tour another member creates is a tour on the phone too
The list read marked tour places with tour_place_id, but the single-place
read behind place:created and place:updated did not, and the phone's
places browser kept a second tour list of its own that no realtime event
updated. A tour another member made, or one imported from GPX, showed as
an ordinary place on the phone until the screen remounted, and every
tour did offline. The single read carries the mark now, the browser asks
the planner, and the tour id set is memoised instead of rebuilt on every
render.
2026-10-07 16:52:05 +02:00
Maurice 72fbbd55d4 fix: a trashed note file leaves its note, quieter MCP sweep, bounded compose logs
A note's attachment moved to the trash from the file manager kept
showing on the note; the hydration read now skips it, as the chat one
already did. The MCP session sweep logged a line every minute while any
session was open; it logs only when it cleaned something. The compose
file caps the container log, and three test mocks that pointed at
modules long gone are removed.
2026-10-07 16:47:53 +02:00
Maurice 76df44d8de fix(oauth): purge tokens whose refresh token expired a month ago
Every refresh writes a new oauth_tokens row and nothing ever deleted the
old ones, so an MCP client refreshing hourly grew the table, and every
backup with it, without end. A nightly job now deletes tokens whose
refresh token expired more than 30 days ago, leaves first, and keeps any
row a live chain still points at for replay detection. Also types the
backup's per-entry store flag.
2026-10-07 16:45:21 +02:00
Maurice a869269591 fix(deploy): follow PORT in every probe, wait for a slow boot, and probe the database
Changing PORT left the Dockerfile and compose healthchecks, the chart's
containerPort, Service target and probes on 3000, so the container went
unhealthy and Kubernetes restarted it in a loop. All of them follow PORT
now. The chart gains a startup probe (ten minutes for a first boot, a
long migration or a restore at boot, which liveness used to kill after
about 105 s), a readiness probe on the new /api/health/ready that asks
the database, extraEnv and extraEnvFrom for the settings the chart does
not list, slots for the four secrets it had none for, and the
DEFAULT_LANGUAGE and MCP_MAX_SESSION_PER_USER values it documented but
never set. The Dockerfile no longer hides a failed npm ci behind a ';',
pins gosu, and the language lists name all 27 locales.
2026-10-07 16:40:40 +02:00
Maurice 8c3fad5e82 fix(auth): reset-admin.js ends the account's sessions and matches its email in any case
Resetting a locked-out or compromised admin left every session on that
account valid, because the script did not move password_version on the
way every password change in the app does. It also looked the address
up case-sensitively and created a second account when the stored one
had capitals.
2026-10-07 16:36:39 +02:00
Maurice 8923918eb4 fix(i18n): send Catalan and Vietnamese notification mails in their language
Both locales had their notification texts, but the index never imported
them, so their users got English mails and the parity gate passed
because the files existed. The locale map is now typed against the
supported languages, so the next one left out fails to compile.
2026-10-07 16:36:01 +02:00
Maurice 8f02337d45 fix(i18n): hand Intl and the document the real language tag for br and gr
TREK's codes 'br' (Brazilian Portuguese) and 'gr' (Greek) went straight
into <html lang> and into ten Intl calls. 'br' is Breton, so Brazilian
users got Breton month names and screen readers announced the page as
Breton; 'gr' is no language at all, so Greek users got the default
locale. getIntlLanguage maps both, and every one of those sites goes
through it.
2026-10-07 16:34:59 +02:00
Maurice 461a63fc86 fix(auth): send the queued changes before a logout deletes the offline database
Logging out deleted this user's offline database straight away, so a
change made offline and not yet sent was lost without a word. With a
connection, the logout now flushes the queue first, for at most five
seconds.
2026-10-07 16:32:57 +02:00
Maurice 38656eeffd fix(offline): let the cache drop what was deleted elsewhere
The trip sync and every repo list only ever upserted, so a place a
collaborator deleted while this device was offline came back on the
next offline read, and a trip the user had been removed from stayed on
the device. A sync and a full list now replace the trip's rows, keeping
only the ones created offline that have not synced yet, and a cached
trip the server no longer lists is cleared.
2026-10-07 16:32:02 +02:00
Maurice f092e7a95f fix(offline): a write the server keeps failing on no longer blocks every other write
A queued change the server answered with a 5xx was put back at the head
of the line and the whole flush stopped, every 30 seconds, forever: one
bad payload held every later change of every trip. Now only its own
trip waits, with gaps doubling from 30 s, and after eight failures it is
parked as failed. A DELETE answered 404 counts as done instead of
showing as a failed sync. Settings > Offline offers to try parked
changes again or discard them.
2026-10-07 16:30:07 +02:00
Maurice 3f9b751ee3 fix(realtime): open no socket after a logout, and reconnect as soon as the network is back
A connect still waiting for its ws token when the user logged out went on
to open a socket authenticated as that user, which the next login on the
same browser then reused. And a socket in its reconnect backoff waited up
to 30 s after the network came back or the tab became visible. Both
events now reconnect right away.
2026-10-07 16:25:00 +02:00
Maurice f02253a44c fix(planner): drop search and day-panel answers that arrive too late
The desktop day panel stays mounted across a day switch, so a forecast
or the stays still loading for the previous day overwrote the new day's
once they arrived. The location search in the transport form showed
results for text the user had already changed, or refilled the list
after a pick. Both now drop an answer for a request that no longer
matches.
2026-10-07 16:23:47 +02:00
Maurice 205759e3cf fix(scheduling): do not start a cron tick while the last one still runs
CronJob ran a tick on schedule even when the previous one had not
finished, so an hourly auto-backup of a large library could overlap
itself, and a rejected tick only reached cron's own console line. Jobs
now wait for completion and log failures through the app logger. The
backup also stores photos, videos and PDFs instead of deflating
already-compressed files at level 9.
2026-10-07 16:21:16 +02:00
Maurice 7e8c9ac3a5 fix(server): give the last six outbound fetches a timeout, and lint for it
Overpass, the Wikipedia photo lookup (which holds one of five shared
photo slots), the plugin download and registry, and the two API key
checks had no signal, so a provider that took the connection and went
quiet held the request for undici's 300 s default. ESLint now refuses a
fetch() whose init carries no signal.
2026-10-07 16:19:55 +02:00
Maurice a8fc93dfde fix(notifications): take today from the instance's TZ and catch up missed trip reminders
The reminder crons run at 09:00 in the instance's TZ but computed today
in UTC, so east of Greenwich a trip reminder arrived a day late. A day
the job did not run also lost that day's trip reminders for good: the
match was 'starts exactly N days from today'. Trips now record the start
date a reminder went out for, and the job sends every reminder that is
due and not yet sent. The active-trip redirect and the upcoming
reservations use the instance's date and clock as well.
2026-10-07 16:16:26 +02:00
Maurice e0aedbb2c6 fix(trips): count trip dates in calendar days, the same on REST and MCP
Filling in a missing end date and keeping a trip's length when its start
moved both shifted a local-time Date, so a trip crossing a DST change
lost or gained a day. Both now use addIsoDays. The one-date-makes-a-week
default moves from the REST controller into TripsService.create, so the
MCP create_trip tool no longer stores a half-dated trip, and the trip and
its days are written in one transaction.
2026-10-07 16:11:41 +02:00
Maurice bfa512589c fix(db): baseline a database from the pre-ORM Tours branch correctly
That branch numbered its own steps after the upstream step it was cut
from, so a database at schema_version 243 to 246 (or 216 to 218) with the
Tours tables had four upstream migrations marked as run without ever
running them. The baseline now cuts such a version back to the last
upstream step, and the Tours migration adds the difficulty cap to a tours
table an earlier branch stage created without it.
2026-10-07 16:08:48 +02:00
Maurice 63f7eab4bb fix(auth): match email addresses case-insensitively everywhere
Login and the OIDC fallback match emails case-insensitively, but the
password reset lookup and the admin create and edit checks compared
exactly. An account stored as Foo@x.com never got a reset mail, and an
admin could create bob@x next to Bob@x, after which login picked
whichever row came first. The signup collision check and the first-user
count also move inside the signup transaction.
2026-10-07 16:07:30 +02:00
Maurice e7ab0fc21e fix(users): delete a user who recorded a payment between two other members
budget_settlements.created_by_user_id has no ON DELETE action and the
account cleanup never cleared it, so deleting such a user failed on the
foreign key and rolled the whole deletion back. The cleanup now clears
the recorder; the payment itself belongs to the two members and stays.
2026-10-07 16:05:02 +02:00
Maurice 71a0af02b0 fix(db): refuse a database or backup a newer TREK migrated, and keep a copy before a restore
Rolling an image back, or restoring a backup a newer release made, ran
older code against a schema it does not know and failed later, far from
the cause. Boot now refuses a database that records migrations this
build does not ship, and the restore check refuses such a backup while
the current database is still in place. A restore also keeps the
database it replaces as data/pre-restore-<time>.db.
2026-10-07 16:03:19 +02:00
Maurice 99263fdbeb fix(security): refuse an ENCRYPTION_KEY that disagrees with the key file
An env value different from data/.encryption_key used to overwrite the
file on boot, destroying the only copy of the key the stored secrets were
encrypted with. The server now refuses to start and names the rotation
script, which re-encrypts the AI Parsing addon key as well and moves the
key file to the new key. Under test the suite's fixed key no longer
overwrites the developer's key file.
2026-10-07 15:57:53 +02:00
Maurice c6b6b83d38 fix(docker): take the version from the image, not from the container's env
Portainer's recreate and a copied run config carry the previous image's
APP_VERSION into a container built from the new one, so the server kept
announcing the old release and clients never handed over to the new
bundle. The Dockerfile now writes the version into server/VERSION, which
the server reads before APP_VERSION, and prerelease bundles take their
version from the same build argument.
2026-10-07 15:57:52 +02:00
5005 changed files with 362179 additions and 255819 deletions
+6
View File
@@ -0,0 +1,6 @@
# Commits that only reformat code. git blame skips them with
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (GitHub reads this file on its own).
# style: format server and shared with prettier
724c88c201b2c16d31317eaea5f59f43d314b833
+71
View File
@@ -0,0 +1,71 @@
# Version updates, weekly, into dev. Minor and patch bumps of one ecosystem
# arrive as a single grouped PR; a major bump gets a PR of its own so its
# breaking notes are read on their own.
#
# Security updates are a repository setting, not part of this file, and
# GitHub opens them against the default branch whatever target-branch says.
# enforce-target-branch.yml moves those onto dev.
#
# Every PR runs test.yml like any other, so ci-ok decides whether it can
# merge. Dependabot runs get no Actions secrets, so s3-contract and the Sonar
# scan skip themselves on these PRs and ci-ok counts the skip as passed.
version: 2
updates:
# The root lockfile covers the client, server and shared workspaces.
- package-ecosystem: npm
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
prefix-development: chore(deps-dev)
groups:
npm-minor-and-patch:
update-types: [minor, patch]
# plugin-sdk is standalone with a lockfile of its own and ships to npm.
- package-ecosystem: npm
directory: /plugin-sdk
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(plugin-sdk)
prefix-development: chore(plugin-sdk)
groups:
plugin-sdk-minor-and-patch:
update-types: [minor, patch]
# Keeps the SHA pins of third-party actions current, comment included.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: ci
groups:
actions-minor-and-patch:
update-types: [minor, patch]
# The Dockerfile's base images. The Node major follows .nvmrc and the
# engines ranges, so it moves by hand, together with them.
- package-ecosystem: docker
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: chore(docker)
ignore:
- dependency-name: node
update-types: [version-update:semver-major]
@@ -10,10 +10,11 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const badTitles = [
@@ -11,10 +11,11 @@ permissions:
jobs:
close-stale:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
+3 -2
View File
@@ -10,10 +10,11 @@ permissions:
jobs:
check-title:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Flag or redirect issue
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const title = context.payload.issue.title.trim();
+48 -21
View File
@@ -1,5 +1,11 @@
name: Build & Push Docker Image (Prerelease)
# The canary. Dispatched by hand, usually on dev, it builds the dispatched
# commit as <next minor or major>-pre.N, publishes :VERSION, :MAJOR-pre and
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
# release it refuses a commit whose ci-ok is not green. docker.yml with
# bump=auto later finalizes the line once main carries it.
on:
workflow_dispatch:
inputs:
@@ -11,6 +17,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: prerelease-build
@@ -19,23 +26,41 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being built
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
- name: Determine prerelease version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
@@ -43,14 +68,12 @@ jobs:
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
IFS='.' read -r MAJOR MINOR _ <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
@@ -63,31 +86,34 @@ jobs:
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -100,7 +126,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -119,7 +145,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -127,26 +153,27 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -200,7 +227,7 @@ jobs:
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
BASE_VERSION="${VERSION%%-pre.*}"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
+109 -50
View File
@@ -1,10 +1,24 @@
name: Build & Push Docker Image
# The stable release. Dispatched by hand on main, it:
# 1. refuses to run unless ci-ok passed on the main commit it releases,
# 2. works out the version from the tags and the bump input, commits the
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
# 3. builds both platforms from exactly that bump commit (never from
# whatever main points to by then) and publishes :VERSION,
# :MAJOR.MINOR, :MAJOR and :latest,
# 4. publishes the Helm chart from the same commit.
#
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
# Without a prerelease in flight there is no default: the run stops until
# someone picks patch, minor or major.
on:
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
description: 'auto finalizes the prerelease in flight; otherwise pick patch, minor or major'
type: choice
options: [auto, patch, minor, major]
default: auto
@@ -15,6 +29,7 @@ on:
permissions:
contents: write
checks: read
concurrency:
group: stable-build
@@ -23,39 +38,65 @@ concurrency:
jobs:
version-bump:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/create-github-app-token@v1
- name: Release from main only
if: github.ref != 'refs/heads/main'
run: |
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
exit 1
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
# The dev push, the main push and any manual run each leave a ci-ok on
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
# each, refuses while one is still going, and requires the one that
# finished last to be green, so a later green run supersedes a flaky or
# cancelled one. It prints the link of every run it counted.
- name: Require a green ci-ok on the commit being released
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
runs="$RUNNER_TEMP/ci-ok-runs.json"
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
- name: Determine bump type and update version
id: bump
env:
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
run: |
set -eu
git fetch --tags
# Derive version from git tags — no package.json dependency
# Derive version from git tags, no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
if [ "$CONFIRM_MAJOR" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
@@ -68,72 +109,83 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
# auto finalizes the in-flight prerelease and nothing else. It used
# to fall back to a patch, which would have shipped any change,
# however big, as a patch nobody chose.
PRE_BASE=""
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ -z "$PRE_BASE" ] || [ "$HIGHEST" != "$PRE_BASE" ] || [ "$PRE_BASE" = "$STABLE" ]; then
echo "::error::No prerelease above $STABLE is in flight, so bump=auto has nothing to finalize. Dispatch again with bump=patch, minor or major."
exit 1
fi
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump, it would bypass confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE to $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
fi
# Patch releases have carried migrations before, so this only warns:
# whoever pinned :MAJOR.MINOR gets this schema change too.
if [ "$BUMP" = "patch" ] && [ -n "$STABLE_TAG" ] \
&& [ -n "$(git diff --name-only --diff-filter=A "$STABLE_TAG" HEAD -- server/src/db/migrations/)" ]; then
echo "::warning::$NEW_VERSION is a patch release that adds database migrations."
fi
echo "Bump type: $BUMP"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION ($BUMP)"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "$STABLE to $NEW_VERSION ($BUMP)"
# Update all workspace + root package.json files and the root lockfile in one shot
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
# Commit and tag. The push fast-forwards main from the commit ci-ok
# was checked on, so it fails rather than release past anything that
# landed on main in the meantime.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
git push origin HEAD:main --follow-tags
# build, merge and release-helm check out exactly this commit.
echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
build:
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
env:
PLATFORM: ${{ matrix.platform }}
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -146,7 +198,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -165,7 +217,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
@@ -173,24 +225,25 @@ jobs:
retention-days: 1
merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Download build digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
# Deliberately NOT merged: the per-platform folder names are what lets
# the manifest below be assembled in a fixed order.
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -201,6 +254,10 @@ jobs:
set -euo pipefail
VERSION="${{ needs.version-bump.outputs.version }}"
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
# the conservative pin for an install that wants to wait out a
# release that migrates.
MINOR_TAG="$(echo "$VERSION" | cut -d. -f1-2)"
# amd64 first, always. This used to be a glob over hash-named files, so
# the order changed from one release to the next: 3.4.1 listed amd64
# first and 4.0.0 listed arm64 first. Clients that read the first entry
@@ -224,6 +281,7 @@ jobs:
--annotation "index:org.opencontainers.image.licenses=AGPL-3.0-only" \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$MINOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
@@ -231,16 +289,17 @@ jobs:
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.version-bump.outputs.sha }}
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+9 -8
View File
@@ -7,7 +7,8 @@ on:
jobs:
check-target:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
pull-requests: write
issues: write
@@ -15,7 +16,7 @@ jobs:
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const base = context.payload.pull_request.base.ref;
@@ -68,12 +69,12 @@ jobs:
return;
}
// Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml
// applies to version updates only; security updates always open against
// the default branch, and security updates are all this repository gets
// (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch
// shut them 24 hours later, which is how 13 of them died. Retarget instead:
// a bot cannot click "Edit" the way the comment below asks a human to.
// Dependabot's security updates cannot be pointed at dev. `target-branch`
// in .github/dependabot.yml applies to version updates only; security
// updates always open against the default branch. Labelling them meant
// close-stale-wrong-branch shut them 24 hours later, which is how 13 of
// them died. Retarget instead: a bot cannot click "Edit" the way the
// comment below asks a human to.
if (context.payload.pull_request.user.login === 'dependabot[bot]') {
try {
await github.rest.pulls.update({
+14 -8
View File
@@ -9,29 +9,34 @@ concurrency:
jobs:
lint:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
node-version-file: .nvmrc
- name: Install dependencies
run: npm install
- name: Run lint & format check
# Both are check-only: lint used to run eslint --fix, which rewrote the
# files before format:check looked at them, so neither could fail on
# formatting. lint:format holds every file outside
# scripts/format-baseline.json to Prettier; the list only shrinks.
id: checks
continue-on-error: true
run: |
cd shared
npm run lint
npm run format:check
npm run lint:format
- name: Comment on PR if checks failed
if: steps.checks.outcome == 'failure'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
@@ -45,8 +50,9 @@ jobs:
'',
'```bash',
'cd shared',
'npm run lint',
'npm run format',
'npm run lint:fix',
'npx prettier --write <file> # each file lint:format names',
'npm run lint:format -- --update # when a listed file is formatted now',
'```',
'',
'Then commit and push the changes.',
+7 -6
View File
@@ -15,17 +15,18 @@ permissions:
jobs:
publish:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# 22 matches the TREK server runtime and has node:sqlite, which the
# dev-server tests exercise.
node-version: 22
# The Node the TREK image runs the plugins on. It has the node:sqlite
# the dev-server tests exercise.
node-version-file: .nvmrc
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish
+52 -9
View File
@@ -1,12 +1,27 @@
name: Security Scan
# Neither job is a required check. What they report depends on the advisory
# databases as much as on the change: a CVE published overnight would turn
# every open PR red at once, including the ones that cannot fix it. The
# security floors that hold the fixes live in the root package.json's
# overrides, which a regenerated lockfile keeps.
#
# - npm-audit reads the lockfile only, so it runs in seconds on every PR
# and push into dev and main. It audits what the image installs (the
# server and shared workspaces without dev dependencies) and the
# standalone plugin-sdk.
# - scout scans the built image, base image packages included. It needs
# the Docker Hub login, so it runs on PRs and pushes into main only and
# skips fork PRs. Whether the image builds at all is test.yml's
# docker-image job, which a red scan cannot hide.
on:
pull_request:
branches: [main]
branches: [main, dev]
push:
branches: [main]
branches: [main, dev]
permissions:
contents: read
pull-requests: write
concurrency:
@@ -14,29 +29,57 @@ concurrency:
cancel-in-progress: true
jobs:
npm-audit:
name: npm audit
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
# The client's dependencies end up in a browser bundle, not in the
# image, and are audited by hand until maplibre-gl's fix (a major) lands.
- name: Audit the image's runtime dependencies
run: npm audit --package-lock-only --omit=dev --audit-level=high --workspace=server --workspace=shared
- name: Audit trek-plugin-sdk
working-directory: plugin-sdk
run: npm audit --package-lock-only --omit=dev --audit-level=high
scout:
# Docker Hub secrets are not exposed to pull requests from forks, so the
# Scout login can never succeed there.
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
if: >-
github.repository == 'liketrek/TREK'
&& github.event.pull_request.head.repo.fork != true
&& (github.base_ref == 'main' || github.ref == 'refs/heads/main')
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/build-push-action@v5
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@v3
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@v1
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
with:
command: cves
image: trek:scan
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -18,10 +18,11 @@ concurrency:
jobs:
deploy:
if: github.repository == 'liketrek/TREK'
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
uses: Andrew-Chen-Wang/github-wiki-action@1bbb4280446f9630e8e21a18012cbacf3b0f992e # v5.0.6
with:
strategy: clone
+1
View File
@@ -4,6 +4,7 @@ node_modules/
# Build output
client/dist/
server/dist/
server/VERSION
shared/dist/
server/public/*
!server/public/.gitkeep
+1
View File
@@ -0,0 +1 @@
24
+46 -11
View File
@@ -20,8 +20,9 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
```bash
npm run dev # build shared, then watch shared + server + client concurrently
npm run build # build shared → server → client (order matters)
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
npm run lint # lint all three workspaces, check-only (npm run lint:fix applies the fixes in shared and server)
npm run format # prettier --write all three
npm run lint:knip # knip over client/server/shared (knip.jsonc): unused files, exports, types, deps may only shrink against scripts/ci/knip-baseline.json (CI gate; --update lowers it)
```
Tests:
@@ -32,9 +33,34 @@ npm run test --workspace=server # vitest run (server); also t
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright (local only)
cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local)
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
cd client && npm run lint:strict # strictNullChecks + noImplicitAny errors may only shrink per file; new files start at zero (CI gate)
cd client && npm run lint:warnings # eslint (typed) errors fail, warnings per rule may only shrink, new rules start frozen (CI gate)
cd server && npm run lint:warnings # the same for the server, against server/scripts/eslint-baseline.json (CI gate)
cd server && npm run lint:format # Prettier outside the baseline list (CI gate; shared has the same in lint-prettier.yml)
cd server && npm run lint:strict # strict type errors per file may only shrink, a new file has none (CI gate)
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
cd client && npm run lint:layers # components never import pages/ or mobile/ (CI gate)
cd client && npm run lint:offline # views may not import src/api/ beyond the baseline (CI gate)
cd client && npm run lint:skips # no .only; skipped tests may only shrink (CI gate)
cd client && npm run lint:i18n-keys # every key the client names exists in en; unused en keys may only shrink (CI gate)
cd server && npm run lint:boundaries # no new import cycles or cross-domain reach-ins (CI gate)
cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md)
cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL)
cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate)
cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate)
cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate)
cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate)
cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate)
cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate)
cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate)
cd server && npm run lint:response-contracts # route handlers without @ResponseContract may only shrink (CI gate)
cd client && npm run theme:lint # styling that bypasses appearance tokens may only shrink per file (CI gate)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
```
@@ -52,7 +78,7 @@ npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). `.nvmrc` pins the Node major the Docker image runs and every CI job reads; `engines` in each package.json states the range that installs (npm only warns outside it). `server/.env.example` is the env-var reference.
## Server invariants
@@ -62,8 +88,8 @@ 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. 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.
- **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 `server/src/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`. Tests observe broadcasts through a `FakeRealtimeService` (`server/tests/helpers/fake-realtime.ts`), never by mocking the transport module.
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
@@ -75,14 +101,16 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `client/src/sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `client/src/store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` holds the bypasses per file to a baseline that only shrinks (CI gate); mark intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment, which is counted too.
- **Reading direction**: Arabic runs the app right to left, so spacing, insets and alignment that follow the text use the logical forms (`ms-`/`me-`/`ps-`/`pe-`/`start-`/`end-`/`text-start`/`border-s`/`rounded-s`, `marginInlineStart`, `insetInlineEnd`, `textAlign: 'start'`). `lint:rtl` counts the physical ones per file against `client/scripts/rtl-baseline.json`, which may only shrink; geometry that is physical on purpose in new code (maps, measured positions, time axes) carries an `rtl-lint-disable` comment.
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
- Count-bearing strings are plural groups: the key holds the general form and `key.one`, `.few` … the forms the language's `Intl.PluralRules` selects. Pass `{ count }` as a number and never choose between two keys with `count === 1`; details in `shared/CLAUDE.md`.
## Project direction
@@ -90,7 +118,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 — 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.
- **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. The Kysely table types the repositories query through are generated the same way (`gen:db-types`/`check:db-types`, `server/scripts/generate-db-types.ts`).
- **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.
@@ -106,7 +134,14 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
- **Runners and Node**: jobs run on `ubuntu-24.04`, not `ubuntu-latest`, and every `setup-node` reads `.nvmrc`. Third-party actions (anything outside `actions/*`) are pinned by commit SHA with the tag in a comment; Dependabot keeps both current.
- **Security scans are advisory.** `security.yml` runs `npm audit` (what the image installs, plus `plugin-sdk`) on every PR and push into dev and main, and Docker Scout on the built image for main. Neither is required: a newly published advisory would turn every open PR red at once. Fixes are held as floors in the root `package.json` `overrides`; npm ignores an `overrides` block in a workspace `package.json`, so never put one there.
- **Releases** are dispatched by hand and refuse a commit whose `ci-ok` is not green. `docker-dev.yml` (any branch, usually dev) is the canary: it builds the dispatched commit as the next minor or major `-pre.N` and moves only `:latest-pre` and `:<major>-pre`. `docker.yml` (main only) bumps the version, commits and tags it, builds both platforms from exactly that commit and moves `:latest`, `:<major>`, `:<major>.<minor>` and `:<version>`. Its `bump=auto` only finalizes a prerelease in flight; without one it stops and asks for patch, minor or major. Ship a release that migrates or changes a lot as a prerelease first, and as a minor, so installs pinned to `:<major>.<minor>` can wait it out.
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
@@ -117,7 +152,7 @@ These principles come out of a full-repo audit and shape all new code:
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
+18 -6
View File
@@ -2,8 +2,10 @@
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
# The binary and its runtime behaviour are identical to the apt package.
FROM golang:1.25-alpine AS gosu-build
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
FROM golang:1.26-alpine AS gosu-build
# Pinned to the commit of gosu's 1.19 tag: @latest made every build pick up whatever
# was pushed last, and the tag itself is not a semver version go install accepts.
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@6456aaa0f3c854d199d0f037f068eb97515b7513
# ── Stage 1: shared ──────────────────────────────────────────────────────────
FROM node:24-alpine AS shared-builder
@@ -24,6 +26,9 @@ COPY client/scripts/patch-maplibre.mjs ./client/scripts/
RUN npm ci --workspace=client
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY client/ ./client/
# A prerelease build does not bump client/package.json, so the bundle takes its
# version from the same build argument the server's VERSION file comes from.
ARG APP_VERSION=dev
RUN npm run build --workspace=client
# ── Stage 3: server ──────────────────────────────────────────────────────────
@@ -37,7 +42,9 @@ COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY server/ ./server/
RUN npm run build --workspace=server
# tsc needs more than the 2 GB heap Node picks on a host with about 12 GB of RAM;
# on a bigger build machine the default is larger, so only small hosts failed.
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run build --workspace=server
# ── Stage 4: production runtime ──────────────────────────────────────────────
FROM node:24-trixie-slim
@@ -68,7 +75,7 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
libkitinerary-bin libsqlite3-0 && \
npm ci --workspace=server --omit=dev --ignore-scripts && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
{ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor || true; } && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
@@ -120,8 +127,13 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
ENV NODE_ENV=production
ENV NODE_USE_ENV_PROXY=1
ENV PORT=3000
# The version goes into a file, not into ENV. A container keeps its environment
# when it is recreated on a newer image (Portainer's recreate, a copied run
# config), so an APP_VERSION env var would keep announcing the old release and
# the clients would never pick up the new bundle. The server reads this file
# first (server/src/app-config/image-version.ts).
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
RUN if [ "$APP_VERSION" != "dev" ]; then printf '%s\n' "$APP_VERSION" > /app/server/VERSION; fi
# OCI metadata: Renovate, Watchtower and the registries read the source label to
# link an image update to its release notes (#1498). The release workflows add
@@ -137,7 +149,7 @@ LABEL org.opencontainers.image.title="TREK" \
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD wget -qO- "http://localhost:${PORT:-3000}/api/health" || exit 1
# Start-up lives in a script, not an inline `sh -c` string: container management
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
+2 -2
View File
@@ -43,7 +43,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK2.webp" alt="TREK, a tour of the app" width="100%" />
</div>
@@ -223,7 +223,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
<div align="center">
![Node.js](https://img.shields.io/badge/Node.js_22-339933?style=flat-square&logo=node.js&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js_24-339933?style=flat-square&logo=node.js&logoColor=white)
![NestJS](https://img.shields.io/badge/NestJS_11-E0234E?style=flat-square&logo=nestjs&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-003B57?style=flat-square&logo=sqlite&logoColor=white)
![React](https://img.shields.io/badge/React_19-61DAFB?style=flat-square&logo=react&logoColor=black)
+3 -2
View File
@@ -5,8 +5,9 @@
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
- If left empty, the server resolves the key automatically: existing installs fall back to
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
auto-generate a key persisted to the data PVC.
+6
View File
@@ -16,6 +16,12 @@ data:
{{- if .Values.env.TREK_WIKI_DIR }}
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
{{- end }}
{{- if .Values.env.DEFAULT_LANGUAGE }}
DEFAULT_LANGUAGE: {{ .Values.env.DEFAULT_LANGUAGE | quote }}
{{- end }}
{{- if .Values.env.MCP_MAX_SESSION_PER_USER }}
MCP_MAX_SESSION_PER_USER: {{ .Values.env.MCP_MAX_SESSION_PER_USER | quote }}
{{- end }}
{{- if .Values.env.ALLOWED_ORIGINS }}
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
{{- end }}
+37 -2
View File
@@ -9,7 +9,7 @@ spec:
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
# RollingUpdate would start a second pod holding the same volume before the old one
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
# the old pod down first. Keep it, see updateStrategy in values.yaml.
strategy:
type: {{ .Values.updateStrategy | default "Recreate" }}
selector:
@@ -40,10 +40,14 @@ spec:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- containerPort: 3000
- name: http
containerPort: {{ .Values.env.PORT | default 3000 }}
envFrom:
- configMapRef:
name: {{ include "trek.fullname" . }}-config
{{- with .Values.extraEnvFrom }}
{{- toYaml . | nindent 12 }}
{{- end }}
env:
- name: ENCRYPTION_KEY
valueFrom:
@@ -99,11 +103,42 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: VAPID_PRIVATE_KEY
optional: true
- name: SMTP_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: SMTP_PASS
optional: true
- name: MAPBOX_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: MAPBOX_ACCESS_TOKEN
optional: true
- name: CARTO_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: CARTO_API_KEY
optional: true
- name: DEMO_ADMIN_PASS
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: DEMO_ADMIN_PASS
optional: true
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: data
mountPath: /app/data
- name: uploads
mountPath: /app/uploads
{{- with (default dict .Values.probes).startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with (default dict .Values.probes).liveness }}
livenessProbe:
{{- toYaml . | nindent 12 }}
+24
View File
@@ -32,6 +32,18 @@ data:
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | b64enc | quote }}
{{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -74,4 +86,16 @@ stringData:
{{- if .Values.secretEnv.VAPID_PRIVATE_KEY }}
VAPID_PRIVATE_KEY: {{ .Values.secretEnv.VAPID_PRIVATE_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.SMTP_PASS }}
SMTP_PASS: {{ .Values.secretEnv.SMTP_PASS | quote }}
{{- end }}
{{- if .Values.secretEnv.MAPBOX_ACCESS_TOKEN }}
MAPBOX_ACCESS_TOKEN: {{ .Values.secretEnv.MAPBOX_ACCESS_TOKEN | quote }}
{{- end }}
{{- if .Values.secretEnv.CARTO_API_KEY }}
CARTO_API_KEY: {{ .Values.secretEnv.CARTO_API_KEY | quote }}
{{- end }}
{{- if .Values.secretEnv.DEMO_ADMIN_PASS }}
DEMO_ADMIN_PASS: {{ .Values.secretEnv.DEMO_ADMIN_PASS | quote }}
{{- end }}
{{- end }}
+1 -1
View File
@@ -8,7 +8,7 @@ spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
targetPort: http
protocol: TCP
name: http
selector:
+37 -12
View File
@@ -4,9 +4,10 @@ image:
# tag: latest
pullPolicy: IfNotPresent
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
# Deployment update strategy. Keep Recreate: TREK is a single-writer SQLite app, and
# during a RollingUpdate the new pod migrates the database while the old one still
# writes to it. A ReadWriteMany volume does not make that safe; SQLite's WAL needs both
# processes on one host, which network filesystems do not provide.
updateStrategy: Recreate
# Optional image pull secrets for private registries
@@ -33,7 +34,7 @@ env:
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# Supported: ar, az, br, ca, cs, de, en, es, et, fr, gr, hu, id, it, ja, ko, nl, pl, ru, sk, sv, th, tr, uk, vi, zh, zh-TW
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
@@ -168,15 +169,19 @@ secretEnv:
# Leave both empty to let TREK keep a generated pair in its database.
VAPID_PRIVATE_KEY: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades.
# Only with helm install/upgrade against the cluster: under `helm template`, ArgoCD or Flux
# the chart cannot read the existing secret and renders a new key every time. TREK then
# refuses to start rather than lose its secrets; use existingSecret in those setups.
generateEncryptionKey: false
# If set, the chart renders no Secret of its own and instead reads every secret env
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
# VAPID_PRIVATE_KEY. Missing keys are simply left unset (they are optional), so only
# provide the ones your setup needs.
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
# Missing keys are simply left unset (they are optional), so only provide the ones your
# setup needs.
existingSecret: ""
existingSecretKey: ENCRYPTION_KEY
@@ -202,20 +207,40 @@ persistence:
# httpGet handler. Switching handler means clearing the old one in the same override
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
#
# The startup probe gives a first boot, a long migration or a restore at boot up to ten
# minutes before liveness takes over; liveness alone killed such a boot after about
# 105 s, and the next start began it again. Readiness asks the database, so a pod whose
# database does not answer stops getting traffic without being restarted.
probes:
startup:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 60
liveness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
port: http
periodSeconds: 30
readiness:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
path: /api/health/ready
port: http
periodSeconds: 10
# Further environment variables, for any setting the env block above does not list.
# extraEnv:
# - name: SESSION_DURATION
# value: 7d
extraEnv: []
# Whole ConfigMaps or Secrets:
# extraEnvFrom:
# - secretRef:
# name: trek-smtp
extraEnvFrom: []
resources:
requests:
cpu: 100m
+32 -12
View File
@@ -8,15 +8,28 @@ Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PW
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit (CI)
npm run lint # eslint . (lint:check in CI)
npm run lint:strict # tsc over tsconfig.strict.json (strictNullChecks + noImplicitAny): errors per file may only shrink against scripts/strict-baseline.json, a new file starts at zero (CI gate; --list shows them, --update lowers it)
npm run lint # eslint . (CI runs lint:warnings instead, which fails on the same errors)
npm run lint:warnings # eslint (typed), failing on any error and on any warning count per rule above scripts/eslint-baseline.json and on any entry above its count (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it). Frozen as warnings: floating/misused promises, `.catch(() => {})` (trek/no-swallowed-catch), react-hooks v7 recommended, complexity/max-depth/max-params/max-lines-per-function (120 per .ts function, 300 per .tsx), `use*Store()` without a selector, `window.__*` and window.dispatchEvent buses (trek/no-window-globals), no-console, no-non-null-assertion; the local rules live in scripts/lib/eslint-rules.mjs
npm run lint:pages # enforce the Page pattern (CI gate)
npm run lint:rtl # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
npm run lint:size # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
npm run lint:i18n-keys # every translation key src/ names exists in shared en, and en keys nothing in src/, server/src or plugin-sdk/src reaches may only shrink per en file against scripts/i18n-unused-baseline.json (CI gate; --unused lists them, --update lowers the baseline)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (local only; e2e:report opens the last report)
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
npm run theme:lint # colour literals, palette classes, raw text sizes, z-index literals above z-50 and dark_mode reads may only shrink per file, theme-lint-disable markers too (CI gate; --list shows them, --update lowers both baselines)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
```
Every baseline ratchet above also fails on an entry that allows more than the tree holds: a count above what its file or rule has now, or a file that is gone. Run the check with `--update` in the change that made it smaller, so a fixed file cannot grow back to its old entry and a new file at a deleted path does not inherit one.
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
@@ -32,15 +45,16 @@ The layering is **component → feature hook → store/slice → `repo/` → `ap
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. Versions are append-only: a new `this.version(N)` adds its stores to `HISTORY` and a case to `UPGRADE_CASES` in `tests/unit/db/offlineDb.upgrade.test.ts`, which opens the previous version with seeded rows in fake-indexeddb and upgrades it to the current one; a guard there fails on a declared version without both. Never edit a shipped version's stores or `upgrade()`.
- **`src/sync/`**: in `mutationQueue.ts`, offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as `failed` or `conflict` holds back the later writes to the same `resource` + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Every row `enqueue()` writes carries `schemaVersion` (`MUTATION_SCHEMA_VERSION` in `offlineDb.ts`) and `buildVersion`; bump the constant when a row's fields or the way the replay reads them change incompatibly. A flush replays its own format and older ones (an unstamped row is format 1) and skips a row from a newer format without sending, marking or dropping it, holding back the later writes to its entity, so a tab still on an older bundle never replays a shape it does not know. The guard exists only from the release that introduced `schemaVersion` on; a bundle older than that replays every row, so the first bump of `MUTATION_SCHEMA_VERSION` must not ship while a rollback target older than the guard is still in play. A skipped row stays `pending` with no retry or discard path, so on a rolled-back install it holds its entity until the user clears the cache. Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask `mutationQueue.mustQueue()` before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send `X-Base-Updated-At`, so the server refuses the stale replay with a 409 instead. `flush()` takes the Web Lock `trek-mutation-flush:<db name>` so only one tab replays at a time; a tab that finds it taken waits for it, and a `flush()` called while this tab's flush runs or waits for the lock gets that same flush back (with one more pass asked for), so `flush()` never resolves while a tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flush alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
## Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal, and especially never *write* from one. `lint:offline` enforces it: a file under `components/`, `mobile/`, `pages/` or `hooks/` may not import anything from `src/api/` except types. The long tail that already does is listed in `scripts/offline-baseline.json`; the list only shrinks (a file that stops leaves it via `--update`) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- **Imports go downwards** (`lint:layers`, rules in `scripts/lib/layers.mjs`). A component never imports `pages/` or `mobile/`, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in `src/` is the layer of its name, so `src/types.ts` follows the `types/` rules. A model or helper both sides need moves down (`utils/`, `hooks/`, `types/`); the counts in `scripts/layers-baseline.json` are debt, not precedent.
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
@@ -50,19 +64,25 @@ The offline core is flagship work surrounded by a periphery that ignores it. New
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` counts literals, `bg-[#...]`-style and palette classes, raw `text-sm`- and `text-[13px]`-style sizes, z-index literals and `dark_mode` reads per file against `scripts/theme-baseline.json`.
## Big-picture pieces
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop ↔ mobile shells and the SonarQube duplication gate
## Desktop and phone views
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
## Tests
+2 -2
View File
@@ -459,12 +459,12 @@ const SCRIPTS: Record<string, GuideScript> = {
target: p => card(p).getByText('Upload', { exact: true }),
act: async p => {
await card(p).locator('input[type="file"][multiple]').setInputFiles(await coverFixture())
await expect(card(p).getByRole('button', { name: '1 files' })).toBeVisible({ timeout: 30_000 })
await expect(card(p).getByRole('button', { name: '1 file', exact: true })).toBeVisible({ timeout: 30_000 })
await settle(p)
},
},
// The list opens itself once the upload lands.
only(p => card(p).getByRole('button', { name: '1 files' })),
only(p => card(p).getByRole('button', { name: '1 file', exact: true })),
// No click: openFile hands the browser a download.
only(p => card(p).getByRole('button', { name: UPLOADED })),
],
+1 -1
View File
@@ -650,7 +650,7 @@ const SCRIPTS: Record<string, GuideScript> = {
await importCta(p).click()
await expect(p.getByText('Import from AirTrail', { exact: true })).toHaveCount(0, { timeout: 30_000 })
// The toast counts flight ids, so the joined pair reports two.
const toast = p.getByText('2 flight(s) imported', { exact: true })
const toast = p.getByText('2 flights imported', { exact: true })
await expect(toast).toBeVisible({ timeout: 15_000 })
await expect(card(p, JOINED.title)).toBeVisible({ timeout: 20_000 })
// The toast fades after three seconds; the card's picture and the
+5 -1
View File
@@ -10,11 +10,13 @@ import type { Page } from '@playwright/test'
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
* (carousel arrows) and satisfy the wait before the modal even mounts.
*
* A notice closes one of two ways depending on its shape:
* A notice closes one of three ways depending on its shape:
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
* X button (`aria-label="Dismiss"`), shown on the last page.
* - CTA-less notices show an "OK" button that pages forward and dismisses on
* the last page.
* - The release notes (ReleaseNoticeModal), shown again after every update,
* close through their X (`aria-label="Close"`).
* Multi-page notices are paged through via the pager's Next button first.
* Dismissal is persisted server-side per user, so clearing once keeps it
* cleared for every later spec in the run (shared test DB).
@@ -32,8 +34,10 @@ export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000):
}
const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true })
const ok = dialog.getByRole('button', { name: 'OK', exact: true })
const close = dialog.getByRole('button', { name: 'Close', exact: true })
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
else if (await ok.isVisible().catch(() => false)) await ok.click()
else if (await close.isVisible().catch(() => false)) await close.click()
else break
// Exit animation + the next queued notice mounting.
await page.waitForTimeout(400)
+24 -17
View File
@@ -5,16 +5,19 @@ import { dismissSystemNotices } from './helpers'
//
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
// it — a jsdom unit test cannot express "coarse pointer at 834px".
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. What
// turned the swipe into a drag was the drag-drop-touch polyfill, confined to hybrid
// laptops since. #1616 then gave tablets their drag back on purpose, through the
// long-press bridge ([data-touch-drag]): a row may be draggable, but only a press that
// is held becomes a drag, so a swipe still scrolls. Only a real device context proves
// that; a jsdom unit test cannot express "coarse pointer at 834px".
//
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
// is WebKit underneath, which is why the reporter saw this in all three they tried.
test.use({ ...devices['iPad Pro 11'] })
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
test('#1432 iPad: the places list scrolls under a swipe', async ({ page }) => {
await page.goto('/dashboard')
await dismissSystemNotices(page)
@@ -58,12 +61,22 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
// 1. A swipe must not become a drag. What turned it into one in #1432 was the
// drag-drop-touch polyfill; it belongs to hybrid laptops only now
// (utils/touchDragPolyfill.ts), so a tablet must never load it. A row may be
// draggable, but only behind the long-press bridge (#1616).
const polyfillLoaded = await page.evaluate(() =>
performance.getEntriesByType('resource').some(e => /drag-?drop-?touch/i.test(e.name)))
expect(polyfillLoaded, 'the drag-drop-touch polyfill stays off a tablet').toBe(false)
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
await expect(row).toHaveAttribute('draggable', 'false')
await expect(row).toBeVisible()
const swipeSafe = await row.evaluate(el => el.getAttribute('draggable') !== 'true' || !!el.closest('[data-touch-drag]'))
expect(swipeSafe, 'a draggable row sits behind the long-press bridge').toBe(true)
// 2. The list must scroll, and no drop-to-import overlay may appear.
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
// The list's scroll container carries trek-stagger among its other classes. Taken
// from the row above: the first draggable on the page can be another panel's.
const scroller = row.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " trek-stagger ")]')
const before = await scroller.evaluate(el => el.scrollTop)
const box = (await scroller.boundingBox())!
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
@@ -72,17 +85,11 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
expect(after, 'places list scrolled').toBeGreaterThan(before)
await expect(page.getByText('Drop to import')).toHaveCount(0)
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
// they must be visible (they were opacity:0 above 767px).
const arrowOpacity = await page.evaluate(() => {
const el = document.querySelector('.reorder-buttons')
return el ? getComputedStyle(el).opacity : 'absent'
})
expect(['1', 'absent']).toContain(arrowOpacity)
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
// 3. The iPad must still get the desktop shell — isMobile stayed width-based. One
// panel at a time (#2247), so opening Places closed the day plan and left its
// tab as the way back.
await expect(page.locator('.leaflet-container')).toBeVisible()
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
// The top tab bar has a button named Plan as well; the way back is the panel's
// own tab, which is accented while its panel is closed.
await expect(page.locator('button[aria-label="Plan"].bg-accent')).toBeVisible()
})
+14 -3
View File
@@ -71,18 +71,29 @@ test('#1809 iPhone: flow screens scroll the document, full-screen ones do not',
await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0)
// 4. The shared body scroll lock: with the document as the scroller, an open
// sheet must freeze the page behind it and give the position back.
// sheet must freeze the page behind it and give the position back. The
// lock is body overflow: hidden, which reaches the viewport only while
// html stays overflow: visible (index.css), and there it stops the swipe.
// It does not stop a script: window.scrollBy moves an overflow: hidden
// viewport in every engine, so the lock is read where it takes effect.
await page.evaluate(() => window.scrollBy(0, 300))
const locked = await page.evaluate(readScroll)
expect(locked.top).toBeGreaterThan(0)
await page.getByRole('button', { name: 'New Trip' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await page.evaluate(() => window.scrollBy(0, 300))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
const lock = await page.evaluate(() => ({
body: getComputedStyle(document.body).overflowY,
html: getComputedStyle(document.documentElement).overflowY,
top: document.scrollingElement!.scrollTop,
}))
expect(lock.body, 'the open sheet locks body').toBe('hidden')
expect(lock.html, 'html stays visible, so the lock reaches the viewport').toBe('visible')
expect(lock.top, 'opening the sheet does not move the page').toBe(locked.top)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
expect(await page.evaluate(() => getComputedStyle(document.body).overflowY), 'the lock is released').not.toBe('hidden')
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top)
await page.evaluate(() => window.scrollBy(0, 200))
expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top)
+51 -2
View File
@@ -5,6 +5,7 @@ import eslintConfigPrettier from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import trek from './scripts/lib/eslint-rules.mjs';
// Minimal stub so the existing `// eslint-disable-next-line react/no-danger`
// directive in src/i18n/TransHtml.tsx resolves without pulling in the full
@@ -46,6 +47,11 @@ export default tseslint.config(
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react: reactStub,
trek,
},
languageOptions: {
// Typed linting, for the rules that need to know a value is a promise.
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
'react/no-danger': 'off',
@@ -54,8 +60,12 @@ export default tseslint.config(
// --- Severities tuned to keep CI green on a codebase that was never linted ---
// (each rule below has pre-existing violations; surfaced as warnings, not blockers)
// rules-of-hooks has one conditional-hook violation in PlaceInspector.tsx -> warn (not error).
'react-hooks/rules-of-hooks': 'warn',
// A hook called conditionally is a crash waiting for the render that skips it.
// The rest of react-hooks v7's recommended set (the React Compiler's checks:
// state set in effects or render, refs read in render, impure renders,
// mutated props) as warnings, each count held by scripts/eslint-baseline.json.
...Object.fromEntries(Object.keys(reactHooks.configs['recommended-latest'].rules).map((rule) => [rule, 'warn'])),
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
@@ -73,6 +83,45 @@ export default tseslint.config(
'no-useless-escape': 'warn',
'no-useless-assignment': 'warn',
'preserve-caught-error': 'warn',
// --- Added later as warnings; lint:warnings freezes what exists and fails on each new one ---
// A promise nobody awaits or catches loses its failure, and an async
// handler where a void one is expected (onClick={async () => ...})
// rejects into nowhere.
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': ['warn', { checksVoidReturn: { attributes: true } }],
'trek/no-swallowed-catch': 'warn',
// A `!` silences the null the type admits instead of handling it.
'@typescript-eslint/no-non-null-assertion': 'warn',
// Whole-store subscriptions; the message points to useShallow.
'trek/store-without-selector': 'warn',
// window.__dragData, window.__addToast and window.dispatchEvent buses.
'trek/no-window-globals': 'warn',
'no-console': 'warn',
},
},
{
// Size and branching, in app code only. ESLint's defaults for complexity
// (20) and max-depth (4). Five parameters: a hook with more takes an
// options object. A function body may hold 120 lines in a .ts file, which
// is logic only; a .tsx component also holds its markup, which is long by
// nature, so it gets 300, still well under the god components this is
// meant to stop (LoginPage was one function of 1300 lines).
files: ['src/**/*.{ts,tsx}'],
ignores: ['src/**/*.test.{ts,tsx}'],
rules: {
complexity: ['warn', 20],
'max-depth': ['warn', 4],
'max-params': ['warn', 5],
'max-lines-per-function': ['warn', { max: 120, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
files: ['src/**/*.tsx'],
ignores: ['src/**/*.test.tsx'],
rules: {
'max-lines-per-function': ['warn', { max: 300, skipBlankLines: true, skipComments: true, IIFEs: true }],
},
},
{
+16 -3
View File
@@ -1,6 +1,9 @@
{
"name": "@trek/client",
"version": "4.3.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"private": true,
"type": "module",
"scripts": {
@@ -17,10 +20,20 @@
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"lint:check": "eslint .",
"lint:warnings": "node scripts/eslint-ratchet.mjs",
"lint:pages": "node scripts/check-page-pattern.mjs",
"lint:rtl": "node scripts/rtl-lint.mjs",
"lint:size": "node scripts/size-lint.mjs",
"lint:format": "node scripts/format-lint.mjs",
"lint:layers": "node scripts/layer-lint.mjs",
"lint:offline": "node scripts/offline-lint.mjs",
"lint:dup": "node scripts/dup-lint.mjs",
"lint:pairs": "node scripts/pairs-lint.mjs",
"lint:skips": "node scripts/skip-lint.mjs",
"lint:strict": "node scripts/strict-lint.mjs",
"lint:i18n-keys": "node scripts/i18n-keys.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
"theme:lint": "node scripts/theme-lint.mjs",
"theme:lint:strict": "node scripts/theme-lint.mjs --strict",
"e2e": "playwright test",
"shots": "playwright test --project=screenshots",
"help:media": "node e2e/help/run.mjs",
@@ -54,7 +67,6 @@
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "5.24.0",
"marked": "^18.0.13",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
@@ -66,13 +78,14 @@
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"tz-lookup": "^6.1.25",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@jscpd/core": "^4.2.5",
"@jscpd/tokenizer": "^4.2.6",
"@playwright/test": "^1.63.0",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.3",
+1
View File
@@ -0,0 +1 @@
{}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/*
* lint:dup: keeps copied code from coming back.
*
* Most features have a desktop view and a phone view, and each used to carry
* its own copy of the feature's logic, so a fix reached one of them and the
* other kept the bug. The logic now lives in one shared hook or module per
* feature, and this check holds the copies that remain to what they are. It
* counts, per file under src/, the lines inside a block of code that repeats
* elsewhere (or in the same file), at the thresholds SonarCloud uses on a
* pull request. The files that still hold such lines are listed in
* scripts/dup-baseline.json with their count, and a file may hold no more
* than its entry (a file without an entry holds none). An entry above what
* its file holds now, or for a file that is gone, fails as well until
* --update lowers it.
*
* npm run lint:dup check against the baseline (CI)
* npm run lint:dup -- --list print every copied block with its other side
* npm run lint:dup -- --update lower the baseline to what the files hold now;
* it never raises an entry
*
* The measuring lives in scripts/lib/dup.mjs.
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/dup.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:dup', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
+52
View File
@@ -0,0 +1,52 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
"trek/no-swallowed-catch": 133,
"max-params": 35,
"trek/store-without-selector": 69,
"react-hooks/refs": 559,
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
"react-hooks/preserve-manual-memoization": 15,
"no-useless-assignment": 7,
"no-empty": 19,
"trek/no-window-globals": 41,
"react-refresh/only-export-components": 46,
"unused-disable-directive": 6,
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 40,
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 491,
"@typescript-eslint/no-non-null-assertion": 1946,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
"trek/no-window-globals": 332,
"trek/no-swallowed-catch": 14,
"react-hooks/globals": 5,
"react-hooks/refs": 22,
"@typescript-eslint/no-this-alias": 1,
"trek/store-without-selector": 3,
"@typescript-eslint/no-unsafe-function-type": 2,
"no-console": 2,
"react-refresh/only-export-components": 1
}
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/*
* lint:warnings: ESLint warnings may only go down.
*
* Most rules in eslint.config.mjs are warnings, set that way to keep CI green
* on code that was never linted, and a warning never fails a build. So they
* piled up: unused variables hiding dead props, missing effect dependencies
* of the kind that has already shown stale money. This runs ESLint over the
* client and counts the warnings per rule, separately for app code and
* tests, and the messages an eslint-disable comment silenced as a third
* group. Each count may not pass its entry in scripts/eslint-baseline.json,
* and an entry above its count fails as well until --update lowers it, so a
* fixed warning cannot come back unseen.
* Any error fails as well, so the check can stand in for `npm run lint:check`,
* and so does a block comment that configures a rule inline (such as one that
* turns no-explicit-any off): it leaves no message to count, so it would
* otherwise be the free way past the ratchet.
*
* npm run lint:warnings check against the baseline (CI)
* npm run lint:warnings -- --update lower the baseline to today's counts;
* it never raises an entry
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/eslint-warnings.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update') }));
+932
View File
@@ -0,0 +1,932 @@
{
"pairs": [
{
"feature": "Accommodation (stay) create/edit",
"hooks": ["src/components/Planner/stayFormModel.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Planner/useDayDetail.ts"],
"mobile": ["src/mobile/screens/trip/sheets/MAccommodationSheet.tsx"]
},
{
"feature": "Add place to collection",
"hooks": ["src/components/Collections/addPlaceModel.ts", "src/components/Collections/useAddPlaceToCollection.ts"],
"desktop": ["src/components/Collections/AddPlaceToCollectionModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollAddSheet.tsx"]
},
{
"feature": "Addon manager",
"hooks": [
"src/components/Admin/addons/addonModel.ts",
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.ts"
],
"desktop": ["src/components/Admin/AddonManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Admin notification preference matrix",
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
"desktop": ["src/pages/admin/AdminNotificationsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminNotifyMatrix.tsx"]
},
{
"feature": "Admin notifications tab",
"hooks": ["src/components/Admin/useAdminNotificationSettings.ts"],
"desktop": ["src/pages/admin/AdminNotificationsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminNotificationsSection.tsx"]
},
{
"feature": "Admin page shell",
"hooks": ["src/pages/admin/useAdmin.ts"],
"desktop": ["src/pages/AdminPage.tsx"],
"mobile": ["src/mobile/screens/admin/MAdmin.tsx"]
},
{
"feature": "Admin settings tab: trekApiModel",
"hooks": ["src/components/Admin/trekApiModel.ts"],
"desktop": ["src/pages/admin/TrekApiCard.tsx"],
"mobile": ["src/mobile/screens/admin/MApiProviderBlocks.tsx"]
},
{
"feature": "Admin settings tab: useAdminSettingsActions",
"hooks": ["src/components/Admin/useAdminSettingsActions.ts"],
"desktop": ["src/pages/admin/AdminSettingsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminSettingsSection.tsx"]
},
{
"feature": "Admin users invite status",
"hooks": ["src/pages/admin/adminModel.ts"],
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
},
{
"feature": "Atlas country/region action sheet",
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
"desktop": ["src/pages/AtlasPage.tsx"],
"mobile": ["src/mobile/screens/atlas/MAtlasCountryPopup.tsx"]
},
{
"feature": "Atlas page: atlasModel",
"hooks": ["src/pages/atlas/atlasModel.ts"],
"desktop": [
"src/components/Atlas/AtlasCountryPlaces.tsx",
"src/components/shared/CountryFlag.tsx",
"src/pages/AtlasPage.tsx"
],
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/MAtlasBucketSheet.tsx"]
},
{
"feature": "Atlas page: useAtlas",
"hooks": ["src/pages/atlas/useAtlas.ts"],
"desktop": ["src/pages/AtlasPage.tsx"],
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/atlasController.ts"]
},
{
"feature": "Audit log",
"hooks": ["src/components/Admin/useAuditLog.ts"],
"desktop": ["src/components/Admin/AuditLogPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAuditLogPanel.tsx"]
},
{
"feature": "Backup panel",
"hooks": ["src/components/Admin/useBackupAdmin.ts"],
"desktop": ["src/components/Admin/BackupPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminBackupPanel.tsx"]
},
{
"feature": "Booking forms: linked expense and file attach",
"hooks": ["src/components/Planner/useBookingExpenseIntent.ts"],
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
"mobile": [
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Bookings and transports lists",
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
"desktop": [
"src/components/Planner/DayDetailPanel.tsx",
"src/components/Planner/DayPlanSidebar.tsx",
"src/components/Planner/PlaceInspector.tsx",
"src/components/Planner/ReservationModal.tsx",
"src/components/Planner/ReservationsPanel.tsx",
"src/components/Planner/TransportModal.tsx",
"src/components/Planner/bookings/BookingCard.tsx",
"src/components/Planner/bookings/BookingDetailDialog.tsx",
"src/components/Planner/bookings/BookingDetailHost.tsx",
"src/components/Planner/bookings/BookingDialogShell.tsx",
"src/components/Planner/bookings/BookingsHeader.tsx",
"src/components/Planner/bookings/BookingsList.tsx",
"src/components/Planner/bookings/BookingsTimeline.tsx",
"src/components/Planner/bookings/bookingFacts.ts",
"src/components/Planner/bookings/bookingParts.tsx",
"src/components/Planner/bookings/timelineModel.ts",
"src/components/Planner/bookings/useReservationListFilter.ts",
"src/components/Planner/planCosts.ts",
"src/pages/sharedTrip/SharedBookingsView.tsx",
"src/pages/sharedTrip/SharedDayCard.tsx"
],
"mobile": ["src/mobile/screens/trip/tabs/MBookingsTab.tsx", "src/mobile/screens/trip/tabs/MTransportsTab.tsx"]
},
{
"feature": "Bottom nav context-aware create action",
"hooks": ["src/components/Layout/useCreateAction.ts"],
"desktop": ["src/components/Layout/BottomNav.tsx"],
"mobile": ["src/mobile/components/MBottomNav.tsx"]
},
{
"feature": "Bulk save selected places to a collection",
"hooks": ["src/components/Collections/useSaveTripPlacesToList.ts"],
"desktop": ["src/components/Collections/SaveTripPlacesToListModal.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx"]
},
{
"feature": "Category manager",
"hooks": ["src/components/Admin/useCategoryAdmin.ts"],
"desktop": ["src/components/Admin/CategoryManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminCategoryManager.tsx"]
},
{
"feature": "Collab chat: collabModel",
"hooks": ["src/components/Collab/collabModel.ts"],
"desktop": [
"src/components/Collab/CollabChatMessages.tsx",
"src/components/Collab/CollabPolls.tsx",
"src/components/Collab/useCollabChat.ts"
],
"mobile": [
"src/mobile/screens/trip/tabs/MCollabChat.tsx",
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/mobile/screens/trip/tabs/MCollabPolls.tsx"
]
},
{
"feature": "Collab chat: useCollabChatData",
"hooks": ["src/components/Collab/useCollabChatData.ts"],
"desktop": ["src/components/Collab/useCollabChat.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabChat.tsx"]
},
{
"feature": "Collab notes",
"hooks": ["src/components/Collab/useCollabNotesData.ts"],
"desktop": ["src/components/Collab/CollabNotes.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabNotes.tsx"]
},
{
"feature": "Collab polls",
"hooks": ["src/components/Collab/useCollabPolls.ts"],
"desktop": ["src/components/Collab/CollabPolls.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabPolls.tsx"]
},
{
"feature": "Collection list editor",
"hooks": ["src/components/Collections/useListEditor.ts"],
"desktop": ["src/components/Collections/ListEditorModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollEditSheet.tsx"]
},
{
"feature": "Collection place detail",
"hooks": ["src/components/Collections/useCollectionPlaceForm.ts"],
"desktop": ["src/components/Collections/CollectionPlaceDetail.tsx"],
"mobile": ["src/mobile/screens/collections/MCollPlaceSheet.tsx"]
},
{
"feature": "Collection sharing",
"hooks": ["src/components/Collections/useCollectionSharing.ts"],
"desktop": ["src/components/Collections/ShareCollectionModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollShareSheet.tsx"]
},
{
"feature": "Collections page: collectionsModel",
"hooks": ["src/pages/collections/collectionsModel.ts"],
"desktop": [
"src/components/Collections/AddPlaceToCollectionModal.tsx",
"src/components/Collections/CollectionFilterBar.tsx",
"src/components/Collections/CollectionMap.tsx",
"src/components/Collections/CollectionPicker.tsx",
"src/components/Collections/CollectionPlaceDetail.tsx",
"src/components/Collections/ListEditorModal.tsx",
"src/components/Collections/MSaveToCollectionSheet.tsx",
"src/components/Collections/StatusBadge.tsx"
],
"mobile": [
"src/mobile/screens/collections/MCollAddSheet.tsx",
"src/mobile/screens/collections/MCollEditSheet.tsx",
"src/mobile/screens/collections/MCollPlaceRow.tsx",
"src/mobile/screens/collections/MCollPlaceSheet.tsx",
"src/mobile/screens/collections/MCollections.tsx"
]
},
{
"feature": "Collections page: useCollections",
"hooks": ["src/pages/collections/useCollections.ts"],
"desktop": ["src/pages/CollectionsPage.tsx"],
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
},
{
"feature": "Copy collection places to a trip",
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
"desktop": ["src/components/Collections/CopyToTripModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollTripPickerSheet.tsx"]
},
{
"feature": "Costs/budget ledger: costsModel",
"hooks": ["src/components/Budget/costsModel.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Create/edit trip form",
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
"desktop": ["src/components/Trips/TripFormModal.tsx"],
"mobile": ["src/mobile/screens/dashboard/MNewTripSheet.tsx"]
},
{
"feature": "Dashboard page",
"hooks": ["src/pages/dashboard/useDashboard.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard residuals: dashboardModel",
"hooks": ["src/pages/dashboard/dashboardModel.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx", "src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard residuals: useDashboardPlugins",
"hooks": ["src/components/Dashboard/useDashboardPlugins.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard widgets: useCollectionLists",
"hooks": ["src/components/Dashboard/useCollectionLists.ts"],
"desktop": ["src/components/Dashboard/CollectionsWidget.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
},
{
"feature": "Dashboard widgets: useCurrencyConverter, useDashboardTimezones",
"hooks": [
"src/components/Dashboard/useCurrencyConverter.ts",
"src/components/Dashboard/useDashboardTimezones.ts"
],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
},
{
"feature": "Day detail",
"hooks": ["src/components/Planner/dayDetailModel.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day note create/edit/delete",
"hooks": ["src/hooks/useDayNotes.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MDaySheet.tsx", "src/mobile/screens/trip/sheets/MNoteSheet.tsx"]
},
{
"feature": "Day plan reorder",
"hooks": ["src/utils/mergedOrder.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Day route optimize + Google Maps / CoMaps export",
"hooks": ["src/components/Planner/dayRoute.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day weather chip anchor + weather icon map: dayWeather",
"hooks": ["src/utils/dayWeather.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx", "src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day weather chip anchor + weather icon map: weatherIcons",
"hooks": ["src/components/Weather/weatherIcons.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Weather/WeatherWidget.tsx"],
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Default user settings",
"hooks": ["src/components/Admin/useDefaultUserSettings.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminDefaultUserSettings.tsx"]
},
{
"feature": "Dev test notifications panel",
"hooks": ["src/components/Admin/useDevNotifications.ts"],
"desktop": ["src/components/Admin/DevNotificationsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx"]
},
{
"feature": "Document sync leftovers: docSyncModel",
"hooks": ["src/components/Files/docsync/docSyncModel.ts"],
"desktop": ["src/components/Files/docsync/DocSyncFlow.tsx", "src/components/Files/docsync/DocSyncPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
},
{
"feature": "Document sync leftovers: useScopePicker",
"hooks": ["src/components/Files/docsync/useScopePicker.ts"],
"desktop": ["src/components/Files/docsync/DocSyncScopeModal.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
},
{
"feature": "Expense editor",
"hooks": ["src/components/Budget/useExpenseForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx"]
},
{
"feature": "GitHub releases panel",
"hooks": ["src/components/Admin/useGithubReleases.ts", "src/utils/releaseNotes.ts"],
"desktop": ["src/components/Admin/GitHubPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminGitHubPanel.tsx"]
},
{
"feature": "Google/Naver shared-list import",
"hooks": ["src/components/Planner/useListImport.ts"],
"desktop": ["src/components/Planner/usePlacesSidebar.ts"],
"mobile": ["src/mobile/screens/trip/sheets/ImpListStep.tsx"]
},
{
"feature": "GPX/KML/KMZ file import into places",
"hooks": ["src/components/Planner/usePlacesFileImport.ts"],
"desktop": ["src/components/Planner/FileImportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/ImpFileStep.tsx"]
},
{
"feature": "Journey detail page: JourneyDetailPage.helpers",
"hooks": ["src/pages/journeyDetail/JourneyDetailPage.helpers.ts"],
"desktop": [
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
"src/components/Journey/JourneyDetailPageGalleryView.tsx",
"src/components/Journey/JourneyDetailPageMapView.tsx",
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx",
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx",
"src/components/Journey/JourneyEntryCover.tsx",
"src/components/Journey/MobileEntryView.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/JourneyPublicPage.tsx",
"src/pages/journeyPublic/useJourneyPublic.ts"
],
"mobile": [
"src/mobile/screens/journey/MJourneyCreateSheet.tsx",
"src/mobile/screens/journey/MJourneyDetail.tsx",
"src/mobile/screens/journey/MJourneyEntrySheet.tsx",
"src/mobile/screens/journey/MJourneySettingsSheet.tsx",
"src/mobile/screens/journey/mobileJourneyMeta.ts"
]
},
{
"feature": "Journey detail page: useJourneyDetail",
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
"desktop": ["src/pages/JourneyDetailPage.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey detail screen: useGalleryUpload",
"hooks": ["src/components/Journey/useGalleryUpload.ts"],
"desktop": ["src/components/Journey/JourneyDetailPageGalleryView.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey detail screen: useJourneyCarouselSync",
"hooks": ["src/components/Journey/useJourneyCarouselSync.ts"],
"desktop": ["src/components/Journey/MobileMapTimeline.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey entry editor",
"hooks": ["src/components/Journey/useJourneyEntryForm.ts"],
"desktop": ["src/components/Journey/JourneyDetailPageEntryEditor.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyEntrySheet.tsx"]
},
{
"feature": "Journey list page",
"hooks": ["src/pages/journey/useJourney.ts"],
"desktop": ["src/pages/JourneyPage.tsx"],
"mobile": ["src/mobile/screens/journey/MJourney.tsx"]
},
{
"feature": "Journey settings: useJourneySettings",
"hooks": ["src/components/Journey/useJourneySettings.ts"],
"desktop": [
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx"
],
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
},
{
"feature": "Journey settings: useJourneyShareLink",
"hooks": ["src/components/Journey/useJourneyShareLink.ts"],
"desktop": ["src/components/Journey/JourneyShareSection.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
},
{
"feature": "Lists sub-tab (packing/todo) persistence",
"hooks": ["src/hooks/useListsSubTab.ts"],
"desktop": ["src/pages/tripPlanner/ListsContainer.tsx"],
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
},
{
"feature": "MCP tokens / OAuth sessions admin",
"hooks": ["src/components/Admin/useMcpTokensAdmin.ts"],
"desktop": ["src/components/Admin/AdminMcpTokensPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminMcpTokensPanel.tsx"]
},
{
"feature": "Media lightbox",
"hooks": ["src/components/Files/useMediaLightbox.ts"],
"desktop": ["src/components/Files/FileManagerImageLightbox.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MFileLightbox.tsx"]
},
{
"feature": "Notifications list row actions",
"hooks": ["src/components/Notifications/useNotificationItemActions.ts"],
"desktop": ["src/components/Notifications/InAppNotificationItem.tsx"],
"mobile": ["src/mobile/screens/notifications/MNotificationRow.tsx"]
},
{
"feature": "Notifications page",
"hooks": ["src/pages/inAppNotifications/useInAppNotifications.ts"],
"desktop": ["src/pages/InAppNotificationsPage.tsx"],
"mobile": ["src/mobile/screens/notifications/MNotifications.tsx"]
},
{
"feature": "OAuth scope group picker",
"hooks": ["src/components/OAuth/useScopeSelection.ts"],
"desktop": ["src/components/OAuth/ScopeGroupPicker.tsx"],
"mobile": ["src/mobile/screens/settings/MScopeGroupPicker.tsx"]
},
{
"feature": "Packing bag card and bag weight summary",
"hooks": ["src/components/Packing/useBagCardEditor.ts"],
"desktop": ["src/components/Packing/PackingListPanelBagCard.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MBagsSheet.tsx"]
},
{
"feature": "Packing list: packingListModel",
"hooks": ["src/components/Packing/packingListModel.ts"],
"desktop": [
"src/components/Packing/packingPrintDocument.ts",
"src/components/Packing/usePackingExport.ts",
"src/components/Packing/usePackingListPanel.ts"
],
"mobile": [
"src/mobile/screens/trip/tabs/MBagsSheet.tsx",
"src/mobile/screens/trip/tabs/MPackItemSheet.tsx",
"src/mobile/screens/trip/tabs/MPackingListTab.tsx"
]
},
{
"feature": "Packing list: usePackingBags, usePackingCategoryAssignees, usePackingItemActions, usePackingTemplates",
"hooks": [
"src/components/Packing/usePackingBags.ts",
"src/components/Packing/usePackingCategoryAssignees.ts",
"src/components/Packing/usePackingItemActions.ts",
"src/components/Packing/usePackingTemplates.ts"
],
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MPackingListTab.tsx"]
},
{
"feature": "Packing list: usePackingImport",
"hooks": ["src/components/Packing/usePackingImport.ts"],
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MPackingImportSheet.tsx"]
},
{
"feature": "Packing template manager",
"hooks": ["src/components/Admin/usePackingTemplateAdmin.ts"],
"desktop": ["src/components/Admin/PackingTemplateManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPackingTemplateManager.tsx"]
},
{
"feature": "Per-leg travel mode and connector mode menu",
"hooks": ["src/components/Planner/useLegModeActions.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Permissions panel",
"hooks": ["src/components/Admin/usePermissionsAdmin.ts"],
"desktop": ["src/components/Admin/PermissionsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPermissionsPanel.tsx"]
},
{
"feature": "Place add/edit form and maps search: usePlaceForm",
"hooks": ["src/components/Planner/usePlaceForm.ts"],
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
"mobile": [
"src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx",
"src/mobile/screens/trip/sheets/PlCategoryPicker.tsx"
]
},
{
"feature": "Place add/edit form and maps search: usePlaceSearch",
"hooks": ["src/components/Planner/usePlaceSearch.ts"],
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/PlPlaceSearch.tsx"]
},
{
"feature": "Place detail actions",
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
},
{
"feature": "Places pool list",
"hooks": ["src/components/Planner/usePlacesPool.ts"],
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
"mobile": [
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
]
},
{
"feature": "Plugins admin",
"hooks": [
"src/components/Admin/plugins/pluginModel.ts",
"src/components/Admin/plugins/usePluginDetail.ts",
"src/components/Admin/plugins/usePluginsAdmin.ts"
],
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
},
{
"feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
"desktop": ["src/components/Planner/ReservationModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MReservationSheet.tsx"]
},
{
"feature": "Road-trip add-stop draft",
"hooks": ["src/components/Roadtrip/useStopDraftForm.ts"],
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
},
{
"feature": "Road-trip stay length draft",
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Settings / Account",
"hooks": ["src/components/Settings/useAccountSettings.ts"],
"desktop": ["src/components/Settings/AccountTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
{
"feature": "Settings / Account / Passkeys",
"hooks": ["src/components/Settings/usePasskeys.ts"],
"desktop": ["src/components/Settings/PasskeysSection.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
{
"feature": "Settings / Appearance",
"hooks": ["src/components/Settings/appearanceModel.ts", "src/components/Settings/useAppearanceEditor.ts"],
"desktop": ["src/components/Settings/AppearanceSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAppearance.tsx"]
},
{
"feature": "Settings / General/Display",
"hooks": ["src/components/Settings/useSettingSaver.ts"],
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
},
{
"feature": "Settings / Integrations / AI parsing",
"hooks": ["src/components/Settings/useLlmConnection.ts"],
"desktop": ["src/components/Settings/LlmConnectionSection.tsx"],
"mobile": ["src/mobile/screens/settings/MLlmConnectionSection.tsx"]
},
{
"feature": "Settings / Integrations / AirTrail",
"hooks": ["src/components/Settings/useAirTrailSettings.ts"],
"desktop": ["src/components/Settings/AirTrailConnectionSection.tsx"],
"mobile": ["src/mobile/screens/settings/MAirTrailConnectionSection.tsx"]
},
{
"feature": "Settings / Integrations / MCP",
"hooks": ["src/components/Settings/useMcpIntegration.ts"],
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMcp.tsx"]
},
{
"feature": "Settings / Integrations / Photo providers",
"hooks": ["src/components/Settings/usePhotoProviderConnections.ts"],
"desktop": ["src/components/Settings/PhotoProvidersSection.tsx"],
"mobile": ["src/mobile/screens/settings/MPhotoProvidersSection.tsx"]
},
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
},
{
"feature": "Settings / Map: useMapSettingsForm",
"hooks": ["src/components/Settings/useMapSettingsForm.ts"],
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Notifications",
"hooks": ["src/components/Settings/useNotificationSettings.ts"],
"desktop": ["src/components/Settings/NotificationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsNotifications.tsx"]
},
{
"feature": "Settings / Plugins: usePluginActivity",
"hooks": ["src/components/Settings/usePluginActivity.ts"],
"desktop": ["src/components/Settings/PluginActivityPanel.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
},
{
"feature": "Settings / Plugins: usePluginUserSettings",
"hooks": ["src/components/Plugins/usePluginUserSettings.ts"],
"desktop": ["src/components/Settings/PluginSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
},
{
"feature": "Settings page",
"hooks": ["src/pages/settings/useSettings.ts"],
"desktop": ["src/pages/SettingsPage.tsx"],
"mobile": ["src/mobile/screens/settings/MSettings.tsx"]
},
{
"feature": "Settings shell: visible tabs + integrations gating",
"hooks": ["src/components/Settings/useIntegrationGates.ts"],
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsIntegrations.tsx"]
},
{
"feature": "Storage backends admin: useBackendForm",
"hooks": ["src/components/Admin/storage/useBackendForm.ts"],
"desktop": ["src/components/Admin/storage/BackendForm.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
},
{
"feature": "Storage backends admin: useStoragePanel",
"hooks": ["src/components/Admin/storage/useStoragePanel.ts"],
"desktop": ["src/components/Admin/storage/AdminStoragePanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
},
{
"feature": "To-do list and task add/edit form: useTaskForm",
"hooks": ["src/components/Todo/useTaskForm.ts"],
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MTaskSheet.tsx"]
},
{
"feature": "To-do list and task add/edit form: useTodoList",
"hooks": ["src/components/Todo/useTodoList.ts"],
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MTodoListTab.tsx"]
},
{
"feature": "Toast presenter queue",
"hooks": ["src/components/shared/useToastQueue.ts"],
"desktop": ["src/components/shared/Toast.tsx"],
"mobile": ["src/mobile/components/MToastHost.tsx"]
},
{
"feature": "Tours list filter",
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Tours list facets",
"hooks": ["src/components/Tours/useTourListFilters.ts"],
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
"desktop": [
"src/components/Planner/DayPlanSidebarTransportDetailModal.tsx",
"src/components/Planner/ReservationsPanel.tsx",
"src/components/Planner/bookings/BookingDetailHost.tsx"
],
"mobile": [
"src/mobile/screens/trip/sheets/MTransportSheet.tsx",
"src/mobile/screens/trip/tabs/MBookingsTab.tsx",
"src/mobile/screens/trip/tabs/MTransportsTab.tsx"
]
},
{
"feature": "Transport booking detail view: transportDetailModel",
"hooks": ["src/components/Planner/transportDetailModel.ts"],
"desktop": ["src/components/Planner/DayPlanSidebarTransportDetailModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTransportSheet.tsx"]
},
{
"feature": "Transport booking form: bookingFormModel",
"hooks": ["src/components/Planner/bookingFormModel.ts"],
"desktop": [
"src/components/Planner/ReservationModal.tsx",
"src/components/Planner/TransportModal.tsx",
"src/components/Planner/useBookingFileAttach.ts"
],
"mobile": [
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Transport booking form: transportEndpoints, useTransportForm",
"hooks": ["src/components/Planner/transportEndpoints.ts", "src/components/Planner/useTransportForm.ts"],
"desktop": ["src/components/Planner/TransportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"]
},
{
"feature": "Trip export",
"hooks": ["src/components/Planner/useTripExport.ts"],
"desktop": ["src/components/Planner/TripExportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MExportSheet.tsx"]
},
{
"feature": "Trip files: fileActions",
"hooks": ["src/components/Files/fileActions.ts"],
"desktop": ["src/components/Files/FileManagerAssignModal.tsx", "src/components/Files/useFileManager.ts"],
"mobile": [
"src/mobile/screens/trip/tabs/MFileLinkSheet.tsx",
"src/mobile/screens/trip/tabs/MFileMenuSheet.tsx",
"src/mobile/screens/trip/tabs/MFilesTab.tsx"
]
},
{
"feature": "Trip files: fileListRules",
"hooks": ["src/components/Files/fileListRules.ts"],
"desktop": ["src/components/Files/FileManagerRow.tsx", "src/components/Files/useFileManager.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MFileLinkSheet.tsx", "src/mobile/screens/trip/tabs/filesModel.ts"]
},
{
"feature": "Trip files: useFileTrash",
"hooks": ["src/components/Files/useFileTrash.ts"],
"desktop": ["src/components/Files/useFileManager.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MFileTrashSheet.tsx"]
},
{
"feature": "Trip loading splash",
"hooks": ["src/components/shared/useTripSplashBeats.ts"],
"desktop": ["src/components/shared/TripLoadingSplash.tsx"],
"mobile": ["src/mobile/screens/trip/MTripLoadingSplash.tsx"]
},
{
"feature": "Trip planner page",
"hooks": ["src/pages/tripPlanner/useTripPlanner.ts"],
"desktop": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
},
{
"feature": "Trip sheet wiring: transportEditorOpeners",
"hooks": ["src/pages/tripPlanner/transportEditorOpeners.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx", "src/pages/tripPlanner/usePlannerDialogs.ts"],
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
},
{
"feature": "Trip sheet wiring: tripCover, useBookingExpenseEditor",
"hooks": ["src/pages/tripPlanner/tripCover.ts", "src/pages/tripPlanner/useBookingExpenseEditor.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
},
{
"feature": "User modals/sheets",
"hooks": ["src/components/Admin/useAdminUserActions.ts"],
"desktop": ["src/pages/admin/AdminUserModals.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminSheets.tsx"]
},
{
"feature": "Vacay calendar logging logic: useVacayCalendarLogic",
"hooks": ["src/components/Vacay/useVacayCalendarLogic.ts"],
"desktop": ["src/components/Vacay/VacayCalendar.tsx", "src/components/Vacay/VacayPersons.tsx"],
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay calendar logging logic: vacayFormat",
"hooks": ["src/components/Vacay/vacayFormat.ts"],
"desktop": ["src/components/Vacay/VacayStats.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacay.tsx"]
},
{
"feature": "Vacay fusion invite",
"hooks": ["src/components/Vacay/useVacayUserPicker.ts"],
"desktop": ["src/components/Vacay/VacayPersons.tsx", "src/components/Vacay/VacaySharedCalendars.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacayInviteSheet.tsx", "src/mobile/screens/vacay/MVacayShareSheet.tsx"]
},
{
"feature": "Vacay page",
"hooks": ["src/pages/vacay/useVacay.ts"],
"desktop": ["src/pages/VacayPage.tsx"],
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay read-only calendar sharing",
"hooks": ["src/components/Vacay/useVacayShareActions.ts"],
"desktop": ["src/components/Vacay/VacaySharedCalendars.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacayShareSheet.tsx", "src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay settings",
"hooks": ["src/components/Vacay/useVacaySettings.ts"],
"desktop": ["src/components/Vacay/VacaySettings.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"]
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
"src/mobile/screens/collections/MCollInvites.tsx": "Markup only; the invites and accept and decline come through props from useCollections via MCollections.",
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
"src/mobile/screens/settings/MMobileNavCustomizer.tsx": "Phone only editor for the mobile bottom nav, built on components/Settings/useMobileNavEditor.",
"src/mobile/screens/settings/MSetPickerSheet.tsx": "Phone only option picker bottom sheet, a generic UI primitive.",
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": "Markup only; state from useMRtAlternatives, text from the shared alternativeOverlays.",
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
"src/mobile/screens/trip/roadtrip/useMRtCorridor.ts": "Phone reach and anchor layer over the planner's shared corridor state.",
"src/mobile/screens/trip/sheets/MBookingFields.tsx": "Markup only: the booking code, notes, traveler and route stop fields both phone booking sheets draw.",
"src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx": "Phone booking sheet footer that composes PlFileAttach and MLinkedCosts.",
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
"src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
"src/mobile/screens/trip/tabs/transportsModel.ts": "Phone transport card readers (metadata, day and time cells) for MTransportsTab and MBookingsTab.",
"src/mobile/screens/vacay/MVacayMonth.tsx": "Markup only; its day cell colours come from the phone vacayDayModel.",
"src/mobile/screens/vacay/vacayDayModel.ts": "Phone day cell colour model for MVacayMonth; the desktop card styles its cells inline."
}
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/*
* lint:format: new code is formatted with Prettier.
*
* Client code was never Prettier-checked, and almost every file differs from
* what .prettierrc asks for. Formatting all of it at once would bury the
* history under one commit, so the files that were unformatted when this
* check came in are listed in scripts/format-baseline.json and the list only
* shrinks: every file outside it, a new one in particular, must be formatted,
* and a listed file leaves the list once it has been formatted. The check
* covers .ts, .tsx, .mjs and .css under src/, tests/, e2e/ and scripts/.
*
* npm run lint:format check against the baseline (CI)
* npm run lint:format -- --list print every unformatted file
* npm run lint:format -- --update take formatted and deleted files off the list;
* it never adds one
*
* To format a file: npx prettier --write <file>. The comparison lives in
* scripts/lib/format.mjs.
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/format.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:format', (args) =>
check({ root, update: args.includes('--update'), list: args.includes('--list') })
);
+598
View File
@@ -0,0 +1,598 @@
/*
* lint:i18n-keys: every translation key the client names exists in en.
*
* `t()` takes any string and returns the key itself when en has no such
* entry, so a typo or a key deleted from the locales renders as raw
* "budget.addCategory" text and nothing fails. Locale parity cannot see it:
* it compares the locales with en, not en with the code. This check reads
* src/ (tests aside) and resolves what it finds against shared/src/i18n/en:
*
* t('x.y'), tHtml('x.y'), tr('x.y') every string literal in the first
* argument, so both sides of a
* `cond ? 'a.b' : 'c.d'` are checked
* <TransHtml html="x.y" /> the markup variant
* translateApiError(t, err, 'x.y') the fallback key
* labelKey: 'x.y', titleKey="x.y" any property or prop named *Key that
* holds a dotted key, the tables
* components feed to t() later
* const guideKey = (id) => `help.${id}` a key builder (help/registry.ts):
* a template returned by a const
* named *Key, read as a template key
*
* A key exists when en declares it, or declares `key.other` (a plural group
* whose general form is spelled out). A template whose interpolations can
* only produce string literals (t(`roadtrip.window.${a ? 'onRoute' : 'atStop'}`))
* is expanded into those keys and each is checked like a literal one; a
* literal fallback beside data (`${role ?? 'editor'}`) is checked as a key
* too. Any other template literal key such as
* t(`budget.category.${id}`), or a prefix with the rest appended
* (t('costs.filter.' + f)), cannot be resolved. It is read as a pattern in
* which each interpolation stands for one key segment (no dot), and it passes
* on its own only when that pattern is narrow: it matches at least one en key
* and at most MAX_IMPLICIT_MATCHES of them, the size of an enum such as the
* docsync error codes. A pattern that matches nothing fails. A wider one
* (`settings.${mode}` reaches every settings key, so a wrong suffix would
* pass unseen) and one without a fixed dotted prefix need an entry in
* DYNAMIC_ALLOWED, keyed by file and template, saying what bounds the value.
* Listing every narrow template instead would be well over a hundred entries
* restating their own prefix, and nothing in an entry would be checked that
* the bound does not already check. What the bound cannot see is a runtime
* value with no key under a narrow prefix: that needs the value's type, which
* this text scan does not have, and is the price of not listing them.
* A key held in a variable (`t(opt.label)`) is out of reach unless the table
* names it in a *Key property.
*
* The en keys nothing reaches are a ratchet. Besides src/, the search for
* references reads server/src (system notice and notification channel keys
* the server sends, *Key properties in its registries) and plugin-sdk/src, so
* a key the server uses does not read as dead; those references only count as
* reached, they are not held to the rules above. The unused keys per en
* domain file may not rise above scripts/i18n-unused-baseline.json, and an
* entry above its count fails until --update lowers it. A key reached only
* through a variable the scan cannot follow is reached by naming it: in a
* *Key property of the table, or as a literal t() where the table is read.
*
* npm run lint:i18n-keys check (CI)
* npm run lint:i18n-keys -- --unused also list en keys no literal or pattern reaches
* npm run lint:i18n-keys -- --update lower the unused-key baseline; it never raises an entry
*/
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
// Plain paths rather than `new URL(..., import.meta.url)`: under the test runner's DOM environment URL is
// jsdom's, which fileURLToPath refuses.
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src');
const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts');
const UNUSED_BASELINE = join(HERE, 'i18n-unused-baseline.json');
/**
* Where else a translation key can be named: the server sends keys for the
* client to translate (system notices, notification channels), and the plugin
* SDK names the ones plugin surfaces use. Read only to find which en keys are
* reached.
*/
export const REFERENCE_ROOTS = [join(HERE, '..', '..', 'server', 'src'), join(HERE, '..', '..', 'plugin-sdk', 'src')];
/** How many en keys a template may reach and still pass without an entry below. */
export const MAX_IMPLICIT_MATCHES = 30;
/**
* Template keys the check cannot bound by itself: wider than
* MAX_IMPLICIT_MATCHES, or without a fixed dotted prefix. Each names the file
* it sits in and why the interpolated value can only be a real key; one with
* no fixed prefix also lists the keys it resolves to, which must exist. An
* entry nothing uses any more fails, so the list cannot rot.
*/
export const DYNAMIC_ALLOWED = [
{
file: 'components/Trips/TripShareDialog.tsx',
template: '${opt.label}Hint',
because:
'TripShareDialog appends Hint to each SHARE_OPTIONS label (useTripShare.ts); share.optTravelOnlyHint ' +
'and share.optHideImagesHint exist, and the template has no fixed part to match them by',
resolves: ['share.optTravelOnlyHint', 'share.optHideImagesHint'],
},
...[
'help.ctx.${id}.${part}',
'help.ctx.${id}.bullet.${n}',
'help.guide.${id}.${part}',
'help.guide.${id}.step.${n}',
'help.guide.${id}.tip.${n}',
].map((template) => ({
file: 'help/registry.ts',
template,
because:
'a key builder over the registered help contexts and guides; src/help/registry.test.ts resolves every ' +
'key of every registered entry against en',
})),
{
file: 'mobile/screens/dashboard/MUserMenu.tsx',
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
{
file: 'mobile/screens/trip/sheets/MBookingFields.tsx',
template: 'reservations.${s}',
because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist",
},
...['components/Admin/AdminPluginsPanel.tsx', 'mobile/screens/admin/MAdminPluginsPanel.tsx'].map((file) => ({
file,
template: 'admin.plugins.perm.${perm}',
because:
'PermLabel translates a permission only when PERM_KEYS (generated PLUGIN_PERMISSIONS) holds it and shows ' +
'the raw code otherwise; the plugin-facts gate keeps the permission keys in step with en',
})),
...['components/Settings/PhotoProvidersSection.tsx', 'mobile/screens/settings/MPhotoProvidersSection.tsx'].flatMap(
(file) =>
['memories.${field.label}', 'memories.${field.hint}'].map((template) => ({
file,
template,
because:
'field label and hint names come from the provider field rows PhotoProviderSeeder writes, each a ' +
'memories.* key in en',
}))
),
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/;
const CALL_RE = /(?<![\w$.])(?:t|tHtml|tr)\(|(?<=[\w$]\.)(?:t|tHtml)\(/g;
const HTML_PROP_RE = /<TransHtml\b[^>]*?\bhtml=(["'])([^"'\n]+)\1/g;
const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g;
const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g;
const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, files);
else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path);
}
return files;
}
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
/**
* The source of the first argument of the call whose `(` sits at `open`:
* everything up to the top-level comma or the closing parenthesis, with
* strings, template literals and nested brackets skipped as units.
*/
export function firstArgument(text, open) {
let depth = 0;
for (let i = open + 1; i < text.length; i++) {
const c = text[i];
if (c === "'" || c === '"' || c === '`') {
for (i++; i < text.length && text[i] !== c; i++) {
if (text[i] === '\\') i++;
else if (c === '`' && text[i] === '$' && text[i + 1] === '{') {
// Skip the interpolation, counting its braces.
let braces = 0;
for (; i < text.length; i++) {
if (text[i] === '{') braces++;
else if (text[i] === '}' && --braces === 0) break;
}
}
}
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') {
if (depth === 0) return text.slice(open + 1, i);
depth--;
} else if (c === ',' && depth === 0) return text.slice(open + 1, i);
}
return text.slice(open + 1);
}
/** The quoted literals and template literals of an argument's source. */
export function literalsIn(arg) {
const quoted = [];
const templates = [];
for (const m of arg.matchAll(/(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g)) {
if (m[3] !== undefined) templates.push(m[3]);
else quoted.push(m[2]);
}
return { quoted, templates };
}
/**
* A template literal's key pattern: its fixed parts, one key segment (no dot)
* for each interpolation. Null when it has no fixed dotted prefix.
*/
export function templatePattern(template) {
const parts = template.split(/\$\{[^}]*\}/);
if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null;
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${parts.map(escape).join('[^.]+')}$`);
}
/**
* The top-level operator positions of an expression: ternary `?` and `:`,
* and the `??` and `||` fallbacks. Strings and brackets are skipped as units,
* `?.` is optional chaining.
*/
function topLevelOperators(expr) {
const ops = [];
let depth = 0;
for (let i = 0; i < expr.length; i++) {
const c = expr[i];
if (c === "'" || c === '"' || c === '`') {
for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++;
} else if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (depth > 0) continue;
else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ });
else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i });
else if (c === ':') ops.push({ op: ':', at: i });
}
return ops;
}
const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/;
/** Whether `e` is one parenthesized expression, its first `(` closing at its last character. */
function wrapsWhole(e) {
if (!e.startsWith('(') || !e.endsWith(')')) return false;
let depth = 0;
for (let i = 0; i < e.length; i++) {
const c = e[i];
if (c === "'" || c === '"' || c === '`') {
for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++;
} else if (c === '(') depth++;
else if (c === ')' && --depth === 0) return i === e.length - 1;
}
return false;
}
/**
* The string literals an interpolated expression can produce, and whether
* those are all it can produce. `x ? 'a' : y ? 'b' : 'c'` gives a, b and c,
* complete; `role ?? 'editor'` gives editor, incomplete (role is data);
* `phase` gives nothing. A literal in a condition (`phase === 'start'`) is
* never a result.
*/
export function interpolationResults(expr) {
let e = expr.trim();
while (wrapsWhole(e)) e = e.slice(1, -1).trim();
const ops = topLevelOperators(e);
const q = ops.findIndex((o) => o.op === '?');
if (q >= 0) {
// The matching `:` of the first `?`: nested ternaries in the consequent raise the count.
let open = 0;
for (const o of ops.slice(q + 1)) {
if (o.op === '?') open++;
else if (o.op === ':' && open-- === 0) {
const a = interpolationResults(e.slice(ops[q].at + 1, o.at));
const b = interpolationResults(e.slice(o.at + 1));
return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete };
}
}
return { literals: [], complete: false };
}
const fallback = ops.filter((o) => o.op === '??' || o.op === '||');
if (fallback.length) {
const operands = [];
let from = 0;
for (const o of fallback) {
operands.push(e.slice(from, o.at));
from = o.at + 2;
}
operands.push(e.slice(from));
// The left operands are tested values; only the literal ones among them could be produced.
const literals = operands.flatMap((x) => interpolationResults(x).literals);
return { literals, complete: false };
}
const m = QUOTED_RE.exec(e);
return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false };
}
/** How many keys a template with only literal choices may expand to before it is read as a pattern instead. */
const MAX_EXPANSION = 64;
/**
* The references one template literal makes. Interpolations that can only
* produce string literals (`${open ? 'a' : 'b'}`) are expanded, so each
* branch is checked as a literal key and a typo in one fails. When some
* interpolation is data, the template stays a pattern, and each literal an
* interpolation can still produce (`${role ?? 'editor'}`) is checked with
* that interpolation fixed, under the original template's allow-list site.
*/
function templateRefs(file, line, template) {
const parts = template.split(/\$\{([^}]*)\}/);
const fixed = parts.filter((_, i) => i % 2 === 0);
const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults);
const literal = [];
const dynamic = [];
const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join('');
const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1);
if (results.every((r) => r.complete) && size <= MAX_EXPANSION) {
let keys = [''];
results.forEach((r, i) => {
keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit));
});
for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] });
return { literal, dynamic };
}
dynamic.push({ file, line, template, pattern: templatePattern(template) });
const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`);
results.forEach((r, i) => {
for (const lit of r.literals) {
const choices = raw.map((x, j) => (j === i ? lit : x));
const derived = build(choices);
if (!derived.includes('${')) literal.push({ file, line, key: derived });
else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template });
}
});
return { literal, dynamic };
}
/** Every key reference in one file's source. */
export function scanSource(text, file = '<source>') {
const literal = [];
const dynamic = [];
for (const m of text.matchAll(CALL_RE)) {
const open = m.index + m[0].length - 1;
const { quoted, templates } = literalsIn(firstArgument(text, open));
const line = lineAt(text, m.index);
for (const key of quoted) {
if (KEY_RE.test(key)) literal.push({ file, line, key });
// t('costs.filter.' + f): a prefix with the rest appended, read like a template.
else if (PREFIX_RE.test(key))
dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) });
}
for (const template of templates) {
if (!template.includes('${')) {
if (KEY_RE.test(template)) literal.push({ file, line, key: template });
continue;
}
const refs = templateRefs(file, line, template);
literal.push(...refs.literal);
dynamic.push(...refs.dynamic);
}
}
for (const m of text.matchAll(KEY_BUILDER_RE)) {
// Builders of other strings (cache keys) have no fixed dotted prefix and are not translation keys.
const pattern = templatePattern(m[1]);
if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern });
}
for (const re of [HTML_PROP_RE, API_ERROR_RE, KEY_PROP_RE]) {
for (const m of text.matchAll(re)) {
if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] });
}
}
return { literal, dynamic };
}
/** en's keys with the domain file each sits in, read with the shared parity tooling's reader. */
export async function readEnKeyFiles() {
const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href);
const files = new Map();
for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) files.set(key, file);
if (files.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken');
return files;
}
/** en's keys, read from the locale sources with the shared parity tooling's reader. */
export async function readEnKeys() {
return new Set((await readEnKeyFiles()).keys());
}
export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`);
const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '');
/** How many en keys a pattern reaches, a plural group counted once. */
export function countMatches(pattern, enKeys) {
const reached = new Set();
for (const k of enKeys) {
if (pattern.test(k)) reached.add(k);
else if (pattern.test(base(k))) reached.add(base(k));
}
return reached.size;
}
/**
* The verdict over a scan: literal keys en lacks, template keys no en key
* matches, templates too wide to pass without an allow-list entry,
* allow-list entries that no file uses any more or that the check no longer
* needs, and the en keys nothing reaches. elsewhere holds the references
* outside src/ (scanElsewhere), which only count as reaching keys.
*
* @param {ReturnType<typeof scanElsewhere>} [elsewhere]
*/
export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED, elsewhere = { literal: [], dynamic: [] }) {
const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key));
const site = (file, template) => `${file}\n${template}`;
const allowedSites = new Set(allowed.map((a) => site(a.file, a.template)));
const counts = new Map();
const reach = (template, pattern) => {
if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null);
return counts.get(template);
};
const unmatched = [];
const broad = [];
for (const d of scan.dynamic) {
const n = reach(d.template, d.pattern);
const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template));
if (n === 0 || (n === null && !isAllowed)) unmatched.push(d);
else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n });
}
const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template)));
const stale = allowed.filter((a) => {
if (!usedSites.has(site(a.file, a.template))) return true;
if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true;
// An entry for a template narrow enough to pass by itself only lengthens the list.
const n = reach(a.template, templatePattern(a.template));
return n !== null && n <= MAX_IMPLICIT_MATCHES;
});
// References outside src/ (see REFERENCE_ROOTS) only mark keys as reached.
const reached = new Set([
...scan.literal.map((l) => l.key),
...elsewhere.literal.map((l) => l.key),
...allowed.flatMap((a) => a.resolves ?? []),
]);
const patterns = [...scan.dynamic, ...elsewhere.dynamic].map((d) => d.pattern).filter(Boolean);
const unused = [...enKeys].filter(
(k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k)))
);
return { missing, unmatched, broad, stale, unused };
}
export function scanTree(root = SRC) {
if (!existsSync(root)) throw new Error(`${root} does not exist`);
const scan = { literal: [], dynamic: [] };
for (const path of walk(root)) {
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'));
scan.literal.push(...found.literal);
scan.dynamic.push(...found.dynamic);
}
if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`);
return scan;
}
/**
* Every key reference under the reference roots, for the unused count. A root
* that is missing or names no key at all is fine here: these are extra places
* to look, not the code the check is about.
*
* @param {string[]} [roots]
* @returns {{ literal: { file: string, line: number, key: string }[], dynamic: { file: string, line: number, template: string, pattern: RegExp | null, site?: string }[] }}
*/
export function scanElsewhere(roots = REFERENCE_ROOTS) {
const scan = { literal: [], dynamic: [] };
for (const root of roots) {
if (!existsSync(root)) continue;
for (const path of walk(root)) {
const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/'));
scan.literal.push(...found.literal);
scan.dynamic.push(...found.dynamic);
}
}
return scan;
}
/** The unused keys per en domain file. */
export function unusedPerFile(unused, keyFiles) {
const counts = {};
for (const key of unused) {
const file = keyFiles.get(key);
if (file) counts[file] = (counts[file] ?? 0) + 1;
}
return counts;
}
/**
* The unused counts held against the baseline: the files that gained unused
* keys, the entries above their count (or for a file that is gone), and the
* baseline lowered to today, which --update writes. An entry never rises and
* a file without one holds no unused key.
*/
export function compareUnused(baseline, counts) {
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
const lowered = {};
for (const [file, allowed] of Object.entries(baseline)) {
const now = counts[file] ?? 0;
if (now > 0) lowered[file] = Math.min(allowed, now);
}
const stale = Object.entries(baseline)
.filter(([file, allowed]) => lowered[file] !== allowed)
.map(([file, allowed]) => ({ file, allowed, now: counts[file] ?? 0 }));
return { grown, stale, lowered };
}
/** The committed baseline, which fails closed: a missing or malformed file is an error, never empty. */
export function readUnusedBaseline(path = UNUSED_BASELINE) {
let value;
try {
value = JSON.parse(readFileSync(path, 'utf8'));
} catch (err) {
throw new Error(`scripts/i18n-unused-baseline.json cannot be read (${err.message}); restore it from git`);
}
const ok =
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value).every((n) => Number.isInteger(n) && n > 0);
if (!ok)
throw new Error('scripts/i18n-unused-baseline.json is not an object of file → positive count; restore it from git');
return value;
}
// Compared by real path, so the check still runs when the script is started through a symlink.
const isCli =
Boolean(process.argv[1]) &&
existsSync(process.argv[1]) &&
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
if (isCli) {
try {
const scan = scanTree();
const keyFiles = await readEnKeyFiles();
const enKeys = new Set(keyFiles.keys());
const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys, DYNAMIC_ALLOWED, scanElsewhere());
const unusedCounts = unusedPerFile(unused, keyFiles);
let unusedBaseline = readUnusedBaseline();
if (process.argv.includes('--update')) {
unusedBaseline = compareUnused(unusedBaseline, unusedCounts).lowered;
const sorted = Object.fromEntries(Object.entries(unusedBaseline).sort(([a], [b]) => (a < b ? -1 : 1)));
writeFileSync(UNUSED_BASELINE, JSON.stringify(sorted, null, 2) + '\n');
}
const unusedVerdict = compareUnused(unusedBaseline, unusedCounts);
for (const { file, line, key } of missing) {
console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`);
}
for (const { file, line, template } of unmatched) {
console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`);
}
for (const { file, line, template, matches } of broad) {
console.error(
`FAIL ${file}:${line}: \`${template}\` reaches ${matches} en keys, more than ${MAX_IMPLICIT_MATCHES}: ` +
'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value'
);
}
for (const { file, template } of stale) {
console.error(
`FAIL DYNAMIC_ALLOWED lists \`${template}\` in ${file}, which that file no longer uses, whose keys en ` +
'lacks, or which is narrow enough to pass without it: remove or fix the entry'
);
}
for (const [file, n] of unusedVerdict.grown) {
const keys = unused.filter((k) => keyFiles.get(k) === file).slice(0, 5);
console.error(
`FAIL en/${file}: ${n} key(s) nothing in client, server or plugin-sdk reaches, baseline ` +
`${unusedBaseline[file] ?? 0} (${keys.join(', ')}). Use the key, or delete it from every locale.`
);
}
for (const { file, allowed, now } of unusedVerdict.stale) {
console.error(
`FAIL en/${file} is held at ${allowed} in scripts/i18n-unused-baseline.json, but ${now} key(s) are unused ` +
'now. Run npm run lint:i18n-keys -- --update to lower it.'
);
}
if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`);
console.log(
`i18n keys: ${new Set(scan.literal.map((l) => l.key)).size} literal and ${scan.dynamic.length} template ` +
`reference(s) checked against ${enKeys.size} en keys; ${unused.length} en key(s) reached by neither, ` +
`baseline allows ${Object.values(unusedBaseline).reduce((a, b) => a + b, 0)} (--unused lists them)`
);
if (
missing.length ||
unmatched.length ||
broad.length ||
stale.length ||
unusedVerdict.grown.length ||
unusedVerdict.stale.length
)
process.exit(1);
} catch (err) {
console.error(`FAIL ${err.message}`);
process.exit(1);
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"admin.ts": 152,
"atlas.ts": 10,
"backup.ts": 10,
"budget.ts": 40,
"categories.ts": 1,
"collab.ts": 9,
"collection.ts": 35,
"common.ts": 7,
"dashboard.ts": 65,
"dawarich.ts": 15,
"dayplan.ts": 18,
"docsync.ts": 25,
"files.ts": 4,
"help.ts": 3,
"inspector.ts": 4,
"journey.ts": 70,
"login.ts": 1,
"map.ts": 5,
"memories.ts": 8,
"mobileNav.ts": 2,
"mobileVacay.ts": 4,
"nav.ts": 1,
"notif.ts": 7,
"notifications.ts": 18,
"packing.ts": 6,
"pdf.ts": 1,
"perm.ts": 4,
"places.ts": 10,
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 40,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
"system_notice.ts": 32,
"todo.ts": 4,
"tours.ts": 14,
"trip.ts": 10,
"undo.ts": 1,
"vacay.ts": 17
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/*
* lint:layers: imports follow the layering, downwards only.
*
* Components reached up into pages/ for models and helpers, and sideways into
* mobile/ for phone hooks and sheets, so nothing could be moved or split
* without touching three trees. scripts/lib/layers.mjs holds the rules: a
* component imports neither pages/ nor mobile/, a shared hook no view, and
* nothing under the views (stores, repos, sync, api, db, helpers) a view at
* all. The imports that already broke a rule are counted per file in
* scripts/layers-baseline.json, and a file may hold no more of them than its
* entry (a file without an entry holds none). Type-only imports count: a type
* the lower layer needs belongs below it. An entry above what its file holds
* now, or for a file that is gone, fails as well until --update lowers it.
*
* npm run lint:layers check against the baseline (CI)
* npm run lint:layers -- --list print every import against the layering
* npm run lint:layers -- --update lower the baseline to what the files hold now;
* it never raises an entry
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/layers.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:layers', (args) =>
check({ root, update: args.includes('--update'), list: args.includes('--list') })
);
+62
View File
@@ -0,0 +1,62 @@
{
"src/api/client.ts": 1,
"src/components/Atlas/AtlasCountryPlaces.tsx": 1,
"src/components/Budget/ReceiptScanModal.tsx": 1,
"src/components/Collab/CollabLinks.tsx": 1,
"src/components/Collab/CollabNotes.tsx": 1,
"src/components/Collections/AddPlaceToCollectionModal.tsx": 1,
"src/components/Collections/CollectionFilterBar.tsx": 2,
"src/components/Collections/CollectionLabelFilter.tsx": 1,
"src/components/Collections/CollectionMap.tsx": 1,
"src/components/Collections/CollectionPicker.tsx": 1,
"src/components/Collections/CollectionPlaceDetail.tsx": 1,
"src/components/Collections/ListEditorModal.tsx": 1,
"src/components/Collections/MSaveToCollectionSheet.tsx": 3,
"src/components/Collections/StatusBadge.tsx": 1,
"src/components/Dawarich/DawarichAcceptDialog.tsx": 3,
"src/components/Dawarich/DawarichAtlasDialog.tsx": 3,
"src/components/Dawarich/DawarichSuggestionsPanel.tsx": 1,
"src/components/Journey/ContributorInviteDialog.tsx": 1,
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx": 1,
"src/components/Journey/JourneyDetailPageChips.tsx": 1,
"src/components/Journey/JourneyDetailPageEntryCard.tsx": 2,
"src/components/Journey/JourneyDetailPageEntryEditor.tsx": 2,
"src/components/Journey/JourneyDetailPageGalleryView.tsx": 1,
"src/components/Journey/JourneyDetailPageMapView.tsx": 1,
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx": 1,
"src/components/Journey/JourneyDetailPageProviderPicker.tsx": 2,
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1,
"src/components/Journey/JourneyEntryCover.tsx": 2,
"src/components/Journey/MobileEntryView.tsx": 1,
"src/components/Map/MapView.tsx": 1,
"src/components/Map/MapViewGL.tsx": 1,
"src/components/Planner/IcsSubscribeModal.tsx": 1,
"src/components/Planner/TransitJourneyModal.tsx": 1,
"src/components/Planner/TransitSearchPanel.tsx": 1,
"src/components/Planner/TripWarningsBanner.tsx": 1,
"src/components/Planner/transitDisplay.tsx": 1,
"src/components/Plugins/PluginFrame.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 1,
"src/components/Studio/StudioShell.tsx": 1,
"src/components/Studio/StudioSidebar.tsx": 1,
"src/components/Studio/TravelElements.tsx": 1,
"src/components/Todo/useTodoList.ts": 1,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/shared/ConfirmDialog.tsx": 1,
"src/components/shared/CountryFlag.tsx": 1,
"src/components/shared/EmptyState.tsx": 1,
"src/components/shared/TripLoadingSplash.tsx": 1,
"src/components/shared/dialogFocus.ts": 1,
"src/hooks/useDawarichConnection.ts": 1,
"src/hooks/useDawarichJournalTrail.ts": 1,
"src/hooks/useDawarichSuggestions.ts": 1,
"src/hooks/useDayNotes.ts": 1,
"src/hooks/useRouteCalculation.ts": 4,
"src/hooks/useTransportRoutes.ts": 1,
"src/hooks/useWebPush.ts": 1,
"src/repo/pluginPoiRepo.ts": 1,
"src/store/slices/budgetSlice.ts": 1,
"src/store/studioStore.ts": 2,
"src/store/vacayStore.ts": 1
}
+139
View File
@@ -0,0 +1,139 @@
/*
* The measuring and the comparison behind lint:dup (scripts/dup-lint.mjs).
*
* Copies are found by jscpd's detector at the thresholds SonarCloud applies to
* TypeScript: a block counts once MIN_TOKENS tokens repeat over at least
* MIN_LINES lines. Both .ts and .tsx are read as TypeScript, because Sonar
* treats them as one language and a hook in a .ts file that repeats a view's
* .tsx code is a copy all the same. A file's count is the number of its lines
* inside a copied block, the duplicated lines Sonar reports. Which of three
* copies a block is matched against depends on the order the files are read
* in, so they are read sorted by path, and with readText, so every machine
* counts the same.
*/
import { Detector, getModeHandler, MemoryStore } from '@jscpd/core';
import { Tokenizer } from '@jscpd/tokenizer';
import { join } from 'node:path';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
export const MIN_TOKENS = 100;
export const MIN_LINES = 10;
export const DIRS = ['src'];
/** The files SonarCloud analyses as source: tests and declaration files are left out. */
export function accepts(key) {
return /\.tsx?$/.test(key) && !key.endsWith('.d.ts') && !TEST_FILE.test(key);
}
/**
* Every copied block under root as a pair of line ranges, one per side. Both
* sides can be the same file.
*
* @returns {Promise<{ key: string, start: number, end: number }[][]>}
*/
export async function findCopies(root) {
const keys = listFiles(root, DIRS, accepts)
.map((path) => toKey(root, path))
.sort();
const detector = new Detector(new Tokenizer(), new MemoryStore(), [], {
minTokens: MIN_TOKENS,
minLines: MIN_LINES,
mode: getModeHandler('mild'),
});
const side = ({ sourceId, start, end }) => ({ key: sourceId, start: start.line, end: end.line });
const copies = [];
for (const key of keys) {
const clones = await detector.detect(key, readText(join(root, key)), 'typescript');
for (const clone of clones) copies.push([side(clone.duplicationA), side(clone.duplicationB)]);
}
return copies;
}
/** Per file in path order, how many of its lines sit inside a copied block. */
export function countLines(copies) {
const lines = {};
for (const pair of copies) {
for (const { key, start, end } of pair) {
const set = (lines[key] ??= new Set());
for (let line = start; line <= end; line++) set.add(line);
}
}
return Object.fromEntries(
Object.entries(lines)
.filter(([, set]) => set.size > 0)
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([key, set]) => [key, set.size])
);
}
/** Per file, its copied blocks as "start-end = other:start-end", in line order. */
export function describeCopies(copies) {
const found = {};
const range = ({ start, end }) => `${start}-${end}`;
for (const [a, b] of copies) {
(found[a.key] ??= []).push({ at: a.start, text: `${range(a)} = ${b.key}:${range(b)}` });
(found[b.key] ??= []).push({ at: b.start, text: `${range(b)} = ${a.key}:${range(a)}` });
}
return Object.fromEntries(
Object.entries(found).map(([key, list]) => [key, list.sort((x, y) => x.at - y.at).map(({ text }) => text)])
);
}
/** Runs the check against the baseline at baselinePath and returns the exit code. */
export async function check({
root,
baselinePath = join(root, 'scripts/dup-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const copies = await findCopies(root);
const counts = countLines(copies);
const blocks = describeCopies(copies);
let baseline = readBaseline(baselinePath, countMap);
if (list) {
for (const [key, n] of Object.entries(counts)) {
log(`${key} (${n}, baseline ${baseline[key] ?? 0})`);
for (const line of blocks[key]) log(` ${line}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
writeBaseline(baselinePath, baseline);
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
const stale = staleCounts(baseline, counts);
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} line(s) in copied blocks, baseline ${baseline[key] ?? 0}:`);
for (const line of blocks[key]) error(` ${line}`);
}
if (grown.length) {
error(
'Put the code both places need in one shared hook or module and import it from both instead of copying it ' +
'(useInstanceSettings and useRangeBypass under src/components/Admin/ are the pattern).'
);
}
reportStale(stale, { file: 'dup-baseline.json', command: 'lint:dup', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`dup: ${sum(counts)} line(s) in copied blocks in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
);
return grown.length || stale.length ? 1 : 0;
}
+183
View File
@@ -0,0 +1,183 @@
/*
* The client's own ESLint rules, as the local plugin `trek` in eslint.config.mjs.
*
* Each one finds a smell client/CLAUDE.md forbids and that no stock rule sees
* on its own. They run as warnings, so lint:warnings
* (scripts/eslint-ratchet.mjs) holds what exists today in
* scripts/eslint-baseline.json and fails on each new one.
*/
/** The expression under any `as`, `!`, `satisfies` or type assertion, which do not change the value. */
function unwrap(node) {
let current = node;
while (
current &&
(current.type === 'TSAsExpression' ||
current.type === 'TSNonNullExpression' ||
current.type === 'TSSatisfiesExpression' ||
current.type === 'TSTypeAssertion')
)
current = current.expression;
return current;
}
const isWindow = (node) => {
const target = unwrap(node);
return (
(target?.type === 'Identifier' && (target.name === 'window' || target.name === 'globalThis')) ||
(target?.type === 'MemberExpression' &&
!target.computed &&
target.property.name === 'window' &&
unwrap(target.object)?.type === 'Identifier' &&
unwrap(target.object).name === 'globalThis')
);
};
const propertyName = (member) =>
!member.computed && member.property.type === 'Identifier'
? member.property.name
: member.property.type === 'Literal' && typeof member.property.value === 'string'
? member.property.value
: null;
/**
* `.catch(() => {})`, `.catch(() => undefined)`, `.catch(() => null)`: the
* failure disappears without a trace. A write that fails needs a rollback and
* a toast; a read that may fail says why it may, in code that handles it.
*/
/** @type {import('eslint').Rule.RuleModule} */
const noSwallowedCatch = {
meta: {
type: 'problem',
schema: [],
messages: {
swallowed:
'This .catch() swallows the error. Roll back and tell the user (toast), or handle the failure on purpose ' +
'and say why in the handler; never .catch(() => {}).',
},
},
create(context) {
const empty = (fn) => {
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression') return false;
const body = fn.body;
if (body.type === 'BlockStatement') return body.body.length === 0;
return (
(body.type === 'Identifier' && body.name === 'undefined') ||
(body.type === 'Literal' && body.value === null) ||
(body.type === 'UnaryExpression' && body.operator === 'void')
);
};
return {
CallExpression(node) {
const callee = node.callee;
if (callee.type !== 'MemberExpression' || propertyName(callee) !== 'catch') return;
if (node.arguments.length === 1 && empty(node.arguments[0])) context.report({ node, messageId: 'swallowed' });
},
};
},
};
/**
* `useTripStore()` with no selector subscribes the component to the whole
* store, so it re-renders on every change anywhere in it. And on Zustand 5 the
* obvious repair, a selector that builds an object, loops forever without
* useShallow, because each call returns a new object.
*/
/** @type {import('eslint').Rule.RuleModule} */
const storeWithoutSelector = {
meta: {
type: 'problem',
schema: [],
messages: {
whole:
'{{name}}() without a selector subscribes to the whole store and re-renders on every change in it. ' +
'Select what you read: {{name}}(s => s.field), or for several fields {{name}}(useShallow(s => ({ a: s.a, b: s.b }))). ' +
'On Zustand 5 an object selector without useShallow loops forever. In an effect or handler use {{name}}.getState().',
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type === 'Identifier' &&
/^use[A-Z]\w*Store$/.test(node.callee.name) &&
node.arguments.length === 0
)
context.report({ node, messageId: 'whole', data: { name: node.callee.name } });
},
};
},
};
/** Events the browser itself fires on window; dispatching one of these is simulating the platform, not a bus. */
const PLATFORM_EVENTS = new Set([
'resize',
'scroll',
'storage',
'online',
'offline',
'focus',
'blur',
'popstate',
'hashchange',
'visibilitychange',
'load',
'beforeunload',
'pagehide',
'pageshow',
'message',
'keydown',
'keyup',
]);
/**
* Global mutable state and event buses on window: `window.__dragData`,
* `window.__addToast` and any other `window.__*`, and window.dispatchEvent
* with an event of the app's own. Nothing types them, nothing finds their
* listeners, and a test has to fake the global to reach them.
*/
/** @type {import('eslint').Rule.RuleModule} */
const noWindowGlobals = {
meta: {
type: 'problem',
schema: [],
messages: {
global:
'window.{{name}} is global mutable state. Keep it in a module (a store, a small session module) and import it.',
bus:
'window.dispatchEvent with an event of our own is an untyped global bus. Call a store action, or bump a ' +
'version counter in a slice that the listener selects.',
},
},
create(context) {
const ownEvent = (arg) => {
const event = unwrap(arg);
if (event?.type !== 'NewExpression' || event.callee.type !== 'Identifier') return false;
if (event.callee.name === 'CustomEvent') return true;
if (event.callee.name !== 'Event') return false;
const name = event.arguments[0];
return !(name?.type === 'Literal' && typeof name.value === 'string' && PLATFORM_EVENTS.has(name.value));
};
return {
MemberExpression(node) {
const name = propertyName(node);
if (name?.startsWith('__') && isWindow(node.object))
context.report({ node, messageId: 'global', data: { name } });
},
CallExpression(node) {
const callee = unwrap(node.callee);
if (callee?.type !== 'MemberExpression' || propertyName(callee) !== 'dispatchEvent') return;
if (isWindow(callee.object) && ownEvent(node.arguments[0])) context.report({ node, messageId: 'bus' });
},
};
},
};
export default {
meta: { name: 'trek' },
rules: {
'no-swallowed-catch': noSwallowedCatch,
'store-without-selector': storeWithoutSelector,
'no-window-globals': noWindowGlobals,
},
};
+195
View File
@@ -0,0 +1,195 @@
/*
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/
import { ESLint } from 'eslint';
import { join } from 'node:path';
import ts from 'typescript';
import {
countMap,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
/**
* Where a warning is counted: in the app code, in the tests, or, for a
* message an eslint-disable comment silenced, among the suppressed ones. An
* inline disable must not be the cheap way under the ratchet, and an inline
* rule config, which silences without a trace, fails outright (see tally).
*/
export const AREAS = ['src', 'tests', 'suppressed'];
const areaOf = (key) => (key.startsWith('tests/') || TEST_FILE.test(key) ? 'tests' : 'src');
const ruleOf = (message) =>
message.ruleId ?? (/eslint-disable/.test(message.message) ? 'unused-disable-directive' : '(no rule)');
/** A baseline: each area an object of rule → positive count. */
export function areaCounts(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of area → rule counts';
for (const area of Object.keys(value))
if (!AREAS.includes(area)) return `"${area}" is not one of ${AREAS.join(', ')}`;
for (const area of AREAS) {
if (!(area in value)) return `the "${area}" area is missing`;
const problem = countMap(value[area]);
if (problem) return `in "${area}", ${problem}`;
}
return null;
}
const scriptKind = (file) =>
file.endsWith('.tsx')
? ts.ScriptKind.TSX
: /\.[cm]?ts$/.test(file)
? ts.ScriptKind.TS
: file.endsWith('.jsx')
? ts.ScriptKind.JSX
: ts.ScriptKind.JS;
/** ESLint's label for a block comment that configures rules, as opposed to eslint-disable and the like. */
const INLINE_CONFIG = /^\s*eslint(?:\s|$)/;
/**
* The block comments in a file that configure ESLint rules, such as
* `/* eslint no-empty: off *\/`, as `line:column comment`. Such a comment
* switches a rule off for the whole file without a single message reaching
* either the warnings or the suppressed ones, so it would walk past the
* ratchet unseen. The comments come from the TypeScript parser, so the same
* text inside a string or a template does not count.
*/
export function inlineConfigsOf(source, file) {
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file));
const seen = new Set();
const found = [];
const collect = (ranges) => {
for (const range of ranges ?? []) {
if (seen.has(range.pos)) continue;
seen.add(range.pos);
if (range.kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
const text = source.slice(range.pos, range.end);
if (!INLINE_CONFIG.test(text.slice(2, -2))) continue;
const { line, character } = sf.getLineAndCharacterOfPosition(range.pos);
found.push(`${line + 1}:${character + 1} ${text.replace(/\s+/g, ' ').slice(0, 80)}`);
}
};
const visit = (node) => {
collect(ts.getLeadingCommentRanges(source, node.pos));
collect(ts.getTrailingCommentRanges(source, node.pos));
for (const child of node.getChildren(sf)) visit(child);
};
visit(sf);
return found;
}
/**
* The warnings per area and rule in ESLint's results, the files behind each
* count, and every error (an error, a parse failure included, always fails).
* An inline rule config comment (see inlineConfigsOf) is an error too: the
* place to change a rule is eslint.config.mjs.
*/
export function tally(results, root) {
const counts = Object.fromEntries(AREAS.map((area) => [area, {}]));
const files = Object.fromEntries(AREAS.map((area) => [area, {}]));
const errors = [];
const add = (area, rule, key) => {
counts[area][rule] = (counts[area][rule] ?? 0) + 1;
const where = (files[area][rule] ??= {});
where[key] = (where[key] ?? 0) + 1;
};
for (const result of results) {
const key = toKey(root, result.filePath);
for (const comment of result.inlineConfigs ?? [])
errors.push(`${key}:${comment} configures a rule inline; set it in eslint.config.mjs (inline-config)`);
for (const message of result.messages) {
if (message.fatal || message.severity === 2)
errors.push(`${key}:${message.line ?? 0}:${message.column ?? 0} ${message.message} (${ruleOf(message)})`);
else add(areaOf(key), ruleOf(message), key);
}
for (const message of result.suppressedMessages ?? []) add('suppressed', ruleOf(message), key);
}
return { counts, files, errors };
}
/**
* The part of an ESLint result the tally reads.
*
* @typedef {{ ruleId: string | null, severity: number, message: string, fatal?: boolean, line?: number, column?: number }} LintMessage
* @typedef {{ filePath: string, messages: LintMessage[], suppressedMessages?: LintMessage[], inlineConfigs?: string[] }} LintResult
*/
/**
* ESLint over the whole client, with its own config, as `npm run lint:check`
* runs it, each result with the inline rule configs of its file.
*
* @param {string} root
* @returns {Promise<LintResult[]>}
*/
export async function lintClient(root) {
const results = await new ESLint({ cwd: root }).lintFiles(['.']);
return results.map((result) => ({
...result,
inlineConfigs: inlineConfigsOf(readText(result.filePath), result.filePath),
}));
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/**
* Runs ESLint (or lint, in a test) and compares the warnings with the
* baseline at baselinePath. Returns the exit code.
*/
export async function check({
root,
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint = lintClient,
log = console.log,
error = console.error,
}) {
let baseline = readBaseline(baselinePath, areaCounts);
const { counts, files, errors } = tally(await lint(root), root);
if (update) {
baseline = Object.fromEntries(AREAS.map((area) => [area, lowerCounts(baseline[area], counts[area])]));
writeBaseline(baselinePath, baseline);
}
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue;
grown++;
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([key, k]) => `${key} (${k})`);
error(`FAIL ${area}: ${n} ${rule} warning(s), baseline ${allowed}. Most in: ${top.join(', ')}`);
}
const fallen = staleCounts(baseline[area], counts[area]);
reportStale(fallen, {
file: 'eslint-baseline.json',
command: 'lint:warnings',
label: (rule) => `${area}: ${rule}`,
error,
});
stale += fallen.length;
}
if (grown) {
error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.'
);
}
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown || stale ? 1 : 0;
}
+84
View File
@@ -0,0 +1,84 @@
/*
* The comparison behind lint:format (scripts/format-lint.mjs): which files
* Prettier would rewrite, against the list of files that were unformatted
* when the check came in.
*/
import { join } from 'node:path';
import * as prettier from 'prettier';
import {
fileList,
listFiles,
lowerList,
RatchetError,
readBaseline,
readText,
toKey,
writeBaseline,
} from './ratchet.mjs';
/** The app, its tests, the Playwright specs and the client's own tooling. */
export const DIRS = ['src', 'tests', 'e2e', 'scripts'];
export const accepts = (key) => /\.(?:tsx?|mjs|css)$/.test(key);
/**
* Whether Prettier leaves the file as it is, with the config that applies to
* it. The text is read with LF line ends, so a Windows checkout agrees with CI
* about endOfLine. A file Prettier cannot parse is not formatted.
*/
export async function isFormatted(path) {
const options = await prettier.resolveConfig(path);
if (!options) throw new RatchetError(`no Prettier config applies to ${path}`);
try {
return await prettier.check(readText(path), { ...options, filepath: path });
} catch {
return false;
}
}
/** The files under root that Prettier would rewrite, keyed by their path from root. */
export async function unformatted(root) {
const found = [];
for (const path of listFiles(root, DIRS, accepts)) if (!(await isFormatted(path))) found.push(toKey(root, path));
return found;
}
/**
* Runs the check against the list at baselinePath. A file outside the list
* must be formatted. A listed file that is formatted now, or gone, fails the
* check as well until --update takes it out: the list only shrinks, and a
* stale entry would let the next file at that path in unformatted. Returns
* the exit code.
*/
export async function check({
root,
baselinePath = join(root, 'scripts/format-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = await unformatted(root);
let baseline = readBaseline(baselinePath, fileList);
if (update) {
baseline = lowerList(baseline, found);
writeBaseline(baselinePath, baseline);
}
const allowed = new Set(baseline);
const fresh = found.filter((key) => !allowed.has(key));
const now = new Set(found);
const stale = baseline.filter((key) => !now.has(key));
if (list) for (const key of found) log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
for (const key of fresh) {
error(`FAIL ${key} is not formatted. Run: npx prettier --write ${key}`);
}
for (const key of stale) {
error(`FAIL ${key} is listed in scripts/format-baseline.json but is formatted now or gone.`);
}
if (stale.length) error('Run npm run lint:format -- --update to take the formatted files off the list.');
log(`format: ${found.length} unformatted file(s), ${baseline.length} listed in the baseline`);
return fresh.length || stale.length ? 1 : 0;
}
+87
View File
@@ -0,0 +1,87 @@
/*
* The imports of a source file, read from its syntax tree rather than with a
* regular expression, so an import split over lines, a dynamic import() and a
* re-export count, and an import inside a comment or a string does not.
*/
import { dirname, join, relative, resolve } from 'node:path';
import ts from 'typescript';
import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs';
const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly);
/**
* Every module the file names, with the line it is named on and whether the
* import only brings in types (erased from the bundle, so no runtime path).
*/
export function importsOf(source, file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
const found = [];
const add = (node, literal, typeOnly) => {
if (!literal || !ts.isStringLiteralLike(literal)) return;
found.push({
specifier: literal.text,
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
typeOnly,
});
};
const visit = (node) => {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
const named =
clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null;
const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named))));
add(node, node.moduleSpecifier, typeOnly);
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null;
add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named)));
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
add(node, node.moduleReference.expression, node.isTypeOnly);
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
add(node, node.argument.literal, true);
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
add(node, node.arguments[0], false);
}
ts.forEachChild(node, visit);
};
visit(sf);
return found;
}
/**
* The layer of a path from src/: its top directory ("components", "api"), or,
* for a file directly in src/, its name without the extension. src/types.ts
* is then the layer "types" both when it imports and when it is imported as
* '../types', and its rules are those of the src/types/ directory.
*/
export function layerOfPath(fromSrc) {
const [top, ...rest] = fromSrc.split('/');
return rest.length ? top : top.replace(/(\.d)?\.[cm]?[jt]sx?$/, '');
}
/**
* The layer a relative import lands in (see layerOfPath), or null for a
* package or a path outside src/.
*/
export function layerOf(srcRoot, fromFile, specifier) {
if (!specifier.startsWith('.')) return null;
const target = relative(srcRoot, resolve(dirname(fromFile), specifier))
.split('\\')
.join('/');
if (!target || target.startsWith('..')) return null;
return layerOfPath(target);
}
/** Every source file under root/src outside the tests, with its key, its layer and its imports resolved to layers. */
export function sourceGraph(root) {
const src = join(root, 'src');
return listFiles(root, ['src'], (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key)).map((path) => ({
key: toKey(root, path),
layer: layerOfPath(toKey(src, path)),
imports: importsOf(readText(path), path).map((imp) => ({ ...imp, layer: layerOf(src, path, imp.specifier) })),
}));
}
+103
View File
@@ -0,0 +1,103 @@
/*
* The rules and the comparison behind lint:layers (scripts/layer-lint.mjs).
*/
import { join } from 'node:path';
import { sourceGraph } from './imports.mjs';
import { countMap, lowerCounts, readBaseline, reportStale, staleCounts, writeBaseline } from './ratchet.mjs';
const VIEW = ['components', 'mobile', 'pages', 'hooks'];
/**
* For each directory under src/, the directories its files may not import
* from. The view layers sit on top: a page composes components, a component
* may use a shared hook, and the phone screens reuse both. Nothing below the
* views (stores, repos, sync, api, the Dexie schema and the helpers) knows a
* view exists, a repo sits under the stores, and the Dexie schema under all
* of them. help/ (the help center's screen registry, plain data) and vacay/
* (date helpers) are helpers like utils/. Directories without an entry are
* free on purpose: mobile and pages sit at the top, the app root wires
* everything, and managed/ is the slot an operator fills with whole screens
* at build time. A file directly in src/ is the layer of its name, so
* src/types.ts falls under types like the src/types/ directory.
*/
export const RULES = {
components: ['pages', 'mobile'],
hooks: ['pages', 'mobile', 'components'],
store: VIEW,
sync: VIEW,
api: VIEW,
utils: VIEW,
i18n: VIEW,
theme: VIEW,
constants: VIEW,
services: VIEW,
push: VIEW,
types: VIEW,
help: VIEW,
vacay: VIEW,
repo: [...VIEW, 'store'],
db: [...VIEW, 'store', 'repo', 'sync', 'api'],
};
/**
* Every import that crosses a boundary the wrong way, per file. A type-only
* import counts too: a type the lower layer needs belongs below it.
*/
export function violations(root, rules = RULES) {
const found = {};
for (const file of sourceGraph(root)) {
const banned = rules[file.layer];
if (!banned) continue;
const wrong = file.imports.filter((imp) => imp.layer && banned.includes(imp.layer));
if (wrong.length)
found[file.key] = wrong.map((imp) => `${imp.line}: '${imp.specifier}' (${file.layer} into ${imp.layer})`);
}
return found;
}
/** Runs the check against the baseline at baselinePath and returns the exit code. */
export function check({
root,
baselinePath = join(root, 'scripts/layers-baseline.json'),
rules = RULES,
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = violations(root, rules);
const counts = Object.fromEntries(Object.entries(found).map(([key, list]) => [key, list.length]));
let baseline = readBaseline(baselinePath, countMap);
if (list) {
for (const [key, wrong] of Object.entries(found)) {
log(`${key} (${wrong.length}, baseline ${baseline[key] ?? 0})`);
for (const line of wrong) log(` ${line}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
writeBaseline(baselinePath, baseline);
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
const stale = staleCounts(baseline, counts);
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`);
for (const line of found[key]) error(` ${line}`);
}
if (grown.length) {
error(
'Move what both sides need down into a layer they may share (a model or helper under utils/, ' +
'a hook under hooks/, a type under types/) instead of importing upwards.'
);
}
reportStale(stale, { file: 'layers-baseline.json', command: 'lint:layers', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
);
return grown.length || stale.length ? 1 : 0;
}
+77
View File
@@ -0,0 +1,77 @@
/*
* The comparison behind lint:offline (scripts/offline-lint.mjs).
*/
import { join } from 'node:path';
import { sourceGraph } from './imports.mjs';
import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs';
/** The layers that render or drive a view, and so must not hold a network call. */
export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks'];
/**
* The view files that import from src/api/ for anything but types, each with
* the imports that do it. A type-only import is erased from the bundle and
* carries no request, so it does not count.
*/
export function bypasses(root) {
const found = {};
for (const file of sourceGraph(root)) {
if (!VIEW_LAYERS.includes(file.layer)) continue;
const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly);
if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`);
}
return found;
}
/**
* Runs the check against the list at baselinePath. A view file outside the
* list may not import src/api/. A listed file that no longer does fails the
* check as well until --update takes it out, so the list only shrinks and
* the next file at that path does not inherit the exemption. Returns the exit
* code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/offline-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = bypasses(root);
const offenders = Object.keys(found);
let baseline = readBaseline(baselinePath, fileList);
if (update) {
baseline = lowerList(baseline, offenders);
writeBaseline(baselinePath, baseline);
}
const allowed = new Set(baseline);
const fresh = offenders.filter((key) => !allowed.has(key));
const stale = baseline.filter((key) => !found[key]);
if (list) {
for (const [key, direct] of Object.entries(found)) {
log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
for (const line of direct) log(` ${line}`);
}
}
for (const key of fresh) {
error(`FAIL ${key} imports src/api/ directly:`);
for (const line of found[key]) error(` ${line}`);
}
if (fresh.length) {
error(
'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' +
'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' +
'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".'
);
}
for (const key of stale) {
error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`);
}
if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.');
log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`);
return fresh.length || stale.length ? 1 : 0;
}
+147
View File
@@ -0,0 +1,147 @@
/*
* The manifest and the checks behind lint:pairs (scripts/pairs-lint.mjs).
*/
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { importsOf } from './imports.mjs';
import { listFiles, readBaseline, readText, TEST_FILE, toKey } from './ratchet.mjs';
/** The phone screens every entry of the manifest is weighed against. */
export const SCREENS = 'src/mobile/screens';
/**
* Where a shared hook may live: below the views that both import it, or next
* to a page in its folder under pages/ (use<Page> and its models), which the
* phone shell of that page imports as well. Never in a view itself.
*/
const SHARED = /^src\/(?:components|hooks|utils)\/|^src\/pages\/[^/]+\//;
const SOURCE = /\.tsx?$/;
const isPath = (value) => typeof value === 'string' && SOURCE.test(value) && !value.includes('\\');
const isPathList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPath);
/**
* scripts/feature-pairs.json: pairs is a list of features, each with the
* shared hooks or models that hold its logic and the desktop and phone files
* that run on them; mobileOnly maps each phone screen file that has no
* desktop twin, or no logic a twin could share, to the reason.
*/
export function manifestShape(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object';
const keys = Object.keys(value).sort().join(',');
if (keys !== 'mobileOnly,pairs') return 'it needs exactly the keys "pairs" and "mobileOnly"';
if (!Array.isArray(value.pairs)) return '"pairs" is not a list';
const names = new Set();
for (const pair of value.pairs) {
if (!pair || typeof pair !== 'object' || typeof pair.feature !== 'string' || !pair.feature.trim())
return `a pair without a feature name: ${JSON.stringify(pair)}`;
if (names.has(pair.feature)) return `the feature "${pair.feature}" is listed twice`;
names.add(pair.feature);
for (const field of ['hooks', 'desktop', 'mobile'])
if (!isPathList(pair[field]))
return `"${pair.feature}" needs "${field}" as a list of source files from the client root`;
}
const only = value.mobileOnly;
if (!only || typeof only !== 'object' || Array.isArray(only)) return '"mobileOnly" is not an object of file → reason';
for (const [key, reason] of Object.entries(only)) {
if (!isPath(key)) return `"${key}" in "mobileOnly" is not a source file from the client root`;
if (typeof reason !== 'string' || !reason.trim()) return `"${key}" in "mobileOnly" has no reason`;
}
return null;
}
/** The path a relative import lands on from the client root, without its extension. */
function targetOf(root, fromFile, specifier) {
if (!specifier.startsWith('.')) return null;
return toKey(root, resolve(dirname(fromFile), specifier)).replace(/(?:\/index)?(?:\.[cm]?[jt]sx?)?$/, '');
}
const stem = (key) => key.replace(/\.[cm]?[jt]sx?$/, '');
/** The modules a file imports for more than types, as paths from the root without extensions. */
function runtimeImports(root, key) {
const path = join(root, key);
return new Set(
importsOf(readText(path), path)
.filter((imp) => !imp.typeOnly)
.map((imp) => targetOf(root, path, imp.specifier))
.filter(Boolean)
);
}
/**
* Everything wrong with the manifest against the tree, as lines to print:
* a listed file that is gone, a hook outside the shared layers, a view of a
* pair that does not import every hook of the pair, a phone screen listed
* both as a pair and as phone only, and a phone screen that is in neither.
*/
export function problems(root, manifest) {
const found = [];
const listed = new Set();
const present = (key, where) => {
if (existsSync(join(root, key))) return true;
found.push(`${key} is listed under ${where} but does not exist`);
return false;
};
for (const pair of manifest.pairs) {
const where = `"${pair.feature}"`;
const hooks = pair.hooks.filter((key) => present(key, where));
for (const key of hooks)
if (!SHARED.test(key))
found.push(`${key}, the hook of ${where}, is not under src/components, src/hooks, src/utils or a page folder`);
for (const key of [...pair.desktop, ...pair.mobile]) {
if (pair.mobile.includes(key)) listed.add(key);
if (!present(key, where)) continue;
const imported = runtimeImports(root, key);
const missing = hooks.filter((hook) => !imported.has(stem(hook)));
for (const hook of missing) found.push(`${key} is a view of ${where} but does not import ${hook}`);
}
}
for (const key of Object.keys(manifest.mobileOnly)) {
if (!present(key, 'mobileOnly')) continue;
if (listed.has(key)) found.push(`${key} is listed in a pair and under mobileOnly; it is one or the other`);
listed.add(key);
}
const screens = listFiles(
root,
[SCREENS],
(key) => SOURCE.test(key) && !TEST_FILE.test(key) && !key.endsWith('.d.ts')
);
for (const path of screens) {
const key = toKey(root, path);
if (!listed.has(key)) found.push(`${key} is a phone screen file that scripts/feature-pairs.json does not list`);
}
return found;
}
/** Runs the check against the manifest at manifestPath and returns the exit code. */
export function check({
root,
manifestPath = join(root, 'scripts/feature-pairs.json'),
list = false,
log = console.log,
error = console.error,
}) {
const manifest = readBaseline(manifestPath, manifestShape);
const found = problems(root, manifest);
if (list) {
for (const pair of manifest.pairs) log(`${pair.feature}: ${pair.hooks.join(', ')}`);
}
for (const line of found) error(`FAIL ${line}`);
if (found.length) {
error(
'A feature that has a desktop view and a phone view keeps its logic in one shared hook both import. ' +
'Add a new phone screen to scripts/feature-pairs.json: as a view of the pair whose hook it runs on, ' +
'or under mobileOnly with the reason it has no twin. See client/CLAUDE.md, "Desktop and phone views".'
);
}
const only = Object.keys(manifest.mobileOnly).length;
log(
`pairs: ${manifest.pairs.length} shared feature(s), ${only} phone-only screen file(s), ${found.length} problem(s)`
);
return found.length ? 1 : 0;
}
+193
View File
@@ -0,0 +1,193 @@
/*
* The parts every ratchet in scripts/ shares: walking the tree, reading a file
* the same way on Windows and Linux, and reading, lowering and writing a
* baseline.
*
* A ratchet fails closed. A baseline that is missing, cannot be parsed or has
* the wrong shape stops the run with its reason instead of reading as empty
* (as empty, the check would fail with no hint at the cause, and --update
* would write an empty file over the real one). A directory that should be
* scanned and is not there, or a scan that finds no file at all, stops it too:
* a check that looked at nothing must not report green.
*/
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
/** A problem with the check itself rather than a violation it found. */
export class RatchetError extends Error {}
export const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Every file under root/<dir> for each dir whose path passes accept, as absolute paths. */
export function listFiles(root, dirs, accept) {
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (accept(toKey(root, path))) files.push(path);
}
};
for (const dir of dirs) {
const path = join(root, dir);
if (!existsSync(path) || !statSync(path).isDirectory()) {
throw new RatchetError(`${dir}/ does not exist under ${root}, so there is nothing to check`);
}
walk(path);
}
if (!files.length) throw new RatchetError(`no file to check under ${dirs.map((d) => `${d}/`).join(', ')}`);
return files.sort();
}
/** A path relative to root with forward slashes, the form every baseline uses. */
export function toKey(root, path) {
return relative(root, path).split('\\').join('/');
}
/**
* A text file as the repository holds it: without a byte order mark and with
* LF line ends, so a Windows checkout with CRLF counts the same as CI.
*/
export function readText(path) {
const text = readFileSync(path, 'utf8');
return (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, '\n');
}
const isCount = (n) => Number.isInteger(n) && n > 0;
/** A JSON object of file → positive count. */
export function countMap(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of file → count';
for (const [key, n] of Object.entries(value))
if (!isCount(n)) return `"${key}" holds ${JSON.stringify(n)}, not a positive whole number`;
return null;
}
/** A JSON array of distinct file paths. */
export function fileList(value) {
if (!Array.isArray(value)) return 'it is not an array of file paths';
const seen = new Set();
for (const entry of value) {
if (typeof entry !== 'string' || !entry) return `${JSON.stringify(entry)} is not a file path`;
if (seen.has(entry)) return `"${entry}" is listed twice`;
seen.add(entry);
}
return null;
}
/**
* The baseline as committed, checked against shape (one of the validators
* above, which returns null when the value fits and the reason otherwise).
*/
export function readBaseline(path, shape) {
const name = basename(path);
let text;
try {
text = readFileSync(path, 'utf8');
} catch (err) {
throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`);
}
let value;
try {
value = JSON.parse(text);
} catch (err) {
throw new RatchetError(`scripts/${name} is not valid JSON (${err.message}); restore it from git`);
}
const problem = shape(value);
if (problem) throw new RatchetError(`scripts/${name} has the wrong shape: ${problem}; restore it from git`);
return value;
}
/** Writes value as sorted, two-space JSON with a final newline. */
export function writeBaseline(path, value) {
const sorted = Array.isArray(value)
? [...value].sort()
: Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n');
}
/**
* The baseline lowered to the counts measured now. An entry never rises, a
* file that is not in the baseline never enters it, and an entry whose count
* fell to floor or below leaves. floor is a number, or a function of the key
* when the limit differs between files.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
*/
export function lowerCounts(baseline, counts, floor = 0) {
const lowered = {};
for (const [key, allowed] of Object.entries(baseline)) {
const now = counts[key] ?? 0;
if (now > (typeof floor === 'function' ? floor(key) : floor)) lowered[key] = Math.min(allowed, now);
}
return lowered;
}
/**
* The entries of a count baseline that allow more than is measured now: a
* count that fell below its entry, one back at floor or under it, or a file
* that is gone. Exactly the entries lowerCounts would lower or drop. Each one
* fails its check until --update lowers it, or the count could grow back to
* the old entry unseen, and a deleted file would hand its allowance to the
* next file at its path.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
* @returns {{ key: string, entry: number, now: number }[]}
*/
export function staleCounts(baseline, counts, floor = 0) {
const lowered = lowerCounts(baseline, counts, floor);
return Object.entries(baseline)
.filter(([key, entry]) => lowered[key] !== entry)
.map(([key, entry]) => ({ key, entry, now: counts[key] ?? 0 }));
}
/**
* Prints a FAIL line for each stale entry (see staleCounts) of the baseline
* scripts/<file>, then how to lower them. With root, an entry whose file is
* not there is called gone; label names an entry that is not a file path.
*
* @param {{ key: string, entry: number, now: number }[]} stale
* @param {{ file: string, command: string, root?: string, label?: (key: string) => string, error: (line: string) => void }} options
*/
export function reportStale(stale, { file, command, root, label = (key) => key, error }) {
for (const { key, entry, now } of stale) {
const gone = root !== undefined && !existsSync(join(root, key));
error(
`FAIL ${label(key)} is held at ${entry} in scripts/${file}, ` +
(gone ? 'but the file is gone.' : `but there are ${now} now.`)
);
}
if (stale.length) {
error(
`Run npm run ${command} -- --update to lower the baseline with the change that made it smaller: ` +
'an entry above the count lets it grow back unseen.'
);
}
}
/** The baseline entries that are still offenders: a list only ever loses files. */
export function lowerList(baseline, offenders) {
const now = new Set(offenders);
return baseline.filter((file) => now.has(file));
}
/**
* Runs one ratchet as a command. main returns the exit code; a RatchetError is
* printed as a failure of the check itself. There is no "am I the entry
* point" guard to get wrong: the command files only ever run, and the logic
* they call lives in scripts/lib/ where tests import it.
*/
export async function runCli(name, main) {
try {
process.exitCode = await main(process.argv.slice(2));
} catch (err) {
if (!(err instanceof RatchetError)) throw err;
console.error(`FAIL ${name}: ${err.message}`);
process.exitCode = 1;
}
}
+149
View File
@@ -0,0 +1,149 @@
/*
* The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs).
*/
import { join } from 'node:path';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
import { markedInComment, withoutComments } from './source.mjs';
// Tailwind utilities that name a physical side, with any variant prefix
// (sm:, hover:, group-hover:) and the negative form (-ml-2).
const CLASS = new RegExp(
String.raw`(?<![\w-])(?:[\w-]+:)*-?(?:` +
[
String.raw`(?:ml|mr|pl|pr|left|right|scroll-m[lr]|scroll-p[lr])-(?:\[[^\]\s]+\]|[\w./]+)`,
String.raw`text-(?:left|right)(?![\w-])`,
String.raw`border-[lr](?:-[\w[\]#./()-]+)?(?![\w-])`,
String.raw`rounded-(?:l|r|tl|tr|bl|br)(?:-[\w[\]./]+)?(?![\w-])`,
String.raw`(?:float|clear)-(?:left|right)(?![\w-])`,
String.raw`space-x-[\w.]+`,
].join('|') +
')',
'g'
);
// The same sides as React style keys, and as properties in a stylesheet.
const STYLE =
/\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g;
const CSS =
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g;
export const DISABLE = 'rtl-lint-disable';
/**
* One file read once: every physical use as `line: match`, skipping the lines
* marked as physical on purpose, and how many lines carry that mark.
*/
export function inspect(source, file) {
const isCss = file.endsWith('.css');
const uses = [];
let markers = 0;
const lines = source.split('\n');
withoutComments(source, file)
.split('\n')
.forEach((code, i) => {
if (markedInComment(lines[i], code, DISABLE)) {
markers++;
return;
}
const patterns = isCss ? [CSS] : [CLASS, STYLE];
for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`);
});
return { uses, markers };
}
/** Every physical use in one file, as `line: match`. */
export function physicalUses(source, file) {
return inspect(source, file).uses;
}
/** The physical uses and the disable markers per file under root/src, keyed by the path from src/. */
export function scan(root) {
const counts = {};
const listed = {};
const markers = {};
const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key);
for (const path of listFiles(root, ['src'], accept)) {
const found = inspect(readText(path), path);
const key = toKey(join(root, 'src'), path);
if (found.uses.length) {
counts[key] = found.uses.length;
listed[key] = found.uses;
}
if (found.markers) markers[key] = found.markers;
}
return { counts, listed, markers };
}
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
/**
* Runs the check against both baselines: rtl-baseline.json for the physical
* sides, rtl-disable-baseline.json for the lines that opt out of the check. A
* marker hides a side from the count, so the markers are a ratchet as well:
* a file holds no more of them than its entry. Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/rtl-baseline.json'),
disableBaselinePath = join(root, 'scripts/rtl-disable-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const { counts, listed, markers } = scan(root);
let baseline = readBaseline(baselinePath, countMap);
let allowedMarkers = readBaseline(disableBaselinePath, countMap);
if (list) {
for (const [file, uses] of Object.entries(listed).sort()) {
log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`);
for (const use of uses) log(` ${use}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
allowedMarkers = lowerCounts(allowedMarkers, markers);
writeBaseline(baselinePath, baseline);
writeBaseline(disableBaselinePath, allowedMarkers);
}
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
const stale = staleCounts(baseline, counts);
const staleMarkers = staleCounts(allowedMarkers, markers);
for (const [file, n] of grown) {
error(
`FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` +
'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' +
'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' +
`If the side is geometry, mark the line with ${DISABLE} in a comment.`
);
}
for (const [file, n] of marked) {
error(
`FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/rtl-disable-baseline.json allows ${allowedMarkers[file] ?? 0}. ` +
'Use the logical form where the side follows the text. A side that is geometry on purpose ' +
'(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.'
);
}
reportStale(stale, { file: 'rtl-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
reportStale(staleMarkers, { file: 'rtl-disable-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
log(
`rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
);
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
}
+108
View File
@@ -0,0 +1,108 @@
/*
* The measuring and the comparison behind lint:size (scripts/size-lint.mjs).
*
* A file's size is its lines as an editor wraps them at WIDTH columns: a line
* up to WIDTH characters counts once, a longer one once per WIDTH characters
* it spans. Counting newlines alone let a file stay under its entry by joining
* lines, and the trip planner page already held a 3760 character one; WIDTH is
* the printWidth in .prettierrc, so formatted code counts what it shows.
*/
import { join } from 'node:path';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
export const WIDTH = 120;
/**
* The kinds of file the check covers, each with the size a file without a
* baseline entry may reach. Tests get more room: a spec mirrors the component
* it covers and holds fixtures, but past LIMIT it is a god file too. The
* Playwright specs and their fixtures under e2e/ count as tests.
*/
export const GROUPS = [
{
name: 'test',
limit: 2000,
accepts: (key) =>
/\.tsx?$/.test(key) && (key.startsWith('tests/') || key.startsWith('e2e/') || TEST_FILE.test(key)),
},
{
name: 'source',
limit: 1000,
accepts: (key) => key.startsWith('src/') && /\.tsx?$/.test(key) && !TEST_FILE.test(key),
},
{ name: 'stylesheet', limit: 1000, accepts: (key) => key.startsWith('src/') && key.endsWith('.css') },
];
export const DIRS = ['src', 'tests', 'e2e'];
export function groupOf(key) {
return GROUPS.find((group) => group.accepts(key)) ?? null;
}
/** The size of a text as wrapped at WIDTH; a final newline does not start another line. */
export function sizeOf(text) {
if (text === '') return 0;
const lines = text.replace(/\r\n?/g, '\n').split('\n');
if (text.endsWith('\n')) lines.pop();
return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / WIDTH)), 0);
}
/** Every covered file under root with its size, keyed by its path from root. */
export function measure(root) {
const sizes = {};
for (const path of listFiles(root, DIRS, (key) => groupOf(key) !== null))
sizes[toKey(root, path)] = sizeOf(readText(path));
return sizes;
}
const limitOf = (key) => groupOf(key)?.limit ?? 0;
/**
* Runs the check against the baseline at baselinePath. With update, the
* baseline is lowered first. An entry above the file's size now, or for a
* file that is gone, fails as well until --update lowers it. Returns the
* exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/size-baseline.json'),
update = false,
log = console.log,
error = console.error,
}) {
const sizes = measure(root);
let baseline = readBaseline(baselinePath, countMap);
if (update) {
baseline = lowerCounts(baseline, sizes, limitOf);
writeBaseline(baselinePath, baseline);
}
const grown = Object.entries(sizes).filter(([key, n]) => n > Math.max(baseline[key] ?? 0, limitOf(key)));
const stale = staleCounts(baseline, sizes, limitOf);
for (const [key, n] of grown) {
const entry = baseline[key];
error(
`FAIL ${key}: ${n} lines (wrapped at ${WIDTH} columns), ` +
(entry ? `its baseline is ${entry}. ` : `the limit for a ${groupOf(key).name} file is ${limitOf(key)}. `) +
'Move a concern into a module of its own instead of growing the file or packing its lines.'
);
}
reportStale(stale, { file: 'size-baseline.json', command: 'lint:size', root, error });
log(
`size: ${Object.keys(sizes).length} file(s), ${Object.keys(baseline).length} past their limit held at their baseline`
);
return grown.length || stale.length ? 1 : 0;
}
+151
View File
@@ -0,0 +1,151 @@
/*
* The matching and the comparison behind lint:skips (scripts/skip-lint.mjs).
*/
import { join } from 'node:path';
import ts from 'typescript';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
/** The test functions of vitest and Playwright a modifier can hang off. */
const RUNNERS = new Set(['it', 'test', 'describe', 'suite', 'bench']);
/** The jasmine-style shorthands vitest also knows. */
const SHORTHAND = new Set(['xit', 'xtest', 'xdescribe']);
export const DIRS = ['src', 'tests', 'e2e'];
const accepts = (key) => /\.tsx?$/.test(key) && (key.startsWith('src/') ? TEST_FILE.test(key) : true);
/** The identifier a chain like test.describe.skip.each starts from. */
function rootOf(node) {
while (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) node = node.expression;
return ts.isIdentifier(node) ? node.text : null;
}
/** Playwright specs live under e2e/; everything else runs under vitest. */
const isPlaywright = (file) => file.startsWith('e2e/');
const isTitle = (node) => Boolean(node) && (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node));
const isFunction = (node) => Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
/**
* Whether a skip or fixme call switches tests off for good. Under vitest it
* always does: the first argument is the title, a literal or not. Playwright
* also knows a run-time form, test.skip(condition, 'why'), which only skips
* when the condition holds. A call counts there unless it is that form: it
* declares a test (a title first or a test body second), it has no argument
* (called bare, it skips the test or group it sits in), or its condition is
* the literal true.
*/
function switchesOff(call, file) {
const [first, second] = call.arguments;
if (!isPlaywright(file) || !first || isTitle(first) || isFunction(second)) return true;
return first.kind === ts.SyntaxKind.TrueKeyword;
}
/**
* The tests a file switches off or narrows down, as `line: code`.
*
* skipped: it.skip / describe.skip / test.todo / test.fixme and the
* x-shorthands, each switching off a test that never runs. Playwright's
* test.skip(!seed.id, 'why') decides at run time and does not count (see
* switchesOff); neither do skipIf and runIf, unless their condition is the
* literal that always skips: skipIf(true), runIf(false).
*
* only: .only anywhere, which silently drops every other test in the file.
*
* file is the path from the client root: it decides vitest or Playwright.
*/
export function modifiers(source, file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
const skipped = [];
const only = [];
const at = (node) =>
`${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}: ${node.getText(sf).slice(0, 60)}`;
const visit = (node) => {
if (ts.isPropertyAccessExpression(node) && RUNNERS.has(rootOf(node.expression) ?? '')) {
const name = node.name.text;
if (name === 'only') only.push(at(node));
else if (name === 'todo') skipped.push(at(node));
else if (name === 'skip' || name === 'fixme') {
const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null;
// test.skip.each(table)(...) declares skipped tests without being called directly.
if (!call || switchesOff(call, file)) skipped.push(at(node));
} else if (name === 'skipIf' || name === 'runIf') {
// skipIf(true) and runIf(false) are a skip in disguise: the condition can never change.
const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null;
const never = name === 'skipIf' ? ts.SyntaxKind.TrueKeyword : ts.SyntaxKind.FalseKeyword;
if (call?.arguments[0]?.kind === never) skipped.push(at(node));
}
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SHORTHAND.has(node.expression.text)) {
skipped.push(at(node.expression));
}
ts.forEachChild(node, visit);
};
visit(sf);
return { skipped, only };
}
/** The skipped and focused tests per file under src/, tests/ and e2e/, keyed by the path from root. */
export function scan(root) {
const skipped = {};
const only = {};
for (const path of listFiles(root, DIRS, accepts)) {
const key = toKey(root, path);
const found = modifiers(readText(path), key);
if (found.skipped.length) skipped[key] = found.skipped;
if (found.only.length) only[key] = found.only;
}
return { skipped, only };
}
/**
* Runs the check: no .only anywhere, and no file with more skipped tests than
* its entry in the baseline at baselinePath. Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/skip-baseline.json'),
update = false,
log = console.log,
error = console.error,
}) {
const { skipped, only } = scan(root);
const counts = Object.fromEntries(Object.entries(skipped).map(([key, list]) => [key, list.length]));
let baseline = readBaseline(baselinePath, countMap);
if (update) {
baseline = lowerCounts(baseline, counts);
writeBaseline(baselinePath, baseline);
}
for (const [key, list] of Object.entries(only)) {
error(`FAIL ${key}: .only runs this test alone and drops every other one in the file:`);
for (const line of list) error(` ${line}`);
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} skipped or todo test(s), baseline ${baseline[key] ?? 0}:`);
for (const line of skipped[key]) error(` ${line}`);
}
if (grown.length) {
error('Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.');
}
const stale = staleCounts(baseline, counts);
reportStale(stale, { file: 'skip-baseline.json', command: 'lint:skips', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only`
);
return grown.length || stale.length || Object.keys(only).length ? 1 : 0;
}
+53
View File
@@ -0,0 +1,53 @@
/*
* Reading source the way the line ratchets need it: with the comments blanked
* out, and telling a disable marker in a comment from the same text in code.
* Shared by lint:rtl (scripts/lib/rtl.mjs) and theme:lint (scripts/lib/theme.mjs).
*/
import ts from 'typescript';
/**
* The source with its comments blanked out, so prose like "top right" or
* "left out" is no side. TypeScript finds the comments in a .ts/.tsx file,
* which keeps `/*` and `//` inside strings and JSX text where they belong
* (accept="image/*", a URL); a stylesheet only has block comments.
*/
export function withoutComments(source, file) {
const chars = source.split('');
const blank = (pos, end) => {
for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' ';
};
if (file.endsWith('.css')) {
for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length);
return chars.join('');
}
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind);
const seen = new Set();
const take = (ranges) => {
for (const r of ranges ?? []) {
if (seen.has(r.pos)) continue;
seen.add(r.pos);
blank(r.pos, r.end);
}
};
const visit = (node) => {
take(ts.getLeadingCommentRanges(source, node.pos));
take(ts.getTrailingCommentRanges(source, node.end));
// JSX text holds no comments, and asking for them there would read `//` in a URL as one.
if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child);
};
visit(sf);
take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos));
return chars.join('');
}
/**
* Whether line carries marker inside a comment; in a string or in code it is no
* marker. code is the same line from withoutComments.
*/
export function markedInComment(line, code, marker) {
for (let at = line.indexOf(marker); at !== -1; at = line.indexOf(marker, at + 1)) {
if (!code.slice(at, at + marker.length).trim()) return true;
}
return false;
}
+118
View File
@@ -0,0 +1,118 @@
/*
* The tally and the comparison behind lint:strict (scripts/strict-lint.mjs).
*/
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import {
countMap,
lowerCounts,
RatchetError,
readBaseline,
reportStale,
staleCounts,
writeBaseline,
} from './ratchet.mjs';
// `src/a.ts(12,5): error TS2531: Object is possibly 'null'.` as tsc prints it with --pretty false.
const FILE_ERROR = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
// An error tied to no file, such as a broken tsconfig, starts the same way without the position.
const OTHER_ERROR = /^(?:.+?: )?error (TS\d+): (.*)$/;
/**
* The strict errors per file in tsc's output, with each error's line, and the
* errors tied to no file (a broken config), which always fail. A file key is
* the path tsc printed, relative to the client root, with forward slashes; the
* indented lines that continue a message belong to the error above them.
*
* @param {string} output
* @returns {{ counts: Record<string, number>, listed: Record<string, string[]>, general: string[] }}
*/
export function parseTscOutput(output) {
const counts = {};
const listed = {};
const general = [];
for (const line of output.replace(/\r\n?/g, '\n').split('\n')) {
const hit = FILE_ERROR.exec(line);
if (hit) {
const key = hit[1].split('\\').join('/');
counts[key] = (counts[key] ?? 0) + 1;
(listed[key] ??= []).push(`${hit[2]}:${hit[3]} ${hit[4]} ${hit[5]}`);
continue;
}
if (!/^\s/.test(line) && OTHER_ERROR.test(line)) general.push(line.trim());
}
return { counts, listed, general };
}
/** tsc over tsconfig.strict.json, as its plain-text output. */
export function runTsc(root) {
const tsc = createRequire(join(root, 'package.json')).resolve('typescript/bin/tsc');
const result = spawnSync(process.execPath, [tsc, '-p', 'tsconfig.strict.json', '--noEmit', '--pretty', 'false'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
});
if (result.error) throw new RatchetError(`tsc did not run: ${result.error.message}`);
// tsc exits 0 when clean and 1 or 2 when it reports errors; anything else is a crash.
if (![0, 1, 2].includes(result.status ?? -1))
throw new RatchetError(
`tsc stopped with exit code ${result.status}: ${(result.stderr || result.stdout).slice(0, 500)}`
);
return `${result.stdout}\n${result.stderr}`;
}
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
/**
* Runs tsc (or tsc, in a test) and compares the strict errors per file with
* the baseline at baselinePath. Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/strict-baseline.json'),
update = false,
list = false,
tsc = runTsc,
log = console.log,
error = console.error,
}) {
let baseline = readBaseline(baselinePath, countMap);
const { counts, listed, general } = parseTscOutput(tsc(root));
if (list) {
for (const [file, errors] of Object.entries(listed).sort()) {
log(`${file} (${errors.length}, baseline ${baseline[file] ?? 0})`);
for (const line of errors) log(` ${line}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
writeBaseline(baselinePath, baseline);
}
for (const line of general) error(`FAIL ${line}`);
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
for (const [file, n] of grown) {
const first = listed[file].slice(0, 3).join('; ');
error(
`FAIL ${file}: ${n} error(s) under strictNullChecks and noImplicitAny, baseline ${baseline[file] ?? 0}. ` +
`First: ${first}`
);
}
if (grown.length) {
error(
'Handle the null or type the value rather than asserting it away with `!` or `any`: both are counted by ' +
'lint:warnings. Run npm run lint:strict -- --list for every error.'
);
}
const stale = staleCounts(baseline, counts);
reportStale(stale, { file: 'strict-baseline.json', command: 'lint:strict', root, error });
log(
`strict: ${sum(counts)} error(s) in ${Object.keys(counts).length} file(s) under strictNullChecks and ` +
`noImplicitAny, baseline allows ${sum(baseline)}`
);
return general.length || grown.length || stale.length ? 1 : 0;
}
+202
View File
@@ -0,0 +1,202 @@
/*
* The matching and the comparison behind theme:lint (scripts/theme-lint.mjs).
*/
import { join } from 'node:path';
import {
countMap,
listFiles,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
import { markedInComment, withoutComments } from './source.mjs';
export const DISABLE = 'theme-lint-disable';
// A Tailwind variant prefix (dark:, hover:, md:, group-hover:) and the important mark.
const VARIANT = String.raw`(?<![\w-])(?:[\w-]+:)*!?`;
const PALETTE =
'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
const COLOR_UTILITY = String.raw`bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|from|via|to|divide|outline|decoration|placeholder|caret|accent|shadow`;
/**
* The z-index steps in src/index.css start at 60 (--z-bar). A literal above
* Tailwind's own z-50 is a fixed surface placing itself by a number of its own
* instead of docking onto a step; z-0 to z-50 stay free for stacking inside a
* component.
*/
export const Z_LITERAL_ABOVE = 50;
/**
* Each rule of the contract in src/theme/README.md the check can see in source,
* with the fix its failure message names. A rule returns the offending matches
* in one line of code (comments already blanked out).
*/
export const RULES = [
{
name: 'arbitrary-color',
fix: 'a token utility (bg-surface*, text-content*, border-edge*, bg-accent) or bg-[var(--token)]',
re: new RegExp(
String.raw`\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()[^\]]*\]`,
'g'
),
},
{
name: 'inline-color',
fix: 'var(--token) in the style, or the token utility as a class',
re: /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/g,
},
{
name: 'inline-font-size',
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption)',
re: /fontSize\s*:\s*['"`]?\d/g,
},
{
name: 'palette-class',
fix: 'a token utility: bg-surface*, text-content*, border-edge*, bg-accent with text-accent-text, or a status color',
re: new RegExp(
`${VARIANT}(?:${COLOR_UTILITY})-(?:(?:${PALETTE})-\\d{2,3}|white|black)(?:\\/\\d{1,3})?(?![\\w-])`,
'g'
),
},
{
name: 'raw-text-size',
fix: 'a type tier (text-title, text-subtitle, text-body, text-caption), which follows the text size setting',
// The named sizes and the arbitrary ones (text-[13px], text-[0.8rem]) alike.
re: new RegExp(`${VARIANT}text-(?:xs|sm|base|lg|xl|[2-9]xl|\\[\\d*\\.?\\d+(?:px|rem|em)\\])(?![\\w-])`, 'g'),
},
{
name: 'z-index-literal',
fix: 'a step of the layering scale: z-[var(--z-modal)] as a class, zIndex: "var(--z-modal)" inline',
match: (code) => {
const found = [];
const add = (m, n) => {
if (Math.abs(Number(n)) > Z_LITERAL_ABOVE) found.push(m.trim());
};
for (const m of code.matchAll(new RegExp(`${VARIANT}-?z-(?:\\[(-?\\d+)\\]|(\\d+))(?![\\w-])`, 'g')))
add(m[0], m[1] ?? m[2]);
for (const m of code.matchAll(/\b(?:zIndex\s*:|z-index\s*:)\s*['"`]?\s*(-?\d+)/g)) add(m[0], m[1]);
return found;
},
},
{
name: 'dark-mode-read',
fix: 'the .dark class (useIsDark, dark: variants); only applyAppearance reads the setting',
re: /\??\.dark_mode\b/g,
},
];
const matchesOf = (rule, code) => (rule.match ? rule.match(code) : [...code.matchAll(rule.re)].map((m) => m[0].trim()));
/**
* One file read once: every hit as `line: rule: match`, skipping the lines
* that carry the marker in a comment, and how many lines carry it.
*/
export function inspect(source, file) {
const hits = [];
let markers = 0;
const lines = source.split('\n');
withoutComments(source, file)
.split('\n')
.forEach((code, i) => {
if (markedInComment(lines[i], code, DISABLE)) {
markers++;
return;
}
for (const rule of RULES) for (const m of matchesOf(rule, code)) hits.push(`${i + 1}: ${rule.name}: ${m}`);
});
return { hits, markers };
}
/** The hits and markers per file under root/src (tests excluded), keyed by the path from src/. */
export function scan(root) {
const counts = {};
const listed = {};
const markers = {};
const accept = (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key);
for (const path of listFiles(root, ['src'], accept)) {
const found = inspect(readText(path), path);
const key = toKey(join(root, 'src'), path);
if (found.hits.length) {
counts[key] = found.hits.length;
listed[key] = found.hits;
}
if (found.markers) markers[key] = found.markers;
}
return { counts, listed, markers };
}
const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0);
/**
* Runs the check against theme-baseline.json (hits per file) and
* theme-disable-baseline.json (marked lines per file). Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/theme-baseline.json'),
disableBaselinePath = join(root, 'scripts/theme-disable-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const { counts, listed, markers } = scan(root);
let baseline = readBaseline(baselinePath, countMap);
let allowedMarkers = readBaseline(disableBaselinePath, countMap);
if (list) {
for (const [file, hits] of Object.entries(listed).sort()) {
log(`${file} (${hits.length}, baseline ${baseline[file] ?? 0})`);
for (const hit of hits) log(` ${hit}`);
}
}
if (update) {
baseline = lowerCounts(baseline, counts);
allowedMarkers = lowerCounts(allowedMarkers, markers);
writeBaseline(baselinePath, baseline);
writeBaseline(disableBaselinePath, allowedMarkers);
}
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
const stale = staleCounts(baseline, counts);
const staleMarkers = staleCounts(allowedMarkers, markers);
for (const [file, n] of grown) {
const rules = [...new Set(listed[file].map((hit) => hit.split(': ')[1]))];
error(`FAIL ${file}: ${n} styling bypass(es) of the theme tokens, baseline ${baseline[file] ?? 0}.`);
for (const name of rules) error(` ${name}: use ${RULES.find((rule) => rule.name === name).fix}.`);
}
if (grown.length) {
error(
`Run npm run theme:lint -- --list to see each hit. A colour CSS variables cannot reach (map paint, PDF, ` +
`brand) is marked with ${DISABLE} in a comment on its line. See src/theme/README.md.`
);
}
for (const [file, n] of marked) {
error(
`FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/theme-disable-baseline.json allows ` +
`${allowedMarkers[file] ?? 0}. Use a token where one reaches; a surface it cannot reach (map paint, PDF, ` +
'brand colours) needs a reviewer to raise the entry by hand.'
);
}
reportStale(stale, { file: 'theme-baseline.json', command: 'theme:lint', root: join(root, 'src'), error });
reportStale(staleMarkers, {
file: 'theme-disable-baseline.json',
command: 'theme:lint',
root: join(root, 'src'),
error,
});
log(
`theme: ${sum(counts)} styling bypass(es) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
);
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
}
+159
View File
@@ -0,0 +1,159 @@
[
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.ts",
"src/components/Admin/plugins/usePluginDetail.ts",
"src/components/Admin/plugins/usePluginsAdmin.ts",
"src/components/Admin/storage/useStorageAdmin.ts",
"src/components/Admin/useAdminNotificationMatrix.ts",
"src/components/Admin/useAdminNotificationSettings.ts",
"src/components/Admin/useAdminSettingsActions.ts",
"src/components/Admin/useAdminUserActions.ts",
"src/components/Admin/useAuditLog.ts",
"src/components/Admin/useBackupAdmin.ts",
"src/components/Admin/useCategoryAdmin.ts",
"src/components/Admin/useDefaultUserSettings.ts",
"src/components/Admin/useDevNotifications.ts",
"src/components/Admin/useGithubReleases.ts",
"src/components/Admin/useGoogleQuota.ts",
"src/components/Admin/useInstanceSettings.ts",
"src/components/Admin/useMcpTokensAdmin.ts",
"src/components/Admin/useNotificationChannels.ts",
"src/components/Admin/usePackingTemplateAdmin.ts",
"src/components/Admin/usePermissionsAdmin.ts",
"src/components/BackgroundTasks/BackgroundTasksWidget.tsx",
"src/components/Budget/ReceiptPreviewModal.tsx",
"src/components/Budget/receiptUploads.ts",
"src/components/Collab/CollabChatLinkPreview.tsx",
"src/components/Collab/CollabLinks.tsx",
"src/components/Collab/CollabNotes.tsx",
"src/components/Collab/CollabNotesAuthedImg.tsx",
"src/components/Collab/CollabNotesFilePreviewPortal.tsx",
"src/components/Collab/CollabNotesWebsiteThumbnail.tsx",
"src/components/Collab/useCollabChatData.ts",
"src/components/Collab/useCollabNotesData.ts",
"src/components/Collab/useCollabPolls.ts",
"src/components/Collections/CollectionPicker.tsx",
"src/components/Collections/ImportFromTripModal.tsx",
"src/components/Collections/useAddPlaceToCollection.ts",
"src/components/Collections/useCollectionPlaceForm.ts",
"src/components/Collections/useCollectionSharing.ts",
"src/components/Collections/useListEditor.ts",
"src/components/Collections/useSaveToCollection.ts",
"src/components/Collections/useSaveTripPlacesToList.ts",
"src/components/Collections/useTripCopyPicker.ts",
"src/components/Dashboard/useCollectionLists.ts",
"src/components/Dawarich/DawarichAtlasDialog.tsx",
"src/components/Files/FileManagerAuthedImg.tsx",
"src/components/Files/FileManagerImageLightbox.tsx",
"src/components/Files/docsync/useConflicts.ts",
"src/components/Files/docsync/useDocSync.ts",
"src/components/Files/useFileManager.ts",
"src/components/Files/useMediaLightbox.ts",
"src/components/Journey/ContributorInviteDialog.tsx",
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
"src/components/Journey/useConnectedPhotoProviders.ts",
"src/components/Journey/useEntryPhotoOrder.ts",
"src/components/Journey/useJourneyEntryForm.ts",
"src/components/Journey/useJourneySettings.ts",
"src/components/Journey/useJourneyShareLink.ts",
"src/components/Journey/useJourneyTripSuggestion.ts",
"src/components/Map/MapPluginLayers.tsx",
"src/components/Map/MapPluginMarkers.tsx",
"src/components/Map/MapView.tsx",
"src/components/Map/MapViewGL.tsx",
"src/components/Map/RouteCalculator.ts",
"src/components/Map/routeUsageCounter.ts",
"src/components/Map/usePoiExplore.ts",
"src/components/OAuth/useScopeSelection.ts",
"src/components/PDF/TripPDF.tsx",
"src/components/Packing/ApplyTemplateButton.tsx",
"src/components/Packing/usePackingBags.ts",
"src/components/Packing/usePackingCategoryAssignees.ts",
"src/components/Packing/usePackingExport.ts",
"src/components/Packing/usePackingImport.ts",
"src/components/Packing/usePackingListPanel.ts",
"src/components/Packing/usePackingTemplates.ts",
"src/components/Planner/AddressInput.tsx",
"src/components/Planner/AirTrailImportModal.tsx",
"src/components/Planner/AirportSelect.tsx",
"src/components/Planner/BookingImportModal.tsx",
"src/components/Planner/LocationSelect.tsx",
"src/components/Planner/PlaceDetailsColumn.tsx",
"src/components/Planner/PlaceInspector.tsx",
"src/components/Planner/TransitSearchPanel.tsx",
"src/components/Planner/TripWarningsBanner.tsx",
"src/components/Planner/useDayDetail.ts",
"src/components/Planner/useListImport.ts",
"src/components/Planner/usePlaceSearch.ts",
"src/components/Planner/usePlacesFileImport.ts",
"src/components/Planner/usePlacesPool.ts",
"src/components/Plugins/PluginContributions.tsx",
"src/components/Plugins/PluginDaySchedule.tsx",
"src/components/Plugins/PluginFrame.tsx",
"src/components/Plugins/TripCardBadges.tsx",
"src/components/Plugins/usePluginUserSettings.ts",
"src/components/Roadtrip/useDayBoundaries.ts",
"src/components/Roadtrip/useRefuelSearch.ts",
"src/components/Roadtrip/useRoadtripVias.ts",
"src/components/Settings/ApiKeysSection.tsx",
"src/components/Settings/useAccountSettings.ts",
"src/components/Settings/useAirTrailSettings.ts",
"src/components/Settings/useMcpIntegration.ts",
"src/components/Settings/useNotificationSettings.ts",
"src/components/Settings/useOfflineSettings.ts",
"src/components/Settings/usePasskeys.ts",
"src/components/Settings/usePhotoProviderConnections.ts",
"src/components/Settings/usePluginActivity.ts",
"src/components/Studio/useBookPresence.ts",
"src/components/Studio/useBookStore.ts",
"src/components/Todo/useTodoList.ts",
"src/components/Trips/TripFormModal.tsx",
"src/components/Trips/useTripCoverPicker.ts",
"src/components/Trips/useTripShare.ts",
"src/components/Vacay/holidayRegions.ts",
"src/components/Vacay/useVacayCalendarLogic.ts",
"src/components/Vacay/useVacaySettings.ts",
"src/components/Vacay/useVacayUserPicker.ts",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/hooks/useAirtrailConnection.ts",
"src/hooks/useDawarichConnection.ts",
"src/hooks/useDawarichSuggestions.ts",
"src/hooks/useInAppNotificationListener.ts",
"src/hooks/usePlaceSuggestions.ts",
"src/hooks/useRoadtripPreferencesSync.ts",
"src/hooks/useTripWebSocket.ts",
"src/hooks/useWebPush.ts",
"src/mobile/screens/journey/MJourneyDetail.tsx",
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/OAuthAuthorizePage.tsx",
"src/pages/admin/AdminSettingsTab.tsx",
"src/pages/admin/useAdmin.ts",
"src/pages/admin/useNotificationDefaults.ts",
"src/pages/atlas/useAtlas.ts",
"src/pages/atlas/useAtlasCountryActions.ts",
"src/pages/collections/useCollections.ts",
"src/pages/dashboard/useDashboard.ts",
"src/pages/dashboard/useTripSearch.ts",
"src/pages/forgotPassword/useForgotPassword.ts",
"src/pages/help/useHelp.ts",
"src/pages/join/useJoinTrip.ts",
"src/pages/journey/useJourney.ts",
"src/pages/journeyDetail/useJourneyDetail.ts",
"src/pages/journeyDetail/useProviderPhotoAdds.ts",
"src/pages/journeyPublic/useJourneyPublic.ts",
"src/pages/journeyStudio/useJourneyStudio.ts",
"src/pages/login/useLogin.ts",
"src/pages/oauthAuthorize/useOAuthAuthorize.ts",
"src/pages/resetPassword/useResetPassword.ts",
"src/pages/settings/useSettings.ts",
"src/pages/sharedTrip/useSharedTrip.ts",
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
"src/pages/vacay/useVacay.ts"
]
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
/*
* lint:offline: views go through the offline core, not around it.
*
* The data path is component, feature hook, store, repo, then api or Dexie,
* and writes go through the mutation queue. A view that imports src/api/
* skips the cache, the optimistic write and the idempotent replay, so the
* feature breaks offline. Such files kept growing, by about fifteen a month.
* The view files (components/, mobile/, pages/, hooks/) that already import
* src/api/ for more than types are listed in scripts/offline-baseline.json;
* no other view file may, and the list only shrinks.
*
* npm run lint:offline check against the baseline (CI)
* npm run lint:offline -- --list print every view file that imports src/api/
* npm run lint:offline -- --update take the files that stopped off the list;
* it never adds one
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/offline.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:offline', (args) =>
check({ root, update: args.includes('--update'), list: args.includes('--list') })
);
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/*
* lint:pairs: a desktop view and its phone twin run on the same logic.
*
* Most features have a desktop view under components/ or pages/ and a phone
* view under mobile/, and each used to carry its own copy of the feature's
* state, loading and handlers, so a fix on one side drifted away from the
* other. The logic now lives in one shared hook per feature, and
* scripts/feature-pairs.json names, for every feature, the hooks and the
* desktop and phone files that run on them. The check fails when a listed
* view stops importing one of its pair's hooks, when a listed file is gone,
* when a hook sits in a view layer, 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, never a quiet second copy. lint:dup catches the
* copies that look alike; this catches the ones written anew.
*
* npm run lint:pairs check the manifest against the tree (CI)
* npm run lint:pairs -- --list print every pair with its hooks
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/pairs.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:pairs', (args) => check({ root, list: args.includes('--list') }));
+113
View File
@@ -0,0 +1,113 @@
{
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Budget/BudgetPanelMemberChips.tsx": 6,
"components/Budget/CostsPanel.tsx": 3,
"components/Budget/ReceiptPreviewModal.tsx": 2,
"components/Collab/CollabChatEmojiPicker.tsx": 3,
"components/Collab/CollabChatReactionBadge.tsx": 3,
"components/Collab/CollabChatReactionMenu.tsx": 1,
"components/Collab/CollabPolls.tsx": 1,
"components/Collections/CollectionPlaceDetail.tsx": 2,
"components/Files/FileManagerAvatarChip.tsx": 3,
"components/Journey/JourneyDayJump.tsx": 1,
"components/Journey/JourneyDetailPageDatePicker.tsx": 1,
"components/Journey/JourneyDetailPageEntryEditor.tsx": 1,
"components/Journey/JourneyDetailPageGalleryView.tsx": 1,
"components/Journey/JourneyDetailPagePhotoGrid.tsx": 1,
"components/Journey/JourneyDetailPageProviderPicker.tsx": 1,
"components/Journey/JourneyEntryCover.tsx": 1,
"components/Journey/JourneyMap.tsx": 1,
"components/Journey/JourneyMapGL.tsx": 2,
"components/Journey/MobileEntryView.tsx": 1,
"components/Journey/PhotoLightbox.tsx": 3,
"components/Journey/journeyPhotoLayer.ts": 1,
"components/Layout/Navbar.tsx": 1,
"components/Layout/OfflineBanner.tsx": 1,
"components/Map/DawarichTrailPill.tsx": 1,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 6,
"components/Map/MapViewGL.tsx": 8,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/PoiCategoryPill.tsx": 1,
"components/Map/hazardPopup.css": 2,
"components/Map/hazardPopup.ts": 1,
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/PDF/TripPDF.tsx": 8,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
"components/Packing/PackingListPanelItemRow.tsx": 2,
"components/Packing/PackingShareControl.tsx": 3,
"components/Planner/DayDetailPanel.tsx": 1,
"components/Planner/PlaceInspector.tsx": 1,
"components/Planner/TransitSearchPanel.tsx": 3,
"components/Planner/bookings/BookingsTimeline.tsx": 24,
"components/Roadtrip/AnchoredPopover.tsx": 2,
"components/Settings/IntegrationsTab.tsx": 2,
"components/Settings/PluginSettingsTab.tsx": 2,
"components/Settings/ToggleSwitch.tsx": 1,
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
"components/Studio/StudioInspector.tsx": 1,
"components/Studio/StudioSavedLayouts.tsx": 1,
"components/Studio/StudioSidebar.tsx": 3,
"components/Studio/TravelElements.tsx": 4,
"components/Studio/TravelPreview.tsx": 1,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 3,
"components/SystemNotices/releaseNotice.css": 2,
"components/Vacay/VacayCalendar.tsx": 3,
"components/Vacay/VacayMonthCard.tsx": 7,
"components/Vacay/VacaySettings.tsx": 1,
"components/shared/ContextMenu.tsx": 3,
"components/shared/CustomDateTimePicker.tsx": 2,
"components/shared/CustomSelect.tsx": 4,
"components/shared/CustomTimePicker.tsx": 3,
"components/shared/DetailShell.tsx": 1,
"components/shared/ElevationProfile.tsx": 2,
"components/shared/NavigationMenu.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 1,
"components/shared/SlidingTabs.tsx": 3,
"components/shared/StarRating.tsx": 3,
"components/shared/Toast.tsx": 1,
"components/shared/Tooltip.tsx": 2,
"components/shared/dialogParts.tsx": 1,
"help/contexts/tripMap.ts": 1,
"hooks/useAnchoredPosition.ts": 3,
"hooks/useElementRect.ts": 3,
"mobile/components/MSegmented.tsx": 3,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/dashboard/MDashboard.tsx": 1,
"mobile/screens/journey/MJourneyDetail.tsx": 3,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 1,
"mobile/screens/trip/MTripShell.tsx": 3,
"mobile/screens/trip/map/MMapArea.tsx": 3,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 1,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 2,
"mobile/screens/vacay/MVacay.tsx": 1,
"mobile/screens/vacay/MVacayMonth.tsx": 1,
"pages/AtlasPage.tsx": 2,
"pages/JourneyDetailPage.tsx": 1,
"pages/JourneyPublicPage.tsx": 3,
"pages/LoginPage.tsx": 23,
"pages/TripPlannerPage.tsx": 11,
"pages/VacayPage.tsx": 1,
"pages/admin/AdminNotificationsTab.tsx": 1,
"pages/admin/AdminSettingsTab.tsx": 1,
"pages/atlas/AtlasLayerToggle.tsx": 1,
"pages/sharedTrip/SharedMap.tsx": 4,
"styles/collections.css": 1,
"styles/studio.css": 7,
"utils/formatters.ts": 1,
"utils/mapViewport.ts": 4,
"utils/touchDragBridge.ts": 1
}
+4
View File
@@ -0,0 +1,4 @@
{
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/*
* lint:rtl: keeps the layout following the reading direction.
*
* Arabic runs the app with dir="rtl". A physical class or style (ml-2,
* text-left, paddingLeft, left: 0) stays on the same side in either direction,
* so a gap, an indent or an alignment that follows the text in English lands
* on the wrong side in Arabic. The logical forms (ms-2, text-start,
* paddingInlineStart, insetInlineStart) follow the direction, and in a
* left-to-right language they are exactly the physical ones.
*
* Some things are physical on purpose: a map, a chart axis, a drag handle, a
* popover placed at measured coordinates. Those stay. Every file's physical
* uses are counted against scripts/rtl-baseline.json, and the check fails when
* a file holds more than its entry (a file without an entry holds none), so
* new code reaches for the logical forms. A line that is physical on purpose
* carries `rtl-lint-disable` in a comment (in a string it is no marker). The
* marked lines are counted too, against scripts/rtl-disable-baseline.json, so
* the marker cannot quietly take the place of the logical forms: a new one
* raises its file's entry by hand, where review sees it. An entry of either
* baseline above what its file holds now, or for a file that is gone, fails
* as well until --update lowers it.
*
* npm run lint:rtl check against the baselines (CI)
* npm run lint:rtl -- --list print every counted use, file by file
* npm run lint:rtl -- --update lower both baselines to what the files hold now;
* it never raises an entry
*
* The matching lives in scripts/lib/rtl.mjs.
*/
import { fileURLToPath } from 'node:url';
import { runCli } from './lib/ratchet.mjs';
import { check } from './lib/rtl.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:rtl', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
+32
View File
@@ -0,0 +1,32 @@
{
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1910,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
"src/components/Planner/DayPlanSidebar.tsx": 2971,
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
"src/components/Planner/PlaceFormModal.tsx": 1250,
"src/components/Planner/PlaceInspector.tsx": 1304,
"src/components/Studio/TravelElements.tsx": 1738,
"src/components/Studio/autoLayout.ts": 1691,
"src/components/Studio/bookTemplates.data.ts": 1309,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1142,
"src/pages/atlas/useAtlas.ts": 1287,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
"src/styles/studio.css": 2087
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/*
* lint:size: keeps files from growing into the next god file.
*
* A file past a thousand lines holds several concerns that no longer fit in
* one reading, and every change to one of them risks the others. Each source
* file and stylesheet under src/ may hold 1000 lines, each test (under tests/,
* a Playwright spec or fixture under e2e/, or a co-located *.test.*) 2000. Lines are counted as an editor wraps them
* at 120 columns, so joining lines does not make a file smaller. The files
* that were already larger are listed in scripts/size-baseline.json with the
* size they had, and the check fails when one of them grows past its entry. A
* file that needs to grow is split by concern instead (the trip planner hook,
* page and road trip sidebar are the precedent). An entry above the file's
* size now, or for a file that is gone, fails as well until --update lowers
* it: otherwise the file could grow back unseen, and a new file at a deleted
* path would inherit its allowance.
*
* npm run lint:size check against the baseline (CI)
* npm run lint:size -- --update lower the baseline to what the files hold now;
* it never raises an entry, and drops a file
* that is back under its limit
*
* The measuring lives in scripts/lib/size.mjs.
*/
import { fileURLToPath } from 'node:url';
import { runCli } from './lib/ratchet.mjs';
import { check } from './lib/size.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:size', (args) => check({ root, update: args.includes('--update') }));
+3
View File
@@ -0,0 +1,3 @@
{
"src/pages/JourneyDetailPage.test.tsx": 35
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/*
* lint:skips: no test is focused, and skipped tests may only go away.
*
* A stray .only passes CI while the rest of its file never runs, so it fails
* this check wherever it appears. A skipped or todo test is coverage that was
* switched off and then forgotten (JourneyDetailPage.test.tsx has carried 35
* skipped blocks since April); the files that hold some are listed in
* scripts/skip-baseline.json with their count, and a file may hold no more.
* An entry above what its file holds now, or for a file that is gone, fails
* as well until --update lowers it.
* Playwright's test.fixme counts as a skip. Only a skip that decides at run
* time is left out: skipIf and runIf, and in a Playwright spec
* test.skip(condition, 'why'), unless the condition is a literal that always
* skips (skipIf(true), runIf(false), test.skip(true, 'why')).
* Covers the vitest tests under src/ and tests/ and the Playwright specs
* under e2e/.
*
* npm run lint:skips check against the baseline (CI)
* npm run lint:skips -- --update lower the baseline to what the files hold now;
* it never raises an entry
*/
import { fileURLToPath } from 'node:url';
import { runCli } from './lib/ratchet.mjs';
import { check } from './lib/skips.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:skips', (args) => check({ root, update: args.includes('--update') }));
+166
View File
@@ -0,0 +1,166 @@
{
"src/components/Admin/AddonManager.tsx": 2,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2,
"src/components/Admin/AdminPluginsPanel.tsx": 3,
"src/components/Admin/storage/useStorageAdmin.test.tsx": 1,
"src/components/Admin/useBackupAdmin.ts": 1,
"src/components/Budget/BudgetPanelMemberChips.tsx": 1,
"src/components/Budget/CostsPanel.test.tsx": 1,
"src/components/Collab/CollabChat.helpers.ts": 1,
"src/components/Collab/CollabChat.tsx": 3,
"src/components/Collab/CollabChatEmojiPicker.tsx": 5,
"src/components/Collab/CollabChatMessageText.tsx": 2,
"src/components/Collab/CollabChatMessages.tsx": 4,
"src/components/Collab/CollabChatReactionBadge.tsx": 1,
"src/components/Collab/CollabChatReactionMenu.tsx": 2,
"src/components/Collab/CollabChatTwemojiImg.tsx": 1,
"src/components/Collab/CollabNotes.helpers.ts": 3,
"src/components/Collab/CollabNotes.test.tsx": 2,
"src/components/Collab/CollabNotes.tsx": 23,
"src/components/Collab/CollabNotesCard.tsx": 1,
"src/components/Collab/CollabNotesCategorySettingsModal.tsx": 6,
"src/components/Collab/CollabNotesEditableCatName.tsx": 2,
"src/components/Collab/CollabNotesFormModal.tsx": 8,
"src/components/Collab/CollabPanel.tsx": 6,
"src/components/Collab/CollabPolls.test.tsx": 1,
"src/components/Collab/CollabPolls.tsx": 4,
"src/components/Collab/WhatsNextWidget.test.tsx": 16,
"src/components/Collab/WhatsNextWidget.tsx": 5,
"src/components/Collab/useCollabChat.ts": 6,
"src/components/Files/FileManager.tsx": 2,
"src/components/Files/FileManagerAssignModal.tsx": 2,
"src/components/Files/FileManagerMarkdownPreviewModal.tsx": 4,
"src/components/Files/FileManagerPdfPreviewModal.tsx": 5,
"src/components/Files/FileManagerPreviewModals.test.tsx": 3,
"src/components/Files/useFileManager.ts": 5,
"src/components/Journey/JourneyDayDawarich.test.tsx": 1,
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx": 1,
"src/components/Journey/JourneyMap.test.tsx": 6,
"src/components/Journey/journeyGeo.ts": 4,
"src/components/Layout/Navbar.test.tsx": 1,
"src/components/Map/MapView.test.tsx": 2,
"src/components/Map/MapView.tsx": 8,
"src/components/Map/MapViewGL.tsx": 1,
"src/components/Map/ReservationOverlay.test.tsx": 1,
"src/components/Map/transitGeometry.ts": 1,
"src/components/Map/useDawarichTrailGL.test.ts": 4,
"src/components/Map/useMarkerThumbs.ts": 1,
"src/components/PDF/TripPDF.tsx": 40,
"src/components/Packing/PackingListPanelItemRow.test.tsx": 2,
"src/components/Planner/DayPlanSidebar.constants.ts": 1,
"src/components/Planner/DayPlanSidebar.tsx": 61,
"src/components/Planner/DayPlanSidebarNoteModal.test.tsx": 1,
"src/components/Planner/PlaceFormModal.test.tsx": 3,
"src/components/Planner/PlaceFormModal.tsx": 1,
"src/components/Planner/PlaceInspector.tsx": 17,
"src/components/Planner/PlacesBulkCategoryModal.test.tsx": 1,
"src/components/Planner/PlacesSidebar.tsx": 1,
"src/components/Planner/PlacesSidebarMobileDayPicker.tsx": 8,
"src/components/Planner/ReservationModal.tsx": 4,
"src/components/Planner/TransitSearchPanel.tsx": 2,
"src/components/Planner/assignmentOptions.ts": 1,
"src/components/Planner/bookings/bookingFacts.ts": 1,
"src/components/Planner/bookings/bookingsModel.ts": 2,
"src/components/Planner/bookings/timelineModel.ts": 4,
"src/components/Planner/reservationFormModel.test.ts": 1,
"src/components/Planner/transportEndpoints.ts": 2,
"src/components/Planner/useDayDetail.ts": 2,
"src/components/Planner/usePlacesSidebar.ts": 1,
"src/components/Planner/useTripExport.ts": 1,
"src/components/Plugins/PluginFrame.tsx": 2,
"src/components/Roadtrip/RoadtripAlternativesBar.tsx": 1,
"src/components/Roadtrip/RoadtripCorridorPanel.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarBadges.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 2,
"src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 5,
"src/components/Roadtrip/RoadtripTrackModal.tsx": 4,
"src/components/Roadtrip/ServiceStopSection.tsx": 2,
"src/components/Roadtrip/useRoadtripRoutes.test.ts": 2,
"src/components/Roadtrip/useRoadtripRoutes.ts": 4,
"src/components/Settings/DisplaySettingsTab.tsx": 2,
"src/components/Settings/MapboxPreview.tsx": 1,
"src/components/Settings/NotificationsTab.test.tsx": 1,
"src/components/Settings/PasskeysSection.tsx": 1,
"src/components/Settings/PhotoProvidersSection.test.tsx": 2,
"src/components/Studio/StudioCanvas.tsx": 1,
"src/components/Studio/StudioInspector.tsx": 1,
"src/components/Studio/autoLayout.ts": 1,
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
"src/components/Vacay/VacayPersons.tsx": 4,
"src/components/Vacay/VacaySettings.tsx": 2,
"src/components/Vacay/VacaySharedCalendars.tsx": 1,
"src/components/Vacay/holidayRegions.test.ts": 2,
"src/components/Vacay/holidayRegions.ts": 4,
"src/components/Weather/WeatherWidget.tsx": 14,
"src/components/shared/CustomSelect.test.tsx": 1,
"src/components/shared/PlaceAvatar.test.tsx": 2,
"src/components/shared/PlaceAvatar.tsx": 1,
"src/components/shared/PlaceAvatarUpload.test.tsx": 1,
"src/hooks/useDawarichSuggestions.test.ts": 1,
"src/mobile/screens/admin/MAdminAddonManager.tsx": 2,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 6,
"src/mobile/screens/journey/MJourneySettingsSheet.tsx": 1,
"src/mobile/screens/settings/MSettingsAccount.tsx": 1,
"src/mobile/screens/trip/MTripShell.tsx": 1,
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
"src/mobile/screens/trip/places/MToursSelectionList.tsx": 6,
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
"src/mobile/screens/trip/sheets/MReservationSheet.tsx": 1,
"src/mobile/screens/trip/sheets/MTripSheets.tsx": 1,
"src/mobile/screens/trip/tabs/transportsModel.ts": 1,
"src/mobile/screens/vacay/MVacaySettingsSheet.tsx": 1,
"src/pages/AtlasPage.tsx": 6,
"src/pages/FilesPage.tsx": 1,
"src/pages/JourneyDetailPage.tsx": 1,
"src/pages/JourneyPublicPage.test.tsx": 1,
"src/pages/LoginPage.tsx": 1,
"src/pages/VacayPage.tsx": 1,
"src/pages/admin/useAdmin.ts": 4,
"src/pages/atlas/atlasModel.ts": 2,
"src/pages/atlas/useAtlas.ts": 9,
"src/pages/collections/collectionsModel.ts": 1,
"src/pages/journeyDetail/useJourneyDetail.ts": 3,
"src/pages/sharedTrip/SharedPlanView.tsx": 1,
"src/pages/tripPlanner/PlanMapStage.tsx": 1,
"src/pages/tripPlanner/b4a-wiring.test.ts": 1,
"src/pages/tripPlanner/useBookingEdits.ts": 1,
"src/pages/tripPlanner/useDayPlanEdits.ts": 2,
"src/pages/tripPlanner/useMapPlaces.ts": 3,
"src/pages/tripPlanner/usePlaceEdits.ts": 2,
"src/pages/tripPlanner/usePlaceFormOpeners.test.ts": 2,
"src/pages/tripPlanner/usePlaceFormOpeners.ts": 1,
"src/pages/tripPlanner/useTripPlanner.ts": 2,
"src/repo/tripRepo.ts": 2,
"src/store/slices/budgetSlice.test.ts": 1,
"src/utils/formatters.test.ts": 1,
"src/utils/touchDragPolyfill.ts": 1,
"tests/integration/api/client.test.ts": 1,
"tests/integration/hooks/useRouteCalculation.test.ts": 1,
"tests/unit/i18n/i18nKeysScript.test.ts": 3,
"tests/unit/mobile/admin/MAdminAddonManager.test.tsx": 2,
"tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx": 1,
"tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx": 1,
"tests/unit/mobile/admin/MAdminStoragePanel.test.tsx": 1,
"tests/unit/mobile/dashboard/MDashWidgets.test.tsx": 1,
"tests/unit/mobile/settings/MSettingsNotifications.test.tsx": 3,
"tests/unit/mobile/settings/MSettingsPlugins.test.tsx": 2,
"tests/unit/mobile/trip/MCostsTab.test.tsx": 1,
"tests/unit/mobile/trip/filesModel.test.ts": 3,
"tests/unit/remoteEventHandler/budget.test.ts": 1,
"tests/unit/remoteEventHandler/trip.test.ts": 1,
"tests/unit/scripts/eslint-ratchet.test.ts": 1,
"tests/unit/slices/budgetSlice.test.ts": 1,
"tests/unit/studio/autoLayout.travel.test.ts": 3,
"tests/unit/studio/mapFraming.test.tsx": 1
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
/*
* lint:strict: the errors strict null checks would raise may only go down.
*
* The client compiles with strict: false, so a `.nullable()` field from
* @trek/shared arrives as if it were always there and an untyped parameter is
* an `any` nobody sees. Turning strict on at once would mean fixing hundreds
* of files in one go. Instead tsconfig.strict.json is the same program with
* strictNullChecks and noImplicitAny on, and this counts its errors per file
* against scripts/strict-baseline.json: a file may not gain one, and a file
* without an entry, which every new file is, has to be clean. An entry above
* its count, or for a file that is gone, fails until --update lowers it, so a
* file brought down stays down. When a whole directory reaches zero, it can
* move into the main tsconfig's strict settings for good.
*
* npm run lint:strict check against the baseline (CI)
* npm run lint:strict -- --list print every error, file by file
* npm run lint:strict -- --update lower the baseline to today's counts;
* it never raises an entry
*
* The parsing and the comparison live in scripts/lib/strict.mjs.
*/
import { fileURLToPath } from 'node:url';
import { runCli } from './lib/ratchet.mjs';
import { check } from './lib/strict.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:strict', (args) =>
check({ root, update: args.includes('--update'), list: args.includes('--list') })
);
+323
View File
@@ -0,0 +1,323 @@
{
"App.tsx": 11,
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/DefaultUserSettingsTab.tsx": 1,
"components/Atlas/AtlasCountryPlaces.tsx": 6,
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
"components/Budget/BudgetPanel.helpers.ts": 2,
"components/Budget/BudgetPanelMemberChips.tsx": 4,
"components/Budget/CostsPanel.tsx": 37,
"components/Budget/ReceiptPreviewModal.tsx": 6,
"components/Budget/ReceiptScanModal.tsx": 2,
"components/Budget/costsCategories.tsx": 14,
"components/Collab/CollabChat.tsx": 1,
"components/Collab/CollabChatAttachment.tsx": 4,
"components/Collab/CollabChatEmojiPicker.tsx": 1,
"components/Collab/CollabChatReactionBadge.tsx": 1,
"components/Collab/CollabChatReactionMenu.tsx": 1,
"components/Collab/CollabLinks.tsx": 1,
"components/Collab/CollabNotesFilePreviewPortal.tsx": 2,
"components/Collab/CollabPolls.tsx": 3,
"components/Collections/CollectionHero.tsx": 1,
"components/Collections/MSaveToCollectionSheet.tsx": 12,
"components/Collections/StatusBadge.tsx": 4,
"components/Dawarich/DawarichAtlasDialog.tsx": 3,
"components/Files/FileManagerAvatarChip.tsx": 1,
"components/Files/FileManagerImageLightbox.tsx": 8,
"components/Journey/ContributorInviteDialog.tsx": 76,
"components/Journey/JournalBody.tsx": 1,
"components/Journey/JourneyDayDawarich.tsx": 2,
"components/Journey/JourneyDayJump.tsx": 6,
"components/Journey/JourneyDayScrubber.tsx": 3,
"components/Journey/JourneyDetailPageChips.tsx": 2,
"components/Journey/JourneyDetailPageDatePicker.tsx": 43,
"components/Journey/JourneyDetailPageEntryCard.tsx": 73,
"components/Journey/JourneyDetailPageEntryEditor.tsx": 2,
"components/Journey/JourneyDetailPageExpandableStory.tsx": 17,
"components/Journey/JourneyDetailPageGalleryView.tsx": 15,
"components/Journey/JourneyDetailPageMapView.tsx": 50,
"components/Journey/JourneyDetailPagePhotoGrid.tsx": 6,
"components/Journey/JourneyDetailPageProviderPicker.tsx": 163,
"components/Journey/JourneyDetailPageScrollTrigger.tsx": 4,
"components/Journey/JourneyDetailPageVerdictSection.tsx": 51,
"components/Journey/JourneyEntryCover.tsx": 15,
"components/Journey/JourneyMap.tsx": 3,
"components/Journey/JourneyMapGL.tsx": 5,
"components/Journey/JourneyShareSection.tsx": 41,
"components/Journey/MobileEntryView.tsx": 94,
"components/Journey/MobileMapTimeline.tsx": 8,
"components/Journey/PhotoLightbox.tsx": 13,
"components/Journey/VideoPlayer.tsx": 2,
"components/Journey/VideoPlayerLazy.tsx": 1,
"components/Journey/journeyMapPopup.ts": 17,
"components/Journey/moodConfig.ts": 5,
"components/Layout/BottomNav.tsx": 3,
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 22,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
"components/Map/LocationButton.tsx": 1,
"components/Map/MapPluginMarkers.tsx": 2,
"components/Map/MapTogglePill.tsx": 1,
"components/Map/MapView.tsx": 8,
"components/Map/MapViewGL.tsx": 11,
"components/Map/NightPauseTooltip.tsx": 1,
"components/Map/PlaceHoverCard.tsx": 1,
"components/Map/ReservationOverlay.tsx": 5,
"components/Map/RoadtripViaMarkers.tsx": 1,
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/placePopup.ts": 6,
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/Notifications/InAppNotificationItem.tsx": 8,
"components/PDF/TripPDF.tsx": 53,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
"components/Packing/PackingListPanelItemRow.tsx": 2,
"components/Packing/PackingShareControl.tsx": 3,
"components/Packing/packingPrintDocument.ts": 24,
"components/Planner/AirTrailImportModal.tsx": 4,
"components/Planner/BookingCostsSection.tsx": 1,
"components/Planner/BookingImportModal.tsx": 4,
"components/Planner/DayDetailPanel.tsx": 1,
"components/Planner/IcsSubscribeModal.tsx": 10,
"components/Planner/PlaceInspector.tsx": 1,
"components/Planner/PlacesSidebarMobileDayPicker.tsx": 3,
"components/Planner/SubscribeLinks.tsx": 5,
"components/Planner/SuggestionDropdown.tsx": 1,
"components/Planner/TransitJourneyModal.tsx": 4,
"components/Planner/TransitSearchPanel.tsx": 2,
"components/Planner/TravelerPicker.tsx": 3,
"components/Planner/bookings/BookingsTimeline.tsx": 1,
"components/Plugins/PluginContributions.tsx": 5,
"components/Plugins/PluginWidgets.tsx": 1,
"components/Roadtrip/AnchoredPopover.tsx": 1,
"components/Roadtrip/ServiceStopSection.tsx": 1,
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
"components/Studio/StudioSidebar.tsx": 2,
"components/Studio/StudioTravelPanel.tsx": 1,
"components/Studio/TravelElements.tsx": 5,
"components/Studio/printSheets.ts": 5,
"components/Studio/templates.ts": 3,
"components/SystemNotices/ReleaseNoticeVisuals.tsx": 4,
"components/SystemNotices/SystemNoticeBanner.tsx": 28,
"components/SystemNotices/SystemNoticeModal.tsx": 107,
"components/Todo/TodoListPanel.tsx": 6,
"components/Todo/todoListModel.ts": 3,
"components/Trips/TripFormModal.tsx": 9,
"components/Trips/TripMembersModal.tsx": 6,
"components/Vacay/VacayBadge.tsx": 3,
"components/Vacay/VacayCalendar.tsx": 20,
"components/Vacay/VacayMonthCard.tsx": 5,
"components/Vacay/VacayPersons.tsx": 4,
"components/Vacay/VacaySettings.tsx": 27,
"components/Vacay/VacaySharedCalendars.tsx": 6,
"components/Vacay/VacayStats.tsx": 14,
"components/Weather/WeatherWidget.tsx": 4,
"components/shared/ConfirmDialog.tsx": 8,
"components/shared/ContextMenu.tsx": 1,
"components/shared/CopyTripDialog.tsx": 12,
"components/shared/CustomDateTimePicker.tsx": 1,
"components/shared/CustomSelect.tsx": 1,
"components/shared/CustomTimePicker.tsx": 1,
"components/shared/EmptyState.tsx": 1,
"components/shared/ErrorBoundary.tsx": 2,
"components/shared/Modal.tsx": 3,
"components/shared/NavigationMenu.tsx": 1,
"components/shared/NoteColorPicker.tsx": 2,
"components/shared/PlaceAvatarUpload.tsx": 3,
"components/shared/PublicLanguagePicker.tsx": 3,
"components/shared/Skeleton.tsx": 2,
"components/shared/Spinner.tsx": 2,
"components/shared/StarRating.tsx": 1,
"components/shared/Toast.tsx": 8,
"components/shared/Tooltip.tsx": 1,
"components/shared/TrackColorPicker.tsx": 2,
"components/shared/TripLoadingSplash.tsx": 10,
"mobile/components/MBadge.tsx": 2,
"mobile/components/MBottomNav.tsx": 2,
"mobile/components/MChip.tsx": 2,
"mobile/components/MDayImpactList.tsx": 4,
"mobile/components/MListRow.tsx": 1,
"mobile/components/MSegmented.tsx": 2,
"mobile/components/MSheet.tsx": 1,
"mobile/components/MStorageTestResult.tsx": 3,
"mobile/components/MToastHost.tsx": 2,
"mobile/components/MUserPickerList.tsx": 1,
"mobile/screens/admin/MAdmin.tsx": 9,
"mobile/screens/admin/MAdminAddonManager.tsx": 26,
"mobile/screens/admin/MAdminAuditLogPanel.tsx": 15,
"mobile/screens/admin/MAdminBackupPanel.tsx": 8,
"mobile/screens/admin/MAdminCategoryManager.tsx": 8,
"mobile/screens/admin/MAdminDefaultUserSettings.tsx": 5,
"mobile/screens/admin/MAdminDevNotificationsPanel.tsx": 6,
"mobile/screens/admin/MAdminGitHubPanel.tsx": 30,
"mobile/screens/admin/MAdminMcpTokensPanel.tsx": 14,
"mobile/screens/admin/MAdminNotifyMatrix.tsx": 5,
"mobile/screens/admin/MAdminPackingTemplateManager.tsx": 8,
"mobile/screens/admin/MAdminPermissionsPanel.tsx": 7,
"mobile/screens/admin/MAdminPluginsPanel.tsx": 129,
"mobile/screens/admin/MAdminSettingsSection.tsx": 10,
"mobile/screens/admin/MAdminSheets.tsx": 11,
"mobile/screens/admin/MAdminStoragePanel.tsx": 42,
"mobile/screens/admin/MAdminUi.tsx": 10,
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
"mobile/screens/atlas/MAtlas.tsx": 7,
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
"mobile/screens/collections/MCollAddSheet.tsx": 7,
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
"mobile/screens/collections/MCollEditSheet.tsx": 5,
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
"mobile/screens/collections/MCollShareSheet.tsx": 14,
"mobile/screens/collections/MCollSheetKit.tsx": 6,
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
"mobile/screens/collections/MCollections.tsx": 21,
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
"mobile/screens/dashboard/MDashboard.tsx": 36,
"mobile/screens/dashboard/MNewTripSheet.tsx": 21,
"mobile/screens/dashboard/MUserMenu.tsx": 7,
"mobile/screens/journey/MJourney.tsx": 10,
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
"mobile/screens/journey/MJourneyDetail.tsx": 6,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
"mobile/screens/notifications/MNotificationRow.tsx": 4,
"mobile/screens/notifications/MNotifications.tsx": 3,
"mobile/screens/settings/MAirTrailConnectionSection.tsx": 2,
"mobile/screens/settings/MConfirmSheet.tsx": 2,
"mobile/screens/settings/MDawarichConnectionSection.tsx": 5,
"mobile/screens/settings/MMobileDashOrder.tsx": 2,
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
"mobile/screens/settings/MSettings.tsx": 2,
"mobile/screens/settings/MSettingsAbout.tsx": 5,
"mobile/screens/settings/MSettingsAccount.tsx": 21,
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
"mobile/screens/settings/MSettingsMap.tsx": 5,
"mobile/screens/settings/MSettingsMcp.tsx": 30,
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
"mobile/screens/settings/MSettingsOffline.tsx": 18,
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
"mobile/screens/settings/MSettingsUi.tsx": 11,
"mobile/screens/settings/MWebPushCard.tsx": 1,
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
"mobile/screens/trip/MTripShell.tsx": 9,
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
"mobile/screens/trip/places/MPlacesFilterSheet.tsx": 3,
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
"mobile/screens/trip/places/MToursSelectionList.tsx": 9,
"mobile/screens/trip/plan/MPlanTimeline.tsx": 14,
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 26,
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 31,
"mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 17,
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 8,
"mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 2,
"mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 7,
"mobile/screens/trip/roadtrip/MRtDraftSheet.tsx": 5,
"mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": 9,
"mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 3,
"mobile/screens/trip/roadtrip/MRtStaySheet.tsx": 17,
"mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 12,
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
"mobile/screens/trip/sheets/MExportSheet.tsx": 2,
"mobile/screens/trip/sheets/MImportSheet.tsx": 2,
"mobile/screens/trip/sheets/MLinkedCosts.tsx": 9,
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
"mobile/screens/trip/tabs/MCollabTab.tsx": 1,
"mobile/screens/trip/tabs/MCostsTab.tsx": 71,
"mobile/screens/trip/tabs/MDocSyncSheet.tsx": 43,
"mobile/screens/trip/tabs/MFileLightbox.tsx": 12,
"mobile/screens/trip/tabs/MFileLinkSheet.tsx": 2,
"mobile/screens/trip/tabs/MFileMenuSheet.tsx": 1,
"mobile/screens/trip/tabs/MFileTrashSheet.tsx": 4,
"mobile/screens/trip/tabs/MFilesTab.tsx": 12,
"mobile/screens/trip/tabs/MPackItemSheet.tsx": 7,
"mobile/screens/trip/tabs/MPackingImportSheet.tsx": 3,
"mobile/screens/trip/tabs/MPackingListTab.tsx": 32,
"mobile/screens/trip/tabs/MReservationCardParts.tsx": 6,
"mobile/screens/trip/tabs/MTaskSheet.tsx": 6,
"mobile/screens/trip/tabs/MTodoListTab.tsx": 15,
"mobile/screens/trip/tabs/MTransportsTab.tsx": 2,
"mobile/screens/trip/tabs/filesModel.ts": 5,
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
"pages/DashboardPage.tsx": 1,
"pages/FilesPage.tsx": 11,
"pages/ForgotPasswordPage.tsx": 18,
"pages/HelpPage.tsx": 21,
"pages/InAppNotificationsPage.tsx": 11,
"pages/JoinTripPage.tsx": 4,
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
"pages/JourneyPublicPage.tsx": 113,
"pages/LoginPage.tsx": 58,
"pages/OAuthAuthorizePage.tsx": 33,
"pages/PluginPage.tsx": 1,
"pages/ResetPasswordPage.tsx": 23,
"pages/TripPlannerPage.tsx": 7,
"pages/VacayPage.tsx": 13,
"pages/atlas/useAtlas.ts": 2,
"pages/collections/collectionsModel.ts": 1,
"pages/collections/useCollections.ts": 1,
"pages/sharedTrip/SharedChrome.tsx": 2,
"pages/sharedTrip/SharedMap.tsx": 1,
"pages/tripPlanner/ListsContainer.tsx": 1,
"pages/tripPlanner/PlanMapStage.tsx": 1,
"utils/touchDragBridge.ts": 1
}
@@ -0,0 +1,74 @@
{
"components/Admin/GitHubPanel.tsx": 3,
"components/Admin/PluginPoiCategoryList.tsx": 1,
"components/Admin/useCategoryAdmin.ts": 1,
"components/Budget/CostsPanel.tsx": 1,
"components/Budget/CostsToolbar.tsx": 1,
"components/Collab/CollabNotesFilePreviewPortal.tsx": 3,
"components/Collections/AddPlaceToCollectionModal.tsx": 1,
"components/Collections/BulkAssignLabelModal.tsx": 1,
"components/Collections/CollectionPicker.tsx": 1,
"components/Collections/ImportCollectionModal.tsx": 1,
"components/Collections/MoveToListModal.tsx": 1,
"components/Collections/SaveTripPlacesToListModal.tsx": 1,
"components/Dawarich/DawarichAtlasSidePanel.tsx": 1,
"components/Files/FileManagerImageLightbox.tsx": 1,
"components/Files/FileManagerRow.tsx": 3,
"components/Files/FileManagerToolbar.tsx": 1,
"components/Help/HelpNav.tsx": 1,
"components/Journey/journeyPhotoLayer.ts": 2,
"components/Map/DawarichTrailLayer.tsx": 1,
"components/Map/dawarichTrail.ts": 10,
"components/Map/useDawarichTrailGL.ts": 1,
"components/Packing/PackingListPanelItemRow.tsx": 1,
"components/Planner/DayReorderPopup.tsx": 1,
"components/Planner/bookings/bookingsModel.ts": 1,
"components/Planner/bookings/transitParts.tsx": 1,
"components/Planner/usePlaceForm.ts": 1,
"components/Roadtrip/RangeStrip.tsx": 1,
"components/Roadtrip/RoadtripAlternativesBar.tsx": 1,
"components/Roadtrip/RoadtripCategoryPicker.tsx": 2,
"components/Roadtrip/RoadtripCorridorPanel.tsx": 3,
"components/Roadtrip/RoadtripSidebarStopRows.tsx": 4,
"components/Roadtrip/RoadtripTrackModal.tsx": 1,
"components/Roadtrip/StopKindPicker.tsx": 2,
"components/Roadtrip/dayColors.ts": 8,
"components/Roadtrip/nightBookend.ts": 1,
"components/Roadtrip/stopKinds.ts": 1,
"components/Settings/AboutTab.tsx": 3,
"components/Settings/AccountTab.tsx": 2,
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
"components/Studio/autoLayout.ts": 50,
"components/Studio/peerColour.ts": 1,
"components/Studio/printSheets.ts": 4,
"components/Trips/TripMemberAvatar.tsx": 2,
"components/Vacay/VacayPersons.tsx": 1,
"components/shared/DialogShell.tsx": 2,
"components/shared/PasswordChecklist.tsx": 2,
"mobile/components/MSheet.tsx": 1,
"mobile/screens/atlas/MAtlas.tsx": 1,
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 2,
"mobile/screens/atlas/MAtlasSearch.tsx": 1,
"mobile/screens/collections/MCollEditSheet.tsx": 2,
"mobile/screens/collections/MCollPlaceSheet.tsx": 2,
"mobile/screens/collections/MCollShareSheet.tsx": 1,
"mobile/screens/collections/collectionsMobileModel.ts": 15,
"mobile/screens/dashboard/MDashboard.tsx": 3,
"mobile/screens/journey/MJourney.tsx": 2,
"mobile/screens/trip/roadtrip/MRoadtripRows.tsx": 2,
"mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
"mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 1,
"mobile/screens/trip/roadtrip/MRtKindSheet.tsx": 1,
"mobile/screens/trip/roadtrip/MRtStopSheet.tsx": 1,
"mobile/screens/trip/sheets/MCostSheet.tsx": 1,
"pages/AtlasPage.tsx": 1,
"pages/admin/AdminUserModals.tsx": 1,
"pages/sharedTrip/SharedChrome.tsx": 4,
"pages/sharedTrip/SharedMap.tsx": 2
}
+35 -66
View File
@@ -1,73 +1,42 @@
#!/usr/bin/env node
/*
* theme:lint — guards the appearance token system.
* theme:lint: styling that bypasses the appearance tokens may only go down.
*
* Flags styling that bypasses the design tokens and therefore won't follow a
* user's chosen scheme / transparency / text-size:
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
* - inline numeric fontSize (fontSize: 13)
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
* A user picks a colour scheme, an accent, transparency and a text size, and
* applyAppearance() turns that into CSS variables. A colour literal, a palette
* class, a raw text size or a z-index of its own never hears about any of it,
* so the surface stays light in a dark scheme, keeps indigo under a red
* accent, or ignores the text size setting. The contract is in
* src/theme/README.md; this counts what of it source shows, per file under
* src/ (tests excluded):
*
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
* arbitrary-color bg-[#..], text-[rgba(..)] and the like
* inline-color color: '#111', background: 'rgba(...)' in a style
* inline-font-size fontSize: 13
* palette-class bg-gray-100, text-indigo-600, bg-white, text-black
* raw-text-size text-xs, text-sm ... text-9xl instead of the type tiers
* z-index-literal z-[9999], zIndex: 10000 above Tailwind's z-50, instead of
* a step of the layering scale (--z-bar ... --z-toast)
* dark-mode-read reading settings.dark_mode instead of the .dark class
*
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
* `--strict` exits non-zero when any violations remain (for once the backlog is
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
* line comment to suppress an intentional exception (map/PDF/brand colors).
* Each file may hold no more hits than its entry in scripts/theme-baseline.json
* (a file without an entry holds none). A line that is literal on purpose (map
* paint, PDF, brand colours) carries `theme-lint-disable` in a comment; those
* lines are counted against scripts/theme-disable-baseline.json, so the marker
* cannot quietly take the place of the tokens. An entry of either baseline
* above what its file holds now, or for a file that is gone, fails as well
* until --update lowers it.
*
* npm run theme:lint check against the baselines (CI)
* npm run theme:lint -- --list print every hit, file by file
* npm run theme:lint -- --update lower both baselines to what the files hold now;
* it never raises an entry
*
* The matching lives in scripts/lib/theme.mjs.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCli } from './lib/ratchet.mjs';
import { check } from './lib/theme.mjs';
let SRC = new URL('../src', import.meta.url).pathname;
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
// paint, standalone PDF documents) — colors there must stay literal.
const EXEMPT = [
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
/ReservationOverlay/, /packingPrintDocument/, /\.test\./, /\.spec\./,
];
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
function walk(dir, files = []) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, files);
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
}
return files;
}
const strict = process.argv.includes('--strict');
const offenders = [];
let total = 0;
for (const f of walk(SRC)) {
if (EXEMPT.some((re) => re.test(f))) continue;
let count = 0;
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (line.includes('theme-lint-disable')) continue;
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
}
if (count) {
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
total += count;
}
}
offenders.sort((a, b) => b[1] - a[1]);
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
if (strict && total > 0) {
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
process.exit(1);
}
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('theme:lint', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') }));
+365 -350
View File
File diff suppressed because it is too large Load Diff
+217 -341
View File
@@ -1,72 +1,67 @@
import React, { ReactNode, Suspense, useEffect, useRef, useState } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router';
import { useAddonStore } from './store/addonStore';
import { useAuthStore } from './store/authStore';
import { usePluginStore } from './store/pluginStore';
import { useSettingsStore } from './store/settingsStore';
import { applyAppearance } from './theme/applyAppearance';
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import { usePluginStore } from './store/pluginStore'
// The one page that stays in the entry chunk. Anyone logged out lands here, and
// every other route redirects here first — a chunk round trip in front of the login
// form would slow down the single screen that has to be there immediately.
import { authApi, isAuthPublicPath } from './api/client';
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget';
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet';
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal';
import HelpPanel from './components/Help/HelpPanel';
import OfflineBanner from './components/Layout/OfflineBanner';
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js';
import ErrorBoundary from './components/shared/ErrorBoundary';
import { ToastContainer } from './components/shared/Toast';
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts';
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync';
import { TranslationProvider, useTranslation } from './i18n';
import MobileShell from './mobile/MobileShell';
import MRouteFallback from './mobile/components/MRouteFallback';
import { useIsPhone } from './mobile/useIsPhone';
import LoginPage from './pages/LoginPage';
import { tripRepo } from './repo/tripRepo';
import { PermissionLevel, usePermissionsStore } from './store/permissionsStore';
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers';
import { lazyWithRetry } from './utils/lazyWithRetry';
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute';
import {
DEFAULT_START_PAGE,
DEFAULT_START_TRIP_TAB,
readStartDestination,
SETTINGS_WAIT_MS,
START_DESTINATION_ROUTE,
tripStartPath,
} from './utils/startDestination';
import { reconcileAppVersion } from './utils/versionHandover';
import LoginPage from './pages/LoginPage'
import { ToastContainer } from './components/shared/Toast'
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
import MobileShell from './mobile/MobileShell'
import MRouteFallback from './mobile/components/MRouteFallback'
import ErrorBoundary from './components/shared/ErrorBoundary'
import { lazyWithRetry } from './utils/lazyWithRetry'
import { reconcileAppVersion } from './utils/versionHandover'
import { useServerVersionStore } from './store/serverVersionStore'
import { useIsPhone } from './mobile/useIsPhone'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi, isAuthPublicPath } from './api/client'
import { tripRepo } from './repo/tripRepo'
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
import { takeResumeRoute, useRememberRoute } from './utils/resumeRoute'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
import { managedRoutes } from './managed';
import './pages/Trips/noticeActions.js';
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
// Every page below loads on demand. The entry chunk used to carry all twenty of
// them eagerly, so opening /dashboard also paid for the planner, the journal, the
// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that
// fails once gets a second, cache-busted attempt before the route boundary reaches
// for a reload.
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'));
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'));
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'));
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'));
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'));
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'));
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'));
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'));
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'));
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'));
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'));
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'));
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'));
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'));
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'));
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'));
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'));
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'));
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'));
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'));
const PluginPage = lazyWithRetry(() => import('./pages/PluginPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage'))
const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage'))
const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage'))
const FilesPage = lazyWithRetry(() => import('./pages/FilesPage'))
const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))
const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage'))
const VacayPage = lazyWithRetry(() => import('./pages/VacayPage'))
const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage'))
const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx'))
const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage'))
// The ten phone screens are chunks of their own, alongside the desktop pages
// rather than inside them. Each page used to import its M screen statically and
@@ -74,64 +69,69 @@ const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePag
// viewport only picked which half stayed dark. Here the branch decides the
// chunk: a phone never loads the desktop planner, a desktop never the mobile
// shell.
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'));
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'));
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'));
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'));
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'));
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'));
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'));
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'));
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'));
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'));
const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard'))
const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell'))
const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin'))
const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings'))
const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay'))
const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas'))
const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney'))
const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail'))
const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections'))
const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications'))
interface ProtectedRouteProps {
children: ReactNode;
adminRequired?: boolean;
addonId?: string;
children: ReactNode
adminRequired?: boolean
addonId?: string
}
function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedRouteProps) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const appRequireMfa = useAuthStore((s) => s.appRequireMfa);
const loggingOut = useAuthStore((s) => s.loggingOut);
const addonStore = useAddonStore();
const { t } = useTranslation();
const location = useLocation();
const isPhone = useIsPhone();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const user = useAuthStore((s) => s.user)
const isLoading = useAuthStore((s) => s.isLoading)
const appRequireMfa = useAuthStore((s) => s.appRequireMfa)
const loggingOut = useAuthStore((s) => s.loggingOut)
const addonStore = useAddonStore()
const { t } = useTranslation()
const location = useLocation()
const isPhone = useIsPhone()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
<p className="text-sm text-slate-500">{t('common.loading')}</p>
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
</div>
</div>
);
)
}
if (!isAuthenticated) {
// A session that ended on its own should come back to where it left off; a
// deliberate sign-out is a fresh start and gets no return ticket, so the
// startup destination decides where the next login lands.
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />;
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash);
return <Navigate to={`/login?redirect=${redirectParam}`} replace />;
if (loggingOut) return <Navigate to="/login" replace state={{ noRedirect: true }} />
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
}
if (appRequireMfa && user && !user.mfa_enabled && location.pathname !== '/settings') {
return <Navigate to="/settings?mfa=required" replace />;
if (
appRequireMfa &&
user &&
!user.mfa_enabled &&
location.pathname !== '/settings'
) {
return <Navigate to="/settings?mfa=required" replace />
}
if (adminRequired && user && user.role !== 'admin') {
return <Navigate to="/dashboard" replace />;
return <Navigate to="/dashboard" replace />
}
if (addonId && addonStore.loaded && !addonStore.isEnabled(addonId)) {
return <Navigate to="/dashboard" replace />;
return <Navigate to="/dashboard" replace />
}
// Below the md breakpoint the new mobile shell owns chrome (tokens, dock,
@@ -148,11 +148,16 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
return (
<MobileShell isPhone={isPhone}>
<RouteMemory />
<ErrorBoundary key={location.pathname} boundaryId="route" level="route" variant={isPhone ? 'mobile' : 'desktop'}>
<ErrorBoundary
key={location.pathname}
boundaryId="route"
level="route"
variant={isPhone ? 'mobile' : 'desktop'}
>
{children}
</ErrorBoundary>
</MobileShell>
);
)
}
/**
@@ -161,7 +166,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
* failed, and the two anonymous share pages.
*/
function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) {
const location = useLocation();
const location = useLocation()
// redirectAuthed (only /login and /register) bounces a visitor who is already
// authenticated when they land here — manual URL, browser back button (#1810).
// Only on a bare URL, because every parameter this page takes is a flow that
@@ -175,15 +180,15 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea
// useLogin navigates away.
// The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours
// the start-page preference the same way useLogin does.
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated);
const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated)
if (redirectAuthed && wasAuthenticated.current && !location.search) {
return <Navigate to={START_DESTINATION_ROUTE} replace />;
return <Navigate to={START_DESTINATION_ROUTE} replace />
}
return (
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
{children}
</ErrorBoundary>
);
)
}
/**
@@ -192,15 +197,12 @@ function PublicRoute({ children, redirectAuthed = false }: { children: React.Rea
* point: the ternary that used to sit in each page only ran once the browser
* had already paid for both trees.
*/
function ViewportRoute({
phone: Phone,
desktop: Desktop,
}: {
phone: React.ComponentType;
desktop: React.ComponentType;
function ViewportRoute({ phone: Phone, desktop: Desktop }: {
phone: React.ComponentType
desktop: React.ComponentType
}): React.ReactElement {
const isPhone = useIsPhone();
return isPhone ? <Phone /> : <Desktop />;
const isPhone = useIsPhone()
return isPhone ? <Phone /> : <Desktop />
}
/**
@@ -218,78 +220,65 @@ function ViewportRoute({
*/
/** Remembers the protected route for the installed app's relaunch (#1024). Renders nothing. */
function RouteMemory() {
useRememberRoute();
return null;
useRememberRoute()
return null
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore();
const settingsLoaded = useSettingsStore((s) => s.isLoaded);
const settings = useSettingsStore((s) => s.settings);
const loadSettings = useSettingsStore((s) => s.loadSettings);
const [target, setTarget] = useState<string | null>(null);
const { isAuthenticated, isLoading } = useAuthStore()
const settingsLoaded = useSettingsStore((s) => s.isLoaded)
const settings = useSettingsStore((s) => s.settings)
const loadSettings = useSettingsStore((s) => s.loadSettings)
const [target, setTarget] = useState<string | null>(null)
// Bounds the wait below: loadSettings deliberately leaves isLoaded false on a
// failed request so it can retry, which would otherwise strand a launch that
// started offline on the spinner.
const [settingsGaveUp, setSettingsGaveUp] = useState(false);
const [settingsGaveUp, setSettingsGaveUp] = useState(false)
useEffect(() => {
if (settingsLoaded) return;
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS);
return () => clearTimeout(timer);
}, [settingsLoaded]);
if (settingsLoaded) return
const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS)
return () => clearTimeout(timer)
}, [settingsLoaded])
useEffect(() => {
if (isLoading || !isAuthenticated || target) return;
if (isLoading || !isAuthenticated || target) return
// The installed app coming back after the system threw it away (#1024):
// back to the trip and tab it was in, ahead of the start page.
const resume = takeResumeRoute();
if (resume) {
setTarget(resume);
return;
}
const mirrored = readStartDestination();
const resume = takeResumeRoute()
if (resume) { setTarget(resume); return }
const mirrored = readStartDestination()
if (!mirrored && !settingsLoaded && !settingsGaveUp) {
// Ask for the settings instead of waiting for whoever else might. The
// store de-dupes concurrent loads, so this is free when one is already in
// flight — and it stops the decision from riding on where that request
// happens to land in the queue behind everything else a launch fires off.
loadSettings();
return;
loadSettings()
return
}
// Loaded settings are the truth; the mirror is what we knew last time.
const { page, tab } = settingsLoaded
? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB }
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB });
if (page !== 'active_trip') {
setTarget('/dashboard');
return;
}
let cancelled = false;
: (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB })
if (page !== 'active_trip') { setTarget('/dashboard'); return }
let cancelled = false
// Through the repo, not the api: offline this answers from Dexie instead of
// bouncing a cached trip to the dashboard.
tripRepo
.active()
.then(({ trip }) => {
if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard');
})
.catch(() => {
if (!cancelled) setTarget('/dashboard');
});
return () => {
cancelled = true;
};
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target]);
tripRepo.active()
.then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') })
.catch(() => { if (!cancelled) setTarget('/dashboard') })
return () => { cancelled = true }
}, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target])
if (isLoading || (isAuthenticated && !target)) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-slate-900"></div>
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
</div>
);
)
}
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />;
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
}
/**
@@ -301,127 +290,79 @@ function RouteFallback() {
// The fallback renders above MobileShell, so it has to pick its own palette —
// on a phone the desktop spinner on bg-surface would be a foreign white sheet
// in front of the mobile screen.
const isPhone = useIsPhone();
if (isPhone) return <MRouteFallback />;
const isPhone = useIsPhone()
if (isPhone) return <MRouteFallback />
return (
<div className="flex min-h-screen items-center justify-center bg-surface">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-edge border-t-content"></div>
<div className="min-h-screen flex items-center justify-center bg-surface">
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
</div>
);
)
}
export default function App() {
const {
loadUser,
isAuthenticated,
demoMode,
setManaged,
setDemoMode,
setDevMode,
setIsPrerelease,
setAppVersion,
setHasMapsKey,
setHasAmapKey,
setPlacesProvider,
setServerTimezone,
setAppRequireMfa,
setTripRemindersEnabled,
setPlacesPhotosEnabled,
setPlacesAutocompleteEnabled,
setPlacesDetailsEnabled,
setPlacesEnrichEnabled,
setPlaceShadowEnabled,
} = useAuthStore();
const { loadSettings } = useSettingsStore();
const { loadAddons } = useAddonStore();
const { loadPlugins } = usePluginStore();
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
useEffect(() => {
if (
!location.pathname.startsWith('/shared/') &&
!location.pathname.startsWith('/public/') &&
!location.pathname.startsWith('/login')
) {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
// If the persist snapshot already has an authenticated user, validate
// silently so the PWA shell renders immediately without a spinner.
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated;
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
if (alreadyAuthenticated) {
useAuthStore.setState({ isLoading: false });
loadUser({ silent: true });
useAuthStore.setState({ isLoading: false })
loadUser({ silent: true })
} else {
loadUser();
loadUser()
}
}
authApi
.getAppConfig()
.then(
async (config: {
managed?: boolean;
demo_mode?: boolean;
dev_mode?: boolean;
is_prerelease?: boolean;
has_maps_key?: boolean;
has_amap_key?: boolean;
places_provider?: string;
version?: string;
timezone?: string;
require_mfa?: boolean;
trip_reminders_enabled?: boolean;
places_photos_enabled?: boolean;
places_autocomplete_enabled?: boolean;
places_details_enabled?: boolean;
places_enrich_enabled?: boolean;
place_shadow_enabled?: boolean;
max_upload_mb?: number;
permissions?: Record<string, PermissionLevel>;
}) => {
setManaged(!!config?.managed);
setDemoMode(!!config?.demo_mode);
if (config?.dev_mode) setDevMode(true);
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease);
if (config?.version) setAppVersion(config.version);
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key);
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key);
if (config?.places_provider) setPlacesProvider(config.places_provider);
if (config?.timezone) setServerTimezone(config.timezone);
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa);
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled);
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled);
if (config?.places_autocomplete_enabled !== undefined)
setPlacesAutocompleteEnabled(config.places_autocomplete_enabled);
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled);
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled);
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled);
if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0)
useAuthStore.getState().setMaxUploadMb(config.max_upload_mb);
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions);
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version);
}
)
.catch(() => {});
}, []);
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
if (typeof config?.max_upload_mb === 'number' && config.max_upload_mb > 0) useAuthStore.getState().setMaxUploadMb(config.max_upload_mb)
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// The version this page starts out with, before the realtime socket reports.
useServerVersionStore.getState().note(config?.version)
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
}, [])
const { settings } = useSettingsStore();
const { settings } = useSettingsStore()
useInAppNotificationListener();
useRoadtripPreferencesSync();
useInAppNotificationListener()
useRoadtripPreferencesSync()
useEffect(() => {
if (isAuthenticated) {
loadSettings();
loadAddons();
loadPlugins();
loadSettings()
loadAddons()
loadPlugins()
}
}, [isAuthenticated]);
}, [isAuthenticated])
useEffect(() => {
registerSyncTriggers();
return () => unregisterSyncTriggers();
}, []);
registerSyncTriggers()
return () => unregisterSyncTriggers()
}, [])
const location = useLocation();
const isSharedPage = location.pathname.startsWith('/shared/');
const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
const run = () =>
@@ -429,23 +370,22 @@ export default function App() {
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
});
run();
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => run();
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => run()
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
}, [settings.dark_mode, settings.appearance, isSharedPage]);
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isPhone = useIsPhone();
const isAuthPage =
location.pathname.startsWith('/login') ||
location.pathname.startsWith('/register') ||
location.pathname.startsWith('/forgot-password') ||
location.pathname.startsWith('/reset-password');
const isPhone = useIsPhone()
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
|| location.pathname.startsWith('/forgot-password')
|| location.pathname.startsWith('/reset-password')
// No session on these, so authenticated-only widgets (system notices,
// background tasks, save-to-collection) have nothing to do and would only fire
// a doomed authenticated request on mount.
@@ -454,32 +394,17 @@ export default function App() {
// drifted, this one naming only /public/journey/ while the response
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname);
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
return (
<TranslationProvider>
{!hideAuthedWidgets && (
<ErrorBoundary boundaryId="widget:system-notice" fallback={null}>
<SystemNoticeHost />
</ErrorBoundary>
)}
{!hideAuthedWidgets && (
<ErrorBoundary boundaryId="widget:help-panel" fallback={null}>
<HelpPanel />
</ErrorBoundary>
)}
<ErrorBoundary boundaryId="widget:toast" fallback={null}>
<ToastContainer />
</ErrorBoundary>
{!hideAuthedWidgets && (
<ErrorBoundary boundaryId="widget:background-tasks" fallback={null}>
<BackgroundTasksWidget />
</ErrorBoundary>
)}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:help-panel" fallback={null}><HelpPanel /></ErrorBoundary>}
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}>
<OfflineBanner />
</ErrorBoundary>
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
@@ -487,63 +412,14 @@ export default function App() {
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route
path="/login"
element={
<PublicRoute redirectAuthed>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/shared/:token"
element={
<PublicRoute>
<SharedTripPage />
</PublicRoute>
}
/>
<Route
path="/public/journey/:token"
element={
<PublicRoute>
<JourneyPublicPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute redirectAuthed>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/forgot-password"
element={
<PublicRoute>
<ForgotPasswordPage />
</PublicRoute>
}
/>
<Route
path="/reset-password"
element={
<PublicRoute>
<ResetPasswordPage />
</PublicRoute>
}
/>
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route
path="/oauth/consent"
element={
<PublicRoute>
<OAuthAuthorizePage />
</PublicRoute>
}
/>
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
<Route
path="/dashboard"
element={
@@ -620,7 +496,7 @@ export default function App() {
/>
{/* Empty in this repository, and read unconditionally so the public
build walks the same path as any other. See client/src/managed. */}
{managedRoutes.map((r) => (
{managedRoutes.map(r => (
<Route
key={r.path}
path={r.path}
@@ -692,5 +568,5 @@ export default function App() {
</Routes>
</Suspense>
</TranslationProvider>
);
)
}
+164 -178
View File
@@ -1,11 +1,12 @@
import { act, render, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { server } from '../tests/helpers/msw/server';
import { resetAllStores } from '../tests/helpers/store';
import App from './App';
import { useAuthStore } from './store/authStore';
import React from 'react'
import { render, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { resetAllStores } from '../tests/helpers/store'
import App from './App'
/*
* #2458: an installed PWA kept serving the previous UI after a server upgrade.
@@ -22,244 +23,233 @@ import { useAuthStore } from './store/authStore';
*/
const sw = vi.hoisted(() => {
class FakeWorker extends EventTarget {
state: string;
readonly scriptURL = '/sw.js';
state: string
readonly scriptURL = '/sw.js'
constructor(state: string) {
super();
this.state = state;
super()
this.state = state
}
become(state: string) {
this.state = state;
this.dispatchEvent(new Event('statechange'));
this.state = state
this.dispatchEvent(new Event('statechange'))
}
}
class FakeRegistration extends EventTarget {
active: FakeWorker | null = null;
installing: FakeWorker | null = null;
waiting: FakeWorker | null = null;
readonly scope = '/';
update = vi.fn();
active: FakeWorker | null = null
installing: FakeWorker | null = null
waiting: FakeWorker | null = null
readonly scope = '/'
update = vi.fn()
}
class FakeContainer extends EventTarget {
controller: FakeWorker | null = null;
getRegistration = vi.fn();
controller: FakeWorker | null = null
getRegistration = vi.fn()
// Listeners a page adds die with that page. Those added while modules load
// (before `trackPage` is switched on) stand for module-level code and stay.
trackPage = false;
private pageListeners: Array<
[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]
> = [];
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions
) {
super.addEventListener(type, listener, options);
if (this.trackPage && listener) this.pageListeners.push([type, listener, options]);
trackPage = false
private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = []
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
super.addEventListener(type, listener, options)
if (this.trackPage && listener) this.pageListeners.push([type, listener, options])
}
dropPageListeners() {
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options);
this.pageListeners = [];
for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options)
this.pageListeners = []
}
}
const oldWorker = new FakeWorker('activated');
const registration = new FakeRegistration();
const container = new FakeContainer();
registration.active = oldWorker;
container.controller = oldWorker;
container.getRegistration.mockImplementation(async () => registration);
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container });
return { FakeWorker, registration, container, oldWorker };
});
const oldWorker = new FakeWorker('activated')
const registration = new FakeRegistration()
const container = new FakeContainer()
registration.active = oldWorker
container.controller = oldWorker
container.getRegistration.mockImplementation(async () => registration)
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
return { FakeWorker, registration, container, oldWorker }
})
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }));
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }));
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }));
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() }))
// Every import above has been evaluated by now; from here on a listener belongs to a page.
sw.container.trackPage = true;
sw.container.trackPage = true
const reload = vi.fn();
let configHits = 0;
const reload = vi.fn()
let configHits = 0
function serveVersion(version: string, gate?: Promise<void>) {
server.use(
http.get('/api/auth/app-config', async () => {
configHits++;
if (gate) await gate;
return HttpResponse.json({ version });
})
);
configHits++
if (gate) await gate
return HttpResponse.json({ version })
}),
)
}
/** The browser's handover: the new worker installs, activates and claims the page. */
function handOver() {
const next = new sw.FakeWorker('activated');
sw.registration.installing = null;
sw.registration.active = next;
sw.container.controller = next;
sw.container.dispatchEvent(new Event('controllerchange'));
const next = new sw.FakeWorker('activated')
sw.registration.installing = null
sw.registration.active = next
sw.container.controller = next
sw.container.dispatchEvent(new Event('controllerchange'))
}
/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */
function updateStartsInstall(then: 'activates' | 'fails') {
sw.registration.update.mockImplementation(async () => {
const next = new sw.FakeWorker('installing');
sw.registration.installing = next;
const next = new sw.FakeWorker('installing')
sw.registration.installing = next
setTimeout(() => {
if (then === 'activates') {
next.become('activated');
handOver();
next.become('activated')
handOver()
} else {
// Workbox precaching is all-or-nothing: one failed fetch out of ~463
// rejects the install event and the new worker is thrown away.
sw.registration.installing = null;
next.become('redundant');
sw.registration.installing = null
next.become('redundant')
}
}, 0);
return sw.registration;
});
}, 0)
return sw.registration
})
}
/** One cold start of the installed app: fresh page, fresh session, same localStorage. */
async function launch() {
sessionStorage.clear();
const hitsBefore = configHits;
sessionStorage.clear()
const hitsBefore = configHits
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
await waitFor(() => expect(configHits).toBe(hitsBefore + 1));
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(hitsBefore + 1))
// Let the probe, update() and the fake install run to completion.
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
view.unmount();
sw.container.dropPageListeners();
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
configHits = 0;
sw.registration.active = sw.oldWorker;
sw.registration.installing = null;
sw.registration.waiting = null;
sw.registration.update.mockReset();
sw.registration.update.mockResolvedValue(sw.registration);
sw.container.controller = sw.oldWorker;
sw.container.dropPageListeners();
sw.container.getRegistration.mockReset();
sw.container.getRegistration.mockImplementation(async () => sw.registration);
reload.mockReset();
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } });
resetAllStores()
vi.clearAllMocks()
configHits = 0
sw.registration.active = sw.oldWorker
sw.registration.installing = null
sw.registration.waiting = null
sw.registration.update.mockReset()
sw.registration.update.mockResolvedValue(sw.registration)
sw.container.controller = sw.oldWorker
sw.container.dropPageListeners()
sw.container.getRegistration.mockReset()
sw.container.getRegistration.mockImplementation(async () => sw.registration)
reload.mockReset()
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
server.use(
http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })),
http.get('/api/system-notices/active', () => HttpResponse.json([]))
);
http.get('/api/system-notices/active', () => HttpResponse.json([])),
)
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
});
});
})
})
describe('#2458 version handover: behaviour that must survive the fix', () => {
it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0');
serveVersion('4.3.1');
updateStartsInstall('activates');
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('activates')
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1');
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
expect(localStorage.getItem('trek_app_version')).toBe('4.3.1')
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledTimes(1)
})
it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => {
// A release that changed nothing the precache holds leaves sw.js byte-identical,
// so update() installs nothing. The bundle this page runs is already current.
localStorage.setItem('trek_app_version', '4.3.0');
serveVersion('4.3.1');
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
});
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
})
it.each([
['a self-built image without the build arg', 'dev'],
['a test instance pinned to an older tag', '4.0.0'],
['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'],
])(
'FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops',
async (_label, serverVersion) => {
// The server reports APP_VERSION, the bundle carries client/package.json. On
// these setups the two never agree, so a check against the bundle's own
// version would see an update on every launch.
serveVersion(serverVersion);
])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => {
// The server reports APP_VERSION, the bundle carries client/package.json. On
// these setups the two never agree, so a check against the bundle's own
// version would see an update on every launch.
serveVersion(serverVersion)
await launch();
await launch();
await launch();
expect(reload).not.toHaveBeenCalled();
expect(sw.registration.update).not.toHaveBeenCalled();
}
);
});
await launch()
await launch()
await launch()
expect(reload).not.toHaveBeenCalled()
expect(sw.registration.update).not.toHaveBeenCalled()
})
})
describe('#2458 version handover: the reported defect', () => {
it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0');
serveVersion('4.3.1');
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'));
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
// Next cold start, network fine this time.
updateStartsInstall('activates');
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalled();
});
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalled()
})
it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => {
// The old worker still controls the page, so a plain reload is answered from
// its precache: the same old UI, now with the marker saying it is current.
localStorage.setItem('trek_app_version', '4.3.0');
serveVersion('4.3.1');
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'));
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error'))
await launch();
expect(reload).not.toHaveBeenCalled();
});
await launch()
expect(reload).not.toHaveBeenCalled()
})
it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => {
localStorage.setItem('trek_app_version', '4.3.0');
serveVersion('4.3.1');
updateStartsInstall('fails');
localStorage.setItem('trek_app_version', '4.3.0')
serveVersion('4.3.1')
updateStartsInstall('fails')
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(1)
expect(reload).not.toHaveBeenCalled()
updateStartsInstall('activates');
await launch();
expect(sw.registration.update).toHaveBeenCalledTimes(2);
expect(reload).toHaveBeenCalledTimes(1);
});
updateStartsInstall('activates')
await launch()
expect(sw.registration.update).toHaveBeenCalledTimes(2)
expect(reload).toHaveBeenCalledTimes(1)
})
// Kept last: it swaps the controlling worker mid-launch, which code that
// remembers "the controller changed since this page loaded" keeps seeing.
@@ -268,28 +258,24 @@ describe('#2458 version handover: the reported defect', () => {
// installs and activates the new worker before /api/auth/app-config has
// answered, the takeover happens while nobody listens, and update() then
// finds nothing newer. The page keeps running the bundle the old worker served.
localStorage.setItem('trek_app_version', '4.3.0');
let open!: () => void;
const gate = new Promise<void>((r) => {
open = r;
});
serveVersion('4.3.1', gate);
localStorage.setItem('trek_app_version', '4.3.0')
let open!: () => void
const gate = new Promise<void>(r => { open = r })
serveVersion('4.3.1', gate)
sessionStorage.clear();
sessionStorage.clear()
const view = render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>
);
await waitFor(() => expect(configHits).toBe(1));
act(() => handOver());
open();
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
view.unmount();
sw.container.dropPageListeners();
</MemoryRouter>,
)
await waitFor(() => expect(configHits).toBe(1))
act(() => handOver())
open()
await act(async () => { await new Promise(r => setTimeout(r, 50)) })
view.unmount()
sw.container.dropPageListeners()
expect(reload).toHaveBeenCalledTimes(1);
});
});
expect(reload).toHaveBeenCalledTimes(1)
})
})
+50 -49
View File
@@ -1,11 +1,12 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../tests/helpers/factories';
import { resetAllStores } from '../tests/helpers/store';
import App from './App';
import { useAddonStore } from './store/addonStore';
import { useAuthStore } from './store/authStore';
import React from 'react'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './store/authStore'
import { useAddonStore } from './store/addonStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser } from '../tests/helpers/factories'
import App from './App'
/**
* The viewport decides which chunk gets fetched, so the switch lives in App.tsx
@@ -16,35 +17,35 @@ import { useAuthStore } from './store/authStore';
* root hangs off the route, and the desktop page is never rendered at all.
*/
const isPhone = vi.hoisted(() => ({ value: false }));
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }));
const isPhone = vi.hoisted(() => ({ value: false }))
vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value }))
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }));
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }));
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }));
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }));
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }));
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }));
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }));
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }));
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }));
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }));
vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () => <div>m-dashboard</div> }))
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }));
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }));
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }));
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }));
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }));
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }));
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }));
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }));
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }));
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }));
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
// The notification listener opens a WebSocket on mount.
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}));
}))
/** path, mobile marker, desktop marker */
const ROUTES: [string, string, string][] = [
@@ -59,20 +60,20 @@ const ROUTES: [string, string, string][] = [
['/collections', 'm-collections', 'd-collections'],
['/collections/1', 'm-collections', 'd-collections'],
['/notifications', 'm-notifications', 'd-notifications'],
];
]
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
);
)
}
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
isPhone.value = false;
resetAllStores()
vi.clearAllMocks()
isPhone.value = false
// An admin passes the role check on /admin; every other route ignores it.
useAuthStore.setState({
isLoading: false,
@@ -80,31 +81,31 @@ beforeEach(() => {
user: buildUser({ role: 'admin', mfa_enabled: true }),
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
});
})
// /journey and /collections sit behind addons; without this they redirect to
// the dashboard and the route under test never renders.
useAddonStore.setState({ loaded: true, isEnabled: () => true });
});
useAddonStore.setState({ loaded: true, isEnabled: () => true })
})
describe('App — viewport routing', () => {
it.each(ROUTES)(
'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint',
async (path, mobile, desktop) => {
isPhone.value = true;
renderAt(path);
isPhone.value = true
renderAt(path)
expect(await screen.findByText(mobile)).toBeInTheDocument();
expect(screen.queryByText(desktop)).not.toBeInTheDocument();
expect(await screen.findByText(mobile)).toBeInTheDocument()
expect(screen.queryByText(desktop)).not.toBeInTheDocument()
}
);
)
it.each(ROUTES)(
'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint',
async (path, mobile, desktop) => {
renderAt(path);
renderAt(path)
expect(await screen.findByText(desktop)).toBeInTheDocument();
expect(screen.queryByText(mobile)).not.toBeInTheDocument();
expect(await screen.findByText(desktop)).toBeInTheDocument()
expect(screen.queryByText(mobile)).not.toBeInTheDocument()
}
);
});
)
})
+1 -6
View File
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
import axios, { AxiosInstance } from 'axios'
import type { z } from 'zod'
import type { Day, Place, Trip } from '../types'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
import { randomId } from '../utils/randomId'
import { postProviderPhotosInBatches } from './providerPhotoBatches'
import {
@@ -525,11 +525,6 @@ export const toursApi = {
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
const fd = new FormData()
fd.append('file', file)
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
},
}
export const assignmentsApi = {
+33
View File
@@ -0,0 +1,33 @@
// FE-API-FERRATAS-001 to FE-API-FERRATAS-002
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { server } from '../../tests/helpers/msw/server';
import { ferratasApi } from './ferratas';
const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
describe('ferratasApi.list', () => {
it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => {
let url = '';
server.use(
http.get('/api/trips/7/ferratas', ({ request }) => {
url = request.url;
return HttpResponse.json(reply);
})
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply);
expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900');
});
it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => {
server.use(
http.get('/api/trips/7/ferratas', () =>
HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 })
)
);
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy();
});
});
+25
View File
@@ -0,0 +1,25 @@
import apiClient from './client';
/**
* How long the browser waits for GET /api/trips/:tripId/ferratas.
*
* The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS
* (25 s by default) and answers 502 when none of them made it. The shared 8 s
* on `apiClient` would give up on a slow but healthy mirror and show an error
* the server was about to resolve, so this sits just above the server's own
* ceiling.
*/
const FERRATAS_TIMEOUT_MS = 30_000;
/**
* GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside
* one map area. In its own file rather than in the `client.ts` god module. The
* answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared
* contract before anything draws it.
*/
export const ferratasApi = {
list: (tripId: number, bbox: string, signal?: AbortSignal): Promise<unknown> =>
apiClient
.get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS })
.then((response) => response.data as unknown),
};
+16
View File
@@ -0,0 +1,16 @@
import { paddleObstaclesResponseSchema, type PaddleObstaclesResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-05A: the paddle objects of a map area and how far TREK's data covers it. */
export const paddleObstaclesApi = {
inBox: (
box: { south: number; west: number; north: number; east: number },
signal?: AbortSignal
): Promise<PaddleObstaclesResponse> =>
apiClient
.get('/paddle/obstacles', {
params: { bbox: [box.south, box.west, box.north, box.east].map((v) => v.toFixed(5)).join(',') },
signal,
})
.then((r) => paddleObstaclesResponseSchema.parse(r.data)),
};
+32
View File
@@ -0,0 +1,32 @@
import {
paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema,
paddleRegionSchema,
successResponseSchema,
type PaddleRegionRequest,
} from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
catalog: (q: string, signal?: AbortSignal) =>
apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
.then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions),
add: (id: string) =>
apiClient
.post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK)
.then((r) => paddleRegionSchema.parse(r.data)),
refresh: (id: string) =>
apiClient
.post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest)
.then((r) => paddleRegionSchema.parse(r.data)),
remove: (id: string) =>
apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourDetailResponse } from '@trek/shared';
import apiClient from './client';
/** An import that was planned over in place keeps its recorded track; this brings it back. */
export const recordedTrackApi = {
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
};
+8
View File
@@ -0,0 +1,8 @@
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
import apiClient from './client';
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
export const tourCopiesApi = {
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
};
+19
View File
@@ -0,0 +1,19 @@
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
import { postMultipart } from './client';
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
const fd = new FormData();
fd.append('file', file);
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) fd.append(key, value);
return fd;
}
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
export const tourGpxApi = {
/** What the file holds, read by the import's own parser; nothing is stored. */
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)),
import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> =>
postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)),
};
+14
View File
@@ -0,0 +1,14 @@
import { tourDetailResponseSchema, type TourDetailResponse, type TourObstacleReviewsRequest } from '@trek/shared';
import apiClient from './client';
/** PAD-06: what the paddlers decided about the obstacles of a paddle tour; answers the detail with its checklist. */
export const tourObstacleReviewsApi = {
save: (
tripId: number | string,
placeId: number | string,
body: TourObstacleReviewsRequest
): Promise<TourDetailResponse> =>
apiClient
.put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body)
.then((r) => tourDetailResponseSchema.parse(r.data)),
};
+12
View File
@@ -0,0 +1,12 @@
import type { TourDetailResponse, TourPaddlePlanRequest } from '@trek/shared';
import apiClient from './client';
/** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */
export const tourPaddlePlanApi = {
update: (
tripId: number | string,
placeId: number | string,
body: TourPaddlePlanRequest
): Promise<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data),
};
+53
View File
@@ -0,0 +1,53 @@
import type {
AdminFeatureToggleResponse,
TourSourceCheckResponse,
WandererConnection,
WandererImportPreview,
WandererImportRequest,
WandererImportResponse,
WandererSaveResponse,
WandererSearchRequest,
WandererSearchResponse,
WandererSettingsRequest,
WandererTestRequest,
WandererTestResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* How long the browser waits for a call that makes the server ask wanderer.
* A preview reads the trail's index entry, its record and its GPX one after
* another, each allowed 15 s on the server, so the shared 8 s would give up
* while the server carries on.
*/
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
export const wandererApi = {
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
apiClient
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
.then((r) => r.data),
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
};
/** The admin's switch for wanderer under the Tours addon. */
export const wandererAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
};
+33 -2
View File
@@ -1,13 +1,13 @@
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
vi.unmock('./websocket')
// FE-WSCORE-001 to FE-WSCORE-014
// FE-WSCORE-001 to FE-WSCORE-016
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/helpers/msw/server'
import {
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
setRefetchCallback, setPreReconnectHook,
setRefetchCallback, setPreReconnectHook, reconnectNow,
} from './websocket'
class MockWebSocket {
@@ -265,3 +265,34 @@ describe('websocket > connection lifecycle', () => {
expect(MockWebSocket.instances).toHaveLength(2)
})
})
describe('websocket > logout and reconnect', () => {
it('FE-WSCORE-015: a logout while the token is on its way opens no socket afterwards', async () => {
server.use(http.post('/api/auth/ws-token', async () => {
await new Promise((r) => setTimeout(r, 50))
return HttpResponse.json({ token: 'previous-user' })
}))
connect()
disconnect()
await vi.advanceTimersByTimeAsync(100)
expect(MockWebSocket.instances).toHaveLength(0)
})
it('FE-WSCORE-016: reconnectNow skips the backoff, and does nothing while logged out or connected', async () => {
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(0)
const first = await openSocket()
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(1)
first.readyState = MockWebSocket.CLOSED
first.onclose?.()
reconnectNow()
await vi.advanceTimersByTimeAsync(0)
expect(MockWebSocket.instances).toHaveLength(2)
})
})
+22
View File
@@ -1,5 +1,7 @@
// Singleton WebSocket manager for real-time collaboration
import { useServerVersionStore } from '../store/serverVersionStore'
type WebSocketListener = (event: Record<string, unknown>) => void
type RefetchCallback = (tripId: string) => void
@@ -22,6 +24,9 @@ let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
// Bumped by disconnect(): a connect that was still waiting for its token when
// the user logged out must not open a socket afterwards.
let generation = 0
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
let preReconnectHook: (() => Promise<void>) | null = null
@@ -78,6 +83,7 @@ function handleMessage(event: MessageEvent): void {
const parsed = JSON.parse(event.data)
if (parsed.type === 'welcome') {
mySocketId = parsed.socketId
useServerVersionStore.getState().note(parsed.version)
return
}
listeners.forEach(fn => {
@@ -106,8 +112,12 @@ async function connectInternal(_isReconnect = false): Promise<void> {
}
connecting = true
const started = generation
const wsToken = await fetchWsToken()
connecting = false
// Logged out while the token was on its way: opening now would leave a socket
// authenticated as the previous user, which the next login would reuse.
if (started !== generation || !shouldReconnect) return
if (!wsToken) {
if (shouldReconnect) scheduleReconnect()
@@ -163,6 +173,17 @@ async function connectInternal(_isReconnect = false): Promise<void> {
}
}
/**
* The network came back or the tab became visible: a socket waiting out its
* backoff (up to 30 s) reconnects now. Does nothing while logged out or while
* a socket is open or opening.
*/
export function reconnectNow(): void {
if (!shouldReconnect) return
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return
connect()
}
export function connect(): void {
shouldReconnect = true
reconnectDelay = 1000
@@ -175,6 +196,7 @@ export function connect(): void {
export function disconnect(): void {
shouldReconnect = false
generation++
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
+10
View File
@@ -0,0 +1,10 @@
import { windForecastResponseSchema, type WindForecastResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-08: the wind forecast for one place and one day. */
export const windApi = {
forecast: (lat: number, lng: number, date: string, signal?: AbortSignal): Promise<WindForecastResponse> =>
apiClient
.get('/weather/wind', { params: { lat, lng, date }, signal })
.then((r) => windForecastResponseSchema.parse(r.data)),
};
-1
View File
@@ -120,7 +120,6 @@ export const IGNORED_WS_EVENTS = [
'budget:settlement-created',
'budget:settlement-updated',
'budget:settlement-deleted',
'reservation:positions',
// Accommodations live in page-local planner state; the client refetches
// them off trip:updated date changes, never off these events.
'accommodation:created',
+208 -256
View File
@@ -1,11 +1,10 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAddonStore } from '../../store/addonStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
import AddonManager from './AddonManager';
@@ -40,7 +39,7 @@ function llmAddon(config: Record<string, unknown> = {}) {
function modelsRoute(names: string[], seen?: (string | null)[]) {
return http.get('/api/admin/llm/local/models', ({ request }) => {
seen?.push(new URL(request.url).searchParams.get('baseUrl'));
return HttpResponse.json({ models: names.map((name) => ({ name, size: 1 })) });
return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) });
});
}
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
return toggle.getAttribute('aria-pressed') === 'true';
}
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
async function unfoldShelves(): Promise<void> {
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
for (const bar of bars) await userEvent.click(bar);
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -71,7 +76,10 @@ beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })));
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
);
});
afterEach(() => {
@@ -83,7 +91,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
server.use(
http.get('/api/admin/addons', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
})
);
@@ -129,15 +137,14 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AddonManager />
</>
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
const toggleBtn = addonToggle('Todo List');
@@ -152,15 +159,14 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })),
http.put('/api/admin/addons/todo', () => HttpResponse.error())
);
render(
<>
<ToastContainer />
<AddonManager />
</>
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.error()
)
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
await user.click(addonToggle('Todo List'));
@@ -178,9 +184,13 @@ describe('AddonManager', () => {
const user = userEvent.setup();
const mockToggle = vi.fn();
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />);
await screen.findByText('Bag Tracking');
await user.click(subToggle('Bag Tracking'));
expect(mockToggle).toHaveBeenCalled();
@@ -192,14 +202,18 @@ describe('AddonManager', () => {
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
)
);
render(<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] }))
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
);
render(<AddonManager bagTrackingEnabled={false} />);
await screen.findByText('Lists');
@@ -220,6 +234,7 @@ describe('AddonManager', () => {
)
);
render(<AddonManager />);
await unfoldShelves();
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
@@ -249,12 +264,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(
<>
<ToastContainer />
<AddonManager />
</>
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
@@ -270,13 +280,11 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
])
);
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]));
render(<AddonManager />);
await screen.findByText('Todo List');
@@ -285,33 +293,14 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({
id: 'immich',
name: 'Immich',
description: 'Self-hosted photos',
type: 'photo_provider',
enabled: true,
}),
buildAddon({
id: 'synologyphotos',
name: 'Synology Photos',
description: 'NAS photos',
type: 'photo_provider',
enabled: false,
}),
buildAddon({
id: 'unsplash',
name: 'Unsplash',
description: 'Stock photos',
type: 'photo_provider',
enabled: false,
}),
])
);
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
@@ -332,25 +321,14 @@ describe('AddonManager', () => {
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({
id: 'immich',
name: 'Immich',
description: 'Self-hosted photos',
type: 'photo_provider',
enabled: false,
}),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(subToggle('Immich'));
@@ -365,22 +343,11 @@ describe('AddonManager', () => {
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({
id: 'unsplash',
name: 'Unsplash',
description: 'Stock photos',
type: 'photo_provider',
enabled: true,
}),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
]),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error())
);
render(
<>
<ToastContainer />
<AddonManager />
</>
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Unsplash');
await user.click(subToggle('Unsplash'));
@@ -390,25 +357,11 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({
id: 'immich',
name: 'Immich',
description: 'Self-hosted photos',
type: 'photo_provider',
enabled: true,
}),
buildAddon({
id: 'synologyphotos',
name: 'Synology Photos',
description: 'NAS photos',
type: 'photo_provider',
enabled: false,
}),
])
);
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Journey');
@@ -427,29 +380,18 @@ describe('AddonManager', () => {
return HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
buildAddon({
id: 'immich',
name: 'Immich',
description: 'Self-hosted photos',
type: 'photo_provider',
enabled: immichOn,
}),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
],
});
}),
http.put('/api/admin/addons/journey', async ({ request }) => {
journeyOn = ((await request.json()) as { enabled: boolean }).enabled;
journeyOn = (await request.json() as { enabled: boolean }).enabled;
// Journey off takes its providers with it, see admin.service.ts.
if (!journeyOn) immichOn = false;
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(addonToggle('Journey'));
@@ -472,14 +414,9 @@ describe('AddonManager', () => {
loads += 1;
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
}),
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AddonManager />
</>
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
await user.click(addonToggle('Todo List'));
@@ -490,28 +427,16 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({
id: 'paperless',
name: 'Paperless-ngx',
description: 'Document archive',
type: 'document_provider',
enabled: true,
}),
buildAddon({
id: 'nextcloud',
name: 'Nextcloud',
description: 'WebDAV files',
type: 'document_provider',
enabled: false,
}),
])
);
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
]));
render(<AddonManager />);
await unfoldShelves();
await screen.findByText('Paperless-ngx');
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
@@ -523,12 +448,10 @@ describe('AddonManager', () => {
});
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
])
);
server.use(addonsRoute([
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
]));
render(<AddonManager />);
await screen.findByText('Documents');
@@ -551,17 +474,12 @@ describe('AddonManager', () => {
});
}),
http.put('/api/admin/addons/documents', async ({ request }) => {
documentsOn = ((await request.json()) as { enabled: boolean }).enabled;
documentsOn = (await request.json() as { enabled: boolean }).enabled;
if (!documentsOn) paperlessOn = false;
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Paperless-ngx');
await user.click(addonToggle('Documents'));
@@ -592,14 +510,9 @@ describe('AddonManager', () => {
http.put('/api/admin/addons/papra', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Papra');
await user.click(subToggle('Papra'));
@@ -618,9 +531,11 @@ describe('AddonManager', () => {
<AddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>
/>,
);
await unfoldShelves();
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
@@ -650,6 +565,7 @@ describe('AddonManager', () => {
onToggleCollabFeature={vi.fn()}
/>
);
await unfoldShelves();
const links = await screen.findByText('Links');
expect(links).toHaveAttribute('title');
@@ -663,9 +579,7 @@ describe('AddonManager', () => {
await screen.findByText('AI Parsing');
// The catalog key now exists, so the tile shows the translation rather than falling
// back to the English description the server sent.
expect(
screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')
).toBeInTheDocument();
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
});
@@ -706,12 +620,16 @@ describe('AddonManager', () => {
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1 ? HttpResponse.json({ error: 'down' }, { status: 500 }) : HttpResponse.json({ models: [] });
})
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<AddonManager />);
await screen.findByText(/Request failed with status code 500/);
// The server's reason is shown, not axios' status line (#2649)
await screen.findByText('down');
expect(screen.queryByText(/Request failed with status code/)).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
@@ -741,6 +659,8 @@ describe('AddonManager', () => {
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
// A gateway can stand in for Anthropic too, so the field stays (#2655).
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
@@ -760,16 +680,11 @@ describe('AddonManager', () => {
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } }
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
@@ -787,22 +702,15 @@ describe('AddonManager', () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([
llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true }),
]),
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
@@ -828,14 +736,9 @@ describe('AddonManager', () => {
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
@@ -859,27 +762,14 @@ describe('AddonManager', () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([
llmAddon({
provider: 'local',
model: '',
baseUrl: 'http://ollama.lan:11434/v1',
apiKey: '',
multimodal: false,
}),
]),
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
@@ -892,13 +782,7 @@ describe('AddonManager', () => {
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: {
provider: 'anthropic',
model: 'claude-haiku-4-5-20251001',
baseUrl: '',
apiKey: 'sk-ant-live',
vision: 'auto',
},
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', vision: 'auto' },
});
});
@@ -910,20 +794,13 @@ describe('AddonManager', () => {
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
})
);
render(
<>
<ToastContainer />
<AddonManager />
</>
}),
);
render(<><ToastContainer /><AddonManager /></>);
// Stored off stays off; a cloud provider is told what Automatic means for it.
await user.click(await screen.findByRole('button', { name: 'No' }));
expect(
screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')
).toBeInTheDocument();
expect(screen.getByText('Automatic means no for a cloud model. Choose Yes if this model reads images.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Yes' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
@@ -935,9 +812,7 @@ describe('AddonManager', () => {
it('FE-ADMIN-ADDON-039: with the local provider the hint says Automatic asks the server', async () => {
server.use(addonsRoute([llmAddon({ provider: 'local', model: 'qwen3.5:4b' })]), modelsRoute([]));
render(<AddonManager />);
expect(
await screen.findByText('Automatic asks the Ollama server whether this model reads images.')
).toBeInTheDocument();
expect(await screen.findByText('Automatic asks the Ollama server whether this model reads images.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
@@ -946,20 +821,12 @@ describe('AddonManager', () => {
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post(
'/api/admin/llm/local/pull',
() =>
new HttpResponse('{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', {
headers: { 'Content-Type': 'application/x-ndjson' },
})
)
);
render(
<>
<ToastContainer />
<AddonManager />
</>
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
@@ -985,4 +852,89 @@ describe('AddonManager', () => {
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
onToggleCollabFeature={vi.fn()}
/>
);
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
expect(bar).toHaveTextContent('2 on');
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
await userEvent.click(bar);
expect(bar).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('Chat')).toBeInTheDocument();
await userEvent.click(bar);
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
const puts: unknown[] = [];
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', async ({ request }) => {
puts.push(await request.json());
return HttpResponse.json({ enabled: true });
})
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
const toggle = await screen.findByRole('button', { name: 'wanderer' });
expect(isOn(toggle)).toBe(false);
await userEvent.click(toggle);
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
server.use(
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
);
render(
<>
<AddonManager />
<ToastContainer />
</>
);
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
});
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
server.use(
addonsRoute([
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
])
);
render(<AddonManager />);
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
});
});
+137 -507
View File
@@ -1,93 +1,36 @@
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared';
import {
BarChart3,
Bookmark,
BookOpen,
Briefcase,
CalendarDays,
Cloud,
Compass,
FileText,
Globe,
Image,
Link2,
ListChecks,
Loader2,
Luggage,
MessageCircle,
Plane,
Puzzle,
RefreshCw,
Server,
Sparkles,
StickyNote,
Terminal,
Users,
Wallet,
} from 'lucide-react';
import { useEffect, useId, useState, type ComponentType } from 'react';
import { adminApi } from '../../api/client';
import { useIsDark } from '../../hooks/useIsDark';
import { useTranslation } from '../../i18n';
import { useAddonStore } from '../../store/addonStore';
import { SETTINGS_BUTTON_PRIMARY, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
import AirTrailIcon from '../shared/AirTrailIcon';
import CustomSelect from '../shared/CustomSelect';
import DawarichIcon from '../shared/DawarichIcon';
import { fs } from '../shared/DialogShell';
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons';
import EmptyState from '../shared/EmptyState';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import { EditorField, INPUT, Segmented } from '../shared/dialogParts';
import AddonSubRow from './AddonSubRow';
import AddonTile from './AddonTile';
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
import { useAddonManager } from './addons/useAddonManager'
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
import WandererMark from '../Tours/wanderer/WandererMark'
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
// used to land on the fallback.
const ICON_MAP = {
ListChecks,
Wallet,
FileText,
CalendarDays,
Puzzle,
Globe,
Briefcase,
Image,
Terminal,
Link2,
Compass,
BookOpen,
Plane,
Bookmark,
Users,
Sparkles,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
Mountain,
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
Route: CarFront,
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
// providers below do. Keyed by the string in the addons table.
Dawarich: DawarichIcon,
};
function ImmichIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M11.986.27c-2.409 0-5.207 1.09-5.207 3.894v.152c1.343.597 2.935 1.663 4.412 2.971 1.571 1.391 2.838 2.882 3.653 4.287 1.4-2.503 2.336-5.478 2.347-7.373V4.164c0-2.803-2.796-3.894-5.205-3.894m7.512 4.49c-.378-.008-.775.05-1.192.186l-.144.047c-.153 1.461-.676 3.304-1.463 5.113-.837 1.924-1.863 3.59-2.947 4.799 2.813.558 5.93.527 7.736-.047l.035-.01c2.667-.866 2.84-3.863 2.096-6.154-.628-1.933-2.081-3.89-4.121-3.934m-14.996.04c-2.04.043-3.493 1.997-4.121 3.93-.744 2.291-.571 5.288 2.096 6.155l.144.046c.982-1.092 2.488-2.276 4.188-3.277 1.809-1.065 3.619-1.808 5.207-2.148-1.949-2.105-4.489-3.914-6.287-4.51l-.036-.012c-.416-.135-.813-.193-1.191-.185m4.672 6.758c-2.604 1.202-5.109 3.06-6.233 4.586l-.021.029c-1.648 2.268-.027 4.795 1.922 6.211 1.949 1.416 4.852 2.177 6.5-.092.023-.031.054-.07.09-.121-.736-1.272-1.396-3.072-1.822-4.998-.454-2.05-.603-4-.436-5.615m1.072 3.338c.339 2.848 1.332 5.804 2.436 7.344l.021.029c1.648 2.268 4.551 1.508 6.5.092 1.949-1.416 3.57-3.943 1.922-6.211-.023-.031-.052-.073-.088-.123-1.437.307-3.352.38-5.316.19-2.089-.202-3.99-.663-5.475-1.321"
fill="currentColor"
/>
</svg>
);
}
function SynologyIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path
d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z"
fill="currentColor"
/>
</svg>
);
}
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
@@ -96,31 +39,13 @@ const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
// The document providers' own marks, single-colour so they read as glyphs in
// a row of lucide icons and invert with the theme.
...DOCUMENT_PROVIDER_ICONS,
};
interface Addon {
id: string;
name: string;
description: string;
icon: string;
type: string;
enabled: boolean;
config?: Record<string, unknown>;
}
interface ProviderOption {
key: string;
label: string;
description: string;
enabled: boolean;
toggle: () => Promise<void>;
}
interface AddonIconProps {
name: string;
size?: number;
name: string
size?: number
/** A switched-off addon greys its icon out, brand marks included. */
enabled?: boolean;
enabled?: boolean
}
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
@@ -128,13 +53,13 @@ function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
// sitting in the middle of it at glyph size, and they grey out when the addon
// is off — the lucide glyphs get that from the slot's text colour, an <img>
// has to be told.
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />;
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
// means every existing install still carries it — so the brand is keyed on
// that rather than on a new name no row would ever have.
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />;
const Icon = ICON_MAP[name] || Puzzle;
return <Icon size={size} />;
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
const Icon = ICON_MAP[name] || Puzzle
return <Icon size={size} />
}
/** What each type means, shown on the tile itself now that the sections are gone. The
@@ -143,116 +68,16 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
};
interface CollabFeatures {
chat: boolean;
notes: boolean;
links?: boolean;
polls: boolean;
whatsnext: boolean;
}
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{
key: 'whatsnext',
icon: Sparkles,
titleKey: 'admin.collab.whatsnext.title',
subtitleKey: 'admin.collab.whatsnext.subtitle',
},
] as const;
export default function AddonManager({
bagTrackingEnabled,
onToggleBagTracking,
collabFeatures,
onToggleCollabFeature,
}: {
bagTrackingEnabled?: boolean;
onToggleBagTracking?: () => void;
collabFeatures?: CollabFeatures;
onToggleCollabFeature?: (key: string) => void;
}) {
const { t } = useTranslation();
const dark = useIsDark();
const toast = useToast();
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
void loadAddons().finally(() => setLoading(false));
}, []);
const loadAddons = async () => {
try {
const data = await adminApi.addons();
setAddons(data.addons);
} catch (err: unknown) {
toast.error(t('admin.addons.toast.error'));
}
};
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
* snapshot back instead would undo a toggle the user hit in parallel. */
const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled;
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: newEnabled } : a)));
try {
await adminApi.updateAddon(addon.id, { enabled: newEnabled });
} catch (err: unknown) {
setAddons((prev) => prev.map((a) => (a.id === addon.id ? { ...a, enabled: !newEnabled } : a)));
toast.error(t('admin.addons.toast.error'));
return;
}
refreshGlobalAddons();
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
// Same for Documents: switching it off disables every document provider in
// the database, and the response carries only the addon itself.
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons();
toast.success(t('admin.addons.toast.updated'));
};
const isPhotoProviderAddon = (addon: Addon) => {
return addon.type === 'photo_provider';
};
const isDocumentProviderAddon = (addon: Addon) => {
return addon.type === 'document_provider';
};
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase();
return (
addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
);
};
const photoProviderAddons = addons.filter(isPhotoProviderAddon);
const documentProviderAddons = addons.filter(isDocumentProviderAddon);
const tripAddons = addons.filter((a) => a.type === 'trip' && !isPhotosAddon(a));
const globalAddons = addons.filter((a) => a.type === 'global');
const integrationAddons = addons.filter((a) => a.type === 'integration');
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}));
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}));
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
const { t } = useTranslation()
const dark = useIsDark()
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
} = useAddonManager()
if (loading) {
return (
@@ -261,7 +86,7 @@ export default function AddonManager({
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
</SettingsCard>
);
)
}
/** Bag tracking, the collab features and the photo providers all hang off their
@@ -277,11 +102,11 @@ export default function AddonManager({
enabled={!!bagTrackingEnabled}
onToggle={onToggleBagTracking}
/>
);
)
}
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
return COLLAB_SUB_FEATURES.map((feat) => {
const Icon = feat.icon;
return COLLAB_SUB_FEATURES.map(feat => {
const Icon = feat.icon
return (
<AddonSubRow
key={feat.key}
@@ -291,16 +116,16 @@ export default function AddonManager({
enabled={collabFeatures[feat.key]}
onToggle={() => onToggleCollabFeature(feat.key)}
/>
);
});
)
})
}
// Document providers are the Documents tile's shelf, the way photo providers
// are Journey's. Unlike those they carry no credential form here: a document
// connection belongs to a trip, not to a user, so it is entered in the trip's
// file manager. The admin decides only whether a provider may be offered.
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
return documentProviderOptions.map((provider) => {
const ProviderIcon = PROVIDER_ICONS[provider.key];
return documentProviderOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
@@ -310,12 +135,12 @@ export default function AddonManager({
enabled={provider.enabled}
onToggle={provider.toggle}
/>
);
});
)
})
}
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map((provider) => {
const ProviderIcon = PROVIDER_ICONS[provider.key];
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
@@ -325,19 +150,30 @@ export default function AddonManager({
enabled={provider.enabled}
onToggle={provider.toggle}
/>
);
});
)
})
}
if (addon.id === 'tours' && addon.enabled) {
return (
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
// just as a form instead of toggle rows.
if (addon.id === 'llm_parsing' && addon.enabled) {
return <LlmParsingConfig addon={addon} />;
return <LlmParsingConfig addon={addon} />
}
return null;
};
return null
}
const tile = (addon: Addon) => {
const label = getAddonLabel(t, addon);
const label = getAddonLabel(t, addon)
return (
<AddonTile
key={addon.id}
@@ -349,8 +185,8 @@ export default function AddonManager({
>
{shelfFor(addon)}
</AddonTile>
);
};
)
}
/* One column per type, side by side, instead of three stacked sections. Stacked, each
section opened its own set of columns and the tall tiles (Collab, Journey) set a
@@ -360,78 +196,59 @@ export default function AddonManager({
{ key: 'trip', addons: tripAddons },
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter((g) => g.addons.length > 0);
const enabledCount = addons.filter(
(a) => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled
).length;
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length;
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
const subtitle = (
<span className="inline-flex flex-wrap items-center gap-1">
{t('admin.addons.subtitleBefore')}
<img
src={dark ? '/text-light.svg' : '/text-dark.svg'}
alt="TREK"
style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }}
/>
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, verticalAlign: 'middle', opacity: 0.7 }} />
{t('admin.addons.subtitleAfter')}
</span>
);
)
return (
<SettingsCard
icon={Puzzle}
title={t('admin.addons.title')}
hint={subtitle}
badge={
totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>
{enabledCount}/{totalCount}
</StatusPill>
</span>
</Tooltip>
) : undefined
}
badge={totalCount > 0 ? (
<Tooltip label={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}>
<span className="inline-flex">
<StatusPill tone={enabledCount > 0 ? 'success' : 'neutral'}>{enabledCount}/{totalCount}</StatusPill>
</span>
</Tooltip>
) : undefined}
>
{addons.length === 0 ? (
<EmptyState scene="idle" title={t('admin.addons.noAddons')} surface="var(--bg-secondary)" />
) : (
<div className="grid grid-cols-1 items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => {
const meta = TYPE_META[g.key];
{groups.map(g => {
const meta = TYPE_META[g.key]
return (
<section
key={g.key}
className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card"
>
<section key={g.key} className="min-w-0 overflow-hidden rounded-[14px] border border-edge-faint bg-surface-card">
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
<div className="divide-y divide-edge-faint">{g.addons.map(tile)}</div>
</section>
);
)
})}
</div>
)}
</SettingsCard>
);
)
}
/** The head band of one type's column: icon tile, the type, its count and what it means. */
function GroupHead({
icon: Icon,
label,
hint,
addons,
t,
}: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>;
label: string;
hint: string;
addons: Addon[];
t: (key: string, params?: Record<string, unknown>) => string;
function GroupHead({ icon: Icon, label, hint, addons, t }: {
icon: ComponentType<{ size?: number; className?: string; strokeWidth?: number }>
label: string
hint: string
addons: Addon[]
t: (key: string, params?: Record<string, unknown>) => string
}) {
const enabled = addons.filter((a) => a.enabled).length;
const enabled = addons.filter(a => a.enabled).length
return (
<div className="flex items-start gap-2.5 border-b border-edge-faint bg-surface-tertiary px-3.5 py-2.5">
<span className="mt-px grid h-7 w-7 flex-none place-items-center rounded-[9px] bg-surface-card text-content-secondary shadow-sm">
@@ -439,44 +256,22 @@ function GroupHead({
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>
{label}
</h3>
<h3 className="m-0 min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{label}</h3>
<Tooltip label={t('admin.addons.group.count', { enabled, total: addons.length })}>
<span className="inline-flex">
<StatusPill>
{enabled}/{addons.length}
</StatusPill>
<StatusPill>{enabled}/{addons.length}</StatusPill>
</span>
</Tooltip>
</div>
{/* Says what the type means: the only place that still explains it. */}
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>
{hint}
</p>
<p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11)}>{hint}</p>
</div>
</div>
);
)
}
const MASKED = '••••••••';
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint';
const SMALL_BUTTON =
'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60';
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1';
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{
id: 'qwen3.5:4b',
label: 'Qwen3.5 — 4B',
note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0',
recommended: true,
vision: true,
},
];
const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py-1 font-medium transition-colors disabled:cursor-default disabled:opacity-60'
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
@@ -485,99 +280,19 @@ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const { t } = useTranslation();
const toast = useToast();
const cfg = (addon.config ?? {}) as Record<string, unknown>;
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local');
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
const [vision, setVision] = useState<LlmVision>(asLlmVision(cfg.vision));
const [saving, setSaving] = useState(false);
const fieldId = useId();
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([]);
const [modelsErr, setModelsErr] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [pulling, setPulling] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0);
const [pullStatus, setPullStatus] = useState('');
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
const loadModels = async () => {
if (provider !== 'local') return;
setLoadingModels(true);
setModelsErr('');
try {
const res = await adminApi.llmLocalModels(effectiveUrl);
setInstalled(res.models.map((m) => m.name));
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server');
setInstalled([]);
} finally {
setLoadingModels(false);
}
};
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') void loadModels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
const pull = async (id: string) => {
if (pulling) return;
setPulling(id);
setPullPct(0);
setPullStatus('starting…');
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error);
if (p.status) setPullStatus(p.status);
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100));
});
toast.success('Model pulled');
setModel(id);
await loadModels();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed');
} finally {
setPulling(null);
setPullPct(0);
setPullStatus('');
}
};
const save = async () => {
setSaving(true);
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, {
config: {
provider,
model: model.trim(),
baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(),
apiKey,
vision,
},
});
toast.success('Saved');
} catch {
toast.error('Failed to save');
} finally {
setSaving(false);
}
};
const { t } = useTranslation()
const {
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
} = useLlmParsingConfig(addon)
const fieldId = useId()
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
];
const visionOptions = LLM_VISION_MODES.map((value) => ({ value, label: t(`admin.addons.llm.vision.${value}`) }));
]
const visionOptions = LLM_VISION_MODES.map(value => ({ value, label: t(`admin.addons.llm.vision.${value}`) }))
/* Lives in the tile's shelf like the collab toggles, so it is one column of
eyebrow fields: the band this used to be had a whole page width. */
@@ -588,61 +303,27 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
</SettingsHint>
<EditorField label="Provider">
<CustomSelect value={provider} onChange={(v) => setProvider(String(v))} options={providerOptions} />
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</EditorField>
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
</EditorField>
{provider !== 'anthropic' && (
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
<input
id={`${fieldId}-url`}
type="url"
autoComplete="off"
className={INPUT}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
onBlur={loadModels}
placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'}
/>
</EditorField>
)}
<EditorField
label="API key"
htmlFor={`${fieldId}-key`}
hint={
provider === 'anthropic'
? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.'
: undefined
}
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
>
<input
id={`${fieldId}-key`}
type="password"
className={INPUT}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'}
/>
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</EditorField>
<EditorField label="Model" htmlFor={`${fieldId}-model`}>
<input
id={`${fieldId}-model`}
autoComplete="off"
className={`${INPUT} font-geist`}
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={
provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'
}
/>
<input id={`${fieldId}-model`} autoComplete="off" className={`${INPUT} font-geist`} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</EditorField>
<EditorField
label={t('settings.aiParsing.multimodal')}
hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}
>
<EditorField label={t('settings.aiParsing.multimodal')} hint={t(provider === 'local' ? 'admin.addons.llm.vision.hintLocal' : 'admin.addons.llm.vision.hintCloud')}>
<Segmented<LlmVision>
label={t('settings.aiParsing.multimodal')}
value={vision}
onChange={(v) => setVision(asLlmVision(v))}
onChange={v => setVision(asLlmVision(v))}
options={visionOptions}
fill
/>
@@ -652,33 +333,22 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{provider === 'local' && (
<div className="flex flex-col gap-2.5 rounded-[12px] border border-edge-faint bg-surface-card p-3">
<div className="flex items-center gap-2">
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>
Installed on the server
</span>
<button
type="button"
onClick={loadModels}
disabled={loadingModels}
<span className={`${EYEBROW} min-w-0 flex-1 truncate`} style={fs(9.5)}>Installed on the server</span>
<button type="button" onClick={loadModels} disabled={loadingModels}
className="inline-flex flex-none items-center gap-1 rounded-full px-2 py-0.5 font-medium text-content-muted hover:bg-surface-secondary hover:text-content disabled:cursor-default disabled:opacity-60"
style={fs(11.5, 'body')}
>
style={fs(11.5, 'body')}>
<RefreshCw size={11} strokeWidth={2.2} className={loadingModels ? 'animate-spin' : undefined} />
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && (
<p className="m-0 break-words text-danger" style={fs(11.5)}>
{modelsErr}
</p>
)}
{modelsErr && <p className="m-0 break-words text-danger" style={fs(11.5)}>{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<SettingsHint>No models installed yet — pull one below.</SettingsHint>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map((name) => (
<button
type="button"
{installed.map(name => (
<button type="button"
key={name}
title={name}
onClick={() => setModel(name)}
@@ -693,38 +363,22 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
)}
<div className="flex flex-col gap-2 border-t border-edge-faint pt-2.5">
<span className={EYEBROW} style={fs(9.5)}>
Pull a recommended model
</span>
{RECOMMENDED_MODELS.map((m) => {
const installedHere = isInstalled(m.id);
const isPulling = pulling === m.id;
const active = model === m.id;
<span className={EYEBROW} style={fs(9.5)}>Pull a recommended model</span>
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className="min-w-0" title={m.note}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{m.label}
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>{m.label}</span>
{m.recommended && <StatusPill tone="success">Recommended</StatusPill>}
{installedHere ? (
<button
type="button"
onClick={() => setModel(m.id)}
disabled={active}
className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`}
style={fs(12, 'body')}
>
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`${SMALL_BUTTON} ${active ? 'bg-surface-tertiary text-content-muted' : 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary'}`} style={fs(12, 'body')}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button
type="button"
onClick={() => pull(m.id)}
disabled={!!pulling}
className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`}
style={fs(12, 'body')}
>
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className={`${SMALL_BUTTON} bg-accent text-accent-text hover:opacity-90`} style={fs(12, 'body')}>
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
@@ -732,47 +386,23 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
{isPulling && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200"
style={{ width: `${pullPct}%` }}
/>
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
{pullStatus}
{pullPct ? ` · ${pullPct}%` : ''}
<div className="h-full rounded-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-1 truncate font-geist tabular-nums text-content-faint" style={fs(11)}>{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
);
)
})}
</div>
</div>
)}
<div className="flex justify-end">
<button
type="button"
onClick={save}
disabled={saving}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
<button type="button" onClick={save} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</li>
);
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
const nameKey = `admin.addons.catalog.${addon.id}.name`;
const descKey = `admin.addons.catalog.${addon.id}.description`;
const translatedName = t(nameKey);
const translatedDescription = t(descKey);
return {
name: translatedName !== nameKey ? translatedName : addon.name,
description: translatedDescription !== descKey ? translatedDescription : addon.description,
};
)
}
+13 -23
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { fs } from '../shared/DialogShell';
import type { ReactNode } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
/**
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
@@ -23,31 +23,21 @@ export default function AddonSubRow({
onToggle,
}: {
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
icon?: ReactNode;
title: string;
description?: string;
enabled: boolean;
onToggle: () => void;
icon?: ReactNode
title: string
description?: string
enabled: boolean
onToggle: () => void
}) {
return (
<li className="flex min-h-[44px] items-center gap-2.5 px-3 py-1.5">
{icon ? (
<span
className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
>
{icon}
</span>
) : (
<span className="w-6 flex-none" aria-hidden />
)}
<span
className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`}
style={fs(12.5, 'body')}
title={description}
>
{icon
? <span className={`grid h-6 w-6 flex-none place-items-center ${enabled ? 'text-content-muted' : 'text-content-faint'}`}>{icon}</span>
: <span className="w-6 flex-none" aria-hidden />}
<span className={`min-w-0 flex-1 truncate font-medium ${enabled ? 'text-content' : 'text-content-muted'}`} style={fs(12.5, 'body')} title={description}>
{title}
</span>
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
</li>
);
)
}
+60 -15
View File
@@ -1,6 +1,13 @@
import type { ReactNode } from 'react';
import ToggleSwitch from '../Settings/ToggleSwitch';
import { fs } from '../shared/DialogShell';
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
import ToggleSwitch from '../Settings/ToggleSwitch'
import { fs } from '../shared/DialogShell'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
function enabledRows(rows: ReactNode[]): number {
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
}
/**
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell';
*
* The row is not a click target, only the switches are. No lift, no pointer
* cursor, no `role="button"`.
*
* A shelf of more than one row starts folded behind a bar that says how many
* options there are and how many are on, so a page of addons stays short and
* the options are still plain to see.
*/
export default function AddonTile({
icon,
@@ -23,14 +34,21 @@ export default function AddonTile({
onToggle,
children,
}: {
icon: ReactNode;
name: string;
description?: string;
enabled: boolean;
onToggle: () => void;
icon: ReactNode
name: string
description?: string
enabled: boolean
onToggle: () => void
/** The sub-shelf: `<AddonSubRow>` children. */
children?: ReactNode;
children?: ReactNode
}) {
const { t } = useTranslation()
const shelfId = useId()
const [open, setOpen] = useState(false)
const rows = Children.toArray(children)
const foldable = rows.length > 1
const active = enabledRows(rows)
return (
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
<div className="flex items-start gap-3">
@@ -38,9 +56,7 @@ export default function AddonTile({
// overflow-hidden so a brand mark that fills the slot keeps the slot's
// rounded corners instead of squaring them off.
className={`grid h-9 w-9 flex-none place-items-center overflow-hidden rounded-[10px] transition-colors ${
enabled
? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint'
: 'bg-surface-tertiary text-content-faint'
enabled ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'bg-surface-tertiary text-content-faint'
}`}
>
{icon}
@@ -64,13 +80,42 @@ export default function AddonTile({
</div>
</div>
{children && (
{rows.length > 0 && (
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
{foldable && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={shelfId}
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
>
{/* The same slot and type as a row below, so the bar reads as one more row. */}
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
<ChevronDown
size={15}
strokeWidth={2.2}
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</span>
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
{t('admin.addons.subOptions', { count: rows.length })}
</span>
<span
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
style={fs(10.5, 'body')}
>
{t('admin.addons.subOptionsOn', { count: active })}
</span>
</button>
)}
{/* Sitting inside the parent on its own fill is enough to say these belong
to it: no rail, no indent guides. */}
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
{(!foldable || open) && (
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
)}
</div>
)}
</article>
);
)
}
@@ -1,8 +1,8 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
@@ -39,7 +39,7 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/mcp-tokens', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ tokens: [] });
})
);
@@ -53,7 +53,11 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Ops Token')).toBeInTheDocument();
@@ -65,7 +69,11 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Never')).toBeInTheDocument();
@@ -73,7 +81,11 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -88,7 +100,11 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -105,7 +121,11 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
const user = userEvent.setup();
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })));
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
@@ -125,15 +145,14 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByLabelText('Delete');
@@ -151,15 +170,14 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })),
http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByLabelText('Delete');
@@ -171,20 +189,19 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
@@ -193,7 +210,11 @@ describe('AdminMcpTokensPanel', () => {
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [] })));
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({ sessions: [] })
)
);
render(<AdminMcpTokensPanel />);
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
});
@@ -223,19 +244,13 @@ describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = [
'trips:read',
'trips:write',
'places:read',
'places:write',
'budget:read',
'budget:write',
'packing:read',
];
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }],
sessions: [
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
@@ -255,24 +270,15 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 5,
client_name: 'Revoke Me',
username: 'carol',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/5', () => HttpResponse.json({ success: true }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
http.delete('/api/admin/oauth-sessions/5', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
@@ -283,7 +289,7 @@ describe('AdminMcpTokensPanel', () => {
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no aria-label, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find((b) => !b.hasAttribute('aria-label'));
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
@@ -296,30 +302,21 @@ describe('AdminMcpTokensPanel', () => {
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 6,
client_name: 'Error Session',
username: 'dave',
scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/6', () => HttpResponse.json({ error: 'forbidden' }, { status: 403 }))
);
render(
<>
<ToastContainer />
<AdminMcpTokensPanel />
</>
http.delete('/api/admin/oauth-sessions/6', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByLabelText('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find((b) => !b.hasAttribute('aria-label'));
const confirmBtn = deleteBtns.find(b => !b.hasAttribute('aria-label'));
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
});
@@ -1,41 +1,15 @@
import { Bot, Key, KeyRound, Loader2, Shield, Trash2, User } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit';
import ConfirmDialog from '../shared/ConfirmDialog';
import { fs } from '../shared/DialogShell';
import { useToast } from '../shared/Toast';
import { Tooltip } from '../shared/Tooltip';
import { type ReactNode } from 'react'
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
import { useTranslation } from '../../i18n'
import ConfirmDialog from '../shared/ConfirmDialog'
import { Tooltip } from '../shared/Tooltip'
import { fs } from '../shared/DialogShell'
import { SETTINGS_ICON_BUTTON, SettingRows, SettingsCard, SettingsHint, StatusPill } from '../Settings/settingsKit'
import { SCOPES_PREVIEW, useMcpTokensAdmin } from './useMcpTokensAdmin'
interface AdminOAuthSession {
id: number;
client_id: string;
client_name: string;
user_id: number;
username: string;
scopes: string[];
access_token_expires_at: string;
refresh_token_expires_at: string;
created_at: string;
}
interface AdminMcpToken {
id: number;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
user_id: number;
username: string;
}
const SCOPES_PREVIEW = 6;
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3';
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary';
const CHIP =
'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted';
const ROW = 'flex flex-wrap items-center gap-x-4 gap-y-2 px-3.5 py-3'
const TILE = 'grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary'
const CHIP = 'inline-flex items-center rounded-full border border-edge-faint bg-surface-secondary px-2 py-[1px] font-geist text-content-muted'
/** The spinner or the empty line a list shows in place of its rows. */
function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode; text: string }) {
@@ -44,114 +18,55 @@ function ListState({ loading, icon, text }: { loading: boolean; icon: ReactNode;
<div className="flex justify-center py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
);
)
}
return (
<div className="flex flex-col items-center gap-2 py-6 text-content-faint">
{icon}
<SettingsHint>{text}</SettingsHint>
</div>
);
)
}
/** One fact on the right of a row: an eyebrow over its value. */
function Meta({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0 text-right">
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>
{label}
</div>
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>
{children}
</div>
<div className="min-w-0 text-end">
<div className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5)}>{label}</div>
<div className="whitespace-nowrap font-geist tabular-nums text-content-muted" style={fs(12, 'body')}>{children}</div>
</div>
);
)
}
/** The owner of a session or token, with the person glyph in front. */
function Owner({ name }: { name: string }) {
return (
<span
className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary"
style={fs(12.5, 'body')}
>
<span className="inline-flex min-w-0 max-w-[180px] items-center gap-1.5 text-content-secondary" style={fs(12.5, 'body')}>
<User size={13} className="flex-none text-content-faint" />
<span className="truncate">{name}</span>
</span>
);
)
}
/** The trash button of a row, named by its tooltip. */
function DeleteAction({ label, onClick }: { label: string; onClick: () => void }) {
return (
<Tooltip label={label} placement="left">
<button
type="button"
onClick={onClick}
aria-label={label}
className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}
>
<button type="button" onClick={onClick} aria-label={label} className={`${SETTINGS_ICON_BUTTON} hover:text-danger`}>
<Trash2 size={14} />
</button>
</Tooltip>
);
)
}
export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [tokens, setTokens] = useState<AdminMcpToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set());
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
const {
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
} = useMcpTokensAdmin()
const { t, locale } = useTranslation()
const toggleScopes = (id: number) =>
setExpandedScopes((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toast = useToast();
const { t, locale } = useTranslation();
useEffect(() => {
adminApi
.oauthSessions()
.then((d) => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false));
adminApi
.mcpTokens()
.then((d) => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setTokensLoading(false));
}, []);
const handleRevoke = async (id: number) => {
try {
await adminApi.revokeOAuthSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setRevokeConfirmId(null);
toast.success(t('admin.oauthSessions.revokeSuccess'));
} catch {
toast.error(t('admin.oauthSessions.revokeError'));
}
};
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id);
setTokens((prev) => prev.filter((tk) => tk.id !== id));
setDeleteConfirmId(null);
toast.success(t('admin.mcpTokens.deleteSuccess'));
} catch {
toast.error(t('admin.mcpTokens.deleteError'));
}
};
const date = (iso: string) => new Date(iso).toLocaleDateString(locale);
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
return (
<div>
@@ -164,38 +79,25 @@ export default function AdminMcpTokensPanel() {
badge={!sessionsLoading && sessions.length > 0 ? <StatusPill>{sessions.length}</StatusPill> : undefined}
>
{sessionsLoading || sessions.length === 0 ? (
<ListState
loading={sessionsLoading}
icon={<Shield size={24} strokeWidth={1.6} />}
text={t('admin.oauthSessions.empty')}
/>
<ListState loading={sessionsLoading} icon={<Shield size={24} strokeWidth={1.6} />} text={t('admin.oauthSessions.empty')} />
) : (
<SettingRows>
{sessions.map((session) => {
const expanded = expandedScopes.has(session.id);
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW);
const hidden = session.scopes.length - SCOPES_PREVIEW;
{sessions.map(session => {
const expanded = expandedScopes.has(session.id)
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id} className={`${ROW} items-start`}>
<span className={TILE}>
<Bot size={16} strokeWidth={1.9} />
</span>
<span className={TILE}><Bot size={16} strokeWidth={1.9} /></span>
<div className="min-w-0 flex-1 basis-60">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{session.client_name}
</p>
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
<div className="mt-1.5 flex flex-wrap gap-1" style={fs(11)}>
{visible.map((scope) => (
<span key={scope} className={CHIP}>
{scope}
</span>
{visible.map(scope => (
<span key={scope} className={CHIP}>{scope}</span>
))}
{hidden > 0 && (
<button
type="button"
onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content"
>
<button type="button" onClick={() => toggleScopes(session.id)}
className="inline-flex items-center rounded-full bg-surface-tertiary px-2 py-[1px] font-semibold text-content-secondary hover:text-content">
{expanded ? 'show less' : `+${hidden} more`}
</button>
)}
@@ -207,7 +109,7 @@ export default function AdminMcpTokensPanel() {
<DeleteAction label={t('common.delete')} onClick={() => setRevokeConfirmId(session.id)} />
</div>
</div>
);
)
})}
</SettingRows>
)}
@@ -220,25 +122,15 @@ export default function AdminMcpTokensPanel() {
badge={!tokensLoading && tokens.length > 0 ? <StatusPill>{tokens.length}</StatusPill> : undefined}
>
{tokensLoading || tokens.length === 0 ? (
<ListState
loading={tokensLoading}
icon={<Key size={24} strokeWidth={1.6} />}
text={t('admin.mcpTokens.empty')}
/>
<ListState loading={tokensLoading} icon={<Key size={24} strokeWidth={1.6} />} text={t('admin.mcpTokens.empty')} />
) : (
<SettingRows>
{tokens.map((token) => (
{tokens.map(token => (
<div key={token.id} className={ROW}>
<span className={TILE}>
<KeyRound size={16} strokeWidth={1.9} />
</span>
<span className={TILE}><KeyRound size={16} strokeWidth={1.9} /></span>
<div className="min-w-0 flex-1 basis-48">
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>
{token.name}
</p>
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>
{token.token_prefix}...
</p>
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
<p className="m-0 mt-0.5 truncate font-geist text-content-faint" style={fs(11)}>{token.token_prefix}...</p>
</div>
<div className="flex flex-none items-center gap-4">
<Owner name={token.username} />
@@ -258,9 +150,7 @@ export default function AdminMcpTokensPanel() {
<ConfirmDialog
isOpen={revokeConfirmId !== null}
onClose={() => setRevokeConfirmId(null)}
onConfirm={() => {
if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId);
}}
onConfirm={() => { if (revokeConfirmId !== null) void handleRevoke(revokeConfirmId) }}
title={t('admin.oauthSessions.revokeTitle')}
message={t('admin.oauthSessions.revokeMessage')}
confirmLabel={t('common.delete')}
@@ -271,14 +161,12 @@ export default function AdminMcpTokensPanel() {
<ConfirmDialog
isOpen={deleteConfirmId !== null}
onClose={() => setDeleteConfirmId(null)}
onConfirm={() => {
if (deleteConfirmId !== null) void handleDelete(deleteConfirmId);
}}
onConfirm={() => { if (deleteConfirmId !== null) void handleDelete(deleteConfirmId) }}
title={t('admin.mcpTokens.deleteTitle')}
message={t('admin.mcpTokens.deleteMessage')}
confirmLabel={t('common.delete')}
danger
/>
</div>
);
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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