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|