Commit Graph

105 Commits

Author SHA1 Message Date
Maurice 64e4c34472 test(costs): the ledger cases wait for the settlement, and the filter and former member cases get files of their own 2026-10-12 21:14:05 +00:00
Maurice b21f9d6739 refactor(costs): the summary card moves into a component of its own 2026-10-12 21:14:05 +00:00
Maurice cfdbfb49c0 fix(costs): expenses and payments saved offline wait in the queue and sync once the connection is back
Every Costs write went straight to the API, so offline an expense failed with
"Unknown error" and was lost. Expenses (add, edit, delete, split, paid tick,
reorder) now go through budgetRepo and the mutation queue like places and
packing items: a temporary row offline, held behind an older queued write
online. The replay reads the expense from `item` only, so the split answer
({ members, item }) keeps the local payers and the paid answer ({ member })
never lands in the expense table. An expense linked to a place or booking
created offline is sent with the real id once that one has synced.

Payments are queued without a local row and a hint says they show once
sent; the ledger reads the balances again when the queue is through, and
offline it shows the last balances it read (new Dexie table, v10). Receipts
need an upload, so an offline save with new ones is stopped with a hint.

Refs #2601
2026-10-12 18:14:30 +02:00
Maurice 2149fd5315 refactor(budget): the receipt split by line lives in shared so the server can split a receipt the way the cost editor does
calculateTicketShares in the cost editor now converts the hundredths of the
new shared splitTicketLines. Same arithmetic as before: a line nobody is
ticked for is carried by everyone on the receipt and the leftover hundredths
of a line go to the lowest user ids first.

Refs #2604
2026-10-12 16:39:07 +02:00
Maurice 35eba21128 fix(costs): row buttons, settle-up rows and share toggles say what they are about to a screen reader
A screen reader lists a page's buttons together, and the costs views gave
it twenty "Edit" and "Delete" buttons with nothing to tell them apart.

- Edit and delete on an expense name the expense ("Edit: Dinner"), edit
  and undo on a payment name who paid whom, on desktop and phone.
- A settle-up row shows only initials in its avatars, its names lived in a
  tooltip; the row now carries them for assistive tech too, and its Settle
  button says who pays whom and how much.
- The search field of every searchable select (currency, time zone,
  category) was read out as "..."; it now says Search in the user's
  language.
- The public link toggles on the phone share sheet and the journey share
  toggles say whether they are on (aria-pressed), not only by colour.
- Japanese still said "you" in the settle-up lines.

Refs #2604
2026-10-12 15:45:56 +02:00
Maurice dbbbd07b1c fix(costs): amounts follow their currency's decimals and format, and payments count on the local day
Several small things in the costs views, on desktop and phone alike:

- The outstanding card no longer says "You're all settled up" while a
  payment is still open in Settle up.
- The custom split shows its suggested shares as a faint placeholder in
  the currency's own form (30000 for won, not 30000.00), so it no longer
  reads like an amount already typed.
- Amount fields take only the decimals the currency has: won and yen take
  none, KWD and the other three-decimal currencies three. A switch of
  currency cuts what was already typed. 1000.555 won was saved as typed,
  listed as 1,001 and reopened as 1001.
- The expense sheet on the phone prints shares and sums with the same
  formatter as the list, with grouping and the currency's decimals.
- A payment recorded with Settle or Settle all is dated on the viewer's
  own day, and one without a date of its own is grouped by its UTC record
  time read on the local calendar. In Seoul before 09:00 it landed on the
  day before.
- "1 expenses need a payer" now uses the plural forms of each language.

Refs #2604
2026-10-12 15:04:32 +02:00
Maurice e70fc334bd fix(costs): people who left the trip are named and keep their balance in the costs lists
Someone removed from a trip stays on the expenses they paid for or shared,
but the costs views only knew the current members. They showed up as "?"
in the expense chips and the settle-up suggestions, and the balances and
final budget lists left them out, so the listed balances no longer added
up to zero.

The names now come from what the server already sends with the expenses
and the settlement, and anyone off the trip who still carries money gets a
row after the members, on desktop and on the phone. The expense form is
unchanged.

Refs #2604
2026-10-12 13:22:32 +02:00
Maurice b9a8bfc3e7 refactor(budget): the cost preview splits through the shared cent split the settlement uses 2026-10-10 21:43:08 +02:00
Maurice e971c41d1a chore(client): delete the old budget panel components that only their own tests still imported
BudgetPanel, its summary, category table, inline edit cell, add row, per person
line, pie chart, ring avatar and the useBudgetPanel hook were replaced by the
costs panel and nothing outside their own tests imports them any more.
BudgetPanel.constants, BudgetPanel.helpers and BudgetPanelMemberChips stay in
use. The rtl, format, offline and eslint baselines drop the deleted files.
2026-10-10 21:31:33 +02:00
Maurice 65a657347e fix(budget): a phone ticket split keeps the share of everyone a receipt line names (#1382)
On the phone, a ticket split where a receipt line named someone who was not
ticked as a participant saved only the ticked people as members. Their share of
the line was dropped, the member amounts no longer added up to total_price, and
the settlement kept a difference nobody could clear. The desktop editor had
been fixed for this in #1382.

Cause: useExpenseForm only added the people named on ticket lines when the
caller passed ticketShareMembers, and only the desktop CostsPanel did. The
phone MCostSheet kept the old behaviour.

Fix: the hook always sends everyone a ticket line names, and the
ticketShareMembers option is gone, so both editors save the same members.

Red test: src/components/Budget/useExpenseForm.test.ts
FE-BUDGET-EXPFORM-021 failed on the unfixed code with
"AssertionError: expected { name: 'Lunch', ... } to match object { ... }":
member_ids was [1] and members held only user 1, without user 2's 6.

Changed assertions: useExpenseForm.test.ts FE-BUDGET-EXPFORM-017 used to
expect the phone run (ticketShareMembers false) to send
members [{ user_id: 1, amount: 6 }] and member_ids [1]; that phone half is
removed and the test now expects every editor to send members for users 1 and
2 with 6 each and member_ids [1, 2].
2026-10-09 19:00:45 +02:00
Maurice 131d57b755 refactor(budget): share the costs ledger and settlement form between desktop and phone
costsModel moves from the phone tabs to components/Budget.
useCostsLedger holds the settlement, the totals, the filters, the CSV
export and the payments, and useSettlementForm holds the payment form.
CostsPanel and MCostsTab both run on them.

The old differences stay behind flags. The desktop also loads the
settlement on a trip change, leaves expenses nobody paid out of my share
(#2225) and writes each currency as stored in the CSV. The phone sheet
starts blank, refills when it opens and ignores a second save while one
is running.
2026-10-09 15:19:42 +02:00
Maurice 80c66d3f83 refactor(budget): share the expense editor between desktop and phone
useExpenseForm holds the payers, the splits, the ticket items and the
receipts of the expense editor in CostsPanel and MCostSheet.

Flags keep the old differences. Only the desktop sends the people named
on a ticket line as members (#1382). The phone ignores a second save
while one is running and stays busy after a successful save. The
rebalance effect uses useEffectEvent instead of an eslint-disable.
2026-10-09 15:19:42 +02:00
Maurice bee500880e fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a
count string's form by the language's own Intl.PluralRules, and every
locale now spells out the forms its language reaches with whole
numbers, about 160 count strings in all: one, few and many in Russian,
Ukrainian and Polish, one and few in Czech and Slovak, five forms in
Arabic, one in the others, none in Japanese, Korean, Chinese, Thai,
Vietnamese and Indonesian. Each language was translated and then
reviewed on its own. English no longer reads "1 files", "1 updates
available" or "photo(s)".

Count labels shown next to a number now take that number too: the
atlas and admin statistics, the trip cards, journey and shared trip
pills, packing templates, receipts, storage usage, the Studio export
note. A heading that shared its key with a counted label keeps a key of
its own (the boarding pass heading, the Bookings tab), so languages that
put a noun after a number in the singular keep a plural heading. The
plugin hosts chip passed its count through .replace() after t() and
never got a form; three toasts sent the count as text. Mail and push
texts for shared photos and suppressed storage failures pick their form
in code through pluralForm.

A whole number sent as text counts too, which is how in-app
notification params arrive, and the plural rule follows the language of
the strings on screen while another one still loads.

The parity check knows which forms each language needs and refuses
missing and impossible ones. It also reads keys that contain a colon
now: it skipped them, which hid 58 missing plugin permission texts in
Catalan and 35 in Vietnamese, both translated. The client parity test
covers every language of the registry instead of a hand-kept list of 19,
and the placeholder spec lets a form spell its number out only where
its category is that one number. Brazilian Portuguese error screens
were in Breton, and an Arabic dashboard label held Hebrew text.
2026-10-08 00:01:59 +00:00
Maurice d4eccb39a3 fix(client): three more places follow the reading direction in Arabic
On a phone in Arabic every system notice, the release notes included,
opened as an empty sheet: its three-slot strip is shifted to show the
middle slot, and in a right-to-left row that shift moved all three out
of view. The shifts point the other way in a right-to-left page now,
which the swipe handling already assumed.

The budget's total amount kept its parts in order but sat on the
opposite edge from its label, because the whole row was made left to
right. Only the amount itself is now. The phone chat's bubble tails
were still physical corners while the desktop ones follow the row; both
use logical corners now.

lint:rtl takes comments from the TypeScript parser instead of scanning
lines, so a "/*" inside a string (accept="image/*") no longer hides the
lines after it.
2026-10-08 00:01:43 +00:00
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00
Maurice 647bb33565 fix(costs): centre the amount/percent switch on its hint 2026-10-01 14:55:42 +02:00
Maurice db4337287b fix: mark deliberately unawaited promises with void (Sonar S9383) 2026-10-01 14:19:52 +02:00
Maurice 3063606f56 feat(costs): enter a custom split in percent (#1709) 2026-10-01 12:58:31 +02:00
Maurice 104f0eef38 feat(costs): each expense row shows who it is split between, with their part
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
2026-10-01 10:17:28 +02:00
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00
Maurice c88f9a4a23 fix(i18n): labels the translators flagged, and two dashes in the UI
vi called the Tracks filter "songs", sv the pending status "commuting", nl
the open-now pill "opening hours", and tr showed "its plan" on the Plan tab
and "being suspended" for pending. The road trip strings added in August
had lost their accents in eight languages (Arrive and Leave, the stay
buttons, the whole day and halfway filters, the avoidance options), uk had
a Latin i in "halfway", hu and tr called No tolls "free of charge", and az
put an en dash between the two ends. sv and vi said "I owe" for I'm owed and
You're owed, sv had "Namnn" as the table's Name, and th said "split
equally" for Split matches total.

The climate hint of the day details and the Unfinished tooltip in Costs
lose their dash in every language.
2026-09-28 12:18:47 +02:00
Maurice 350a144457 fix(ui): full-height document previews, and tooltips only on keyboard focus
The PDF preview on the Files tab and the document preview of a Collab note
were about 150 px tall: the object carried flex-1, and in a column without a
fixed height that falls back to the object's own height instead of 74vh.

A tooltip no longer opens when a closing dialog hands the focus back to the
button that opened it. It floated over whatever came next, at the place the
button had before the page moved. Keyboard focus still opens it.

The table view sorts its dates with localeCompare, and two NaN fallbacks and
an unused import go.
2026-09-28 12:17:15 +02:00
Maurice cb7014b378 feat(costs): table view, and the wiki and help center for the new trip page
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.

On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.

The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
2026-09-27 23:15:19 +02:00
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00
Maurice 237f42a493 fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.

The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
2026-09-26 15:59:05 +02:00
RemEx b1c05bca83 feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it
lands with the two features that send a photo, so it has something to
control.

The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On
Automatic a local Ollama is asked what the model can do (`/api/show`), and a
cloud provider means no; Yes and No are applied as set. The old `multimodal`
flag is not read: the admin screens wrote it false on every save, so a
stored false meant nothing. The per-user toggle keeps its on/off meaning and
is read by the extraction now. The lookup is server-side only: capped read,
validated body, never throws, cached per server and model.
GET /api/llm/capabilities answers it for the signed-in caller, and both
photo pickers below ask it.

The booking import takes JPG, PNG and WEBP when the model reads images.
The photo goes as an image to every provider (image_url, an Anthropic image
block, Ollama's native chat with `images`), capped at 1600px, and skips KDE
Itinerary. A scanned PDF, which used to reach the model as empty text, has
its first two pages drawn by pdf-parse and sent the same way. The rest is
the import as it was: background job, review, the file attached to the
booking, the linked cost. On a real qwen3.5:4b a train ticket came back as
bare clock times until the router gave the model every type's field
meanings, and its price as an object inside a string, which read as 4900;
both are handled.

Costs gets Scan receipt: a dialog laid out like the booking import's takes
one photo, read on the import job queue, then reviewed in the existing
expense editor pre-filled with merchant, total, currency, day and lines,
the photo waiting in Receipts & Invoices to be uploaded on save. No table,
no migration, no category guess, no document-type detection.
2026-09-26 14:38:01 +02:00
Maurice d1dc8542ac 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:27 +02:00
Maurice 44a12e21fc 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 costs are labelled in the trip currency (every linked
row, the link picker and the phone's linked costs), 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 10:36:03 +02:00
Maurice dc60f39554 feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
2026-09-25 12:57:15 +02:00
Maurice d4c6ac9fe8 fix(costs): the settlement fixture carries the rate its type never had
A foreign-currency payment gained an exchange_rate in the ledger fixture without
the field on the local Payment type beside it, and the client typecheck has been
red since.
2026-09-18 13:18:04 +02:00
Maurice 7f87dd512d fix(costs): read a foreign-currency expense at the rate it was booked at
The server freezes the FX rate when an expense or a settle-up transfer is
entered and settles the balances with it, but the client ignored the column
and converted every amount with today's rate. A bill paid in dollars was
therefore worth something different each time it was opened, which a tester
noticed after settling up: the figure moved under him, and the row no longer
matched what had actually changed hands.

convertBooked applies the server's own rule, in one place for both shells and
the transfer rows: an amount already in the trip currency is left to the live
step, a frozen rate converts to the trip currency before the live step, and a
rate of 1 or none at all is a row written before the freeze existed and keeps
converting live.
2026-09-17 22:04:01 +02:00
Maurice 9307025ed0 fix(costs): derive the final budget breakdown on the server, in the same cents
The three figures under a traveler's final budget came from the server, netted
in integer trip cents at the rate frozen on each expense. The rows listed under
them did not: both shells converted the expense list again with whatever live
rate they had, and with none loaded yet (first render, offline) the raw foreign
amount went out under the display currency's symbol. On any trip with a
foreign-currency expense the rows could not add up to the line above them.

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

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

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

The day is now required in both payment forms. Cleared, the server stored
NULL and the ledger quietly fell back to the day the payment was recorded.
2026-09-12 17:33:04 +02:00
RemEx 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 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

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

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

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

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
dersimoezdag 06ecc5135c feat(budget): attach receipts and invoices to expenses 2026-09-08 23:15:26 +02:00
Maurice 85f5247a00 fix(test): the card is labelled Total trip spend 2026-09-06 15:03:08 +02:00
Maurice 8a48131618 test: pin the empty-metadata payload and the payer-less share
Two suites encoded the behaviour this branch changes: MReservationSheet
asserted the literal metadata: null the forms no longer send, and the new
Your-share case reached for a label that is a card footer rather than a
heading.
2026-09-06 15:03:08 +02:00
Maurice 0a35ce83a5 fix: the follow-ups the v4.2.1 audit turned up
Five findings from auditing the release branch, none of them a blocker for it,
all of them things it would have shipped with.

The mirrored booking price only survived an edit when the form happened to fill
metadata of its own. Both reservation forms and the phone sheet sent
`metadata: null` whenever the form had no metadata key to send, and null clears
the column outright — so restaurant, event, tour, parking, other, a hotel with
no check-in times and a transport with no carrier details all lost the price
again on the next save. They send the object now: it still clears what the form
dropped, and the server carries the price across (#2233).

Your share counted expenses nobody has paid for, while the balances right below
it stopped counting them. The tile contradicted the panel it sits in (#2225).

App.tsx kept its own list of routes that must not mount authed-only widgets, and
it had already drifted from the one the response interceptor uses: this one
named /public/journey/, that one covers all of /public/. It reads
isAuthPublicPath now, so a route cannot be public to one and not the other.

LLM_TIMEOUT_MS and TREK_PLUGINS_IGNORE_TREK_RANGE shipped in .env.example but
not in the compose file most installs actually read.

And a typo in the Russian offline copy: Завершёна → Завершена.
2026-09-06 15:03:08 +02:00
Maurice 7c857173cf fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
2026-09-02 21:06:51 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
jubnl 0269273d01 fix(budget): use Math.trunc for user colour index, complete settlement fixtures
The `| 0` truncation in colorForUserId/colorFor existed to coerce an
undefined user_id to 0, but the settlement API always returns user_id —
only the two BudgetPanel test fixtures omitted it. Add user_id to the
fixtures, switch both helpers to Math.trunc (with a Number.isFinite
guard in colorForUserId preserving the old undefined->0 behaviour), and
drop nothing else: TravelerPicker only ever receives required numbers.
2026-08-25 23:16:12 +02:00
Maurice 1ad2e92142 fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.

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

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

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

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

sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
2026-08-25 19:26:38 +02:00
Maurice 2c2d008dc2 fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path

Three findings out of a sweep of every open Sonar security issue. The other 49
turned out to be false positives and are documented in sonar-project.properties
rather than changed — each entry scoped to a file, never a whole rule, so a
future Math.random() on an auth path still fails.

The idempotency key is the one that can lose data. Both producers fell back to
Math.random when crypto.randomUUID was missing, and randomUUID needs a secure
context — TREK's own quickstart is http://localhost:3000 and LAN installs run on
http://192.168.x.x, so on the deployments the README documents the fallback is
what actually runs. The axios one was
`Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a
single character and 0 yields the empty string, which the interceptor reads as
"no key" and skips deduplication for entirely. Two writes sharing a key collapse
into one — the second gets the first one's cached response and the edit is gone
with no error anywhere. Both now come from utils/randomId, which has the
getRandomValues rung that works in insecure contexts too.

The extractor probe stored the bare binary name, so every extraction re-resolved
it through whatever PATH held at that moment. The official image sets
KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install
without it ran an unqualified name from a PATH it does not control. It now walks
the search path itself and keeps the absolute path of the file it verified;
execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through
app-config because process.env is walled off in src/.

* fix: four tests and sorts that were not doing what they claimed

Sonar reported 43 high-impact reliability issues. Reading all of them, four are
real; the rest are cosmetic or would be made worse by the suggested change.

budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid
? 1 : 0` and the handler stores the value verbatim — the test asserted a payload
the server never sends. The line below it already used a number.

admin.controller.test.ts had a `.resolves` assertion with no await. The promise
was dropped, so that line could not fail the test.

The two to-do category lists sorted with a bare .sort(), which is byte order:
every accented name files behind the whole ASCII range, so in a German trip
"Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data.
One side effect worth knowing: katColor picks a category's dot colour by its
index, so re-sorting can move which category shows which colour — it already
moves whenever a category is added, and the colour carries no meaning.

Deliberately not changed: the two `| 0` truncations (Math.trunc is not
equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the
two .map() callbacks whose second parameter is declared and used, and the sha1
in thumbnail.service (its own comment says a swap would orphan every existing
thumbnail).

* refactor: give every button a type, and prefer the Number statics

Two mechanical sweeps, no behaviour change intended in either.

A <button> with no type attribute is type="submit" per the HTML spec. Outside a
form that does nothing today, but it turns into a surprise the moment the markup
is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get
an explicit type="button". Two things were left alone on purpose: buttons that
take a {...spread}, because the spread may carry a type and the merge order
decides which wins, and anything inside a <form> — there the answer is a
judgement call, and checking all eleven form files found every real submit button
already carrying type="submit".

The Number sweep covers only the three globals that are exactly equivalent to
their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global
coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false,
and renaming it would change what the code does. It reaches server and plugin-sdk
as well as the client; both copies of egress-policy.ts got the identical edit, so
the mirror the SDK's parity test guards stays intact.
2026-08-25 15:22:38 +02:00
Maurice e3c524dd02 test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of
them end on a bare `await screen.findByText(...)`, which does throw when the
text never shows up — the test is real, the analyser just cannot see it.
Spelling the check out keeps the behaviour identical and makes it readable.

Writing them out turned three tests red, each for a good reason:

- FE-COMP-NOTES-008 read the heading out of the loading panel, which renders
  its own copy and swaps the whole node out once the fetch settles. It now
  waits for the loaded state.
- FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the
  "Not connected" the badge starts as — the test passed whether or not the
  button did anything. Exact text now.
- FE-FILES-002 compared an empty list against the empty list it already was.
  It seeds a file first, so the 500 has something to leave alone.

JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment;
it now says that out loud and checks no journey entry is left orphaned.
2026-08-25 11:02:39 +02:00
Maurice 7c6a17c020 fix(client): make the new rollbacks roll back only what they wrote
The rollbacks this branch added were snapshot-shaped: capture the whole
collection before the write, restore it on any failure. That is wrong in two
ways at once. It undoes work the server already accepted when a later step is
the one that fails, and it throws away whatever a collaborator's socket event
applied while the requests were in flight.

The day-plan reorder is the clearest case: five awaits, and a failing day-note
write reverted transport positions the server had already stored. It now
records only the fields it is about to overwrite, per booking, and drops each
one as its request lands - so a note failure reaches the catch with nothing to
undo, and a booking that arrived mid-write survives.

The notification toggle had the same shape underneath: the rollback was made
surgical but the request still shipped the whole matrix built from the
pre-toggle render, so two overlapping toggles overwrote each other anyway. It
sends the one cell that changed. The test pinned the disagreement as correct;
it asserts the payload now.

Photo reorder awaits allSettled rather than all, so the UI is not snapped back
while the remaining writes are still landing - a partial run really does leave
the server holding part of the new order, and only a total refusal justifies
restoring the old one. Both the desktop editor and the mobile sheet, which was
still swallowing every rejection.

Also: the geolocation start guard is a run generation now, because a start
issued after a stop re-sets a plain flag while the first run is still parked in
the iOS permission prompt; a category rename reports once for the batch and
rethrows, so the settings dialog stops closing as if it had worked; and a 404'd
import job stops telling the user their file was invalid when it had simply
expired.

The clipboard fallback for plain-HTTP installs was pasted into three files by
this branch. It is one helper now, and the two copy buttons in the members
modal that still called navigator.clipboard unguarded use it too. Same for the
fourth local-date helper written next to the three that already import the
existing one.
2026-08-23 11:59:12 +02:00
Maurice e72338bbbc i18n: the English left in Costs, Studio and the mobile timeline
Three surfaces still had English written straight into JSX in a codebase that
gates on key parity: the reworked expense modal, the Studio quickbar and
rotation handles, and the leg-mode menu on the mobile day plan. Keyed, and
translated into all 23 locales rather than pasted.

Also reworded the plugin geolocation permission: it told the user their
browser would ask, and it never does - TREK answers the plugin from the
page's own location permission.
2026-08-23 11:59:12 +02:00
Maurice Böe 942e1932c9 fix(budget): name the category parking and file parking bookings in it
The key was 'parkings', which is not a word and does not match the 'parking'
the reservation type uses everywhere else. The shared trip page prints the
stored category verbatim, so a share link would have shown "parkings" in every
language.

The category also had no relationship to the booking type of the same name:
RESERVATION_TYPE_TO_COST_CATEGORY still sent a parking booking's auto-expense
to Transport, so the new bucket sat empty next to the costs that belong in it
while a manually typed "parking" resolved into it. Both paths agree now.
2026-08-21 14:28:53 +02:00
MikeDabrowski 999236f944 feat(budget): add "Fuel" and "Parking" expense categories
- Adds two new fixed budget categories: fuel ("Fuel") and parkings
  ("Parking"), alongside the existing set (Accommodation, Food & drink,
  Groceries, Transport, Flights, Activities, Sightseeing, Shopping,
  Fees & tickets, Health, Tips, Other).
- Icon/color metadata added for both (Fuel and ParkingCircle from
  lucide-react), plus legacy free-text aliases (gas/petrol->fuel,
  parking->parkings) for existing/imported expense rows.
- Label strings added for all 23 supported locales.
- Adds test coverage for the new categories and legacy alias mapping.
2026-08-21 14:28:53 +02:00