diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css
index 93b8a6c92..c1e6af0df 100644
--- a/app/assets/tailwind/sure-design-system/components.css
+++ b/app/assets/tailwind/sure-design-system/components.css
@@ -188,10 +188,14 @@
Chart hover tooltip surface (see utils/chart_tooltip.js for the JS-side
contract). Matches the design reference exactly: hairline border ring
composed with a soft 8/24 drop shadow (Tailwind shadow utilities don't
- compose, hence the component class), 10px radius, 12x14 padding, and an
- 80ms left/top glide so the card eases between scrub positions instead of
- teleporting. Dark mode swaps the ring to alpha-white; the drop shadow is
- near-invisible there, which is fine — the ring carries the edge.
+ compose, hence the component class), 10px radius, 10x12 padding. Dark
+ mode swaps the ring to alpha-white; the drop shadow is near-invisible
+ there, which is fine — the ring carries the edge.
+
+ No position transition here on purpose: cursor-following tooltips
+ (sankey, time-series) update left/top every mousemove and a transition
+ makes them trail the pointer. Snap-positioned tooltips (goal projection,
+ which jumps between dates) opt into the 80ms glide via inline style.
*/
.chart-tooltip {
background: var(--color-container);
@@ -200,9 +204,6 @@
box-shadow:
0 0 0 1px var(--color-alpha-black-50),
0 8px 24px rgba(11, 11, 11, 0.12);
- transition:
- left 80ms ease-out,
- top 80ms ease-out;
@variant theme-dark {
box-shadow:
diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js
index 34ebb3d1d..43e3c5481 100644
--- a/app/javascript/controllers/goal_projection_chart_controller.js
+++ b/app/javascript/controllers/goal_projection_chart_controller.js
@@ -449,6 +449,10 @@ export default class extends Controller {
// hand-copied class string that drifted from the other charts the moment
// the contract changed.
const tooltip = createChartTooltip(root);
+ // This tooltip snaps between discrete dates (not raw cursor positions),
+ // so the glide reads as easing, not lag. Cursor-following tooltips must
+ // not do this — see the .chart-tooltip comment in components.css.
+ tooltip.style.transition = "left 80ms ease-out, top 80ms ease-out";
const tooltipDate = document.createElement("div");
tooltipDate.className = CHART_TOOLTIP_CONTEXT_CLASSES;
const tooltipValue = document.createElement("div");
@@ -461,12 +465,13 @@ export default class extends Controller {
tooltip.replaceChildren(tooltipDate, tooltipValue, tooltipRelation);
const setRelation = (amount) => {
- const target = Number(data.target_amount) || 0;
- if (target <= 0 || !data.target_amount_short_label) {
+ // `targetAmount` is _draw()'s outer const (data.target_amount) — no
+ // local copy, which previously shadowed the `target` date const.
+ if (targetAmount <= 0 || !data.target_amount_short_label) {
tooltipRelation.style.display = "none";
return;
}
- const percent = Math.round((amount / target) * 100);
+ const percent = Math.round((amount / targetAmount) * 100);
tooltipRelation.textContent = this.targetRelationTemplateValue
.replace("{percent}", percent)
.replace("{target}", data.target_amount_short_label);
diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js
index 0f14383a2..eba61846b 100644
--- a/app/javascript/controllers/sankey_chart_controller.js
+++ b/app/javascript/controllers/sankey_chart_controller.js
@@ -550,7 +550,9 @@ export default class extends Controller {
// what's hovered. No color swatch — the hover highlight on the diagram
// itself already says which ribbon the card belongs to.
#tooltipContext(label) {
- return `
${label}
`;
+ // max-w-64 gives truncate a constraint to fire against — an absolute
+ // tooltip otherwise grows to fit and never ellipsizes deep flows.
+ return `${label}
`;
}
#showTooltip(event, value, percentage, contextHtml = null) {
diff --git a/db/schema.rb b/db/schema.rb
index c055adbfc..04dfb5053 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -42,7 +42,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["account_id"], name: "index_account_shares_on_account_id"
t.index ["user_id", "include_in_finances"], name: "index_account_shares_on_user_id_and_include_in_finances"
t.index ["user_id"], name: "index_account_shares_on_user_id"
- t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying, 'read_write'::character varying, 'read_only'::character varying]::text[])", name: "chk_account_shares_permission"
+ t.check_constraint "permission::text = ANY (ARRAY['full_control'::character varying::text, 'read_write'::character varying::text, 'read_only'::character varying::text])", name: "chk_account_shares_permission"
end
create_table "account_statements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -91,9 +91,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence"
t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence"
t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order"
- t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status"
+ t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status"
t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source"
- t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status"
+ t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -106,7 +106,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.uuid "accountable_id"
t.decimal "balance", precision: 19, scale: 4
t.string "currency"
- t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
+ t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.uuid "import_id"
t.uuid "plaid_account_id"
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
@@ -117,8 +117,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.string "institution_domain"
t.text "notes"
t.uuid "owner_id"
- t.datetime "disabled_at"
t.integer "account_providers_count", default: 0, null: false
+ t.datetime "disabled_at"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["currency"], name: "index_accounts_on_currency"
@@ -547,7 +547,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["provider_key"], name: "index_debug_log_entries_on_provider_key"
t.index ["source"], name: "index_debug_log_entries_on_source"
t.index ["user_id"], name: "index_debug_log_entries_on_user_id"
- t.check_constraint "level::text = ANY (ARRAY['debug'::character varying, 'info'::character varying, 'warn'::character varying, 'error'::character varying]::text[])", name: "chk_debug_log_entries_level"
+ t.check_constraint "level::text = ANY (ARRAY['debug'::character varying::text, 'info'::character varying::text, 'warn'::character varying::text, 'error'::character varying::text])", name: "chk_debug_log_entries_level"
end
create_table "depositories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -767,7 +767,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.string "default_account_sharing", default: "shared", null: false
t.string "enabled_currencies", array: true
t.datetime "last_sync_all_attempted_at"
- t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying, 'private'::character varying]::text[])", name: "chk_families_default_account_sharing"
+ t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing"
t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range"
end
@@ -849,7 +849,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state"
t.index ["family_id"], name: "index_goals_on_family_id"
t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length"
- t.check_constraint "state::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'completed'::character varying, 'archived'::character varying]::text[])", name: "chk_savings_goals_state_enum"
+ t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum"
t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive"
end
@@ -999,10 +999,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["id", "family_id"], name: "idx_import_sessions_on_id_family", unique: true
t.check_constraint "client_session_id IS NULL OR btrim(client_session_id::text) <> ''::text", name: "chk_import_sessions_client_session_id_present"
t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive"
- t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object"
t.check_constraint "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type"
- t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status"
+ t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object"
t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object"
+ t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status"
end
create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1020,10 +1020,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["import_session_id"], name: "index_import_source_mappings_on_import_session_id"
t.index ["target_type", "target_id"], name: "idx_import_source_mappings_on_target"
t.check_constraint "btrim(source_id::text) <> ''::text", name: "chk_import_source_mappings_source_id_present"
- t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type"
t.check_constraint "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present"
- t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type"
t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_present"
+ t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type"
+ t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type"
end
create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1078,9 +1078,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["import_session_id"], name: "index_imports_on_import_session_id"
t.check_constraint "checksum IS NULL OR length(checksum::text) = 64", name: "chk_imports_checksum_sha256_length"
t.check_constraint "client_chunk_id IS NULL OR btrim(client_chunk_id::text) <> ''::text", name: "chk_imports_client_chunk_id_present"
- t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object"
t.check_constraint "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present"
t.check_constraint "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present"
+ t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object"
t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_imports_summary_object"
t.check_constraint "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive"
end
@@ -1616,7 +1616,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do
t.index ["kind"], name: "index_securities_on_kind"
t.index ["price_provider", "offline_reason"], name: "index_securities_on_price_provider_and_offline_reason"
t.index ["price_provider"], name: "index_securities_on_price_provider"
- t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind"
+ t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying::text, 'cash'::character varying::text])", name: "chk_securities_kind"
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|