Skip to content

fix(web): keep typed percents, survive offline saves, validate search params - #36

Merged
musa-kal merged 12 commits into
mainfrom
fix/entry-search-offline
Nov 14, 2026
Merged

musa-kal merged 12 commits into
mainfrom
fix/entry-search-offline

Conversation

@musa-kal

Copy link
Copy Markdown
Member

Intent

Fixes from the full-app dogfood pass:

  1. Percent split keeps every typed value exactly as typed (only the edited person changes), the status says how much is left or over, and save is refused until it totals 100. The leftover cent rule (largest remainder, ties to the smaller user id) is pinned with shared cases in core (split-remainder.test.ts) and pgTAP (31_split_rounding.test.sql); no SQL logic change.
  2. Search params are always validated, so /search?q=30 searches for "30" instead of crashing, and every route gets a styled error page with Try again and Go home.
  3. Offline: an app-wide offline banner; expense and payment forms disable Save while offline and keep what was typed, closing the tab asks first, and a save caught by a dropped connection says it will go through when back.
  4. Category and subcategory chips are one Tab stop each with arrow keys; people are listed in the same order with the same initials in every split mode.
  5. The receipts count is separate text, not glued to the label.
    Must keep: money in integer minor units, i18n for every string, no em or en dashes.

What Changed

  • Expense splits: typing a percent now changes only that person's value (typePercent replaces the rebalancing setPercent). The status shows how much is left or over, and the form will not save until the total is exactly 100%. People appear viewer first, in the same order with the same initials, in every split mode. Category and subcategory chips are each a single Tab stop and use arrow keys to move. The receipts count is now separate text that describes the list, not part of the label. The leftover cent rule (largest remainder, ties go to the smaller user id) is pinned by matching cases in packages/core (split-remainder.test.ts) and pgTAP (31_split_rounding.test.sql). No SQL logic changed.
  • Offline: new useOnline and useWarnBeforeUnload hooks (lib/online.ts). An app-wide OfflineBanner sits in the root layout. React Query's onlineManager is set from navigator.onLine at startup. The expense and settle forms disable Save while offline and keep what was typed. They ask before the tab closes while an unsaved entry is at risk. A save that fails with a network error shows OfflineSaveNote with Try again and refetches group money, and is not retried automatically. New app.offline.* strings are added in en, de, es, fr and pt.
  • Routing: stringParam and booleanParam now return every key they own and turn numbers and booleans back into text, so /search?q=30 searches for "30" instead of crashing. The expenses, insights and new-plan search params use the same validators. A new styled RouteError page with Try again and Go home is the root and default error component. Unit tests cover all of this, and the expense-entry, expenses and insights e2e specs plus the docs are updated.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: Client-only UI changes with no SQL logic change. Round 2 now classifies a failure once in onError and invalidates the group's money keys (settlements and expenses sit under moneyKeys.group), and I traced it as correct for both forms. The percent, search-param, category and route-error changes behave as the intent and the later recorded decisions require. The only remaining item is a stale comment.

Testing

  • ⏭️ Test - skipped

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ apps/web/src/features/expenses/form/expense-form.tsx:557 - Intent item 3 says 'a save caught by a dropped connection says it will go through when back', but the new paused path is almost never reachable. useOnline() and React Query's onlineManager follow the same window online/offline events, and both forms now return early when !online (expense-form.tsx:563, settle-flow.tsx:370). So mutate() only starts paused in the sub-frame race between the offline event and the re-render. The real sequence is different: the connection drops while the create_expense/record-payment request is in flight, fetch rejects, and mutations have no retry (query-client.ts only sets queries.retry). The mutation then errors instead of pausing, isPaused stays false, and OfflineSaveNote never shows app.offline.paused. The user gets the pre-existing errors.network toast ('We will retry when you are back'), and that claim is also false: nothing retries the mutation. The entry is kept and the user can save again by hand. The unit test 'says a save caught by the connection dropping will go through' (expense-form.test.tsx:507) only passes because it calls onlineManager.setOnline(false) without the browser event, a state real usage does not produce. The same gap exists at settle-flow.tsx:366 (pay.isPaused). The smallest honest fix would retry non-idempotent creates on network errors, which needs idempotency keys or server-side dedupe to avoid duplicate expenses or payments. That fix extends scope, so it needs the author's decision. The alternative is to narrow the claim and the copy to what actually happens.

🔧 Fix applied.
3 issues (1 warning, 2 infos) still open:

  • ⚠️ apps/web/src/features/expenses/form/expense-form.tsx:557 - Intent item 3 says 'a save caught by a dropped connection says it will go through when back', but the new paused path is almost never reachable. useOnline() and React Query's onlineManager follow the same window online/offline events, and both forms now return early when !online (expense-form.tsx:563, settle-flow.tsx:370). So mutate() only starts paused in the sub-frame race between the offline event and the re-render. The real sequence is different: the connection drops while the create_expense/record-payment request is in flight, fetch rejects, and mutations have no retry (query-client.ts only sets queries.retry). The mutation then errors instead of pausing, isPaused stays false, and OfflineSaveNote never shows app.offline.paused. The user gets the pre-existing errors.network toast ('We will retry when you are back'), and that claim is also false: nothing retries the mutation. The entry is kept and the user can save again by hand. The unit test 'says a save caught by the connection dropping will go through' (expense-form.test.tsx:507) only passes because it calls onlineManager.setOnline(false) without the browser event, a state real usage does not produce. The same gap exists at settle-flow.tsx:366 (pay.isPaused). The smallest honest fix would retry non-idempotent creates on network errors, which needs idempotency keys or server-side dedupe to avoid duplicate expenses or payments. That fix extends scope, so it needs the author's decision. The alternative is to narrow the claim and the copy to what actually happens.
  • ℹ️ apps/web/src/features/expenses/form/expense-form.tsx:560 - Introduced by fix round 1. unreached is worked out on every render as m.isError && isNetworkError(m.error). isNetworkError returns true for any error while navigator.onLine === false (errors.ts:52), so a mutation that failed for a server reason gets relabeled later. Example: Save is refused with split_member_not_in_group and the toast shows. The phone then loses signal, the offline event re-renders the form, and unreached becomes true. The note now says 'Could not reach Potluck... try again' with a disabled Try again, instead of the plain app.offline.form text, and beforeunload is armed for that reason. When the connection comes back the note disappears again. Sibling site: settle-flow.tsx:369 (pay.isError && isNetworkError(pay.error)). Fix: classify the failure once, when it happens. For example, set a flag in onError (cleared in onMutate), or check only the error's shape (FETCH_FAILURES) for the failed state, so a refusal stays a refusal after going offline.
  • ℹ️ apps/web/src/features/shell/offline.tsx:51 - This is a tradeoff left by the fix the user chose in round 1. A fetch that rejects after the server committed create_expense / record_payment (for example, the connection drops while the response is coming back) still shows 'Could not reach Potluck. What you typed is still here'. Try again (expense-form.tsx:597, settle-flow.tsx:428) then creates a second expense or payment. The docs added in development.md:303 say saves are not retried automatically because a request that did arrive would be saved twice, but a manual Try again carries the same risk. Without idempotency keys this is acceptable. No change needed unless the author wants softer copy, such as 'may not have saved; check before trying again'.

🔧 Fix applied.
1 info still open:

  • ℹ️ apps/web/src/lib/online.ts:13 - Stale comments left by fix round 1, which removed the paused save path. The useOnline doc says React Query's onlineManager follows the same events "so a save that would pause offline and this flag agree", and query-client.ts:18 says opening offline should "pause queries and saves". Saves no longer pause: both forms return early while offline (expense-form.tsx onSubmit, settle-flow.tsx submit), and a failed save is classified in onError. The onlineManager seeding still matters for queries. Fix: reword both comments to cover queries only, so nobody brings back the removed paused branch on the strength of them.
⏭️ **Test** - skipped

Step was skipped.

✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

musa-kal and others added 12 commits November 14, 2026 02:14
Typing a percent used to rebalance everyone else in proportion, and the fields only committed on
blur or Enter, so 50 / 30 / 20 could save as 48.7 / 31.3 / 20 and fast filling as 0 / 80 / 20,
all while the status read 100%. Each field now passes on every keystroke and changes only that
person; the status says how much is left to assign or how much too much, and saving waits for
100%. Cents are only worked out from the percents, by the same largest remainder rule as shares
and adjustments, now pinned by shared cases in core and pgTAP.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
/search?q=30 took the whole app down: the router reads the address as JSON, the validator
dropped the number, and TanStack lays validated params over the raw ones, so q still reached
the screen as 30 and crashed on trim(). Text params now take numbers and booleans back as text
and every validator returns all of its keys, undefined when dropped, so a raw value can never
show through.

A screen that throws now shows our own page with Try again and Go home, inside the app frame,
instead of the router's bare "Something went wrong!".

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
A small banner shows on every screen while the browser is offline. The expense and payment
forms turn their save button off with a note that what was typed stays put, and the tab asks
before closing while that entry would be lost. A save already on its way when the connection
drops is paused by React Query; it now says it will go through once the connection is back
instead of spinning with no word. React Query also starts from navigator.onLine rather than
assuming it is online.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The category row (and its subcategory row) is now a proper radio group: one Tab stop on the
picked chip, arrow keys, Home and End to move and pick, instead of sixteen Tab presses.

The split chips, rows and slider list you first and then everyone in member order, as the by
item chips already did, and the by item chips use the same avatar size, so initials no longer
switch between "O" and "OO" when the split type changes.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The count sat inside the label element, so the field read "Receipts and photos1 of 5" to screen
readers and in copied text. The label is now just the label, the count is separate text with a
real space before it, and the list takes the count as its description.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
@musa-kal
musa-kal force-pushed the fix/entry-search-offline branch from 3582d66 to bad17f9 Compare November 14, 2026 07:52
@musa-kal
musa-kal merged commit 1cc1d81 into main Nov 14, 2026
7 checks passed
@musa-kal
musa-kal deleted the fix/entry-search-offline branch November 14, 2026 08:05

This branch was successfully deployed

1 active deployment
preview — bad17f93 Deployed Nov 14, 2026 by musa-kal via Deploy web to Cloudflare #99
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant