Commit Graph

69 Commits

Author SHA1 Message Date
Maurice ed18c37903 fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an
independent check against the code is fixed here, with a test for each.

Data and credentials
- A document sync connection edited to a new address no longer carries the
  stored token along: a blank form under another origin is refused, the same
  rule the probe route already had. Dawarich and AirTrail testConnection send
  the stored key only to the host it was stored for.
- The key rotation script now covers document_connections.secrets and
  trip_document_links.webhook_secret, which it had skipped.
- Turning a booked night into a pause in the road trip popup asks first and
  says that the booking and its expense go with it. Deleting a place says the
  same when a night is booked there, on the desktop and on the phone.
- A moved version marker over unchanged bytes no longer replaces the TREK file
  and its attachments; a real revision carries the attachments over.

Planner
- A drop in the day plan names the row it lands ahead of in the full day, so a
  hidden hotel stop no longer shifts it one slot early; the store sorts before
  it splices. Via re-anchoring, the booked-night guard and the sidebar's day
  membership read the stored day, the index space the server uses.
- A hotel stop placed mid-day re-pins the road trip vias of that day, and the
  booking session receives the new stop order too. The Public API and the iCal
  feed list a booked hotel once. A plugin can no longer write a stay against
  another trip's day.

Road trip and map
- The route overview paces its requests to the public routers and retries a
  rate limit, and it says which legs it could not route instead of quietly
  reporting a shorter trip. A click on a via handle no longer drops a second
  via; handles hide when zooming out. Read-only members cannot edit the driving
  limits. The phone shows a way back when no day is picked and prints the night
  drive departure in the chosen clock format.

Integrations and search
- Google transit keeps conventional trains when the train chip is on.
  TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and
  accepts the whole boolean family. A caller-supplied description no longer
  lands in the instance-wide enrichment cache. Nominatim searches carry a
  timeout. An OSM place id is checked for shape before it reaches Overpass.
- Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off
  knows what the Atlas already shows, and a host change with a new key clears
  the old instance's state.
- Document sync: a webhook address pasted by hand works on the token alone,
  deleting a connection unregisters its subscriptions, Nextcloud under a
  sub-path connects, Synology answers a name collision before uploading and
  verifies a move into the bin, Paperless titles are capped at 128 characters,
  a DNS failure reads as unreachable rather than as a policy verdict, videos
  from the store are accepted like uploads, and remote_missing has a way out.
  Conflict buttons only for the owner, a refused choice is reported, a refused
  credential gets a Reconnect button, disconnecting asks first on the desktop
  as it does on the phone, and the folder picker no longer spins forever.

Release
- The server holds the release notice back from a bundle that cannot draw it,
  so the previous shell served from the service worker cache after an update
  no longer shows bare keys and no longer lets a click use the notice up.
- Import into an existing list gets the same body limit as a new list. Chat
  images say when the limit is reached and the new controls are translated.
  The transit backend switch reports a failed save. The Helm chart carries the
  Amap credentials in its Secret; the shipped templates document the Places
  API and Amap variables and the Overpass default. The wiki follows.
2026-09-20 13:54:31 +02:00
Maurice 00de3c3a1d fix(mobile): offer public transit in the day plan's leg menu, as on the desktop
The connector menu on the phone listed only the road profiles, so the
transit search could not be opened for a single leg there. It now carries
the Public transit entry whenever the trip has dates, seeded with the leg's
two ends and the departure of the stop it leaves, and the transport sheet
passes that departure on to the search.

The prefill is built by one module the desktop day plan now uses as well.

Closes #2398
2026-09-19 22:39:05 +02:00
Maurice 154c88b48a feat(roadtrip): leave a stop at its End and show the stay that makes
A visit's End could be edited and was shown in road trip mode, but the
schedule never read it: the drive always left a stop after its stay, so
a traveller who knew when they had to leave but not how long they would
stand there had no way to say so.

The End is now the time the drive leaves. RoadtripStop.leaveAt is
filled from the visit's End (or its place's) on the client by
useRoadtripRoutes and on the server by roadtrip-plan.service, so
calculate_roadtrip and get_roadtrip_context agree with the planner, and
computeSchedule takes it as the departure it already knew how to hold.
With Start and End both set the stay is the time between them. With
daily travel times on, the stay is the time until then, and an End
before the window start starts the day early the way a Start does.
Before the first fixed time the chain works backwards through an End as
it does through a pinned Start.

An End is read against the drive: the last passing of that clock time
counts as missed when the drive went past it on the road or earlier
the same day, anything else is the next one, so a drive across
midnight neither waits a whole day nor pretends to be on time. A drive
that gets there after the End leaves on arrival and files a
missedLeave finding, shown beside the late badge in the rail and on the
phone.

The rail's STAY badge, the inspector's Stay tile, the phone stop sheet
and the phone chain show the stay the End makes, with an "until 14:00"
hint. It is read off the schedule and never written to
duration_minutes: the stay belongs to the place, the End to one visit,
and a stored copy would go stale as soon as the arrival moved. For such
a stop the stay dialog, desktop and phone, shows that stay read only
and offers to remove the End, through the visit's time route with an
offline path (assignmentRepo.setTimes, setAssignmentTimes). While the
road trip is on, the End field in both place forms says the drive
leaves at that time.

The dead checkoutAt in the plan service is gone. New strings in all 23
locales.
2026-09-19 19:24:52 +02:00
Maurice 843582863c fix(docsync): offer the sync button only where something is behind it
The Documents addon put a "Document sync" button on every trip's Files
tab, and the seed ships every provider switched off, so after the
upgrade each of those buttons led into "No document providers are
available". The button now shows on a trip that is already bound, to
every member, and otherwise only to somebody who may bind the trip on
an instance that offers a provider. Without it the Files toolbar is
the one from before the feature, trash flush right, on both shells.

/api/addons carries no document providers for a normal user, so the
answer comes from the docsync routes through a new online-only
docSyncRepo and is kept per session in docSyncOfferStore. A revisit
renders the last answer at once and checks it again; offline nothing
is asked and the last answer stands. Creating a binding, removing one
and removing a connection now ping the trip with docsync:changed and
zero counts, so a member already on the Files tab sees the button come
and go without leaving it.

Both shells keep listing a binding whose provider the admin has since
switched off instead of claiming there are no providers, and name its
store through one storeName() helper. A load that answers 403 or 404
clears the bindings as well, so they do not linger once the Documents
addon is gone.

The phone admin screen gets the Documents shelf the desktop one has:
the document providers as sub rows under the addon, toggled through the
same admin route and re-read after Documents itself is toggled, because
the server switches the providers off with it.

isTripOwner and the isOwner prop become canManageSync and canManage,
which is what canManageDocSync answers. Both folder pickers take
loadScopes out of the sync object before their effect, so
exhaustive-deps is satisfied without the effect running on every
render.
2026-09-19 13:36:33 +02:00
Maurice 0e2d255d81 test(roadtrip): find the phone's navigate link by its state, not its label
The Up next card stopped printing a fixed key when the audit replaced the
nonexistent places.navigate with the single navigation target's own name, so
the assertion went looking for text that no longer exists and took the 4.3.0
branch red. The link is found by aria-disabled now, which is what the test is
actually about.
2026-09-17 22:03:52 +02:00
Maurice 7806560c8f fix(roadtrip): four more from the audit, each one traced to what the user would see
**"Today" was read off UTC while the clock beside it is local.** `localDate.ts` exists for
exactly this and says so in its own comment: between local midnight and the UTC rollover the
two disagree, so east of Greenwich the up-next card hung on yesterday's stage for the first
hours of every night, which is when somebody on a long stage is actually driving. Both the
stage hook and the corridor hook now ask the same helper the rest of the app asks.

**A refused day end left the switch showing a state the trip never reached.** The writer
catches its own failures and shows a toast, so it never throws and the sheet's catch could
not fire, which left the optimistic value standing. It answers whether the day end moved
now, and the switch comes back when it did not. The desktop control ignores that answer, as
it reads its state back from the trip.

**A timed-out corridor search reported more unsearched boxes than it had.** Every worker
still alive ran the deadline branch and each added the whole remainder, so three workers
reported three times the boxes left, which can exceed the number of boxes there ever were.
The remainder is claimed before it is counted, which also ends the other workers' loops.

**A night that fell mid-leg had its morning drive dropped from the stage map.** The join
mark went on the first leg of every day opening on an automatic night, but only a night
standing ON a stop is a connection between two days. `dayWindow` records that difference as
a whole `position` against a fractional one, and where it is fractional the morning drive is
the last stretch to this day's own first stop. It was being filtered off the stage while its
distance stayed in the day's total.
2026-09-17 16:49:03 +02:00
Maurice c8f9edad87 fix(i18n): the corridor's truncation note takes a singular, and three plurals were wrong anyway
`mobileTrip.rtTruncated` interpolated a count into a sentence naming the stretch once, so at
one truncated area it read "1 stretches". Two keys now, picked by the count, the shape this
codebase already uses where a number changes its noun.

Writing the singular turned up three plurals that were wrong at counts the old one-form key
could never reach. Polish and Czech both had the genitive plural with a singular verb, which
is right at five and wrong at two; Arabic had the 3 to 10 form, wrong at two and again from
eleven. All three now put the count after a colon, where no form has to agree with it.
Russian and Ukrainian were already correct at both, since their phrase leads with a
locative, and were left alone.

The eight languages that keep the singular noun after a numeral differ only in the numeral,
which is what they should do.
2026-09-17 16:40:05 +02:00
Maurice 92ccd24eed fix(roadtrip, journey): six the audit turned up, each one verified before it was touched
**A saved stay left an invisible sheet standing, and the day swipe stayed dead behind it.**
The stay sheet reopened 'rtstop' by spreading its own payload, which carries a place id.
That sheet is located by day and assignment, so it resolved to nothing and drew nothing,
while `shell.sheet` still pointed at it and `menuOpen: !!shell.sheet` kept the day swipe
blocked until something else closed it. The row rides along now, both ways, and with no row
to go back to the sheet closes rather than opening one that resolves to nothing.

**"End the day here" showed off for a day that already ends there, and switching it on
deleted that day end.** Two things close a day: the stop's own flag and a manual boundary
filed against it. The writer has always known about both; the phone switch read only the
flag, so the tap ran through the writer's boundary branch and cleared the boundary. The
question is asked once now, in the planner beside that writer, and the desktop reads the
same answer it always computed inline.

**The road trip chain printed 24-hour clocks at a reader on a twelve-hour one.** The model
hands clocks over as HH:MM, which is what the schedule computes in, and the chain, the head
card and the up-next card printed it straight. The desktop rail, the day timeline and this
tab's own sheets all honour the setting; these go through the same formatter, carried in the
row chrome so no two rows can disagree.

**The up-next card's navigate button read "places.navigate".** No locale has that key. It is
`inspector.navigation`, which is what the stop sheet beside it, the place inspector and the
place sheet all use, with the single-target label when there is only one.

**Offered roads were invisible on a satellite basemap.** The imagery is anchored under the
first layer TREK draws, found by prefix, and the roads offered for a leg are added before
the route source so they sit under the current route. That also puts them before the first
`trip-` layer, so the raster went straight over them. The prefix list knows them now.

**A journal grew a day section for every day Dawarich recorded, all year.** The hook reads
every open stay the account has, and the timeline took all of their dates. A week in May
therefore sprouted sections from September. Bounded by the journal's own span now, its
entries together with the dates of the trips it links, and with no span at all it offers
nothing rather than everything.
2026-09-17 16:38:17 +02:00
Maurice ce5cc49b58 Merge pull request #2384 from liketrek/feat/roadtrip-mobile
feat(roadtrip): bring the road trip addon to the phone
2026-09-17 15:33:47 +02:00
Maurice d07aa80093 fix(roadtrip): the stage map shows its own night pause, and only that one
The moon pill marking where a travel day ends is back on the phone, filtered to the day
on screen. The markers are built from the whole drive, which is right for the desktop
rail and for the all-days view, but a stage draws one day: unfiltered, day 1 carried day
2's pill as well, sitting in the middle of a map that is otherwise entirely day 1. Two
ends on a map that draws one is a question rather than a label.

Off a stage the list is left alone, because in the all-days view every night does belong
to a line that is drawn. The plan tab is unaffected either way: its vias come from the
route calculation, which has no night pauses in it.

This is what the map lost when the pills were dropped altogether: on a day whose drive
ends somewhere other than its last stop, the pill is the only thing that says where.
2026-09-17 13:31:43 +02:00
Maurice 0b1ca1c98a feat(roadtrip): switch a stop between a pause and a destination from the phone chain
The desktop rail turns a place on the drive into a service stop by its own disc: click
the 3, pick the pump, and the 3 becomes an orange disc while everything below it
renumbers. The phone chain drew the same disc but it did nothing, so the only way to
mark a petrol stop was to open the trip on a computer.

The disc is now the control here too, for the same reason it is there: it is exactly
what changes, so it is its own preview. It opens 'rtkind', a sheet rather than the
desktop's popover under the disc, because a palette aimed at an anchor would land under
the thumb that opened it. The kinds stay discs rather than seven rows of words, each
named for a screen reader. A second tap on the kind a stop already is takes it back to
a destination, and once there is something to undo the sheet says so in words too.

It writes through planner.setRoadtripStopKind, the very call the rail makes, so the
chain redraws off the store with no second code path. Without place_edit there is no
handler and the disc is a plain disc, not a disabled-looking control.

A leg pill with no figures in it is fixed on the way past: a segment that reaches the
chain before its routing round has landed carries metres and seconds but empty texts,
and reading only the texts printed the template with both slots empty, so the pill read
" in " and said nothing. The numbers are formatted when the texts are missing, which is
what the rail does with the same segment, and with neither the leg says it is pending.
2026-09-17 13:10:28 +02:00
Maurice a250fa6339 fix(roadtrip): the phone map loses the stage bar over the dock
It named the day's destination with its picture, its stop count, its arrival and the
stage distance, and it stood there for the whole of the road trip tab. The day and
the distance are in the shell's stage header already, and on a map that fills the
screen a permanent card over the bottom edge costs more than the three facts it
added. Tapping it opened the destination's stop sheet; the chain opens the same sheet
from the row that names it.

The slot stays for the one bar that earns it: while other ways of driving a leg are
on offer, the picker's bar stands there. That wrapper is now mounted with the bar
rather than lying empty over the map's own buttons the rest of the time, and the map
floor lifts only while it is up, so on the stage the round controls stand on the dock
like everywhere else.
2026-09-17 12:58:09 +02:00
Maurice 222313d198 fix(roadtrip): closing offers or other roads no longer flies the phone map back to an old stop
A final pass over the feedback commits found two gaps in the phone map.

The planner keeps a point it was asked to show until its next routing
round, and once the fuel offers or the other roads for a leg close it hands
that very array back. MMapArea read it as a new focus, so closing either one
flew the camera to the stop "Show on map" had pointed at, on another day's
stage too. holdFocus now remembers the arrays it already held and only
takes a new one as a focus, with the open picker's own roads as the one
exception.

The all days view still took its pins from the planner's planned list,
which on a phone leaves out booked nights and hidden service stops, so the
drive ended at a hotel with no pin to open it from. It now reads the places
of the stored visits, like the stage does.
2026-09-17 00:36:57 +00:00
Maurice 6a6c7f1882 feat(roadtrip): other ways to drive a leg, on the phone
The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
2026-09-17 00:10:51 +00:00
Maurice 9b415c8ab9 feat(maps): the phone trip map keeps its credit in the bottom right corner
The credit button sat in the middle of the round controls band, beside the
locate button. On the trip map it now has the bottom right corner to
itself, a gap above the dock (or above the stage bar on the road trip
tab), and the band floats one credit row higher, so the switcher, the
compass and the locate button stay on one line.

MMapArea marks its layer as a credit corner and defines the floor the map
ends at; mobile.css places both GL engines' compact control there, keeps
an opened GL credit's button in the corner, moves the Mapbox wordmark into
the same row instead of under the compass, and unfolds the Leaflet credit
to the left of its button. MapAttributionToggle reads the same two
variables with today's values as the fallback, so the other phone maps and
the desktop keep their placement.
2026-09-16 23:21:40 +00:00
Maurice bf0ec85372 feat(roadtrip): badges and a place picture on the phone stage bars, a compact head card
The stage bar over the road trip map joined its figures with middle dots
and led with a bare day colour dot. It now leads with a picture of the
place it names (PlaceAvatar, falling back to the category icon), ringed in
the day colour while day colours are on, and shows the stop count, the
arrival and the distance as badges. The header pill over the tab does the
same with the day and the distance, and leaves out a distance nothing has
measured yet instead of printing 0 m. Both bars carry an accessible name
built from their parts, because pills side by side lose the spaces between
them when a screen reader reads them out.

The badges are a new non interactive MBadge, a span, since every place it
sits in is itself a button. The value rules shared by both bars live in
stageBadges.ts.

The head card at the top of the chain is more compact: the drive and the
stop count ride on the date line as badges, the two clocks are a size
smaller and the separate stats band is gone. The day colour dot is gone
from it: on the list half the line it keys lies hidden under the chain, so
it read as an unexplained status light.
2026-09-16 23:21:40 +00:00
Maurice 9de18634fe fix(roadtrip): a place tapped on the phone stage map opens its stop
The bar over the road trip map names a place, but a tap on it switched to
the whole day's chain and the place never opened. It now opens that stop's
sheet, the one its row in the chain opens. Its name, clock and tap come off
one row (stageEnd), so the bar no longer pairs the destination with the
automatic day end's clock (Kyoto Station 22:00 while the head card said
12:40). The header's list switch stays the way back to the chain.

A pin on the stage went through the plan tab's marker click, which moves
the place selection and brought up the plan tab's place card. On the road
trip tab a pin now opens its stop on the stage card, or the first routed
visit in the all days view, and only a place no routed day stops at still
gets the place card. The handler keeps one identity for the life of the
map. "Show on map" in the stop sheet and the up next card move the camera
through focusRoadtripPoint instead of the selection, picking the stop's
card first when it is not on screen. A pending focus now lets go once the
day moves, so later stages frame their own drive again.

Stage pins come from the trip store instead of the plan tab's map list.
That list carries the plan tab's day declutter and place filters, so after
the all days switch and back most pins of the next stage were missing, and
the unplanned filter or hidden service stops removed stops the chain draws.
2026-09-16 22:49:44 +00:00
Maurice 5b60dda150 feat(roadtrip): edit a stop's place from its phone sheet, title centred on the disc
The stop sheet had no way into the place, so a fixed time could not be set
from the road. A pencil in the header now hands over to the place editor
the plan tab uses, with this visit in context, so Start (the time the chain
anchors on) can be set or cleared there. It shows with place_edit, once the
place row resolves from the whole trip store and the visit carries a server
id. No second writer for the time is added.

Opening the editor on a booked night or a service stop the day lists hide
used to miss the visit, prefill Start from the pool place and write that
over the pin on a plain save, dropping the note too. MPlaceEditSheet now
resolves the visit from the stored assignments.

The header row is centred, so a one line name no longer hangs from the top
of the 34px disc.
2026-09-16 22:23:01 +00:00
Maurice bf03d91345 feat(roadtrip): the phone dry band gets the desk's mascot and reserve lamp
"Tank runs out here" on the phone was a pink card with a black pill. It now
has the desktop band's design sized for a thumb: the mascot on its
skateboard and not enjoying it, a small uppercase warning, and the reserve
lamp as a 44px button. While a search runs or its offers show, the lamp
steps aside for a close (nothing in the band could clear the offer pins
before), and an empty answer leaves a retry.

Which control shows, which offers and which empty sentence is decided in
refuelBandState in refuelSuggestion.ts, next to the fuel and battery words.
The desktop RefuelBand reads the same helper now, with no visual change, so
the two shells hold markup only.
2026-09-16 22:23:01 +00:00
Maurice 79450a5380 fix(roadtrip): head the phone stage with its start, not the first stop's departure
The head card read LEAVE 11:30 over a chain whose first stop is pinned at
10:00 with an hour and a half there. The figure was the departure from the
first stop, a clock no row prints, while ARRIVE was the last stop's arrival.
So the number was the wrong quantity and "Leave" the wrong word for it.

Both figures now come off the arrival column the rows print: START is the
first timed arrival (or the resume point on a morning after an automatic
night), ARRIVE is the last one. The rule lives in stageClocks in the shared
row model and reaches the tab as rt.clocks. The label is a new key,
mobileTrip.rtStart, because roadtrip.stay.leave keeps meaning a stop's own
departure in the stop and stay sheets. Both clocks carry dir=ltr like the
rows.
2026-09-16 22:23:01 +00:00
Maurice bd347f553b fix(maps): the satellite switch answers on the phone, and the compass stops hiding under it
Two faults behind one round button on the phone map.

The base layer switcher that both engines draw in the bottom left corner
sat on top of the GL compass, which started 8px to its left and ran on
under the frosted shell, so it read as a second button showing through.
The compass now stands beside the switcher, placed off the switcher's own
exported inset and size so the two cannot drift apart again.

Satellite never drew on GL because applySatellite gave up whenever
isStyleLoaded() was false and only retried on styledata. That flag is false
while any source has tiles or a setData in flight, which on a phone is the
usual state, and styledata does not fire when those finish. On the road
trip stage every tap met a busy style, because the settings write
re-rendered the shell and handed the map fresh stage arrays. The guard is
gone (the pass only runs after load, and addSource/addLayer only refuse a
style document that is not in yet), an existing layer just flips its
visibility, and a source left without its layer still gets the layer.

The stage data in MMapArea is memoised, so a store write no longer refits
the camera or sets the route, access and place sources again.
2026-09-16 22:22:46 +00:00
Maurice aaff8f9db4 feat(roadtrip): bring the road trip addon to the phone
A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.

The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.

The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.

The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.

Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
2026-09-16 21:55:18 +02:00
Konstantinos Thermos 37163e9a41 fix(places): render markdown in the mobile place description
The mobile shell printed place descriptions and notes as plain text while
the desktop rows and inspector run the same field through react-markdown,
so a description written with the formatting bar read as `# headings` and
`**asterisks**` on the phone.

Put the renderer in one shared component instead of copying it next to
each mobile twin, with a clamped variant for list rows that keeps them one
line high the way the desktop sidebar does. A clamped caption sits inside
the tappable row, so its links keep their text and lose the anchor.

Fixes #2337
2026-09-16 15:42:07 +03:00
Maurice a066c94ec5 fix(costs): tell the phone when the settlement did not load
The mobile Costs tab swallowed a failed settlement request, and the final
budget card then filled every traveler's row from the empty fallback: "0.00"
for everyone, which reads as "the trip cost nobody anything". The desktop
panel already keeps a settlementError flag and prints its notice instead of a
number; the mobile tab now does the same in the final budget card.
2026-09-12 17:33:15 +02:00
Maurice 9307025ed0 fix(costs): derive the final budget breakdown on the server, in the same cents
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.

The settlement response now carries the rows with the figure: `sources` on each
finalBudgets entry lists what the person fronted per expense, the recorded
transfers on their side and the open flows, in display cents. They are filled
by the same two loops that fill the totals, so an expense with no split members
stays out of them as it stays out of the ledger, and each list is spread over its
figure's already allocated cents with the same largest-remainder split, so the
sum is exact in every display currency. The shells print those cents and only
resolve names; the rows carry the sign of the line they sit under.
2026-09-12 17:33:15 +02:00
RemEx e0c9a8d1b6 feat(costs): show what the trip finally costs each participant
The settle-up view says who owes whom, but nothing on the Costs screen
answers "what did the trip cost me": the balances move every time a
payment is recorded, and the tiles only describe the signed-in user.

The settlement response now carries finalBudgets, one row per participant
in the ledger: expenses (what they fronted on split expenses), reimbursed
(recorded transfers, received minus sent), pending (their current
balance) and final = expenses - reimbursed - pending.

- Computed in calculateSettlement from the same integer trip cents as the
  balances, filled by the same loops, so the breakdown can never
  contradict the balance printed next to it. Each component is converted
  to the display currency as its own set and the final is subtracted in
  display cents, so the three lines add up to the total in any currency.
- A "Final budget" card on desktop, the narrow desktop layout and the
  mobile Costs tab: one amount per traveler, and a tap opens the three
  lines plus the expenses, transfers and open flows behind them. The
  filtering lives in CostsPanel.helpers so both shells share it.
- Contract in shared (budgetParticipantFinalSchema); the MCP
  get_settlement_summary description mentions the new field it returns.
- Four new costs.* keys, translated in every locale.
2026-09-12 17:33:15 +02:00
Maurice aa848cc6b1 fix(budget): keep a payment's currency when it is edited on a phone
The mobile payment sheet always sent the display currency, while it seeded
the amount raw in the currency the payment was recorded in. Correcting only
the day of a 1000 JPY payment turned it into 1000 EUR and froze a fresh FX
rate for it. The sheet now carries its own currency, seeded from the
payment (or the display currency for a new one) with the same picker the
expense sheet has, and the payment row shows the recorded amount next to
the converted one when they differ, like the desktop row does.

The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
2026-09-12 17:33:04 +02:00
RemEx 3dd6674d3f feat(budget): let a recorded payment be edited from mobile Costs too
Editing/undoing a settle-up payment was desktop-only — the mobile ledger
row rendered it read-only, with a comment explaining the gap was
deliberate for now. Give it the same edit/undo affordance the expense row
already has: edit reopens AddPaymentSheet pre-filled (now doubling as an
edit form, same as desktop's SettlementModal), undo deletes it outright
with no confirmation, mirroring CostsPanel.tsx's undoSettlement.
2026-09-12 17:33:04 +02:00
RemEx bd46089450 feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
2026-09-12 17:33:03 +02:00
Maurice 29fc586930 test(maps): pin the coordinates a suggestion carries
The half of the change that saves the pick from a text search for its own
label was covered by nothing. MAPS-AUTO-010 reads `source` and stops there;
the client test writes the coordinates into its own mock and then asserts them.
Delete `lat: p.lat, lng: p.lng` from the service and both stay green, while the
fallback quietly goes back to searching for "Tokio Hauptbahnhof, 東京駅丸の内駅舎"
and taking whatever comes back first.

So the server test now asserts the coordinates on the wire for both indexes,
and the mobile sheet — which carries a copy of the same fallback and had no
test for it at all — gets one for the fallback and one for the source mark.
2026-09-09 18:55:31 +02:00
Maurice 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
Maurice 15c1a5ae06 test(mobile): seed the store the location hint is actually read from
useLocationBias reads useTripStore; the sheet's harness only ever built a
planner prop. So the hook saw an empty store, sent no hint, and the one case
that asserts a box went red while its three siblings passed vacuously - they
expect undefined, which an empty store produces no matter what they seed.

capturedBias now seeds places, assignments and selectedDayId the way the
running app has them, which puts all four cases back to work. The 500km case
is retargeted from Tokyo/Sapporo to Tokyo/Nagoya: 264km across, biased under
the old diagonal rule and dropped under the 60km radius one, so it guards the
rule that actually shipped. A new 029b pins the day-first precedence - a trip
too wide to bias still hints at the day being planned.
2026-09-07 14:05:33 +02:00
Maurice 8a48131618 test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 15:03:08 +02:00
Maurice 04b31ec2e5 fix(mobile): give the trip map a way back to every day
The phone map filters down to one day and had no way back out. The chip
rail can only swap one day for another, the second tap on the active chip
is the day sheet, and the seed guard deliberately does not re-seed — so a
day picked on the map stayed picked until you left the trip and came back
(#2257). Desktop has had the deselect since the day header got it.

Its own round control beside the rail, map view only: the plan timeline is
single-day, so there is nothing to widen there, and the day sheet keeps the
one route into it that also exists on the map. It toggles — the first press
parks the day it came from and frames the whole trip, the second puts that
day back with its route, or falls back to the focus day if a remote delete
retired it in between. Leaving the map restores it too, since an empty
timeline would be the worse answer.
2026-09-06 13:35:30 +02:00
Maurice 11a0780a5b fix(planner): give narrow screens one panel at a time, and unclip the public language picker
Three reports from the same week, all "the layout is in my way".

#2247 — the Plan tab shows both side panels from 768px up, unconditionally.
A Pixel 10 Pro Fold unfolded is ~860 CSS px, so 340 + 300 of panel left the
map a 200px strip; those same widths became fit padding, shrinking the
framing window to 140px and opening the camera two zoom levels out over half
of East Asia. The floating POI/compass cluster is centred on the viewport at
z-25, which at that width put it on top of both collapse tabs (z-1 inside
z-20 containers) and on the Add Place/Activity button — and its pills set
pointer-events: auto, so it ate the taps meant for them. The resize handles
are mousedown-only, so a touch user could not narrow anything either.

Between 768 and 1023px only one panel is open now, the day plan first, the
places list one tap away — the Google Maps shape the reporter asked for. The
collapse flags stay the wide-layout state, so a fold back restores what was
there, and a marker tap reopening "both" no longer re-crowds the screen. The
control cluster centres on the corridor the panels leave (the formula
PlaceInspector and DayDetailPanel already use), the tabs finally have
accessible names, the dead resize handles go, and a panel dragged to 520px on
a desktop is clamped so the map keeps 360px without touching the stored
width. computeMapViewport refuses to frame for less than a third of the box,
so absurd padding can never pick the zoom again.

#2248 — the public journey hero clips its own overflow to keep its
decorative circles from widening the document, which also amputated the
language menu at the hero's bottom edge: 8 of 23 locales, the rest
unreachable. Both share pages inlined the same menu, so it is one component
now, portalled to the body and anchored to its trigger the way CustomSelect
is, with a height cap and its own scroll. It closes on Escape and on an
outside click, which it never did, and `zh-TW` stops labelling itself 简体中文.

#2249 — the note row's pencil/trash pill is absolutely positioned over the
row's own reorder chevrons, and a coarse-pointer `opacity: 1 !important`
pinned it permanently visible on tablets. On a mouse it was no better: the
chevrons are revealed on hover, which is exactly when the opaque pill covers
them, so note reordering has never been clickable on any pointer. The pill is
gone; the row opens the note the way the phone shell already does, and delete
lives in that dialog behind the same confirmation. A link in the note body
keeps its own tap, on both shells.

Closes #2247
Closes #2248
Closes #2249
2026-09-05 13:22:56 +02:00
Maurice bafcd456c5 fix(collab): notes show and keep their website on the phone
The note website round-trips correctly through the API and the desktop
grid renders it, but the mobile notes tab never carried the field: the
form had no input, the payload had no key, and neither the card nor the
read-only sheet showed a stored link. Since an editor only ever opens the
form and a viewer only ever opens the sheet, no role on a phone could see
or reach a website saved from a desktop.

Editing a note on mobile did not wipe an existing link. The key was
omitted rather than nulled, and the server's CASE WHEN guard kept the
stored value, so this was a visibility gap rather than a destructive one.

The form now always sends the field, null included, because the server
only clears on an explicit non-undefined value. Every render path goes
through safeExternalHref: the REST contract accepts any string, so an
unguarded href would execute a stored javascript: value in this origin.

The desktop expanded view had the same omission and is fixed alongside it,
so a member without collab_edit no longer loses the link there either.

Closes #2222
2026-09-04 12:15:38 +02:00
Maurice 07e4a558e6 fix(bookings): the phone keeps a booking's stop and shows the files it already has (#2216, #2217)
Editing a booking on the phone erased the stop it was linked to. The sheet
seeded its assignment from the create-for-a-stop flag, which is null on an
edit, and saved that straight back over a link the user had made on the
desktop; the server writes the field whenever the key is present. It now
seeds from the booking being edited, and the picker the desktop dialog has
always had sits in the sheet too, so a stop can be seen, changed and cleared
from a phone. The option list moved to its own module rather than being
copied.

Two more of the same shape, found on the way. The endpoint set was sent as an
empty list on every save, and the server swaps the whole set when that key is
present, so editing a transit booking dropped its stations; neither form
edits endpoints, so an edit no longer sends the key at all. And the sheet's
Files block listed only what was picked in this session, so a file uploaded
from the desktop was invisible on the phone. It lists what is attached now,
resolved the way the desktop resolves it.

A place also shows the files of the bookings that sit on it, on the phone and
on the desktop: a confirmation lives on the booking, and the place card was
the one screen that could not reach it.
2026-09-03 15:51:13 +02:00
Maurice a2cb6a12d1 fix(mobile): a stay chip in the timeline header opens the stay, not the day (#2210)
The hotel chips next to the day pill shared its target, so tapping a stay
opened the day sheet. A chip now carries its stay and opens it the way the
stay card in the day sheet does: the accommodation editor for members who
may edit days, otherwise the hotel's place, and the day sheet only when
neither applies. Closing the editor returns to the timeline it was opened
from instead of surfacing the day sheet, and the chip's accessible name
says whether it is a check-in, a check-out or an ongoing stay.
2026-09-03 09:15:27 +02:00
Maurice b1ce7c303d feat(todo): sort the task list by due date (#2205)
The list held its manual order, so an item due tomorrow sat behind one created earlier but due later. The sort-by section gains a due-date toggle next to the priority one, on desktop and in the phone tab: nearest deadline first, undated tasks after all dated ones, ties keep the manual order. One sort at a time, and manual drag-reorder pauses while either is active, exactly like the priority sort.
2026-09-02 21:39:01 +02:00
Maurice faa27e3608 fix(planner): a place shows every booking linked to it, not just the first (#2201)
Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
2026-09-02 21:06:42 +02:00
Maurice 03bd5c77af fix(weather): day-local named anchor, cache ttl and past-days forecast window (#2167) 2026-09-01 17:18:10 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
Maurice 37d7365707 feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177) 2026-09-01 17:18:10 +02:00
RemEx 595a76321f fix(costs): show recorded settlement payments in the mobile Costs tab
Closes #2151

The desktop Costs view mixes recorded settle-up payments into the day-grouped
expense ledger, but the mobile screen only ever fetched balances/flows and
silently dropped the settlements array the server already returns — a
recorded payment cleared the balance but then left no trace anywhere on
mobile.
2026-09-01 14:39:37 +02:00
Maurice bbe4d62c90 @
fix(export): print the reader clock format, and split window bookings in the feed

Three v4.0.0 reports that all come down to a surface printing what is stored
instead of what the reader asked for.

The PDF (#2066) is assembled as an HTML string outside React, so no hook could
reach it and no time format was ever passed in. It printed the column: four
clock sites, all 24h whatever the personal setting or the admin default said,
and a place time typed by a 12h user came out as "3:00 PM" for a 24h reader.
Both export entry points now hand the format over the way they already hand
over the locale, and the four sites go through formatClockTime.

The journey editors (#2067) used a native <input type="time">, which paints
12h or 24h from the browser locale and cannot be told otherwise by any
attribute. Every other time field in TREK has used CustomTimePicker for exactly
that reason; the journey editor, its phone sheet and the phone place sheet were
never migrated, so the same place form obeyed the setting on desktop and
ignored it on a phone. The picker gained a disabled prop so the read-only
journey sheet keeps the affordance the native input gave it.

In the subscribed calendar (#2068), a booking that is a window rather than an
activity read as one block across its whole range: a week of airport parking
sat on all seven days. Both hand-overs now stand on their own — an hour each,
carrying what the block used to say — and the block goes. Only when the two
ends fall on different days: a same-day booking is one sitting, and a rental
with only its pickup geocoded keeps the block, which is the only thing holding
the return time and its zone at all. Rental cars were missing outright, because
the planner writes reservation_time = NULL when its optional time pickers are
left blank while still setting day_id, and nothing in the exporter ever read
the linked days. It reads them now, as the last resort, for every type — so a
hand-typed bus or train shows up as well.
@
2026-08-27 18:19:33 +02:00
Maurice 800f97b8ce feat(mobile): swipe the day details to step through days (#2059)
* @
feat(mobile): swipe the day details to step through days

The phone shell hangs the days off a chip rail pinned to the top of the
screen, which on a large phone is a thin target a thumb cannot reach one
handed. The day details fill the rest of the screen and did nothing.

They now take a horizontal swipe: left for the next day, right for the
previous one, with the panel following the finger, rubber banding at the
first and last day and springing back when the swipe falls short. The
chip rail is untouched and still the accessible path; a committed swipe
announces the day it reached through a live region, and the active chip
scrolls itself back into view once the rail overflows.

The gesture never calls preventDefault or stops propagation on a touch
event and sets no touch-action, so the browser keeps sole ownership of
the card scroll and of the header chip strip, and the long press reorder
bridge keeps its own 10px self cancel. In edit mode the swipe also
refuses to claim a press held past the bridge window.
@

* @
fix(mobile): line the map controls up and widen the POI bar

The compass sat on its own under the POI bar, centre top, while the
locate button floated 42px above the dock on the other end of the
screen. Both are one handed controls, so they now share the band just
above the dock: compass bottom left, locate bottom right, off the same
--bottom-nav-h the map already reads, a dock gap lower than before.

That frees the row under the chip rail, so the nearby places bar takes
the full width between the screen margins and its segments end up the
size of everything else the thumb aims at there. Desktop keeps the
floating, content width bar.
@
2026-08-27 12:01:11 +02:00
Maurice 495c5e95ec fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)
The UP NEXT card kept pointing at this morning's first stop hours after it had
started, and at day one of a trip that ended last month. A header promising what
comes next, sitting above something that is over. It now stays away on a day that
has passed and once today's timed plan has run out — a day whose stops carry no
times at all still shows its first stop, there is nothing stale about that. The
timeline card already collapses the reserved space when there is no up-next, so
the layout closes cleanly.

Seeding the plan day also lands on the next day still ahead when today falls into
a gap in the dates, instead of dropping back to day one. A trip that is already
over keeps opening on its first day.

And a day that was selected before the shell mounted now counts as seeded, so
clearing the selection is left alone instead of being re-seeded on the next
render — which is what the guard was there for in the first place.

Based on #2055.
2026-08-27 11:02:09 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.

`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.

**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.

Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00