Commit Graph

1070 Commits

Author SHA1 Message Date
Maurice acd6f1acdf fix(atlas): date a country's first and last trip from the trips that count
The Atlas tooltip took both dates from the start dates of every trip with a
place in the country. One trip showed the month it began twice, the month it
ended never appeared, and a trip only booked could become the last visit of a
country already visited.

Both now come from the trips that give the country its status, places and
bookings alike: the start of the earliest and the end of the latest, with a
trip still under way ending today. The optimistic mark on the client clears the
dates the same way the server does.

The second label rendered the raw key atlas.lastVisitLabel since #1908 removed
it from the locales; the strings are back as they were. The month is built
from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and
showed the month before anywhere west of Greenwich.

Closes #1535
2026-09-15 21:36:34 +02:00
Maurice c1d55c463c fix(dawarich): three things a tester on dev1 ran into
The Atlas country check timed out. It asked Dawarich for a year of visited
cities in one request, and Dawarich computes that over every point in the
window with nothing cached, so a dense recorder never got under the 15 s the
server allows. The year is now asked for a month at a time, one request after
another, and folded back together. The windows sit a second apart because
Dawarich reads the bounds as inclusive.

The recorded route only appeared after a reload. Every Dawarich call from the
browser carried the shared 8 s timeout, so the browser gave up while the server
carried on and cached the answer for the next page load. Calls that make the
server talk to Dawarich now wait two minutes, the Atlas four. On the GL
renderers the draw also gave up whenever isStyleLoaded() was false, which it is
while any tile is still loading, and only retried on style.load. It now waits
for idle. The hazard overlay had the same guard and gets the same wait. The
pill shows a ring while the route loads, so a slow first fetch no longer looks
like "done, nothing there".

A collapsed day now takes its recorded route off the map, following the same
folds the planner already applies to places, the road trip sidebar's included.
2026-09-14 23:00:57 +02:00
Maurice 00d00be1c0 feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked
for it while still deciding. The availability and the published tariffs were
reachable only for a saved place: the route takes a places id, and a hit
found along the route has no row yet.

ChargingService.load already needed nothing but a coordinate and a name, so
it now has a public method that says so, behind a trip-scoped route with the
same guard stack, a Zod contract in shared, and the parallel MCP tool in this
same change. Same service, same caches, so a station looked at in the dialog
and then added keys the entry already fetched. Trip-scoped even though the
trip is not part of the question: without :tripId the trip access guard goes
too, and what is left is an open proxy in front of somebody else's public API.

It renders in the add dialog, and only while the selected kind is charging,
so a fuel or food stop costs nothing and one opened dialog is one lookup.
Deliberately not per result row: the corridor search publishes box by box,
every mounted panel owns a refresh timer, a cache miss can cost seven
upstream requests, and a station in a dense charging park is the case the
upstream matcher refuses as ambiguous.

Power and socket counts are not in this answer and were not asked of it.
They are OSM tag data and already ride along on the corridor row for free.
2026-09-14 12:28:47 +02:00
Maurice f4380727c7 fix(roadtrip): the range warning and the per-leg figures in the server plan
deriveDriveWarnings takes a two-slot "does this stop refuel" pair, slot 0 for
the stop a leg leaves and slot 1 for the stop it reaches, and the assembler
hard-coded slot 1 to false. The suppression the model was written and tested
for could therefore never fire through it: a day that reaches a charger 700 km
in reported running dry at the charger, and a run of warnings that should
collapse to one stayed at full length. Pinned by a spec of its own now, since
the wrapper had none.

The server's plan built each leg by spreading the whole run's segment and then
overriding only its metres and seconds, so every leg of a multi-stop run
carried the run's distance text, its duration text and its polyline midpoint:
leg one of a 480 km day reported 100 km with "480 km" printed beside it. The
four texts and the midpoint are per leg now, in the reader's own unit rather
than the router's hard-coded kilometres, which also ends a response that mixed
miles in its day labels with kilometres in its legs.
2026-09-13 23:49:01 +02:00
Maurice c8cb88133f fix(maps): the projection, the labels and two unbounded reads
A map keeps the datum it was built with, and Leaflet cannot change one
afterwards. The basemap could still turn into Amap's after the fact: a first
settings request that fails leaves the store on defaults for the session and is
healed later, and the raster layer then drew GCJ-02 tiles under a map still
projecting WGS-84, putting every marker, route and cluster a few hundred metres
off the street beneath it until the planner was left and reopened. The datum is
part of the element's identity now, so a flip rebuilds the map. Only the datum:
keying on the whole tile URL would throw the view away on every template edit.

Index POIs were labelled by an exact lookup of a term the index matches as a
substring, so any leaf that is not literally a term missed and took the first
category of the request instead. In a corridor search that is whichever pill
was tapped first, and the panel groups and colours on that field: a trattoria
came back as a petrol station, or a petrol station as tonight's hotel. The
label now comes from the term that actually matched, longest first.

Two outbound reads were missing what the house rule asks for. The brand-logo
fetch had no deadline at all next to a Wikidata hop that allows six seconds, so
a stalled CDN held a request context and a socket for undici's five-minute
default, and its size cap only applied after the bytes were in memory. The
Wikimedia photo proxy beside it had the same shape. And the Amap provider
buffered every answer uncapped and trusted a cast for its arrays, so a `pois`
object rather than a list threw a TypeError that surfaced as a 500 instead of
the OpenStreetMap answer.
2026-09-13 23:49:00 +02:00
Maurice 9deedb80ea fix: receipts, cached places, chat previews and the Google-route tool
Saving an expense twice while its receipt is also attached to a place and a
booking hit the new unique index on (file, expense): the row kept from the
first save was skipped, the next spare link row was adopted into a duplicate
pair, and the constraint threw inside the transaction. The whole edit rolled
back with a 500, every time, so that expense could never be changed again. The
adopt now skips a link the expense already holds, and a receipt named twice in
one save is linked once.

The offline place cache was keyed on the place id alone while it is owned and
evicted per trip, so two trips over the same city took each other's rows: the
second write claimed them, switching the second trip off deleted them for both,
and the first trip's fingerprint still matched so nothing ever refilled them.
Offline search then answered nothing for a trip whose switch read "on". Each
trip keeps its own copies now, and the upgrade clears the fingerprints so the
next sync refills what the rebuild dropped.

The chat composer minted its object URLs inside a state updater, which React
runs twice in development, so one picked image drew two thumbnails and removing
one left a preview for a picture that would not be sent. File and URL are one
record now and every mutator commits a finished list.

import_google_maps_route wrote up to thirty places and their day assignments
under trips:write, which does not imply the places:write that create_place is
gated on, and it was the one non-admin write tool with no demo gate and the
only tool in the repo with no annotations. All three corrected.
2026-09-13 23:49:00 +02:00
Maurice f0e4c9a33d fix: five small defects the audit turned up in the release
An explicit null on a place's duration_minutes advertised a reset it never
performed: the column sat behind COALESCE, so null and absent were the same
thing and the day plan kept budgeting the old stay length. It clears now, and
0 still writes 0.

update_trip_file and link_trip_file never learned the budget_item_id target the
REST routes gained, so a receipt could not be attached to an expense from MCP.
Added to both, along with the cross-trip check that goes with it: adding the
field without it would have opened the hole the same change closed for REST.

The roadtrip preferences PUT dropped the X-Socket-Id it was given, so the tab
that saved a setting received its own change back and spent a second store
commit and a route recompute on an answer it already had. Its two siblings on
the same feature forward it.

A shared journey did not carry the three "this journey does not use that field"
switches, so the phone card read undefined, decided everything was switched on,
and showed mood and weather chips on a journey whose owner had turned them off.

An entry's country flag followed the pin when the entry was edited and not when
the trip sync moved the place underneath it, so a stop dragged across a border
kept the old flag with nothing to ever correct it.

Also: dawarich.helpers.ts held a raw NUL byte as a hash separator, which made
every search tool treat the file as binary and skip it. Same character, written
as an escape.
2026-09-13 23:49:00 +02:00
Maurice 89bae414ff fix(offline): a missing driving-settings row no longer discards the trip bundle
syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
2026-09-13 23:48:59 +02:00
Maurice c42cf5477b fix(public-api): a key narrowed away from days stops getting days back through places
Places, notes and reservations hang off days, so asking for one of them implies
the day container. That implication ran ahead of the grant: a key minted with
scopes ['trips','places'] and deliberately no 'days' still received every day's
title and the day's own free-text notes, either by naming places or by naming
nothing at all, since an absent include narrows to the granted sections and
lands back in the same branch. Asking for days by name was a 403 the whole
time, so the two routes disagreed about the same fields.

The grant now reaches the service alongside the narrowed include. The container
is still implied, because the places inside it are useless without their date,
but the day's title and notes only go out to a key that was granted days. A key
with full access is unaffected: it is granted days whether or not it asks.
2026-09-13 23:48:59 +02:00
Maurice 54e5525568 fix(accommodations): a booking that moves carries its stop instead of rebuilding it
Correcting a booking's date deleted the day stop it had mirrored and inserted
a fresh one. A day stop is not just a day and a place: its participants and any
road-trip day boundary hang off its id by ON DELETE CASCADE, and its note, its
hour and its end-of-day flag live in its own columns. All of it went, silently,
for an edit that only meant to move the night one day on. The row is carried
across now, seated by its check-in the same way an insert would be, and the
delete-and-insert is kept only for the case where the target day already holds
that place under a stop of the traveller's own.

Three more holes in the same mirror, found alongside it:

Dropping a night while keeping the place cleared accommodation_id server side
and told nobody. Days hides a stop that carries a booking id, so on every other
screen the place stayed invisible on a day it was standing on.

A night is seated by its check-in, which renumbers the stops around it, and
nothing said so. The day that changed now sends its order along.

Deleting a place cancelled the nights booked there, which takes each night's
partner booking and that booking's expense with it, and announced neither. The
Bookings list kept the reservation and the Costs total kept the money until a
reload, and an expense linked by reservation_id is not one the place-linked
sweep finds.

Also: day_assignments.accommodation_id went in as a bare INTEGER, so shortening
a trip past a booking's last night cascaded the booking away and left the stop
pointing at nothing, hidden from Days for good while the route still drove to
it. Migration 234 clears the ones already out there and adds a trigger, which
SQLite fires even when the booking went down with a cascade. And a duplicated
trip now carries the stop's booking id, the booking's check-in window and a
place's fill_percent, none of which the copy took along.
2026-09-13 23:48:59 +02:00
Maurice 803a7cb64a fix(oidc): bind the SSO auth code to the browser that earned it
The callback hands the finished login back as a code in a redirect URL,
and /exchange turned any code into a session. That made the code the
whole credential while it travelled somewhere URLs are readable: browser
history on a shared machine, a referrer, a reverse proxy log. It cuts
both ways. Redeem a code you saw over someone's shoulder and you are
them; or complete a login as yourself, hand the victim's browser your
code, and they are silently signed into your account and write their
next trip into it.

The callback now also sets a one-minute httpOnly cookie holding a random
secret, and /exchange wants the code plus that cookie. Only the browser
that came back from the provider holds both, which is the same shape the
state cookie already gives the callback. The code is burnt on every
attempt, right or wrong, and an unbound code answers exactly like an
unknown one so presenting one confirms nothing.
2026-09-13 22:03:24 +02:00
Maurice 9cb74e22f6 fix(dawarich): a stay's length was sixty times too short
Dawarich's `duration` is minutes. It writes the column as
`duration_minutes = ((ended_at - started_at) / 60).to_i` in
`app/services/visits/create.rb` and serialises it straight through; the merge
service rounds the same way. TREK read it as seconds and divided by sixty
again, so an eighteen-minute stop reached the suggestion panel as "0 min" and
an afternoon somewhere as "2 min".

Found by connecting a real Dawarich 1.14.5 and comparing: a visit spanning
11:00:00 to 11:18:45 reports `duration: 18`.

The span between the two timestamps is what the panel uses now. It agrees with
the arrival and departure shown beside it and it survives whatever unit a
future Dawarich picks; the reported figure only stands in when `ended_at` could
not be parsed, and is read as minutes there too.
2026-09-13 21:38:20 +02:00
Maurice c2751d9c80 fix(journey): declare the three field flags on updateJourney
The allow-list learned them and the signature did not, so the only callers that
name them are the tests and only `typecheck:tests` saw it. `typecheck` covers
src alone, which is why this was green locally and red in CI.
2026-09-13 20:44:24 +02:00
Maurice 15aefa449e feat(journey): the marker card, and what testing it turned up
Everything here came out of putting the first commit in front of somebody and
watching them use it.

**The marker card is one card now, and it carries the entry's photos.** Leaflet
showed a one-line label and the GL map a two-line card, so the same marker said
different things depending on a renderer setting nobody connects to journeys.
Both now build from one module: a strip of up to three thumbnails bleeding to
the card's edges, the name, and the place and date as chips. On the GL map the
thumbnails open the entry's photos; the rest of the card still never takes the
pointer, so the map drags from anywhere on it.

**A pin that would not sit still.** Dragging the map left the marker trailing
behind and snapping back at the end. The app's press-feedback rule puts a 180ms
transition on `[role="button"]`, MapLibre and Mapbox give their marker element
exactly that role, and both rewrite its `transform` on every frame of a
movement. Markers are excluded now — every map in TREK, not just this one.

**The phone card, after four rounds of looking at it.** The day is the card's
own edge rather than a bar across its foot, and the ring is inset because the
carousel clips. A card without a photo is opaque: it was a wash of the day's
colour over the map. The country is a real flag rather than two letters —
Windows has no flag glyphs, so the emoji drew "DE" where a flag was meant, and
the image falls back to the emoji when it cannot be fetched. Mood, weather and
the date share the top edge; the flag sits opposite them.

**The day bar answers to a finger.** Press the date and slide sideways to run
through the days. The bar is still a row of buttons underneath, because that is
the only version of it a keyboard can reach, and each one is 20px tall now
rather than 3.

Also here:

- Videos upload to an entry (issue #2341). The gallery has taken them since
  #823 and an entry could not: everything went through the images-only filter
  and came back 400, reported as "1 of 1 photos failed". Same two-part upload,
  same absence of transcoding, and the editor now previews a clip in a `<video>`
  instead of a broken-image glyph.
- The search box takes the gap the toolbar already had instead of clipping its
  own placeholder.
- Entry cards line up with the day header again: the reorder arrows moved out of
  a column of their own into the feed's padding and appear on hover, and the
  day-coloured ring on the active card is a layer over it — an outline tight
  enough to sit on the card's edge was painting behind it.
- Map attribution is collapsed to its ⓘ. It cannot go away — the OSM licence
  asks for it — but it does not have to lie under the carousel.
- The suggestion dismiss and the day-header plus use TREK's own tooltip rather
  than the browser's.
2026-09-13 19:52:14 +02:00
Maurice c2707b7eff feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or
rest on a misunderstanding, one cannot work as described, and three would take
away more than they give; the rest are here, plus two things the list did not
ask for.

**The entry you write on the move now has a name.** Quick capture hid the title
field outright and offered a note instead, so every entry caught on a phone
arrived nameless and a day's timeline read as a column of identical
placeholders. One line of input; it is the single change in this branch that
users will feel most.

**The map stops yanking.** Stepping through the timeline called flyTo with a
zoom floor of 12, so reading a journey from a country view snapped to street
level on the first scroll and stayed there — every marker filling the screen,
and no sense of where anything was. Both renderers pan now and leave the frame
the reader chose. The initial fitBounds still decides how close a journey opens.

**The phone timeline is photos.** A 64px thumbnail beside three lines of text
spent the card on a story nobody reads at that size and truncated the title
after a few characters. The picture is the card now, with the name and place on
it, the country's flag and the date in the corners, and the day carried as a
colour rather than as a number nobody was reading. Above the carousel, a
segment per day: it says how long the journey is, how far in you are, and takes
you to day nine without swiping past days three to eight.

Everything else, briefly:

- A plus in each day header, starting a draft on that day instead of on today.
- A journey opens on today when today is part of it, and at the beginning when
  it is not — a journey that ended last spring is something you read.
- Suggestions can be waved away one at a time. The row survives so the trip sync
  does not offer the same place again; journey settings brings them all back.
- Mood, weather and the pros/cons list can be switched off per journey. Nothing
  written is erased — the form stops asking, the values stay.
- The weather fills itself in at the desk the way it already did on the phone.
  The date picks the source, so writing up last Tuesday gets last Tuesday's.
- Enter opens the next pro or con, below the one you are in.
- The verdict moved behind the story's fold, and the fold itself came in from
  nine lines to four.
- The external picker lost the count it was printing twice and some padding it
  could not afford inside the editor.
- The phone can reorder entries within a day at last; the desktop arrows finally
  have translated labels.
- Marker labels are off where a carousel already names the entry, and on where
  they are the only name a marker has.
- Search over a journey's titles, stories, places and tags, accent-blind.
- An entry knows which country it happened in, resolved once from its
  coordinates against the bundled boundaries.

Not built, and why: the photos-for-markers idea needs clustering first or thirty
markers become soup, and the active entry's photo is already on screen in the
card below. Removing mood, weather and the verdict outright would take them from
everyone who uses them, so they became switches. `capture` on the file picker
takes the photo library away on a phone, which is why the camera is a second
input; it is hidden on desktop now instead. The suggestion strip in the new-entry
flow would be a third surface over the same data — tapping a suggestion already
opens the editor filled in.

The one gap the list missed and this branch does not close: Journey is the only
domain with no offline path. That belongs in its own change.
2026-09-13 18:45:57 +02:00
Maurice fcee6bd2c5 fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around
sitting behind it. The day's auto-sort reads each stop's own hour, and a booked
night has none: nobody types a time into a hotel row, they type a check-in, and
that lives on the booking. It counts now, so the night moves with everything else
instead of staying where it was dropped.

The assignments e2e builds its own tables and had neither the accommodation
column nor day_accommodations, so every create in that file 500'd as soon as the
insert learned to carry a booking id. Both are there now.

And the shared lint, which is stricter than the other two and which I had not run:
three leftovers from taking the check-out out of the chain, none of them used any
more.
2026-09-13 16:19:57 +02:00
Maurice c351a0471c fix(accommodations): a check-in decides where in the day the night sits
The stop went to the end of the day, always, on the reasoning that a night ends
the day. It usually does — but a check-in at eleven says the traveller is at the
desk before whatever they pinned to the afternoon, and putting the stop behind it
built a drive that reaches the hotel at seventeen past five after a sixteen
hundred it was booked around. The order looked like the traveller's and was not;
it was this code's default.

A booking with a check-in now takes the place its hour asks for: ahead of the
first stop pinned later, and still last when nothing is. Editing the check-in
re-seats it, in both directions, read off the chain rather than recomputed —
the index a fresh insert would get is not the index a row already holds.

Stops without an hour of their own are passed over in either direction. Their
place in the chain came from the traveller, not from a clock, and a night has no
business pushing past one.
2026-09-13 16:06:11 +02:00
Maurice 3f6d1e8ed7 fix(accommodations): re-export the module, not the provider inside it
The container refused to boot: Nest will not export a provider that belongs to an
imported module, and the service moved into the domain module in the commit
before. Re-exporting that module gives every importer the same service it always
got, and the wiring test now asserts the shape that actually boots rather than
the one that only typechecks.

Neither tsc nor the unit tests could see this — the module metadata is only read
when a container is built. The e2e harness does build one, and that is what
catches it.
2026-09-13 15:50:58 +02:00
Maurice 77a16d6f26 fix(places): deleting a place cancels the nights booked at it
A stay whose place was deleted kept its row with place_id NULL: still drawn in the
day header, still naming a hotel through its partner booking, and pointing
nowhere. Nothing could reach it any more and nothing cleaned it up.

It goes with the place now, through the accommodations domain rather than a
DELETE in the places path, because a stay takes its partner booking, that
booking's expense and the day stop it wrote with it — none of which is the places
domain's business to know. Both delete paths, single and bulk.

The service moves into an AccommodationsDomainModule, the assignments-domain
precedent: the service never touches PlacesService, only the MCP tool that writes
a place and a stay together does, so it can sit in a module PlacesModule is free
to import without closing a loop. AccommodationsModule keeps exporting it, so
every existing consumer is unchanged.
2026-09-13 15:36:02 +02:00
Maurice 7b50692372 fix(roadtrip): a check-in is a door opening, not an appointment
It is the hour a room becomes available. Reaching it later is reaching it; only a
time somebody pinned to a stop can be missed. Read as an anchor it did the exact
opposite of what it means: arrive at 16:14 at a hotel that checks in from 11:00
and the rail reported five hours fourteen late, then read that 11:00 as earlier
than the 15:00 before it, took it for a new day and pushed the hotel onto
tomorrow. The day it came from was left with one stop and dropped out of the list
altogether, so the stop before it appeared to vanish.

The schedule now takes it as a floor: waited for when the drive gets there first,
and otherwise silent. Waiting still prints in ink, because the hour is one
somebody named; arriving late to it prints as computed, because nothing was
decided.

Same shape of mistake as the check-out, from the other side. One says the earliest
you may be somewhere, the other the latest you may still be — neither says when
you drive.
2026-09-13 15:28:48 +02:00
Maurice 62d4cdcc18 fix(accommodations): catch the nights the upgrade itself could not reach
A migration runs while the old container is still answering. A booking written in
those seconds comes from code that knows nothing about the day stop and lands
behind the sweep that would have given it one: on the test instance exactly one
of ten, and invisible to the drive ever after, with no way back short of saving
it again. The sweep runs a second time, which is safe because the same NOT EXISTS
decides it both times.

The other way into that state is closed too. "Remove from day" in the place panel
took the stop and left the booking, which is the very state this whole change
exists to remove — and it was the only control that could produce it after the
upgrade. A stop a booking wrote is no longer offered there, in either direction:
removing it strands the booking, adding one puts the same hotel on the day twice.
The night is removed where it is made, in the day's overnight block or by turning
it back into a pause in road trip mode.
2026-09-13 15:19:33 +02:00
Maurice d0d3f455a8 fix(planner): let the day list see which stop a booking wrote
The row shapers name every column they pass on, and accommodation_id was in
none of them. So the field the day list hides on never reached the client, and
the booked night was listed as an ordinary place next to the overnight block
that already named it. The stop was correct in the table the whole time; the
three read paths just dropped it on the way out.

The stamp on the freshly written stop had the same hole from the other side: the
INSERT went in without the booking id and an UPDATE added it afterwards, so the
row handed back in the answer, the one the client that booked the night puts
straight into its store, carried none. It goes in with the INSERT now.

The shared link gains the same filter. It has no road trip view to need the stop
and already shows the booking as its own chip on the day, so the row was only
ever the same hotel twice.
2026-09-13 12:59:19 +02:00
Maurice e3471e2dc8 fix(reservations): a night booked on the form belongs on the drive too
The booking form wrote the stay row itself, so it was the one door that left the
day stop out. A hotel entered there stayed invisible to road trip mode, which is
the same complaint as before with a different way in: enter the place twice or it
is not on the route.

It writes the stop now, through AccommodationsService rather than a second copy of
its SQL. The permission argument for not doing so does not hold: the night already
shows in the day header the moment the booking is saved, and the stop is that same
night in the other view, not a second edit to the plan.

Two holes go with it. Moving the booking left the stop on a day nobody sleeps there
any more, and deleting it left the stop behind entirely — in both cases still
carrying the booking's id, which is what the day list hides on, so nobody could
reach it to remove it. The stop now follows the stay and goes with it, and only
once the trip_id guard has confirmed the stay was this trip's to delete.
2026-09-13 12:38:21 +02:00
Maurice edf0538260 fix(roadtrip): keep a booked night out of the day list, and off the traveller's stops
Three things the first pass got wrong.

A stop a booking puts on the day is for road trip mode, which needs the
hotel in the driving chain. The day planner already shows that booking as
its own overnight block, so the row there would be the same hotel twice.
It is hidden in Days whatever the service-stop switch says, and the switch
goes back to meaning what it says: the petrol stations the traveller added.

Cancelling a night from the road trip popup took the stop with it. That
control means "this is a pause now, not a night", and the stop sits
mid-drive where re-adding it would land it at the end of the day. The
booking now hands the stop over instead of deleting it.

Moving a booking whose stop belongs to the traveller put a second one on
the new day and left the first behind, which is the duplicate this change
exists to remove. Only a booking's own stop moves now. Stays booked before
this shipped get theirs from the migration, not from the next edit they
happen to receive.
2026-09-13 01:42:52 +02:00
Maurice f0e1da2d81 feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.

Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.

New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.

The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
2026-09-13 01:13:25 +02:00
Maurice 218d8baf0a feat(server): read visits and recorded routes from Dawarich
A new `integrations/dawarich` domain behind its own addon. Each reader
connects their own instance; the API key is encrypted at rest and never
returned. Nothing is written back to Dawarich, and no raw position is
kept in TREK: the recorded route is fetched per request for the window
being looked at and thrown away again.

What it produces are suggestions, never facts. A recorded stay becomes a
proposed place or journal entry, a country or city becomes a proposed
Atlas mark, and a stay near a wish becomes a proposed tick-off; each one
waits for the reader to confirm it. Identity is (user, Dawarich visit),
so a second sync neither duplicates nor overwrites what was accepted,
and deleting the result puts the suggestion back on the list.

The sync runs as a job on the central scheduler, one user at a time,
guarded against overlapping runs.
2026-09-12 23:36:49 +02:00
Maurice dad1316172 feat(api): let public API keys be scoped to single areas
A key could only ever read everything a user owns. When a second service
reads TREK on that user's behalf, that is more than it needs: Dawarich
wants trips, days and places, not notes, bookings or the wish list.

Keys can now be minted with an explicit list of areas, checked in the
guard and shown when the key is listed. Keys that existed before this
keep full read access, so nothing that works today stops working. A key
marked as limited whose list cannot be read grants nothing rather than
everything.
2026-09-12 23:36:09 +02:00
Maurice 11b69ae815 test(places): give the hand-built places tables the Amap id column 2026-09-12 18:07:39 +02:00
Maurice f2bd116044 docs(maps): an Amap link needs a coordinate
A bare amap.com/place page carries an id and nothing else, and turning that
id into a location would need a keyed detail lookup the URL resolver has no
user for. It answers 400, which AMAP-084 pins. The wiki offered the link as
paste-able and the parseAmapUrl comment still promised the lookup, so both
now say what the code does.

The provider header also claimed photoRefs/photoBytes were deliberately
left out, naming two methods the places-provider interface no longer has.
The reason is unchanged: an Amap picture comes without a licence statement
we could show, so an Amap place gets its picture from Wikimedia.
2026-09-12 18:03:11 +02:00
Maurice ba535e5aab fix(maps): ask Amap only where Amap can answer
With Amap in the keyed slot every reverse lookup went to Amap first,
worldwide. Outside its box that buys a round trip and an empty answer
before Nominatim is asked anyway, which is latency on every right click in
Lisbon. The client side of the same question already filters by the box
(placeNavigation.ts); isOutsideChina is the shared helper both use.

The two fixtures in the file that were cast to any now carry a type: the
key row and the fetch spy whose calls the reverse case reads back.
2026-09-12 18:03:11 +02:00
Maurice 43a6c30325 feat(places): remember the Amap id a place was found by
The provider produced amap_poi_id, but nothing stored it, so a saved
Amap place could never be reopened against Amap. The column now travels
with google_place_id and osm_id through the shared contract, the place
insert and update, duplicate matching, the assignment projections, the
trip copy and the three place MCP tools.
2026-09-12 18:03:10 +02:00
Maurice 1944bdf0a5 feat(maps): let Amap take the keyed provider slot beside the TREK index
The rebase kept maps.service.ts as dev has it: the TREK index and
OpenStreetMap answer every search first, and a keyed provider only fills
what they leave empty. Amap now sits in that slot next to Google instead
of behind an extracted Google provider, which is dropped.

places_provider (auto | google | amap | openstreetmap) decides which keyed
provider holds the slot; auto keeps whatever the install already used.
An amap: id opens against the Amap key regardless of the setting, takes
the plain details lookup on expand, never reaches Google's photo call,
and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding
asks Amap at the instance key before Nominatim.

The migration carries users.amap_api_key, which the rebase had left at a
stale array position, and places.amap_poi_id for the next commit.
2026-09-12 18:03:10 +02:00
A1phaN 57eac32f9f feat(map): amap as map api provider and display template 2026-09-12 18:02:57 +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 bc6d804af6 docs(budget): say what settled_at does when it is left out 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 51626e8184 make roadtrip availability checks explicit 2026-09-12 16:46:22 +02:00
Maurice bc04072dc9 keep overnight checkout planning after a later arrival 2026-09-12 16:22:11 +02:00
Maurice ac57321840 merge dev and reconcile roadtrip planning 2026-09-12 15:45:34 +02:00
Maurice b10b02c685 finish overnight stops and cross-day range warnings 2026-09-12 15:37:09 +02:00
Maurice f56e7a161e add open charging data and polish roadtrip warnings 2026-09-12 13:25:15 +02:00
Maurice 41b18f970c feat(vacay): add manual school holiday calendars 2026-09-12 12:51:27 +02:00
Maurice ff5fa55fad add Google Maps route import and plugin corridor search 2026-09-12 12:24:33 +02:00
Maurice af45b0ca74 add roadtrip warning areas and tidy driving settings 2026-09-12 11:23:09 +02:00
Maurice b773faff70 feat(roadtrip): share trip preferences and improve service stops 2026-09-12 00:24:45 +02:00
Maurice fccc55732b feat(roadtrip): plan and edit trips through MCP without a browser 2026-09-11 23:25:06 +02:00
Maurice 491cfccc56 feat(roadtrip): add daily scheduling and configurable day endings 2026-09-11 22:28:03 +02:00
Maurice 2c06c9904b feat(collections): a list as a file, so it can leave the instance (#2198)
Sharing a list works between people on the same TREK. This is the other
case: giving one to somebody who runs their own, or keeping a copy. Export
sits beside Edit and Share in the list header and downloads the open list;
Import sits beside New list in the rail, reads a file, shows what is in it
and makes a new list of it. Desktop only, which the route already decides.

What travels is what somebody wrote down: the list's name, colour and
description, its labels, and each place with its address, coordinates,
notes, status, contact details, category name and labels. A file is dull
JSON on purpose, so it can be read and edited by a person.

What does not travel is everything that only means something on the instance
it came from. Ids of every kind, since the receiver issues its own and a
category crosses over by name. Star ratings and members, because a rating is
an opinion somebody gave the people on that list. Provenance into trips,
which points at rows the receiver cannot see. And an image path under
/uploads, which resolves on the sender's server: only an absolute https URL
survives.

A file is a stranger's JSON and is read as one. It is parsed in the browser
that chose it and never uploaded; the parsed list goes through the same
contract on both sides; and a link that is not http(s) arrives as nothing
rather than as an anchor. Strict where it must refuse, forgiving where it
can drop: a file that does not say what it is never reaches the importer,
while one unreadable place inside it is skipped and counted, and one bad
field on a good place costs that field alone.

An import is always a new list. Importing into an existing one would mean
deciding what happens to a place that is already there, and every answer to
that is a merge nobody asked for.
2026-09-11 14:58:34 +02:00
Maurice a8e4c45576 feat(share): show a stop and a booking in full on a shared link, and no more (#2320)
A public link showed a stop as its name and one line under it, the address
or the description, whichever came first. The owner had written both, and
a note on the place and another on the day, and had set how long they meant
to stay and how to reach it. All of that shows now, laid out the way the
planner lays it out, and every link leaves the page in a new tab with no
opener. A booking shows the note the owner attached and the page they
linked, labelled by where it goes.

What travels to the page is chosen column by column now, where it used to
be the whole row. The whole row of a booking carried its confirmation
number, its import trail and its raw metadata, and the metadata is where an
imported ticket keeps its seat and its record locator; the whole row of a
place carried the owner's booking notes on it and the Google identifiers.
None of that belongs to somebody holding a link. Metadata keeps the parts
that describe the journey, by an allow-list, so a field the import starts
writing tomorrow does not become public by appearing. Links are accepted
only as http or https, on the server and again on the page.
2026-09-11 11:01:25 +02:00