Commit Graph

2794 Commits

Author SHA1 Message Date
Maurice aa1f76ad11 fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate.
When the server could not fetch rates itself, it counted the amount 1:1
in the trip currency, although the browser had the rates all along. A
VND bill on an AUD trip was settled as millions of AUD.

Such a row is now left out of the balances, flows, final budgets, trip
totals and per-person summaries. The settlement reports it under
`unconverted` and says which currency its figures are in; the trip
summary lists the ids and the budget prompt says how many are missing.

The browser lends the rates it already holds (`fallback_fx`) when it
saves an expense or a settle-up transfer, and to heal existing rows
through the new POST .../budget/freeze-rates route, which an editor's
Costs view calls for the rows the settlement left out. The server still
prefers its own rate, uses a lent one only while it is quoted against
the trip currency, and never replaces a frozen or explicit rate. The
settlement also takes the browser's display rate (`base_rate`) in place
of a display quote the server cannot fetch; the browser leaves out a
figure the query would refuse. The browser's own fetch gives up after
10 s.

Changing a row's currency while no rate is available no longer keeps
the old currency's rate; the row goes back to unfrozen.

MCP gets the same through the new freeze_budget_rates tool, which only
ever pins the server's own rate.
2026-09-26 13:19:26 +02:00
Maurice c6a089107f chore(notices): the release notice points at 4.3.3 2026-09-26 11:22:25 +02:00
Maurice cd06f0f7f4 fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).

The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.

Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.

StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.

The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.

The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
2026-09-26 11:22:19 +02:00
gso e6f66014a5 fix(planner): transport edit window can delete the booking
A transport added from the day-plan sidebar could not be deleted: the edit
window (TransportModal) had no delete action, and the only transport delete
affordances lived on the Transports-tab cards, which that flow never reaches.

Add an onDelete prop and a Delete action in the edit footer, guarded by the
shared ConfirmDialog. Wire it from TripPlannerPage, gated on reservation_edit
(the permission the server enforces for reservation deletion).
2026-09-26 10:36:01 +02:00
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00
Maurice 5f77c2cee9 fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a
data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders
SVG, so notification and password-reset mails showed a broken image with
"TREK" as its alt text. The image data itself was fine; it only rendered in
clients that accept both data: URIs and SVG, such as Apple Mail.

The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg
with the corner radius baked in, and MailerService attaches it to both HTML
sends as an inline part. The header img points at it by cid:, so nodemailer
puts the HTML and the image into multipart/related. That is the form Gmail,
Outlook and Apple Mail all display, and it does not depend on the instance
being reachable from the recipient's mail provider. The plain-text admin
test mail carries no HTML and stays without an attachment.
2026-09-26 04:22:50 +00:00
Maurice cf5920ebe0 fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place,
and a row that was already there was never re-derived. Correcting a
place's location in the trip left the old row in charge, so Atlas, the
dashboard stats and the journey stats kept counting the country the place
had just left, with the trip's dates on it.

A trigger on places now drops the cached row whenever lat, lng or address
actually change, so every writer is covered: the place editor, the MCP
update tools, the plugin RPC and the import enrichment and address
backfill. An edit that writes the same location back keeps the row.

The background lookup that fills the cache also only writes while the
place still sits where it was resolved from. A place moved while its
lookup was running would otherwise get the old answer written back.

Places corrected before this fix would have kept their old country until
they were moved again, because the trigger only sees new edits.
PlaceRegionsRepairJob goes over place_regions once, on the first start
after the update, and remembers that in app_settings. It re-derives each
row from the bundled borders only, never Nominatim, and replaces a row
whose country or region no longer matches where its place is now. A row
the bundle cannot answer for is left alone unless both the coordinates
and the address put the place in another country; then it is dropped and
the next Atlas load looks the place up again. A place without
coordinates keeps no row. Its writes are guarded the same way as the
background lookup, so a place edited during the pass is not overwritten.

The in-memory region cache is now keyed by the country the address names
as well as the coordinates, so correcting only the address of a border
point no longer returns the old answer until a restart.
2026-09-26 04:22:50 +00:00
Maurice a0952bd871 fix(mobile): show the drive from a flight's arrival airport in the plan timeline (#2502)
The phone timeline only put a travel-time connector between two places.
The drive from the arrival airport to the first stop after a flight is
part of the day's route and drawn on the map, and the desktop day plan
shows it under the flight, but on the phone the two rows sat on top of
each other. The drive from the last stop to the departure airport was
dropped the same way, and so was every drive past a booking saved
without a location, like a taxi to the airport or a concert between two
stops.

buildPlanRows now walks the rows the way the route calculation walks
the day: a located booking ends the drive before it at its departure
point and starts the next one at its arrival point, and each leg goes
under the row it leaves from. The route rides straight past a booking
it has no location for, so the drive through it goes under that
booking, as on the desktop, and keeps the mode menu of the stop it
left. Only transport bookings count as located, as in the route
calculation. Two bookings back to back still get no road between them.
A booking whose station is the stop itself, as the transit planner
saves a journey between two of the day's places, leaves a leg of no
length, and that one gets no connector.
2026-09-26 04:22:50 +00:00
Maurice 3c7f6e69dd fix(mobile): pick the plan timeline's hotel legs by what the route drew (#2501)
On a day that opens with a flight, where the traveller also put the
hotel on the day as a stop of their own, the phone timeline started the
day with a hotel row and the drive from that stop to the next place,
above the flight, and then showed the same drive again in its own spot.

hotelLegsForDay guessed the bookends from coordinates: the first
segment leaving the hotel's spot was the morning leg. The route had
correctly drawn no morning leg on that day, but the stop on the
hotel's spot starts a leg there too, so that hop was taken for it.

The route calculation now tags the two legs it draws from and to the
accommodation, and the timeline reads that tag instead. The hotel end
of a tagged leg still has to be the day's own stay, because the
calculation keeps the previous day's legs until the new day has
routed. The stop to stop connectors leave tagged legs alone, so one
leg can no longer show up in both places.
2026-09-26 04:22:50 +00:00
Maurice 30a3f664b1 fix(map): open a place from its pin after the map was dragged (#2504)
Since drag-to-day (#891) a place pin swallows mousedown so the map holds
still while a drag starts. Leaflet only clears its "the map was panned"
flag on a mousedown it sees, so after one drag of the map it took every
later click on a pin for the end of that pan and dropped it. Pins stayed
dead until something else on the map was pressed, which is why zooming
out and clicking a cluster brought them back. Trip planner and
collections map were both affected; the GL renderers never were.

Pressing a draggable pin now clears that flag itself by switching the
map's pan handler off and on again, which is what the map would have
done had it seen the press. The click keeps Leaflet's normal route, so
it still closes an open popup on the map and still reaches listeners on
the page, such as an open context menu.

The new test file drives a real Leaflet map, since the mocked
react-leaflet has no pan state to go stale.
2026-09-26 04:22:50 +00:00
Maurice 0a3a993dfc fix(share): only call a share link expired when the server says so (#2505)
The public share page turned every failed GET /api/shared/:token into
"Link expired or invalid". A 5xx during a restart, a proxy 502, a rate
limit, a timeout or a viewer who is briefly offline all told the viewer
the link was dead and sent them back to the owner for a new one, while
the same link loaded fine on the next try.

The endpoint answers 404 with its JSON { error } body for an unknown,
expired or revoked token, so that is now the only answer that shows the
expired screen. A 404 without that body comes from a proxy in front of
TREK (Traefik answers "404 page not found" while the container is
stopped or still starting) and counts as a failed load, as does a 200
that is not the share payload, such as an auth wall's login page, which
used to crash the page. Any failed load shows "This trip could not be
loaded" with a retry button that refetches in place and stays busy
while it runs. The load also ignores a response that lands after the
page moved on to another token.

Both screens use the theme tokens instead of hex literals. New keys are
translated in all 23 locales.
2026-09-26 04:22:50 +00:00
Maurice bea3af4b45 fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the
previous build asks for chunks the update removed. The server answered those
requests with index.html and a 200, so the import failed with a MIME error,
and everything caching in between was free to keep the HTML as the chunk. A
precache install that met a missing file stored it, and Workbox reuses hashed
entries by URL, so the device showed "A new version is available" on every
load, whatever the reload or the cleared browser cache, and carried the broken
entry into every later release that keeps the file's name.

The SPA fallback now answers a GET for a build file that is not on disk with
a plain 404 (no-store) instead of index.html. A precache install refuses it,
so the worker whose cache is complete stays in charge until the new build is.
index.html is sent relative to the public dir, which also stops an install
living under a dot directory from answering every deep link with a 404.

On the client, the reload after a dead chunk (the automatic one and the
button on the update screen) no longer goes straight back to the worker that
served the broken shell. When that same worker is still in charge and the
server answers, its precache and registration are dropped first, so the
reload reaches the build the server runs now. Offline, and in offline mode,
the shell is kept. Every recovery reload, and the reload of the version
handover, asks for the page under a one-off query parameter, so a proxy or
CDN that keeps index.html against its headers cannot hand back the stale
shell either.

A chunk the entry imports statically fails before any of that code runs.
shell-guard.js, a small classic script loaded ahead of the entry, watches for
exactly that failure and runs the same recovery, once per session.

A stylesheet, font or icon that holds a page raises no error at all, so once
the app has started and the browser is idle, it looks through the precache
for entries that hold a page under a build file's name, fetches each from
the server past every cache and puts it back in place. A repaired stylesheet
is loaded again, and so are the app's stylesheets when a font was repaired,
since the browser does not try a face that failed again in the same
document. Only the headers are read, a precache found sound is not looked at
again until its entries change, nothing is fetched while nothing is broken,
and nothing happens offline, in offline mode or without storage. An answer
that is not the file (an auth wall, its login page, a server error) leaves
the entry for the next launch.

A page the recovery loaded from the network already runs the version the
server reports, so the version handover now records it instead of reloading
it a second time once the new worker takes over. A page loaded past an older
worker that is still in charge, as a hard reload does, still asks for the new
worker and records the version only once that has taken over, so the next
tab or launch cannot get the old shell without a handover.
2026-09-26 04:22:50 +00:00
Maurice 26ab4a58b1 fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 02:26:39 +00:00
Maurice 0887f251ce fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 02:26:39 +00:00
github-actions[bot] 151e32107f chore: bump version to 4.3.2 [skip ci] v4.3.2 2026-09-24 11:14:02 +00:00
Maurice 8779529efb feat(roadtrip): the corridor asks in one line, and a narrow rail stops counting stops
Along the route: once kinds are picked, the closed "Looking for" control shows them by
their coloured icons alone, and names them in its tooltip and to a screen reader; the
names stay in the list. The day the search runs along moved out of the header into the
same line, as a dropdown of the same shape, so the question reads as one: these kinds,
along this day. Both share one small module for the shape and for closing on a click
outside or Escape.

Pulled narrow, the road trip rail drops the stop count from its head, with the hairline
beside it, and from every day header, and keeps distance and time.
2026-09-24 11:32:52 +02:00
Maurice e1315806c2 feat(roadtrip): the ride block and the stay rows read as badges
The flight block in the road trip rail printed each fact two or three times: the booking
title in capitals with its duration cut off, the airport code three times per side, an
unlabelled 14:15 on the right with "Departure 15:15" under it, 17:20 twice, and a warning
"+9 h 39 min" measured against a check-in nobody could see, for a flight that was missed.

It now reads in four lines: the booking as typed; the two codes with their timetable clocks
and the ride between them; the places without the code in brackets; and the check-in as a
badge that carries its own state, the hour when the drive is on time, by how much when it
is late for the check-in, and "Missed, there at 23:54" when it gets there after the
departure, with the duration beside it. A missed ride edges the block and says so in the
day header, also when the day is folded. Trains and buses say boarding. The phone shows
the same, and its rows now read the lateness and missed-leave findings at all.

The stays at the edge of a day are named by the hotel, with the edge in a badge: Check-out
with the latest hour, Day start, Overnight, or Check-in with the hour the room is ready. A
plan that leaves after the check-out turns that badge instead of adding a line. They wear
the hotel stop's own disc, the stay's own stop shows its check-in the same way, and the
driving settings button shows when the stays are switched on.

The two-part badge the rail used in six hand-typed copies is one FigureBadge now, and the
airport code is stripped by one shared helper instead of two private copies.
2026-09-24 11:32:52 +02:00
Maurice e64ade010f fix(roadtrip): a flight day ends at the stay it lands at, and no drive runs where nobody drives
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated
by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight
out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the
flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a
stay now waits behind the landing when it lies nearer the arrival than the departure, and
its check-in only holds it when the ride lands before the room is ready. A time typed on the
stop keeps its seat.

With the stays at both ends of a day switched on:
- a day with nothing but the next stay's check-in and a ride booked across it gets no drive
  from one hotel to the other, the same rule the day plan follows since #2476;
- a stay is never joined by road to a terminal more than 2000 km away. The check moved to
  shared/roadtrip so Days and the road trip read one copy.

A departure terminal's pin is dated: reaching it more than half a day late no longer moves
the flight to the next day without a word, it is a late warning with the full minutes.
A check-in evening also carries the stay's check-in time, as a label only.

calculate_roadtrip runs the same code and its description says all of the above.
2026-09-24 11:32:52 +02:00
Maurice 3062df153f chore(notices): the release notice points at 4.3.2 2026-09-24 11:27:12 +02:00
Maurice 233076030b fix(docsync): a store picked before the panel settles stays picked
The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
2026-09-24 08:14:29 +02:00
Maurice c5ac479915 fix(dayplan): sort the trip's dates with an explicit compare
ISO dates sort the same as plain strings, but a sort without a compare
function reads as an accident to anyone checking it, and SonarCloud flags
it as one. The three places that order the trip's dates now say how.
2026-09-23 23:11:10 +02:00
Maurice 954758082b fix(dayplan): calmer day dialogs, and the shrink question side by side
The day dialog hangs from a fixed top edge, so switching to the delete
question only moves its bottom edge, and the question opens at its top
with the focus on Cancel. Cancel, Close and the quiet add share one
outline; the dated add is drawn in the accent instead of filled with it.
Dates in the dialog, its hints and the new-day toast read the way the day
list writes them, and captions use the muted text colour for contrast. A
stay booked twice is named once in the warning. On the phone the dated
pill shows only its date, so both pills fit side by side.

When the trip dialog asks both how bookings follow a moved start and
which days the new dates remove, the two now stand side by side in a wide
dialog instead of one long column.
2026-09-23 22:40:47 +02:00
Maurice 83be759c49 fix(dayplan): the delete question asks inside the day dialog, and its buttons stay small
Deleting a day no longer stacks a second dialog over the reorder dialog. The
dialog swaps its list for the question at the same size and in the same place;
Cancel and Escape bring the list back with the focus on the row asked about.
The foot is one row of normal sized buttons with the hint line above it, the
undated day is labelled "Without date" in every language, and the dialog is a
step wider so no label breaks. The list of what goes with a day is quieter: a
cancelled stay tints its whole row, a day with nothing on it is one plain line.
On the phone the add buttons are two pills under the scrolling list, and the
confirm sheet moves a long label to its own row instead of breaking it.
2026-09-23 22:40:47 +02:00
Maurice f3ca0b1a4c test(mobile): the delete-day confirm test takes a free id
FE-MOB-SHOST-030 is taken by the invite field test that landed on dev in
the meantime, so the delete-day confirm test moves to 032.
2026-09-23 22:40:47 +02:00
Maurice 302101b186 fix(dayplan): the delete question names every date and night it changes
The question before a day goes now also says which day without a date takes over the last date, which stay running across the day loses a night and when it checks out, and what the cancelled booking costs, or that it has no expense at all; further bookings on the same stay are counted. Road trip boundaries follow their own days when the numbering still has a hole from the old delete, and ones left on no day are dropped.

A long list scrolls inside the confirm card on both shells with the buttons always in reach, Escape closes only the sheet on top and takes the trip dialog back from its review step to the form, and the panel of a deleted day closes, also when a fellow traveller deletes it. Undo steps on a deleted day are dropped, a restored place skips days that are gone, and an undo that fails says so. The day cache is written without holding up the answer once the server has it, the tip under a new start date points at the dated add button, and two Collab tests find the confirm button by its role instead of an old colour class.
2026-09-23 22:40:47 +02:00
Maurice 1639fc6a3e docs(wiki): deleting and adding days, and what shortening a trip removes
The day plan page now walks through the reorder dialog and its phone sheet as they are: the two ways to add a day on a trip with dates, the question before a day is deleted and what it lists, and the step the trip dialog puts before a save that removes days. Accommodations explains why a stay behaves differently when its day is deleted and when the trip is shortened, and the trip dialog, undo, public API, MCP and plugin pages point at the new rules, including the dated option of create_day and days.create, removed_days on update_trip and the last day that cannot be deleted.
2026-09-23 22:40:46 +02:00
Maurice 1b63285ad0 feat(trips): shortening a trip says first which days go and what is on them
The trip dialog on the desktop and the trip sheet on a phone now ask before a save whose new dates remove days with something on them. The question names those days the way the day list does and lists what goes with them in the same rows the delete day question uses: planned places stay in the place list, notes and day texts are deleted, bookings stay under Bookings, and a stay that checks in or out on a removed day goes as a whole while its booking and expense remain, shown in the danger tone. A moved start gets both questions in one step, with the booking hint following the chosen shift mode, and a save that only drops empty days asks nothing.

The rule that lays the days out moved into shared as planDayGrid, with resolveDayGridRange deciding whether the rows are rebuilt at all. generateDays on the server now carries that plan out in one transaction, so the warning names exactly the days the save takes, and a seeded fuzz test holds the new rebuild to the old one over 300 random day grids. The update_trip tool says in its description what shortening takes and lists removed_days in its answer.

Alongside, the dialog sends a day count only when it changed, so a stale count no longer trims a day just added, stays are reloaded after every date change including the one made on a phone, and the date shift tip now points to adding a day under Reorder days.
2026-09-23 22:40:46 +02:00
Maurice b56f335978 feat(dayplan): a day can be added with the next date, which extends the trip by one
On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
2026-09-23 22:40:46 +02:00
Maurice 2f6f921aef feat(dayplan): a day can be deleted from the reorder dialog, after a warning that says what goes with it
Every row of the reorder dialog, and of the days sheet on a phone, now has a delete button. It first asks, and the question lists what goes with the day: planned places stay in the place list, notes and day texts are deleted, bookings stay without a day, a stay that checks in or out on the day is cancelled with its booking and expense (shown in the danger tone), and on a dated trip the later days and their bookings move one date earlier or the trip ends a day earlier.

The server side moved from a bare DELETE into DayRemovalService, which REST, the delete_day tool and the plugin RPC all use. It closes the numbering, keeps the dates on their positions like a reorder, re-stamps the moved bookings, shifts the road trip boundaries, cancels touching stays through the accommodations domain, refuses the last day of a trip, and fans out day:deleted, day:reordered and trip:updated so collaborators renumber and see the new day count and end date. The trip query moved into a leaf file so the days domain can read it without a module cycle.

On the client the delete is online only, runs optimistically through the days slice with the same reducer collaborators apply, and the ConfirmDialog gained a slot for extra content, an Escape that only closes itself, and the danger and accent tokens.
2026-09-23 22:40:46 +02:00
Maurice 436f0740fb fix(planner): the delete question says plainly what goes with a booked place
"The night booked at "Rostock" goes with the place, together with the
booking "Rostock" and any expense linked to it" named the place twice and
read awkwardly. The note now says "This also deletes the stay booked at
"Rostock", its booking and any expense linked to it", quotes the booking
only when its title differs from the place, and reads the same in all 23
languages.
2026-09-23 22:39:54 +02:00
Maurice 1423ba8933 fix(places): follow-ups from the review of the schemeless website fix
create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
2026-09-23 22:35:01 +02:00
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00
Maurice 362b1ee8d1 fix(map): a selected stay is flown to even when no pin stands for it
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
2026-09-23 22:11:51 +02:00
Maurice b5457d9e6a fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days
Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
2026-09-23 21:28:34 +02:00
Maurice a1d6348c5f revert(collections): a saved place carries no price, website or phone again
Takes back the editable price, currency, website and phone on collection
places from 0df82185b and its review follow-up in a910c2a83. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698bdb, which
only mattered for the price field.
2026-09-23 20:32:41 +02:00
Maurice f16698bdb1 fix: an Immich error leaves out Node's advice, a locked save looks locked
A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
2026-09-23 18:03:58 +02:00
Maurice a910c2a837 fix: follow-ups from the review of the price, Immich, moving day and import fixes
Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
2026-09-23 17:41:26 +02:00
Maurice 0df82185bd feat(collections): a saved place keeps its price, website and phone, editable like in a trip
A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
2026-09-23 17:41:26 +02:00
Maurice e8ec3427e5 fix(share): the user picked to invite shows in the field before it is sent
The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
2026-09-23 17:41:26 +02:00
Maurice 8d78c988c3 fix(dayplan): a moving day with a flight draws no drive from one stay to the next
A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
2026-09-23 17:41:25 +02:00
Maurice ae80ad266d fix(immich): a server with a self-signed certificate can be connected, as the other providers can
Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
2026-09-23 17:41:25 +02:00
Maurice 41b14ebc2a fix(import): a booking the AI reads without its venue is asked for one, and a failed import does not look like a success
Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
2026-09-23 17:41:25 +02:00
Maurice 05f533dd0d fix(roadtrip): a late check-out morning says so, a road driven twice takes its via
The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
2026-09-23 16:01:40 +02:00
Maurice e6e520b78f fix(roadtrip): the hotel at the edge of a day stands, drives and reads right
A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
2026-09-23 15:31:09 +02:00
Maurice 05651eed1a docs(wiki): a road trip day can start and end at the stay
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
2026-09-23 15:31:09 +02:00
Maurice 7e7d13c021 feat(roadtrip): a day after a booked night starts at the stay, a day before one ends there
A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
2026-09-23 15:31:09 +02:00
Maurice 514e2e62ee feat(roadtrip): a booked night can stand at both ends of its days
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
2026-09-23 15:31:09 +02:00
Maurice 7fa0434570 refactor(planner): the night's hotels are read in shared
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
2026-09-23 15:31:09 +02:00
Maurice 3a67a8e279 fix(planner): trip places in every manual location field, never at 0,0
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
2026-09-23 13:41:31 +02:00
hiro-nikaitou cc5957dc6a fix(planner): offer trip places in manual transport
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
2026-09-23 13:34:02 +02:00