Commit Graph

387 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 e7e26bf2e6 chore(shared): the format lint script imports URL instead of relying on a global 2026-10-10 20:16:12 +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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 c956fe92ba Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and
DatabaseService the Tours server side was built on are gone. Behaviour
is unchanged; persistence moves onto dev's architecture:

- one MikroORM migration for tour_types, tours and tour_waypoints,
  idempotent so instances that ran the old positional steps keep
  their data; the hike tour type is seeded inside it, the addon row
  in the AddonSeeder
- entities and repositories for the three tables, tours domain on
  repositories and UnitOfWork
- tour facet joins in places, assignments and days re-applied on the
  repository code

Fixes found while porting:

- GPX import checks addon, trip access and permission in the handler
  instead of guards, so a refused upload gets a 403/404 instead of a
  connection reset
- copying a trip copies its tours and waypoints
- a tour cannot be moved onto a day that already has it
- updateTour checks existence inside its transaction

Adds tours e2e tests and rewrites the tours unit tests onto the
migrated test database.
2026-10-06 22:43:09 +02:00