* feat(api): allow creating categories via API
Adds POST /api/v1/categories so external integrations (e.g. bulk
classification scripts that import already-categorized data from
another system) can create categories without going through the web UI.
Mirrors the existing tags create endpoint: requires the read_write
scope, accepts name/color/icon/parent_id, auto-suggests an icon when
omitted, and rejects parent_ids from other families.
Also adds Minitest behavioural coverage, an rswag docs spec, a
CategoryCreateRequest schema, and regenerates docs/api/openapi.yaml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address review feedback on POST /api/v1/categories
- Re-raise ActionController::ParameterMissing in #create so the
BaseController rescue_from handles it as a 400 instead of the
generic 500 from the broad rescue inside the action.
- Add a 403 'insufficient scope' response block to the rswag POST
example so the generated OpenAPI documents read-only key rejection.
- Switch the new create-action Minitest cases to API key auth via
X-Api-Key + api_headers (using the existing api_keys fixtures),
matching the project's API endpoint consistency rule.
- Add Minitest coverage for two more 4xx paths: rejecting third-level
nesting (parent_id pointing at a depth-2 subcategory) and rejecting
requests without the category payload (400).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(test): migrate categories API index/show tests to X-Api-Key
The pre-existing index and show tests in this file authenticated via
Doorkeeper bearer tokens. Per the project's API endpoint consistency
rule (CLAUDE.md, .cursor/rules/api-endpoint-consistency.mdc) Minitest
controller tests under test/controllers/api/v1/ must use ApiKey +
X-Api-Key auth. Drops the Doorkeeper application/access-token setup
and routes every request through the existing api_keys fixtures and
the api_headers helper, matching the create-action tests already in
this file (and the pattern used in sync/users/family_settings tests).
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address second-round review on POST /api/v1/categories
- Add a 400 response block to the POST rswag example so the generated
OpenAPI documents the missing-category-payload contract that
BaseController#handle_bad_request already returns. Regenerate
docs/api/openapi.yaml.
- Replace fixture-backed read_write_api_key / read_only_api_key
helpers with explicit ApiKey.create! calls (matching the pattern in
sync_controller_test, users_controller_test, and
family_settings_controller_test). Setup now destroys active keys for
the test user so the one-active-key-per-source validation does not
collide with fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(api): tighten 422 create-category cases
- Pass color and icon explicitly in the duplicate-name and
third-level-nesting tests so each case is self-documenting about
which validation it isolates (the model's color presence check is
satisfied by the column default today, but reviewers — human and
bot — flagged the implicit reliance).
- Assert the JSON error envelope (error key + present message) on every
422 path so the response shape stays consistent and a regression in
the rendered error body is caught uniformly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): tighten POST /api/v1/categories per review
- Drop the no-op `rescue ActionController::ParameterMissing; raise` and
the broad `rescue => e` from the create action. The BaseController
already has rescue_from ActionController::ParameterMissing → 400, and
unexpected exceptions are best left to Rails' default 500 handling
(which logs identically). Keeps the action focused on its happy path
and the two real error branches.
- Stop accepting `lucide_icon` as a request key. The OpenAPI schema
documents only `icon`; the dual permit was undocumented and pointless.
`icon` is now the single canonical request key, mapped to
`lucide_icon` on the model in category_params.
- Migrate the Minitest helpers to the project's documented API key
pattern: ApiKey.generate_secure_key + api_key.plain_key in the
X-Api-Key header (matching the rswag spec in this PR and the rule in
.cursor/rules/api-endpoint-consistency.mdc), instead of hand-built
display_key strings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Botched conflict merge
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* Updated the `highlight_activity_entry_name` method to escape HTML in activity entry names before highlighting. This change prevents potential XSS vulnerabilities and ensures safe rendering of user-generated content.
* Fixed a minor syntax issue in the _split_parent_row.html.erb file by ensuring the closing tag for the link_to helper is properly formatted. This change enhances code readability and maintains consistency in the view structure.
* Introduced a new helper method `highlight_activity_entry_name` to highlight search terms in activity entry names.
* Updated various views to utilize the new highlighting method for improved user experience in displaying relevant entries.
* Performance improvements in holding calculation pipeline
Investment accounts with large histories were pegging CPU at 100% during
sync. Root cause was a cluster of quadratic and superlinear algorithms in
the inner holding calculation loop. All are replaced with O(1) hash lookups
built from single-pass indexes over the already-loaded data.
Holding::PortfolioCache - load_prices:
Three O(SxN) patterns inside the per-security loop:
1. DB prices: `security.prices.where(...)` fired one SQL query per
security (N+1). Replaced with a single bulk query before the loop:
Security::Price.where(security_id: ..., date: ...).group_by(&:security_id)
70 securities -> 70 queries becomes 1.
2. Trade prices: `trades.select { |t| t.entryable.security_id == id }`
scanned the full trades array for every security - O(SxT). Replaced
with trades_by_security_id, pre-indexed once from the loaded array.
3. Holding prices: `holdings.select { |h| h.security_id == id }` - same
O(SxH) pattern. Replaced with holdings_by_security_id.
Prices are now indexed into prices_by_date and prices_by_date_and_source
hashes during load_prices, making get_price O(1) instead of scanning the
flat prices array on every lookup.
Holding::PortfolioCache - get_trades / get_price:
- get_trades(date:): `trades.select { |t| t.date == date }` (O(T) scan)
replaced with trades_by_date hash (O(1)).
- get_price: two `prices.select { p.date == date ... }.min_by` linear
scans replaced with direct hash lookups into prices_by_date and
prices_by_date_and_source.
Holding::PortfolioCache - collect_unique_securities:
`holdings.map(&:security)` traversed the security association on every
holding record (N+1 if not preloaded). Replaced with a pluck of
security_ids followed by a single Security.where(id: ...) batch load.
Holding::ForwardCalculator / ReverseCalculator:
`holdings += build_holdings(...)` allocated a new array copy on every
iteration - O(N) per day x thousands of days = O(D^2) total allocations.
Replaced with holdings.concat(...) which appends in place, O(1).
Holding::ReverseCalculator - precompute_cost_basis:
Old: walked every date from account.start_date to Date.current (O(D)),
writing a cost_basis entry for every security on every date. For an
account with 2 trades over 9,250 days this wrote ~18,500 hash entries
and consumed the full date range in the outer loop regardless of trade
density.
New: walks only buy trades (O(T)), appending one [date, avg_cost]
snapshot per trade. cost_basis_for binary-searches the sparse snapshot
array - O(log T) per lookup. Memory drops from O(DxS) to O(T).
Holding::Gapfillable:
`security_holdings.find { |h| h.date == date }` was called on every
date in the gapfill range - O(H) per date, O(HxD) total. Replaced with
security_holdings.index_by(:date) built once before the loop, making
each date lookup O(1).
Holding::Materializer - purge_stale_holdings:
`account.entries.trades.map { |entry| entry.entryable.security_id }.uniq`
loaded all trade entry records into Ruby then traversed the entryable
association on each (N+1). Replaced with account.trades.pluck(:security_id).uniq
(single SQL query returning only the IDs).
In testing, these changes were able to reduce sync time of an account with
25 years of history and 70 securities from about 90 minutes down to under
3 minutes.
* Lint fix
* Lint fix
* addressing the open review nits I agreed with:
* return dup'd arrays from PortfolioCache#get_trades so callers can't mutate memoized cache state
* use the precomputed security-id indexes in collect_unique_securities
* keep security-id dedupe in SQL via distinct.pluck(:security_id)
* tighten the DB price preload to select only needed columns
* harden cost-basis assertions with assert_in_delta
* Back out unnecessary AI slop
* Add back dup to trades array returned from memoized hash
trades_by_date[date] returns a live reference into the memoized hash.
Any caller that mutates the result would silently corrupt the cache for
subsequent calls on the same date within the same sync run. Add .dup to
return a shallow copy, matching the safety of the original select path.
The streaming code assumed every stream produced a `response.completed`
event and dereferenced its data unconditionally, causing
`undefined method 'data' for nil` whenever OpenAI emitted
`response.failed`, `response.incomplete`, or a top-level `error` event
(e.g. expired `previous_response_id`, context-window overflow,
transient upstream failures). Surface a descriptive `Provider::Error`
instead.
- Extend `ChatStreamParser` to recognise `response.failed`,
`response.incomplete`, and `error` events and emit an `error` chunk
with a `StreamErrorData` payload (event, message, code, details).
- In `Provider::Openai#native_chat_response`, detect the missing
`response` chunk, build a user-facing error message from the
collected error chunk, and raise `Provider::Error`.
- Add unit tests for the parser (8 cases) and integration tests for
the error path in the chat response flow.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(investments): add India investment subtypes and exchange support
* fix(yahoo-finance): scope Indian exchange de-duplication per company instead of globally
Resolves feedback from Codex and CodeRabbit on #1413.
prefer_indian_exchange previously collapsed all Indian securities into a
single entry, silently dropping unrelated tickers. Now groups Indian
listings by name and only de-duplicates within each group, so distinct
companies (e.g. Reliance and Infosys) are preserved while NSE/BSE
dual-listings still prefer NSE.
- Derive India subtype keys dynamically from Investment::SUBTYPES in tests
- Fix missing keyword arguments in Security.new test calls
* refactor(yahoo-finance): generalize exchange config and dual-listing de-duplication
Replaces hardcoded Indian exchange logic with a declarative EXCHANGE_CONFIG
hash that maps ISO MIC codes to Yahoo-specific settings (symbol suffix,
default currency, dual-listing group, and preference rank). This makes
adding new markets a one-line hash entry instead of scattered conditionals.
* fix(yahoo-finance): normalize security names for dual-listing de-duplication
* fix(yahoo-finance): skip dual-listing de-duplication when filtering by exchange
* fix: address PR review feedback for India market support
- fix cache key mismatch in fetch_security_price by normalizing symbol before building cache key
- remove dead YAHOO_EXCHANGE_CURRENCY constant
- tighten normalize_symbol guard to use end_with?(suffix) instead of include?('.')
- remove misleading '# India' comment from Property::SUBTYPES
- remove 'rented' property subtype in favor of 'investment_property'
- rename 'demat' to 'indian_stocks' for clarity
- add INR to CURRENCY_REGION_MAP so India appears first for INR users
- add dotted-symbol regression test for normalize_symbol
* fix(investments): rename 'demat' subtype to 'indian_stocks' and remove trailing comma
Activates the built-in erb_lint DeprecatedClasses linter to flag raw
text-gray-N, bg-gray-N, border-gray-N, text-white, and bg-white
classes in <%= class="..." %> attributes, with suggestions pointing
at the design-system tokens.
Three rule_set entries cover the foreground, background, and border
families respectively, each with a tailored suggestion message
listing the closest semantic alternatives. The shared addendum
points at the token JSON and the design-system stylesheet for
deeper context.
Note: the cop only inspects literal HTML class attributes. Ruby
string assignments (class: "...") inside ERB tags are not scanned.
A custom Rubocop cop targeting those would close the gap; tracked
as future work.
* chore(design-system): swap raw gray classes for semantic tokens across remaining views
Finalizes the raw-color sweep started in #1652 (settings) and continued
in #1654 (holdings). Covers accounts, budgets, chats, pages, imports,
provider integrations (mercury, lunchflow, sophtron, enable_banking,
coinstats), auth flows (password reset, MFA, registrations), shared
layouts, and selected DS component hover states. 35 files, ~56 line
changes.
Mappings (matching the patterns established in the prior sweeps):
- text-white bg-gray-900 hover:bg-gray-800 (with optional focus:ring-gray-900)
-> text-inverse button-bg-primary hover:button-bg-primary-hover
-> focus:ring-button-bg-primary
- text-gray-500 / 600 / 700 -> text-secondary
- text-gray-800 -> text-primary
- text-gray-400 -> text-subdued
- hover:text-gray-700 / hover:text-gray-100 -> hover:text-primary
- bg-gray-50 / 100 / 200 (standalone) -> bg-surface-inset
- bg-gray-500/5 -> bg-gray-tint-5
- bg-gray-500/10 -> bg-gray-tint-10
- bg-gray-900 (decorative active states) -> bg-inverse
- hover:bg-gray-50 / 100 (standalone) -> hover:bg-surface-inset
- hover:bg-gray-300 -> hover:bg-surface-inset-hover
- bg-white hover:bg-gray-100 -> bg-container hover:bg-container-hover
- border-gray-300 -> border-secondary
- focus:border-gray-200 -> focus:border-secondary
- focus-within:border-gray-900 -> focus-within:border-primary
- DS::Buttonish outline / ghost / icon hover:
hover:bg-gray-100 theme-dark:hover:bg-gray-700
-> hover:bg-container-inset-hover
Left intentionally raw, with rationale:
- bg-gray-300 / bg-gray-400 decorative dots and avatar circles. The
raw value reads OK against both bg-container variants; no semantic
"neutral indicator" token exists. Same pattern as #1652 / #1654.
- bg-gray-400/20 theme-dark:bg-gray-500/20 (onboardings/trial). Custom
alpha tint with no equivalent token.
- bg-white theme-dark:bg-gray-700 (DS::Tabs active pill, budgets tabs).
Custom tab-pill pattern; gray-700 in dark mode (one shade lighter
than page bg-gray-900) is intentional for visibility.
- bg-gray-100 theme-dark:bg-gray-700 (DS::Toggle base bg). Closest
match (bg-container-inset-hover) is semantically a hover state.
- DS::Buttonish secondary variant gray-200/300/700/600 pattern. Same
pattern as #1654 holdings; needs button-bg-secondary-strong from
that PR. Will swap in a follow-up after #1654 merges.
- disabled:bg-gray-500 theme-dark:disabled:bg-gray-400 on inverse
buttons (DS::Buttonish primary, enable_banking, coinstats). Custom
disabled state for the inverse pair; no token.
- text-gray-300 SVG stroke (shared/_progress_circle).
- bg-white text-gray-900 (layouts/print). Print contexts intentionally
light regardless of theme.
- bg-gray-800 / border-gray-700 / text-white / hover:text-gray-100
(impersonation_sessions/_super_admin_bar). Admin overlay styled to
remain dark in both modes; not a theme-aware component.
Files covered by other in-flight PRs were skipped to avoid rebase
conflicts: chats/_ai_consent's fg-inverse swap (#1626), shared/_text_tooltip
and shared/_money_field tooltip pills (#1626), investments/_value_tooltip
(#1626), components/DS/tooltip (#1626).
* fix(design-system): keep changelog avatar text raw to preserve dark-mode contrast
The changelog avatar fallback (when @release_notes[:avatar] is missing)
sits inside the "decorative + raw" exception list — bg-gray-300 stays
fixed across themes since no semantic neutral-indicator token exists.
The earlier sweep partially themed the pair: bg-gray-300 stayed raw but
text-gray-600 became text-secondary. text-secondary resolves to gray-300
in dark mode, which matches the bg → text became invisible against its
own background.
Reverting only the text class to text-gray-600 restores the original
fixed-light placeholder behavior. Both classes raw, both themes
readable.
* fix(design-system): address review feedback on raw-color-sweep-finalize
Six issues caught by CodeRabbit + Codex review:
1. focus:ring-button-bg-primary silently emits no CSS (×6 files).
button-bg-primary is a custom @utility, not a theme color, so Tailwind's
ring-{name} resolution finds no --color-button-bg-primary. Replaces with
focus:ring-gray-900 theme-dark:focus:ring-white — same color flip as the
button bg, but resolved through theme colors so the ring actually renders.
Files: lunchflow/mercury/sophtron _api_error + _setup_required, coinstats_items/new.
2. accounts/show/_activity.html.erb: focus-within:ring-gray-100 was dead
(no ring-width on the parent). Removed.
3. import/confirms/show.html.erb: uniform hover:bg-surface-inset-hover
applied to both active and inactive step indicators created a jarring
dark-to-light flip on the active step (bg-inverse → bg-surface-inset-hover).
Now hover follows the resting state: active uses hover:bg-inverse-hover,
inactive uses hover:bg-surface-inset-hover.
4. password_resets/new.html.erb: bg-white left raw alongside the migrated
hover:bg-surface-inset. Swapped to bg-container so dark mode flips properly.
5. registrations/new.html.erb + password_validator_controller.js: view now
uses bg-surface-inset on password strength block lines, but the Stimulus
controller still toggled bg-gray-200 on validate. Updated controller to
add/remove bg-surface-inset matching the view, so unmet states reset to
the tokenized class instead of leaving raw gray-200 stuck on the element.
* chore(design-system): swap raw gray classes for semantic tokens in holdings/
Continues the raw-color sweep on the holdings/ domain plus the related
account activity feed component. 11 occurrences across 5 files.
Token additions:
- button-bg-secondary-strong (gray-200 / gray-700) and -hover (gray-300 /
gray-600). Holdings CTAs (Add Trade, Add Holding, Edit Cost Basis,
Sync Prices, etc.) used a hand-rolled "secondary-strong" pattern that
doesn't match the existing button-bg-secondary token (which is gray-50
/ gray-700, much subtler). Adding the strong variant preserves the
intentional visual weight of these CTAs and gives future PRs a name
to reuse.
- $version bump 1.0.0 -> 1.1.0 (additive).
Mappings:
- 8x text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700
theme-dark:hover:bg-gray-600 (holdings/show + sync_prices +
cost_basis_cell)
-> text-primary button-bg-secondary-strong hover:button-bg-secondary-strong-hover
- 1x bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100
theme-dark:hover:bg-gray-600 (holdings/index search button)
-> button-bg-secondary hover:button-bg-secondary-hover
- 1x hover:bg-gray-100 theme-dark:hover:bg-gray-700 (cost_basis_cell
hover row)
-> hover:bg-container-inset-hover
- 1x focus-within:border-gray-900 (activity_feed search wrapper)
-> focus-within:border-primary
Left intentionally:
- bg-gray-300 status indicator dot in show.html.erb (same pattern as
the settings pilot; no semantic equivalent for "neutral inactive
indicator" yet).
- bg-gray-700 in _missing_price_tooltip.html.erb (already fixed in
PR #1626; would conflict on rebase).
- focus-within:ring-gray-100 (subtle effect that works in both modes;
ring-color tokens are a separate concern).
* chore(design-system): bump $version to 2.1.0 for additive token additions
Per the design tokens semver contract: PR #1626 already bumped to 2.0.0
(major / breaking when fg-* utilities were removed). This PR adds
button-bg-secondary-strong + hover without removing or changing existing
tokens, so the correct bump is minor (2.0.0 → 2.1.0).
Spotted by CodeRabbit on the rebased branch.
* fix(design-system): drop dead focus-within:ring-gray-100 on activity feed search
The focus-within:ring-gray-100 class only sets --tw-ring-color, but the
parent has no ring-width utility, so it produces no visible ring — dead
code from before the focus-within:border-primary swap landed.
Same issue spotted on app/views/accounts/show/_activity.html.erb in the
finalize sweep PR; applying the equivalent fix here for the holdings
activity feed component.
---------
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
* chore(design-system): swap raw gray classes for semantic tokens in settings/
Pilot for the broader raw-color sweep. Maps 21 occurrences across 11
files to design-system equivalents:
- text-white bg-gray-900 hover:bg-gray-800 (CTA buttons)
-> text-inverse button-bg-primary hover:button-bg-primary-hover
- bg-gray-25 / bg-gray-50 / bg-gray-100 (subtle surface backgrounds)
-> bg-surface-inset
- bg-gray-800 (tooltip pills) -> bg-inverse
- text-white inside tooltips -> text-inverse
- text-gray-300 (muted tooltip labels) -> text-inverse opacity-70
- text-gray-600 (muted body text) -> text-secondary
- hover:text-gray-700 -> hover:text-primary
- focus:ring-gray-900 -> focus:ring-button-bg-primary
The 7 status-indicator dots (`bg-gray-400`) are intentionally left
as raw classes. Gray-400 against both light and dark container bgs
gives reasonable contrast either way, and there's no semantic token
that fits a "neutral inactive indicator" use case yet. Worth a
follow-up if a `bg-subdued` token would benefit other places.
* fix(design-system): use theme-aware focus ring on provider submit buttons
Two issues caught in code review:
1. focus:ring-button-bg-primary silently emits no CSS (CodeRabbit, Codex).
button-bg-primary is a custom @utility, not a theme color, so Tailwind's
ring-{name} resolution finds no --color-button-bg-primary and falls
back to the default. Replaces with focus:ring-gray-900
theme-dark:focus:ring-white — same color flip as the button bg, but
resolved through theme colors so ring-{name} actually generates CSS.
2. _enable_banking_panel.html.erb dropped focus-ring + transition entirely
in the original sweep (CodeRabbit). Restores parity with the other
provider panels using the corrected ring classes.
Long-term cleanup: tracked under issue #1653 (modifier-aware utilities)
to make button-bg-primary also a theme color so ring-button-bg-primary
becomes valid.
* fix(design-system): replace undefined utility classes and broken /N modifiers
Audit of class-name resolution in views surfaced two related silent
failures across ~17 files:
1. Class names that don't exist anywhere in the design system. Tailwind
silently drops them and the element renders with no CSS for that
property.
- bg-primary (and bg-primary/5, /10, /90): never defined as a
custom utility, no --color-primary in @theme. Used as a CTA bg
in 8 places, all rendered transparent.
- text-inverted: typo of text-inverse.
- text-primary-foreground: shadcn/Radix vocabulary, not in our
token system.
- bg-accent / border-accent / text-accent: same shadcn vocabulary;
not defined.
2. Slash modifier (/N) used on custom @utility blocks. Modifiers only
resolve on Tailwind theme colors (anything in tokens.json color.*).
Custom @utility blocks compile to static @apply statements and
silently drop the /N variant. Affected uses:
- border-surface-inset/50 across provider account selectors.
- border-secondary/30, /40 in admin SSO form and simplefin setup.
- bg-surface-inset/30, /40 in settings preferences and simplefin.
Fixes:
| From | To |
|---------------------------------------------------|------------------------------------------------------|
| bg-primary text-white (and similar primary CTAs) | button-bg-primary text-inverse |
| bg-primary text-primary-foreground (badges) | button-bg-primary text-inverse |
| bg-primary text-inverted (typo) | button-bg-primary text-inverse |
| bg-primary text-primary (broken active pill) | bg-inverse text-inverse |
| bg-primary (status dot) | bg-inverse |
| bg-primary/5, bg-primary/10 (subtle accent bg) | bg-gray-tint-5, bg-gray-tint-10 |
| hover:bg-primary/90 | hover:button-bg-primary-hover |
| border-accent bg-accent/10 text-accent (badges) | border-secondary bg-surface-inset text-secondary |
| border-surface-inset/50 | border-secondary |
| border-secondary/30, /40 | border-tertiary |
| bg-surface-inset/30 | bg-surface-inset (full strength) |
| bg-surface-inset/40 | bg-container-inset |
Also documents the alpha-modifier limitation in design/tokens/README.md
under a new "Alpha modifiers in views (/N syntax)" section, with the
opacity-N convention for custom utilities and a note that the
gray-tint-5 / gray-tint-10 family (and similar pre-resolved tints) are
theme colors and accept /N modifiers natively.
The accent-badge mapping uses neutral semantics for now. A dedicated
brand-accent token (text-link-tint-10 etc.) is worth considering as a
follow-up if the "highlighted metadata badge" pattern recurs.
* fix(design-system): replace undefined divide-primary / divide-secondary with alpha tokens
Same class of bug as the rest of this PR: divide-{name} requires the
name to be a theme color (i.e. expose --color-{name}), and our custom
@utility utilities (primary, secondary, etc.) do not. Tailwind silently
drops the unrecognized class and rows render with no separator.
Spotted six instances during the visual audit:
- admin/users/index.html.erb (×2): users table + pending invitations
- admin/sso_providers/index.html.erb (×2): configured + legacy lists
- transactions/categorizes/_transaction_list.html.erb: categorize sidebar
- settings/preferences/show.html.erb: divide-secondary/60 (also broken)
Swapped to the alpha-black/white pattern already used elsewhere in the
codebase (imports/cleans/show, transactions/_summary, etc.):
divide-y divide-primary
-> divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200
divide-y divide-secondary/60
-> divide-y divide-alpha-black-100 theme-dark:divide-alpha-white-100
The lighter (-100) variant on the preferences list matches the original
intent of /60 (more subtle).
* refactor(design-system): migrate fg-* utilities to text-* and remove namespace
The design system carried two parallel namespaces for foreground colors:
text-* (canonical, ~2,000 uses) and fg-* (32 uses). Most fg-* tokens
were 1:1 duplicates of a text-* counterpart. fg-gray was nearly
identical to text-secondary, with a one-step shade difference in dark
mode.
This PR migrates all 32 usages to their text-* equivalents and removes
the fg-* block from the design tokens. Closes#1606.
Mapping:
- fg-inverse -> text-inverse (20 usages, identical light/dark values)
- fg-gray -> text-secondary (7 usages; light values match, dark is
one step lighter: gray-300 vs gray-400)
- fg-primary -> text-primary (3 usages, identical values)
- fg-subdued -> text-subdued (2 usages, identical values)
The four other fg-* tokens (fg-contrast, fg-primary-variant,
fg-secondary, fg-secondary-variant) had zero usages despite being
defined; they are removed without replacement.
JSON / build:
- design/tokens/sure.tokens.json: $version 1.0.0 -> 2.0.0 (breaking
schema change per the policy added in #1620). 8 fg-* token
definitions removed.
- button-bg-ghost-hover's dark value still references "fg-inverse"
internally; rewritten to "bg-gray-800 text-inverse" so the cleanup
doesn't break that utility.
- _generated.css regenerated. 42 utility blocks now (was 50).
Lookbook tokens preview:
- The Text & foregrounds section dropped its split between text-*
(canonical) and fg-* (legacy). Now a single section listing the
five text-* utilities. The "(legacy)" framing is gone since there's
no legacy left.
README:
- design/tokens/README.md's button-bg-ghost-hover edge-case example
updated to reflect the new "bg-gray-800 text-inverse" dark value.
Visual review needed in dark mode:
- Anywhere icons use the application_helper#icon helper with
color: "default" (most icons in the app). The default class moved
from fg-gray (gray-400 dark) to text-secondary (gray-300 dark), so
default-color icons render slightly lighter in dark mode.
- DS::Buttonish icons in secondary buttons (same shade shift).
- DS::Link icons (same).
- Time series chart axes (same).
- All tooltips, account add flow, settings hostings buttons,
invitations, AI consent, family export, danger-zone buttons --
these used fg-inverse, which is identical to text-inverse, so no
visual change expected.
* fix(design-system): use inverse pair on tooltips for readable dark mode
* fix(lookbook): use semantic tokens in menu preview header text
* fix(lookbook): set text-primary on layout body so previews inherit theme
* fix(design-system): keep shadows dark-toned in dark mode
Inverting shadows to white|8% on dark surfaces produces a halo
effect rather than an elevation cue, and stacks redundantly with
the alpha-white 1px ring already in shadow-border-*.
Switch dark-mode shadows to black at progressively higher alpha
(25%/30%/35%/40%/50% for xs..xl) so they read as actual cast
shadows on near-black surfaces. Surface-tint differences and the
existing alpha-white border ring continue to handle elevation
hierarchy and edge definition.
Approach matches Material 3, Apple HIG, IBM Carbon, Refactoring UI,
and the dark-mode shadows used in Linear/Vercel/Stripe.
* fix(design-system): set text-primary on DS::Dialog element
Browser UA stylesheets apply color: black directly to <dialog>,
which overrides ancestor inheritance even when a body or html
ancestor sets a theme-aware color. Unstyled child content then
renders black regardless of theme.
Setting text-primary on the dialog element itself defeats the UA
override and lets descendants inherit the semantic token.
* fix(lookbook): use shadow css vars in effects preview so dark theme renders
* Revert "fix(design-system): keep shadows dark-toned in dark mode"
This reverts commit 3e9d76ed0b.
* fix(design-system): use opacity-70 instead of text-inverse/70 in value tooltip
The custom @utility text-inverse expands to @apply text-white and
isn't modifier-aware, so text-inverse/70 produced no CSS at all and
the muted labels fell through to inherited color (invisible on the
white pill in dark mode).
Replace with text-inverse + opacity-70. Same visual effect, works
with the existing utility definition.
* feat(api): expose rule run history
* fix(api): address rule run review
* fix(api): complete rule run review
* test(api): cover unauthenticated rule run show
* test(api): align rule run api key helper
* Small Sonnet nit-pick
---------
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
* feat(api): expose family settings
* test(api): assert family settings moniker
* test(api): align family settings api key helper
* fix(api): tighten family settings schema
* fix(chat): persist eager pending assistant message to fix subscribe race
When the LLM replies in ~1-2s the assistant message broadcast could
fire before the client's Turbo stream subscription was established,
leaving the UI stuck on the thinking indicator while the response was
already persisted.
Create the AssistantMessage as `pending` synchronously in
`Chat#ask_assistant_later`, so it is rendered server-side on the chat
show page with a "Thinking ..." inline placeholder. The worker then
finds and updates the existing row via `append_text!`, which flips the
status to `complete` and broadcasts updates against a DOM id that is
already in the page — no race possible. On error, the placeholder is
destroyed if no content streamed, otherwise demoted to `failed`.
Replaces the standalone thinking indicator partial and the
`Assistant::Broadcastable` thinking helpers, both now redundant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): bind each assistant job to its specific pending placeholder
Addressing review feedback on #1658:
1. The pending placeholder lookup based on `last pending` was racy —
back-to-back user messages would let one job fill another job's
placeholder. Pass the placeholder through the job arguments
(`AssistantResponseJob.perform_later(user_message, pending)`) so
each turn is bound to its own row.
2. In `Assistant::External#respond_to`, the configured/authorized
guards raise before the local was bound, leaving rescue cleanup
with `nil` and the placeholder visible forever. Bind the parameter
first so cleanup can destroy it on the misconfigured path.
The kwarg defaults to nil so the API#retry path
(`AssistantResponseJob.perform_later(new_message)`) and the model-level
test calls continue to work — they fall back to an in-memory new
message, restoring the original test count assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): i18n the pending assistant placeholder string
Move the hardcoded "Thinking ..." indicator into the locale file per
CLAUDE.md i18n guidelines. With i18n.fallbacks enabled, non-en locales
fall back to English until translated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add thinking label translations
* Fix chat pending assistant expectations
* Fix external assistant pending test lookup
* Scope chat stream targets per chat
* Update message broadcast target tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Performance and bug fixes in provider price fetches
Three distinct bugs caused the price provider API to be called unnecessarily
on every investment account sync.
1. Sold securities triggered a provider call on every sync forever
import_security_prices passed end_date: Date.current for every security
ever traded. Security::Price::Importer short-circuits via all_prices_exist?
only when persisted_count == expected_count, where:
expected_count = (clamped_start_date..Date.current).count
This range increases daily, so a security closed two years ago would have
all historical prices in the DB unnecessarily. This also causes any closed
securities to fetch prices daily, forever.
Fix: separate currently-held securities (end_date: Date.current) from
historical-only securities (end_date: last holding date for that security).
Once a closed position's price range is complete through its last holding
date, all_prices_exist? becomes permanently stable and no further provider
calls occur for that security.
"Currently held" is defined as appearing in account.current_holdings, which
returns the most recent holding per security with qty != 0. On the first
sync after a sell, the pre-sale holding is still the most recent, so the
security correctly receives end_date: Date.current for one final sync before
the new qty=0 holding is materialised.
2. Offline securities were not filtered
account.trades.map(&:security) returned all securities regardless of the
offline flag. This results in fetching of securities even if the provider
cannot serve them, or if the user don't want them served for some reason
(eg when there are symbol collisions that causes the wrong prices to be
returned) The global MarketDataImporter correctly uses Security.online;
the account-scoped importer did not.
Fix: Security.online.where(id: all_security_ids) matches the established
contract. Offline IDs still pass through the pluck step but resolve to nil
in the securities hash and are skipped by the existing `next unless security`
guard.
3. N+1 queries for security loading and per-security start dates
- account.trades.map(&:security): triggered one SQL query per trade to load
the security association (N+1).
- first_required_price_date(security): issued 2 DB queries per security -
one MIN(entries.date) and one EXISTS - so S securities = 2S queries.
Fix: replace with batch queries totalling 4 regardless of security count:
- account.current_holdings.pluck(:security_id) - current security IDs
- account.trades.pluck(:security_id).uniq - traded security IDs
- Security.online.where(id: ...) - load all security records at once
- batch_first_required_price_dates: one GROUP BY security_id MIN(entries.date)
over trades, one pluck for provider-holding security IDs, one GROUP BY
security_id MAX(date) over holdings for historical end dates
* fix(market-data-importer): fetch prices through today for reopened positions
Account::Syncer runs import_market_data before materialize_balances, so
current_holdings reflects the last materialized snapshot rather than the
current trade state. If a security was previously sold (stale holdings show
qty=0) and then repurchased in the same sync cycle, it landed in
historical_ids and had its end_date capped at the old last_holding_date.
This caused all_prices_exist? to short-circuit, skipping the price fetch
through today, and leaving the forthcoming holding materialization without
a price for the repurchase period.
Fix: compare the latest trade date against the last holding date for each
historical security. If the trade is newer, the position was reopened before
holdings were rematerialized; treat end_date as Date.current for that sync.
The cap still applies on subsequent syncs once materialize_balances has
updated the holdings table.
Adds a regression test covering the repurchase scenario.
* hoist account.start_date out of per-security loop
Account#start_date issues SELECT MIN(date) FROM entries on every call.
Inside batch_first_required_price_dates it was called up to twice per
security (holding_date assignment + fallback), producing up to 2N extra
queries for an account with N provider-held securities.
Cache the result in account_start_date before the loop.
* assert offline securities are skipped
Adds a regression test verifying that Account::MarketDataImporter never
calls fetch_security_prices for a security with offline: true, covering
the Security.online filter on line 54 of the importer.