* fix(chat): make the assistant response timeout configurable (#2893)
Self-hosted users running a local model report the chat failing with
"assistant not available" after 90 seconds even though the model
generates a reply and tokens are billed.
Three timeouts are involved and only one was configurable:
- OPENAI_REQUEST_TIMEOUT (60s) — already settable, not the blocker.
- The browser watchdog in chat_controller.js (90s) — hardcoded, and
this is what actually fires.
- Chat::UNDELIVERED_RESPONSE_TIMEOUT (60s) — a constant, so raising
the client value alone would not have helped.
The watchdog cannot be avoided by streaming here: custom
OpenAI-compatible providers route through generic_chat_response, which
forces synchronous calls, so nothing renders until the whole generation
finishes. Time-to-last-token has to beat the deadline.
Adds AI_RESPONSE_TIMEOUT (ENV > Setting > 90s default, floored at 30s),
exposed on the Self-Hosting settings page and passed to the Stimulus
controller at all three mount points — show, index and the sidebar in
the application layout, each of which declares data-controller="chat"
independently.
The server floor is derived from the same value but kept 10s below it.
report_timeout answers 200 whether or not it acted and the client only
retries on a non-ok response, so a floor at or above the client value
would let clock skew strand a pending bubble permanently.
Also guards AssistantMessage#append_text!. The watchdog runs in the web
process while the job holds its own copy of the message, so a job
finishing after the bubble was destroyed or demoted would silently
resurrect it alongside the error the user was already shown.
* fix(chat): let the watchdog retry when report_timeout declines
`report_timeout` answered 200 whether or not `handle_undelivered_response!`
acted. The Stimulus watchdog only stops retrying a URL once it sees a 2xx, so
a declined report was treated as final.
That stranded the bubble whenever the client's clock ran more than
SERVER_TIMEOUT_GRACE ahead of the server's: the watchdog posts at its own
timeout, the server sees a message younger than its floor and no-ops, and
nothing ever retries. The bubble spins forever with no error and no Retry.
Answering 409 instead lets the next 5s tick try again, so any amount of skew
costs retries rather than a stuck chat. The grace window stays as an
optimisation to keep those retries rare, not as the correctness mechanism.
* docs(chat): correct the AI_RESPONSE_TIMEOUT ordering guidance
The docs, locale string and examples all said to keep OPENAI_REQUEST_TIMEOUT at
or above AI_RESPONSE_TIMEOUT. That is backwards.
The two limits span different things. OPENAI_REQUEST_TIMEOUT bounds each HTTP
call to the model on its own; AI_RESPONSE_TIMEOUT covers the whole turn and its
clock starts when the message is queued, so it also absorbs Sidekiq queue time
and, for a tool-using turn, two model calls plus the tool run between them.
Keeping the chat timeout the larger of the two means a slow model surfaces the
specific HTTP timeout error rather than a generic "no response", and the job
stops instead of running on after the chat has given up. The shipped 60/90
defaults already had this ordering; only the guidance was wrong.
compose.example.ai.yml gets 300/660 so the Ollama example can actually complete
a tool-using turn.
* fix(chat): claim the pending bubble atomically before appending
append_text! read the row's status and then saved, leaving a window in which
the watchdog could demote the row to `failed` between the two. The late
content would then land on a bubble the user had already been told failed,
flipping it back to `complete`.
Replaces the read with a conditional UPDATE that only succeeds while the row is
still pending, so the check and the state change cannot be separated.
Uses a conditional UPDATE rather than with_lock because append_text! is called
once per chunk on the streaming path; a row lock and transaction per chunk would
be far more expensive. The claim only runs on the first append, since later ones
are no longer pending.
* test(chat): isolate AI_RESPONSE_TIMEOUT from the environment
Chat.response_timeout reads ENV ahead of Setting, so stubbing only the Setting
left the assertions at the mercy of the environment they run in. With
AI_RESPONSE_TIMEOUT=45 exported, five of these tests failed — the default,
floor and grace assertions were all silently measuring the env value.
Adds a with_setting_timeout helper that stubs the Setting and clears the
variable together, and switches the controller tests to stub
Chat.undelivered_response_timeout directly, since what they care about is the
resolved floor rather than how it was configured.
Both files now pass with or without AI_RESPONSE_TIMEOUT set.
* docs(chat): size AI_RESPONSE_TIMEOUT for chained tool calls
The guidance assumed a tool-using turn costs two model calls. #2767 landed
after this branch was opened and made tool calls iterative: `Assistant::Responder`
now loops until `iteration > max_tool_call_iterations`, so a turn runs to
1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS calls — six by default — with tool
execution in between. At the default 60s per-call timeout that is up to 360s of
model time against a 90s watchdog.
Streaming does not rescue this either. `emit(:output_text)` only fires for a
response that carries text, and tool-call-only rounds carry none, so the bubble
stays on "Thinking…" through every round regardless of provider.
Documents ASSISTANT_MAX_TOOL_CALL_ITERATIONS as the cheaper lever: dropping it to
2 halves the worst case instead of demanding a half-hour timeout, at the cost of
failing long tool chains earlier with a clear limit error. compose.example.ai.yml
now shows that combination rather than a timeout sized for six calls it never had.
* docs(chat): state the whole-turn timeout as a sum, not a maximum
The guidance said to keep AI_RESPONSE_TIMEOUT "above" or "the larger of"
OPENAI_REQUEST_TIMEOUT. That understates it: the watchdog covers the entire turn,
so the bound is
(1 + ASSISTANT_MAX_TOOL_CALL_ITERATIONS) * OPENAI_REQUEST_TIMEOUT
+ tool execution + queue wait
Merely exceeding the per-call limit can still leave the chat reporting failure
while the worker keeps going.
One phrasing was outright wrong: "keep AI_RESPONSE_TIMEOUT the largest of the
three" compared a duration against ASSISTANT_MAX_TOOL_CALL_ITERATIONS, which is a
count, not seconds.
Resizes the examples against the formula — compose.example.ai.yml 1000 -> 1200 and
the Ollama doc example 600 -> 720, both now showing the arithmetic — and states
plainly that the 90s default is sized for typical cloud latency rather than the
worst-case bound, with the formula being what matters once per-call latency
approaches the timeout.
* docs(chat): list the AI settings fields and tag the formula fence
The Settings UI walkthrough listed three of the eight fields on the AI Provider
form. JSON Mode, the three Token Budget fields and the new Chat Response Timeout
were all missing, so the timeout was only discoverable from the troubleshooting
section. Rewrites the list to follow the form's own grouping and uses the labels
the form actually renders.
Also tags the whole-turn formula fence as `text` (markdownlint MD040).
* fix(compose): forward ASSISTANT_MAX_TOOL_CALL_ITERATIONS in standard compose
This file enumerates container environment explicitly — there is no env_file — so
a variable absent from the x-rails-env anchor never reaches web or worker.
The tool-call cap was only named in a comment here, while the docs added in
3360dbf5 tell operators to lower it to keep a turn inside AI_RESPONSE_TIMEOUT.
Following that advice on this compose file silently changed nothing: the app kept
the default of 5 while the timeout was sized for 3 calls, which lands back on the
"no response" error this branch exists to fix.
Left with an empty default so the app's own default governs, matching
OPENAI_MODEL and LLM_CONTEXT_WINDOW above. compose.example.ai.yml already
forwarded it.
`transactions_section_controller.js` was added by #454 for the upcoming
recurring-transactions section. #771 moved recurring transactions to a
dedicated tab and removed the only mount, so the controller and its whole
persistence chain have been dead since then:
`data-controller="transactions-section"` appears nowhere in app/views or
app/components, and `transactions_section_collapsed?` had no callers outside
its own definition.
Removed:
- app/javascript/controllers/transactions_section_controller.js
- User#update_transactions_preferences
- User#transactions_section_collapsed?
- TransactionsController#update_preferences + #preferences_params
- the `patch :update_preferences` route on the transactions collection
The only caller of /transactions/update_preferences was the dead controller
itself. No tests referenced any of it — the `update_preferences` cases in
pages_controller_test cover the dashboard's route — which is how it stayed
dead unnoticed.
No migration: users who collapsed a section before #771 keep a stale
`transactions_collapsed_sections` key in `users.preferences`, and nothing
reads it after this.
The dashboard and reports section-layout preferences are untouched.
* fix(ai): drop empty data-driven enums from assistant function schemas
Enum values in tool schemas are built from family data (account names,
categories, merchants, tags, tickers). A family with none of these gets
enum: [], which is invalid JSON Schema. OpenAI tolerates it, but strict
OpenAI-compatible providers reject the entire request, breaking chat for
fresh families until they create a tag or merchant.
Prune empty enums in build_schema, falling back to a plain string. One
choke point covers every function and both consumers: chat tool
definitions for all providers, and the /mcp endpoint's tools/list.
* fix(ai): address review feedback on enum pruning
Stop recursion at populated enum values: enum members are literal
values, not subschemas, so a literal like enum: [{ enum: [] }] must be
preserved verbatim rather than rewritten.
Also cover PREVIEW_FUNCTION_CLASSES in the registry regression test by
enabling the preview preference on the test user, with a guard assertion
so the test fails if preview functions ever silently drop out.
* fix: gracefully handle invalid family timezone instead of crashing
Family#timezone is a free-text IANA zone name with no validation on
write. If it becomes stale (e.g. tzdata renames a zone, like the
historical Europe/Kiev -> Europe/Kyiv switch) or a migration meant to
remap legacy names never ran, Localize#switch_timezone passed the raw
string straight to Time.use_zone, which raises ArgumentError for any
unrecognized zone.
Since switch_timezone runs as an around_action on every request, this
crashed the entire app for the affected family, including the login
page.
Now validates the zone via ActiveSupport::TimeZone[] first and falls
back to the app default (logging a DebugLogEntry) instead of raising.
The log write is debounced per (family, bad value) via Rails.cache
(once per day) so an affected family doesn't write one DebugLogEntry
row per page view indefinitely.
Fixes#390
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: address review feedback on timezone fallback
- Make the invalid-timezone debounce lease atomic. Rails.cache.fetch
is read-then-write, not atomic, so two concurrent requests could
both observe a cache miss and both log before either write landed.
Rails.cache.write(unless_exist: true) maps to Redis's atomic SET NX
in production, so only one request ever wins the lease.
(via CodeRabbit)
- Stop using "Europe/Kiev" as the invalid-timezone value in tests.
Whether ActiveSupport::TimeZone still resolves that legacy alias
depends on the host's installed tzdata version (tzinfo-data is
Windows/JRuby-only per Gemfile), so the test's pass/fail behavior
wasn't deterministic across machines/CI. Use a deliberately
nonexistent name instead.
(via Codex)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: validate Family#timezone on write to address root cause of #390
The previous commit made the *crash* graceful, but left the actual
defect in place: nothing stopped an unrecognized IANA zone name from
being written to Family#timezone in the first place (direct DB/API
access, an old dump predating a tzdata rename, or a future rename of
a currently-valid zone).
Add a Family-level validation using the same ActiveSupport::TimeZone[]
lookup Localize#resolved_timezone uses at request time, so "valid at
save" and "valid when rendering" can't drift apart.
Deliberately not `inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }`,
matching the neighboring locale/date_format validations: verified
empirically that the settings form submits `tz.tzinfo.identifier` (e.g.
"America/New_York"), not `tz.name` (e.g. "Eastern Time (US & Canada)"),
and those differ for all 150 zones Rails ships. An inclusion check
against `.name` would have rejected every legitimate value the form
submits.
The validation only runs when timezone is actually being changed
(if: :timezone_changed?). A family with a pre-existing bad value (the
exact #390 scenario) must still be able to save unrelated changes --
otherwise this would turn a previously-harmless bad value into a
blocker for any other settings update or background job touching that
family's record.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(goals): stop the days-left phrase splitting across lines
The goal header read "Target 700 € by 08 de Febrer de 2027 · 184 days left"
as one text node, so on a phone it wrapped wherever it ran out of room —
"184 days" on the first line and "left" orphaned on the second.
`header_summary_parts` now returns the dot-separated segments instead of a
joined string, and the view renders each as its own span. Only the segments
after the first are `whitespace-nowrap`: the first carries the target and a
long-format date and has to stay free to wrap, or it would overflow a narrow
screen. Checked at 320px, where the first segment takes two lines and
"675 days left" still moves down whole.
* test(goals): pin the clock for the days-left assertion
The count is `target_date - Date.current`, and the target was set from
`184.days.from_now` a moment earlier — a suite crossing midnight between the
two would compute 183 and fail. Wrap it in travel_to, matching the idiom in
budget_category_test and family_export_test.
The neighbouring 'omits days left once reached' test asserts only the segment
count, which 183 and 184 satisfy equally, so it is left alone.
* fix(dashboard): fit money-flow month labels and put its figures on scale
Two problems in the Money In / Out widget.
The month labels collided on a phone. The axis rendered whatever
`short_month_year` produced with no regard for the space each band actually
has, and that format is only short in some locales: "Mar 2026" in English,
but "Mar de 2026" in ca/es/pt. Measured on a 390px viewport in Catalan, the
labels are 62-71px against a 56px band step — five overlapping pairs, worst
overlap 15px. English is not immune, just further from the edge: it clears by
2px at 390px and overlaps four pairs at 360px.
The chart now measures what it rendered and steps down until it fits: the
full label, then an abbreviated month, then every other tick with the
highlighted month always kept. Measuring beats assuming a character width,
which changes with locale, font and zoom.
The income and expense amounts also carried no size class, so they inherited
the 16px base. That is off the scale the dashboard uses — `text-sm` for a
repeated row, `text-lg` for a section total, `text-3xl` for a hero figure —
and left each amount larger than the label sitting next to it. They are now
`text-sm`, matching both their own labels and the equivalent amount in the
outflows widget.
The balance keeps `text-lg`: that matches the balance sheet's group total,
and with the rows corrected it now reads as the summary of the two beneath it
rather than one more oversized number.
* fix(dashboard): take the thinned-label parity from the highlighted month
The thinning tier kept every even index plus the highlighted month. That month
sits last — build_money_flow_data counts down to the selected month — so at six
bars it kept 0, 2, 4 and 5, leaving the final pair one step apart: exactly the
spacing the code had just measured as too tight.
Take the parity from the highlighted index instead, so that month is part of
the pattern rather than an exception to it. At 240px it now keeps 1, 3, 5
(Abr, Jun, Ago) evenly spaced with the selected month still shown, against
0, 2, 4, 5 before.
* fix(pagination): stop the pager wrapping on narrow screens
The page-number strip put the last page on a second line on a phone. The
container was a plain block, so the inline-flex links flowed as inline
content and wrapped like words. Measured on a 760-page list: the strip needs
~198px, and a 360px viewport leaves ~158px once the chevrons and the
per-page select are taken out.
The failure was not even consistent — at 402px the row wrapped, at 360px the
same markup overflowed its parent instead, because a block box inside a flex
item resolves its width differently at each size.
Make the strip a nowrap flex row, and drop the pages that aren't useful on a
phone. Small screens keep the first page, the last page, the current page and
the gaps ("1 … 5 … 760", ~148px); the full series returns from `sm` up. Short
series are left alone — Pagy only emits those when the collection has that
few pages, so hiding there would render a 3-page pager as "1 3".
`sm:` is a viewport breakpoint and the pager also lives in narrow columns on
wide screens (the account activity feed with both sidebars open), so the row
keeps `overflow-x-auto` as a backstop. At 320px, where even the reduced set
is wider than the space, it scrolls rather than wrapping.
Shared by twelve call sites, so this covers transactions, imports, rules,
account activity, statements, merchants, exports and the debug log.
* fix(pagination): mark the pages the mobile pager drops
Hiding the middle page links without saying so made the survivors read as
adjacent. On a 760-page collection sitting on page 3, Pagy emits
[1, 2, "3", 4, 5, :gap, 760] and a phone rendered "1 3 … 760" — page 2 gone
with nothing to show for it. A gapless 6-page series was worse: "1 6".
Each collapsed run now renders its own mobile-only ellipsis, unless Pagy
already put a gap beside that run, which would otherwise read as "… …".
[1, 2, "3", 4, 5, :gap, 760] -> 1 … 3 … 760
["1", 2, 3, 4, 5, 6] -> 1 … 6
["1", 2, 3, 4, 5, :gap, 760] -> 1 … 760 (Pagy's gap already covers it)
[1, :gap, 3, 4, "5", 6, 7, …] -> 1 … 5 … 760
The helper now returns a per-slot plan rather than a class, since the view
needs to know where a run starts, not just whether a slot is hidden.
Tests carry a property check — no two page numbers may survive side by side
unless they are consecutive — with a guard that fails if every pair happens to
be separated by an ellipsis, which would make the property vacuous. It was, on
the first pass.
* feat(mcp): add pagination to get_tags and get_categories tools
Both tools now accept a `page` param and return `total_results`,
`total_pages`, `page`, and `page_size` — matching the pattern used
by get_transactions and get_holdings. Adds a migration for composite
(family_id, name) indexes on tags and categories to support efficient
paginated ordering within a family scope.
* fix(mcp): make page param optional in get_tags/get_categories schema and fix migration version
Page defaults to 1 in call() so marking it required in the JSON schema was incorrect.
Also fixes migration to use ActiveRecord::Migration[7.2] instead of [8.0].
* fix: update schema.rb with family_id+name indexes for tags and categories
* fix(mcp): stable pagination sort and unique family/name indexes on tags and categories
- Make family_id+name indexes unique (matches existing AR uniqueness validations)
- Add id tie-breaker to alphabetically/alphabetically_by_hierarchy scopes
so paginated results are deterministic
* refactor(mcp): replace Pagy::Backend mixin with Pagy.new in model layer
Pagy::Backend is designed for controllers; using it in plain model
classes is fragile. Switch all four assistant functions to call
Pagy.new(count:, page:, limit:) directly and apply offset/limit
on the scope, which is the correct approach for non-controller contexts.
* feat(auth): sign in with a passkey, without a password
Passkeys could only ever replace the TOTP code: registration required 2FA
to already be on, and the WebAuthn ceremony was reachable only after
User.authenticate_by had succeeded. A registered passkey can now complete
sign-in on its own, from the login page.
The ceremony requests userVerification: "required", so the authenticator
has to confirm the person as well as the device. That makes a lone passkey
two independent factors, the same bar as the password plus TOTP flow it
replaces, which is why this path deliberately skips the TOTP step. A
credential that can only prove presence is rejected here and still works
as a second factor.
Sign-in is usernameless: no email is submitted, because the browser
returns the account handle with the assertion. Nothing on this path can be
probed to learn whether an account exists. Registration now asks for a
discoverable credential with residentKey: "preferred" so the key is
offered by the picker, while authenticators without a free resident-key
slot still register as a second factor.
Where conditional mediation is available, saved passkeys appear in the
email field's autofill menu; everywhere else the button covers it. The
automatic challenge request that conditional mediation makes on every page
load gets its own looser Rack::Attack budget, so ordinary page views can
no longer exhaust the limit that protects the MFA endpoints.
Set AUTH_PASSKEY_LOGIN_ENABLED=false to keep passkeys as a second factor
only. Passkey sign-in follows the same policy as local login, so it stays
closed to regular users when AUTH_LOCAL_LOGIN_ENABLED is false.
* refactor(auth): group the passkey button with the other sign-in methods
It sat directly under the password fields, so the forgot-password link
split it from the identical SSO buttons. It is an alternative to the
credential form rather than part of it.
* fix(auth): close the passkey challenge races and document the upgrade
Three review passes converged on the conditional-mediation flow. The
AbortController was created after `isConditionalMediationAvailable()`
resolved, so a button click or a Turbo disconnect landing in that window
found nothing to abort: the conditional task carried on, re-minted the
challenge, and the assertion the user was about to produce verified
against a challenge the server had already replaced.
It is created before the first await now, and held in a local, because
`abortConditionalMediation()` nulls the field. Checking that one signal
after each await covers both triggers, so no separate connected flag is
needed.
The same symptom had a second cause nobody flagged: `authenticate()` was
not re-entrant. A double-click minted a fresh challenge under an open
authenticator prompt and rejected a perfectly valid passkey, with no race
window at all — and it was live on the MFA step-up too, which shares the
method.
The conditional catch was silent for every failure, including a rejected
assertion the user had deliberately chosen from the autofill menu.
Splitting the try draws the line where it belongs: silence before the
user has been asked anything, feedback once they have picked a passkey.
Filtering on `error.name` cannot draw it, since `fetchOptions` and
`verifyCredential` both raise a plain Error.
Also documents the upgrade: passwordless is on by default and applies to
already-registered credentials, so a passkey added purely as a second
factor can now sign its owner in alone. Nothing in the schema marks a
credential discoverable — the authenticator decides — and the opt-out is
instance-wide.
The invitation test is a guard, not coverage for this change. The pending
token lives in the Rack session and `complete_sign_in` reads it right
after creating the session, so a `reset_session` dropped in between
strands the invitee in their own family, silently and with every existing
test still green.
* fix(auth): cancel the in-flight conditional options request
Aborting the conditional flow did not cancel its options request, because
`fetchOptions` never received the signal. A click landing while that POST
was in flight left it to finish, and its response could apply last.
The challenge rides in the session cookie, so "the server wrote it" only
counts if the Set-Cookie reaches the browser. Threading the signal means
an aborted request's response is discarded, which closes the window
without needing the server to hold two challenges open.
Also drops the absolute claim about which existing credentials gain
passwordless sign-in. `residentKey: "preferred"` is a request an
authenticator may decline, and nothing records what it decided, so the
honest statement is that password managers and platform authenticators
generally store discoverable credentials rather than always.
Add JKT → XIDX exchange MIC mapping, .JK symbol suffix normalization,
IDR default currency, and ID country code for Jakarta exchange.
Yahoo Finance returns Indonesian stocks (e.g. BBCA.JK) with exchange
code 'JKT'. Without this mapping, the provider cannot resolve the
exchange to the XIDX MIC already defined in config/exchanges.yml,
and normalize_symbol cannot append the .JK suffix for price lookups.
Tested manually: Yahoo Finance search and chart endpoints return
valid results for IDX tickers (BBCA.JK, currency=IDR, timezone=WIB).
* Enable Banking: progressive date_from fallback on WRONG_TRANSACTIONS_PERIOD
Some ASPSPs (e.g. Santander Totta and Activo Bank in PT) reject the
transactions window with 422 WRONG_TRANSACTIONS_PERIOD but do NOT return a
corrected date_from in the payload. The existing single-shot retry only fires
when the API supplies detail.date_from, so for these banks the retry was
skipped and the error surfaced as the generic "communication error";
transactions never synced even though the connection and session were valid.
This adds a bounded, progressive fallback: when the period is rejected and no
corrected date is available, retry with progressively shorter windows
(89 -> 60 -> 30 days). The ASPSP-suggested date is still preferred on the first
retry, so existing behaviour is preserved. The step-down only moves the window
forward, guaranteeing progress and avoiding an infinite loop.
Verified on a live instance: Activo Bank went from 0 to 82 transactions
imported once the fallback kicked in.
Refs #2989
Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>
* Address review: forward-only progress across all fallback windows
- Accept the ASPSP-suggested corrected date only when it moves the window
forward (current is nil or corrected > current), preserving the forward-only
retry bound (CodeRabbit).
- Skip fallback windows that are not newer than the current date_from and pick
the first that advances, instead of bailing out on a stale first candidate.
Fixes the case where an initial/user lookback (e.g. 45d) is newer than the
first window (89d) but the bank caps at 30d (Codex).
Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>
* Add tests for progressive transactions date_from fallback
Covers the two cases the previous single-shot retry missed:
- WRONG_TRANSACTIONS_PERIOD without a corrected date_from -> falls back to the
first shorter window (89 days).
- An initial lookback newer than the leading windows (45d) -> skips the 89/60
windows and retries with the first that advances (30 days).
Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>
---------
Signed-off-by: Pedro Santos <pedro_santos@outlook.pt>
Fills every gap in zh-TW against en.yml and corrects strings that were
already present. 124 files, all under config/locales.
- 3,055 keys translated from en.yml, plus 62 locale files that had no
zh-TW counterpart at all.
- Terminology follows what main's own zh-TW files already use.
- Vendor-facing strings stay in English where the user has to match them on
the provider's own site.
i18n-tasks missing -l zh-TW reports nothing that -l en does not also report.
Signed-off-by: Howard Wu <howdywu@gmail.com>
* fix(i18n): replace hardcoded UI strings with translation keys
Fourteen views printed English text directly instead of going through
t(). Those strings could not be translated in any locale, so French and
Italian users saw English there as well, even though both locales are
otherwise complete.
The Mint import form was the worst of them with thirteen hardcoded
strings, including the intro text and the submit button. The valuation
confirmation dialog passed "set" and "update" into an interpolation as
bare English words, so the verb stayed English whatever the locale.
Adds 35 keys to en and de.
* feat(i18n): complete the German locale
German covered 55 percent of the English keys, against 97 for French and
90 for Italian. The gaps ran through settings, imports, goals, insights
and every provider integration, so the interface kept switching language
in the middle of a page.
This fills 3170 keys. Terminology follows what the existing German files
already used: Konto for account, Händler for merchant, Familie for
family. Provider names, ticker symbols and the IBKR flex query field
names stay in English, because that is what users see in those services
themselves.
Interpolation variables were checked against the English source for every
translated key.
* fix(i18n): use one form of address across the German locale
The German files mixed the informal du and the formal Sie, sometimes
within the same dialog. Most of the existing strings already used du, so
the remaining 39 now follow suit.
Several of those sentences also read like form letters. "Bitte versuchen
Sie es erneut" is now "Versuch es noch einmal".
* fix(i18n): correct German names for account types
Depository accounts were called Bargeld, meaning cash, in the views,
while the model called them Bankkonto. The icon is a bank building and
the type covers checking, savings, CDs and money market accounts, so
Bankkonto fits in both places.
Investment accounts were called Investition. In German that word means
the act of investing rather than the account that holds it, so the label
read as if the app were tracking transactions instead of a portfolio.
Investment is the term people use for the account itself.
* fix(i18n): pass the account name to the SimpleFIN dialog title
The key simplefin_items.select_existing_account.title already existed and
interpolates %{account_name}. Calling t(".title") without it raised
I18n::MissingInterpolationArgument before the dialog rendered.
The controller sets @account for this action, so the title can use it and
name the account being linked.
* fix(i18n): finish the German consistency pass
Follow-up to review feedback on this branch.
Quotation marks: 32 strings opened with the German „ but closed with the
straight ASCII quote, which renders as broken punctuation. They now close
with “.
Address form: 311 strings still used the formal Sie, mostly in the
provider integrations that the earlier commit left alone. Mixing both
registers inside one screen was worse than either choice on its own, so
they now use du like the rest.
Wording: the securities breadcrumb said Sicherheit, which means safety
rather than the financial instruments, and now reads Wertpapiere. The
Redbark setup failure had a typo in the imperative. An invitation message
broke off mid-sentence without naming the household. SimpleFin is spelled
SimpleFIN throughout, matching the provider.
* fix(i18n): repair verb forms left by the address change
The previous commit swapped Sie for du with a rule set, which changed the
pronoun but left the verb in its formal form. That produced sentences like
"die du importieren möchten" instead of "möchtest", and "bevor du Brex-Konten
verknüpfen können" instead of "kannst".
Also fixes lowercase deine at the start of a sentence, which came from
replacing Ihre without looking at position, and the last Sie forms in the
Sophtron block scalar and the chat demo banner.
41 strings in total.
* fix(i18n): keep straight quotes inside the HTML attribute
My quote sweep replaced the closing ASCII quote after an opening „ with “,
and in treat_as_html that ASCII quote was the delimiter of class="font-medium".
A curly quote cannot delimit an HTML attribute, so the span rendered with a
broken class attribute rather than merely looking odd.
The attribute now uses straight quotes and only the surrounding citation marks
are typographic. Checked every German value containing markup for the same
mistake; this was the only one.
* fix(i18n): address findings from an independent review pass
Three passes went over this branch: the two bots on the PR, plus a
separate audit of the German values, the t() calls and the YAML structure.
This closes what they found.
Two defects were invisible until then.
settings.providers.status carried the key "false" instead of "off". The
English file writes `off:` unquoted, which YAML reads as the boolean false,
and I had copied the parsed name rather than the intended one. The code
looks up :off, so the status pill for unconfigured providers found nothing
at all. Both files now quote the key, which repairs English too.
The valuation confirmation dialog built a sentence that only works in
English. There, Set and Update open the sentence as imperatives; my German
values were participles, and the partial capitalises them at the start, so
it read "Gesetzt Kontostand am ...". The values are now "Neu:" and
"Geändert:", which carry a sentence opening. The date runs through l()
instead of strftime with a US format, so the German dialog no longer says
"July 30, 2026".
Grammar left behind by the earlier rule-based Sie/du change: 33 strings had
lost the verb particle ("Richte X." instead of "Richte X ein."), six kept an
infinitive after "bevor du", and one turned a pronoun referring to merchants
into a form of address, which reversed the meaning.
Placeholders that had been hardcoded: seven %{moniker}, where users can
choose Gruppe but the German text said Familie regardless, one
%{product_name}, and %{message} in an authorization error that swallowed
the cause.
Structure: entries.selection_bar.edit gave way to the long-standing
transactions.selection_bar.edit, which exists in 15 languages and would
otherwise have fallen back to English for all of them. simplefin_items
gained the missing check_provider_health in en and de, and two buttons
there now use keys that already existed instead of English literals.
Two notes on scope and wording, since this branch has grown well past its
original shape.
The PR began as pure gap-filling and deliberately left existing
translations untouched. The address change and these grammar fixes reach
into them because a dialog that switches register mid-screen is worse than
one that stays formal throughout. Existing values are roughly nine percent
of the diff and sit in separate commits, so they can be dropped on their
own if you would rather keep them out.
German terms stay close to the length of the English source. The buttons
size to their content, but DS::Buttonish sets whitespace-nowrap, so a
longer word widens the button instead of wrapping and pushes its
neighbours around in button rows, table cells and the sidebar. That is why
this locale says Sync rather than Synchronisieren, Setup rather than
Einrichtung, API-Key rather than API-Schlüssel. Where surrounding files use
the longer form, the difference is deliberate.
* fix(i18n): keep flash messages within the notification clamp
The notification partial renders flash messages at md:max-w-80 with
line-clamp-3, so anything past roughly 130 characters is cut off and
survives only in the title attribute. Ten German messages had crossed that
line where the English stayed under it, the longest at 205 characters.
They now say the same thing with fewer detours. "Dieser Vorgang lässt sich
gerade nicht ändern, sein Job steht möglicherweise noch in der Warteschlange
oder läuft" became "Der Job läuft oder wartet noch".
Two of them carried the same text under different keys in the hosting
settings, the older wording at 175 characters. Both read the same now.
Also from review: the region message mixed grammatical cases ("Für diese
Region/Land"), one invitation string still used Sie, and four Sophtron
messages pointed users at an API key configuration when that provider uses
a user ID and an access key.
* test(i18n): cover German locale completeness
* feat(i18n): translate the keys that landed while this PR was open
Upstream added the Plan page, insight acknowledgements and the SnapTrade
account type picker after this branch was cut. The coverage test from
e9e1d4a2 found 43 English keys with no German side, which is what CI has
been red about.
The insights card renamed dismiss/dismissed to acknowledge along the way,
so the two German keys this branch had added for the old names no longer
pointed at anything. Removed them instead of leaving them behind.
Terminology follows what the goals views already use: "hinter Plan",
"offene Zusage", "im Plan".
* fix(i18n): switch the last formal sentence in the PDF import mail
The address change converted the rest of this mailer to "du" but left
next_steps_intro on "Sie", which CodeRabbit flagged and I missed.
* feat(i18n): translate the three keys added since the last merge
CI finally ran on this branch and came back with one failure: breadcrumbs
for the changelog and feedback pages, plus the copy confirmation on the
API key reveal. All three landed upstream after the August 6 merge.
"Was ist neu" matches what pages.changelog.title and the settings label
already say, rather than introducing a third wording for the same page.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* feat(mcp): Add MCP budget update tool
Adds an update_budget assistant/MCP function so AI assistants can write
monthly budgets: total budgeted spending, expected income, and
per-category allocations in one transactional call.
- Month resolution and slug format mirror get_budget (YYYY-MM or
MMM-YYYY, custom month start respected); targeting a valid month with
no budget row bootstraps it via Budget.find_or_bootstrap, same as the
budgets UI.
- Category allocations accept an exact (case-insensitive) name or id and
go through BudgetCategory#update_budgeted_spending!, so subcategory
writes keep the parent total in sync.
- All writes in one call share a transaction: an invalid category rolls
back a totals change from the same call.
- Family-scoped like the budgets UI; amounts validated non-negative.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(mcp): harden update_budget per review feedback
- Extract shared month resolution into Assistant::Function::MonthResolvable
so get_budget and update_budget can't drift on custom month starts
- Run budget bootstrap inside the update transaction so a failed entry
no longer leaves a newly created budget behind
- Apply explicit parent amounts after subcategory syncs so results don't
depend on the caller's array order
- Reject non-finite amounts (NaN/Infinity)
- Explain the synthetic Uncategorized bucket instead of a generic
category-not-found error
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(icons): add search field for icon selection
* fix(icons): address review suggestions on the icon picker
* fix(goals): prevent the icon picker popover from collapsing
`updatePopupPosition` sets `bottom: 0px` when the popover would run past
the fold,
but never clears Tailwind's `top-full`.
With both offsets set and `height: auto`, CSS derives the height from
the offsets
instead of the content, collapsing the popover to 26px.
This PR is what makes it reachable: the search row (42px plus an 8px gap)
and the grid going max-h-40 -> max-h-52 grow the popover ~98px, moving the
overflow trigger point far enough to hit standalone /goals/new at 1280x800.
`h-fit` makes the height non-auto, so the over-constraint resolves by
ignoring `bottom`,
which is why categories, already carrying it, was never affected.
Deleting an account destroys its `goal_accounts` rows (Account has_many
:goal_accounts, dependent: :destroy). Any goal funded only by that account
survives with zero links and permanently fails
`must_have_at_least_one_linked_account`. Two bugs made that state a dead end.
`#update` saved the attributes before attaching the submitted accounts, so
validation ran against the goal's old (empty) link set and raised before the
new links were applied. Editing was the only route back to a valid goal, and
it always returned 422. Assign, sync the links, then persist once — one save
over the fully assembled goal validates what the user actually submitted.
`perform_transition!` discarded the return value of AASM's bang event. AASM
returns false rather than raising when the save that persists the new state
fails validation, so an invalid goal flashed "Goal archived." while the state
never moved. Check the result and surface the validation error instead.
Neither fix changes behaviour for a valid goal: the bang events return true
on success, and the update path persists the same attributes and links as
before.
This does not change what happens to a goal when its account is deleted —
whether the goal should follow the account, block the deletion, or be
surfaced as needing attention is a product decision left open. It only makes
the resulting state recoverable and stops the UI reporting success when
nothing happened.
* fix(insights): drop the dismiss toast and fix both empty states
Three problems with acknowledging an insight, all in the turbo-stream path.
Every dismissal appended an undo toast to the notification tray. Acknowledging
already means "hidden until these numbers change" — GenerateInsightsJob
resurfaces a row whose metadata moves materially, and 6 of 8 generators scope
`dedup_key` to a month — so a toast interrupting the flow bought little.
Removed, along with the now-orphaned `_undo_toast` partial and its two locale
keys.
Dismissing the last insight left the dashboard widget on screen: the stream
re-rendered the well unconditionally, so the section shell stayed with its
header above an empty box until a reload. A full render already drops it
(PagesController#insights_feed_section sets `visible: @feed_insights.any?`);
the stream now removes the whole section to match, targeted by
`[data-section-key='insights_feed']` because the shared dashboard loop emits no
id on the section element.
Dismissing the last insight on /insights left a blank page. The card left via
`turbo_stream.remove`, which emptied #insights-list without re-rendering the
partial that owns the empty state, so "No insights yet" only appeared after a
reload. The list is now replaced rather than the card removed — the same thing
unacknowledge already did.
InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept
and still work; only the toast that reached them is gone, so undo can be
re-wired to a different surface without resurrecting them.
* fix(insights): announce the dismissal now the toast is gone
Removing the undo toast took the only `role=status` element with it, and the
stream also replaces the list containing the "Got it" control the user just
activated — so a screen-reader or keyboard user was left with no confirmation
that anything happened.
Add a shared, visually hidden live region to the notification tray and update
it from the acknowledge stream. It sits outside every stream target and is
rendered with the page, which matters: a live region that arrives together
with its own content is not announced. Updated rather than appended, so
messages replace instead of piling up, and it stays empty (and free) until
something uses it.
This is a general primitive, not an insights one — the tray already holds
`#sync-toast` and `#cta` as stable stream targets, and any flow that changes
the page without leaving something on screen to read can use it.
Verified in a browser: after dismissing, the region reads "Insight dismissed"
at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible.
* fix(pwa): give the installed app icon a safe area
The home-screen icon rendered edge to edge, with the wordmark's outer
letters cut off by the platform's own mask. Measured against the shipped
files, every icon had a 0.0% left/right margin — the artwork's bounding box
was the full canvas, because the icons were rendered straight from the logo
SVG whose viewBox (160x145) is exactly the wordmark with no padding.
This is not iOS- or device-specific. iOS draws an apple-touch-icon full
bleed into its squircle and reserves no safe area, so the corners always
bite. Android is worse: the manifest declared the same full-bleed file as
`purpose: "maskable"`, and a maskable icon is cropped to the launcher's
shape, where only the centred circle of 80% diameter is guaranteed. Under a
circular mask the "s" and "e" were sliced off.
A second, unrelated-looking bug fell out of the same measurement:
apple-touch-icon.png was a black wordmark on a *transparent* background. iOS
composites transparency onto black, so on any device that picks the 180x180
over the 512x512 the icon renders as a solid black square with no logo at
all. The 512 happened to be opaque, which is the only reason this wasn't
already visible.
Regenerated every masked icon from the vector source, flattened onto the
manifest's own background_color (#F9F9F9) so none of them depend on
transparency, and split the maskable icon into its own file with a bigger
margin:
any / apple-touch artwork at 72% of canvas, ~14% side margin
maskable artwork at 59%, corners need a 79.6% circle (safe: 80%)
Favicons are left alone deliberately: they are never masked, and padding a
16px glyph only makes it mush.
* docs(pwa): correct two claims in the manifest icon note
An icon may declare "any maskable" and serve both purposes when its artwork
already sits inside the 80% safe circle; the rule is that a full-bleed image
cannot be reused, not that sharing a file is forbidden. Ours is full-bleed at
the `any` size, which is why maskable still gets its own file.
iOS also does read this file — manifest `icons` since 15.4. The accurate
statement is that the apple-touch-icon links take precedence for the Home
Screen icon, which is why those are the ones that must stay opaque and
self-padded.
Charles Schwab's SimpleFIN feed reports `cost_basis` as the total
position cost rather than per-share, violating the spec in the same
way Vanguard (#1182) and Fidelity (#1718) already do. Schwab wasn't on
the TOTAL_BASIS_INSTITUTIONS allowlist, so the raw total was stored
directly into holdings.cost_basis and treated as per-share downstream.
Holding#calculate_trend multiplies avg_cost by qty again when
reconstructing original cost, so an unadjusted total gets squared by
share count — a $46,950 position with a true +55.7% gain rendered as
-99.8% / -$19.6M "return" on the dashboard and per-account holdings
views.
Picks up where #2626 left off: adds the allowlist entry, fixes the
now-outdated compliant-institution test case it broke, and adds a
dedicated regression test using real observed Schwab payload values.
Fixes#2626
Co-authored-by: Justin <justin@local>
* fix(splits): let category selector size to its content instead of clipping names
- Replace the fixed md:w-44 column with flex-initial + md:min-w-44/md:max-w-72
so the button grows with the selected category name
- Truncate the badge label in an inner span with a native title tooltip and a
max-w-64 cap, keeping the color dot from shrinking
- Widen the dropdown to md:w-80 so option badges stay readable
- select_controller: stop forcing the menu width to 100% inline (the markup's
width classes now decide) and anchor the menu to the button's right edge
when it would overflow the scroll container
Fixes#2934
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(splits): render category badges via DS::Pill
- Options render the canonical categories/badge partial; the button renders
the same DS::Pill directly (a button only allows phrasing content, so the
partial's div wrapper stays out). category_badge_select's clone selector
follows the pill markup.
- The client-side row template in split_transaction_controller gets the same
md:min-w-28 name-field floor as the server-rendered rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Insight titles and bodies are stored as finished prose with the amounts
already interpolated (by the i18n template or the LLM writer), so the
dashboard feed and insight cards rendered raw figures even when the
hide-numbers toggle was active — only the right-aligned key figure was
tagged privacy-sensitive.
Add InsightsHelper#insight_privacy_text, which wraps each numeric
fragment (currency amounts, percentages, bare counts, including
suffix-currency and no-break-space locale formats) in a
privacy-sensitive span at render time, and use it for the title and
body in both the dashboard insights feed and the insight card. The
sentence stays readable while privacy mode blurs the numbers.
The helper splits the raw text before escaping and reassembles it with
safe_join, so HTML in stored prose is still escaped and digit-bearing
entities like ' are never mangled by the number regex.
The y-domain was built from the target, the projection, the required path
and today's balance — everything except the series actually drawn across the
plot. A goal whose linked account had ever held more than the target mapped
that history above the top of the scale, and with no clip region the line
painted straight out of the plot and over the legend until the SVG edge cut
it off.
Two changes.
The domain now accounts for the saved history, with 5% headroom so the peak's
stroke isn't shaved in half. It is not allowed to follow that history without
limit, though: every value the chart reasons about sits at or below the goal's
own scale, so headroom above it buys nothing but taller spikes. A goal funded
from a current account sees the whole balance as saved, and a €700 target
against a salary landing and clearing is an order of magnitude apart — scaled
to that peak, the target and both projection lines collapse into the floor and
the chart stops answering "am I on track". Past twice the goal's scale the
history is clipped instead, which keeps the target line at or above half the
plot height.
The clip region is the structural half. Series paths and the hover dot are
confined to the plot box, so anything outside the domain — a peak past the
ceiling, or a linked current account that went overdrawn and dipped below
zero — stops at the edge instead of painting over the legend above or the
date axis below. Clipped peaks stay readable: the tooltip still reports their
real amount.
Behaviour by how far the history overshoots the goal's scale:
0.6x -> domain 1050, no clipping
1.4x -> domain 1470, no clipping (autoscaled)
2.0x -> domain 2100, no clipping (at the ceiling)
8.0x -> domain 2100, clipped inside the plot
The key-reveal partial binds clipboard#copy on a DS::Button but never
set copied-text-value, so the controller's flashLabel path bailed out
and copying gave no visible confirmation. Add the value (mirroring the
MCP copy button fix from #2314) so the button label flashes "Copied!".
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(app): set breadcrumbs for changelog and feedback pages
* feat(test): add test to assert breadcrumbs
* fix(test): remove changes
* feat(app): update breadcrumbs to use semantic nav element
* feat(test): add breadcrumb assertions to changelog and feedback pages
* fix(app): replace breadcrumb nav element with div containing data-breadcrumbs attribute
* Add not-equal operator for transaction amount rules
Enable excluding a specific amount in rule conditions without
needing paired greater/less than workarounds (#2882).
Co-authored-by: Cursor <cursoragent@cursor.com>
* Strengthen amount not-equal absolute-value coverage
Include a -100 transaction so != 100 proves both signed amounts are excluded.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add tags support for transfer transactions
Expose TagSelect on transfer create/edit so users can classify
fund movements; apply the same family-scoped tags to both sides.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Require annotate permission on both transfer sides for tags
Prevent tagging a read-only destination transaction when the user
only has write access on the outflow account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore transfer tag selections on create form errors
* Localize transfer create validation error messages
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bump version to next iteration after v0.7.4-alpha.2 release
* alpha.3
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>