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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The plural parity check only knows the groups en already declares, so a
"{count} ..." string added as a plain key kept one form in every
language: Polish read "Usuń 1 spakowanych", Czech "Odstranit 1
hotových" and Greek "1 λεπτά". 44 such en keys become plural groups with their
forms in all 27 locales: the collection, packing, day plan and offline
banner counts, the relative times, the audit log, the tour and transit
minutes, the share and photo hints. Labels like "Notes: {count}" get
their forms too, so a language can still inflect them later. The
dashboard's hand-rolled activeOne/activeMany pair, unused, becomes one
group, dashboard.subtitle.active.
i18n-parity now refuses an en string with {count} or {n} that is no
plural group. NOT_PLURAL lists the eleven where the number is never a
quantity (a day number, a step, a waypoint, the equator multiplier),
each with its reason, and an entry that stops matching fails as well.
Callers that worked around the engine:
- Copy to trip switched between two keys on placeIds.length > 1;
collections.copyN now carries the one-place wording as its `one` form,
where the language's `one` is the number 1 alone.
- The plugin dependency toasts said "addon(s)" and "plugin(s)"; they are
groups now and the panels pass the list length as the count.
- The dismissed suggestions count went in as a string and the comp days
as a formatted string, so no form was picked; both pass numbers.
BudgetPanel titled its add button with budget.addCategory (since April)
and the trip form labelled its cover remove button with common.remove
(since August). Neither key was ever in en, and t() returns an unknown
key unchanged, so both rendered as the raw key in every language. Both
are added to all 27 locales.
Locale parity compares the locales with en, never en with the code, so
nothing could catch this. client/scripts/i18n-keys.mjs (npm run
lint:i18n-keys) now resolves every literal key src/ names against en:
the first argument of t(), tHtml() and tr() (both sides of a conditional
included), <TransHtml html>, the fallback of translateApiError and any
*Key property or prop. A template key, a prefix with the rest appended
and the help registry's key builders pass when their fixed parts match
an en key; the one template with no fixed part is on an allow-list that
names the keys it resolves to, and an entry nothing uses fails. With
--unused it lists the en keys nothing reaches, as information only.
en is read with shared/scripts/i18n-catalog.mjs, a reader for the
locale tables as text that the parity tooling will share. It refuses a
value that is not one string literal instead of skipping the key.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The Tours surfaces now use the same building blocks as the rest of the
planner: tinted head bands, eyebrow labels, white field boxes and pills,
place-style list rows and the inspector frame for the map detail.
- Tour detail on the map uses the place inspector's frame, head band,
labelled metric fields, collapsible track colour and footer buttons
- Tour rows in the Plan sidebar and the Tours tab look like place rows,
with the facts as pills and a "+" that opens the trip's days as a menu
- Tours tab rails get the planner head bands, editor fields and
waypoint rows in the day card style; notices share one look
- Map hint shortened to one line and the empty state reads as a single
sentence in all locales
- New "tours" mascot scene with a pack, a trekking pole, hills and a
trail, used by the Tours empty states
PlacesSidebarHeader uses the UNCATEGORIZED constant, placesBrowserModel stops
re-exporting matchesCategoryFilter (its test imports it from utils/placesFilter),
and the orphan comment in the en places strings joins the places.filters key.
On a phone the only way to narrow the trip's pins was to leave the map for
the places list, and the rating floor could not be set at all. The phone map
now has a round "Filters" control at the foot of its right-hand stack, badged
with how many filters are on, opening a sheet with the pool (all / unplanned /
planned / tracks), the minimum rating (the desktop list's floors), the trip's
categories including "no category", and a reset. It is hidden on the road trip
stage, which draws its own pins.
The places browser's filter panel gains the same rating floor; its category
rows move into MPlacesFilterControls so the panel and the sheet share one
copy. Everything writes the trip store, so list and map stay in step.
MapTogglePill takes an optional count badge.