diff --git a/app/controllers/concerns/localize.rb b/app/controllers/concerns/localize.rb index 2c5a19646..db66819c3 100644 --- a/app/controllers/concerns/localize.rb +++ b/app/controllers/concerns/localize.rb @@ -8,12 +8,104 @@ module Localize private def switch_locale(&action) - locale = locale_from_param || Current.family.try(:locale) || I18n.default_locale + locale = locale_from_param || locale_from_user || locale_from_accept_language || locale_from_family || I18n.default_locale I18n.with_locale(locale, &action) end + def locale_from_user + locale = Current.user&.locale + return if locale.blank? + + locale_sym = locale.to_sym + locale_sym if I18n.available_locales.include?(locale_sym) + end + + def locale_from_family + locale = Current.family&.locale + return if locale.blank? + + locale_sym = locale.to_sym + locale_sym if I18n.available_locales.include?(locale_sym) + end + + def locale_from_accept_language + locale = accept_language_top_locale + return if locale.blank? + + locale_sym = locale.to_sym + return unless I18n.available_locales.include?(locale_sym) + + # Auto-save detected locale to user profile (once per user, not per session) + if Current.user.present? && Current.user.locale.blank? + Current.user.update_column(:locale, locale_sym.to_s) + end + + locale_sym + end + + def accept_language_top_locale + header = request.get_header("HTTP_ACCEPT_LANGUAGE") + return if header.blank? + + # Parse language;q pairs and sort by q-value (descending), preserving header order for ties + parsed_languages = parse_accept_language(header) + return if parsed_languages.empty? + + # Find first supported locale by q-value priority + parsed_languages.each do |lang, _q| + normalized = normalize_locale(lang) + canonical = supported_locales[normalized.downcase] + return canonical if canonical.present? + + primary_language = normalized.split("-").first + primary_match = supported_locales[primary_language.downcase] + return primary_match if primary_match.present? + end + + nil + end + + def parse_accept_language(header) + entries = [] + + header.split(",").each_with_index do |entry, index| + parts = entry.split(";") + language = parts.first.to_s.strip + next if language.blank? + + # Extract q-value, default to 1.0 + q_value = 1.0 + parts[1..].each do |param| + param = param.strip + if param.start_with?("q=") + q_str = param[2..] + q_value = Float(q_str) rescue 1.0 + q_value = q_value.clamp(0.0, 1.0) + break + end + end + + entries << [ language, q_value, index ] + end + + # Sort by q-value descending, then by original header order ascending + entries.sort_by { |_lang, q, idx| [ -q, idx ] }.map { |lang, q, _idx| [ lang, q ] } + end + + def supported_locales + @supported_locales ||= LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, locales| + normalized = normalize_locale(locale) + locales[normalized.downcase] = normalized + end + end + + def normalize_locale(locale) + locale.to_s.strip.gsub("_", "-") + end + def locale_from_param return unless params[:locale].is_a?(String) && params[:locale].present? + locale = params[:locale].to_sym locale if I18n.available_locales.include?(locale) end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 067abbd25..59fa68bdb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -105,8 +105,8 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, - :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, - family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ], + :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale, + family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :id ], goals: [] ) end diff --git a/app/models/user.rb b/app/models/user.rb index 3f22a1eb4..3ab9276f8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,6 +39,7 @@ class User < ApplicationRecord validate :ensure_valid_profile_image validates :default_period, inclusion: { in: Period::PERIODS.keys } validates :default_account_order, inclusion: { in: AccountOrder::ORDERS.keys } + validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }, allow_nil: true # Password is required on create unless the user is being created via SSO JIT. # SSO JIT users have password_digest = nil and authenticate via OIDC only. diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb index a017f64d3..f163f5b61 100644 --- a/app/views/onboardings/preferences.html.erb +++ b/app/views/onboardings/preferences.html.erb @@ -76,7 +76,7 @@ <%= family_form.select :locale, language_options, - { label: t(".locale"), required: true, selected: params[:locale] || @user.family.locale }, + { label: t(".locale"), required: true, selected: params[:locale] || @user.locale || I18n.locale }, { data: { action: "onboarding#setLocale" } } %> <%= family_form.select :currency, diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 8911e8178..769b31801 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -5,16 +5,16 @@ <%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= form.select :locale, + language_options, + { label: t(".language"), include_blank: t(".language_auto") }, + { data: { auto_submit_form_target: "auto" } } %> + <%= form.fields_for :family do |family_form| %> <%= family_form.select :currency, Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, { label: t(".currency") }, disabled: true %> - <%= family_form.select :locale, - language_options, - { label: t(".language") }, - { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :timezone, timezone_options, { label: t(".timezone") }, diff --git a/config/locales/views/settings/ca.yml b/config/locales/views/settings/ca.yml index 53f02260d..5d43209eb 100644 --- a/config/locales/views/settings/ca.yml +++ b/config/locales/views/settings/ca.yml @@ -41,6 +41,7 @@ ca: general_subtitle: Configura les teves preferències general_title: General language: Idioma + language_auto: Idioma del navegador page_title: Preferències theme_dark: Fosc theme_light: Clar diff --git a/config/locales/views/settings/de.yml b/config/locales/views/settings/de.yml index 3b554daf1..708145ff9 100644 --- a/config/locales/views/settings/de.yml +++ b/config/locales/views/settings/de.yml @@ -34,6 +34,7 @@ de: default_period: Standardzeitraum default_account_order: Standardreihenfolge der Konten language: Sprache + language_auto: Browsersprache page_title: Einstellungen theme_dark: Dunkel theme_light: Hell diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index da9c6ae6c..ca407957b 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -36,6 +36,7 @@ en: default_period: Default Period default_account_order: Default Account Order language: Language + language_auto: Browser language page_title: Preferences theme_dark: Dark theme_light: Light diff --git a/config/locales/views/settings/es.yml b/config/locales/views/settings/es.yml index 940f5b9fd..1867ac49e 100644 --- a/config/locales/views/settings/es.yml +++ b/config/locales/views/settings/es.yml @@ -35,6 +35,7 @@ es: default_period: Periodo Predeterminado default_account_order: Orden Predeterminado de Cuentas language: Idioma + language_auto: Idioma del navegador page_title: Preferencias theme_dark: Oscuro theme_light: Claro diff --git a/config/locales/views/settings/fr.yml b/config/locales/views/settings/fr.yml index ca323fa47..397f387b9 100644 --- a/config/locales/views/settings/fr.yml +++ b/config/locales/views/settings/fr.yml @@ -35,6 +35,7 @@ fr: default_period: Période par défaut default_account_order: Ordre d'affichage des comptes par défaut language: Langue + language_auto: Langue du navigateur page_title: Préférences theme_dark: Sombre theme_light: Clair diff --git a/config/locales/views/settings/nb.yml b/config/locales/views/settings/nb.yml index 4a0fe5eca..ed759aae6 100644 --- a/config/locales/views/settings/nb.yml +++ b/config/locales/views/settings/nb.yml @@ -19,6 +19,7 @@ nb: general_title: Generelt default_period: Standardperiode language: Språk + language_auto: Nettleserens språk page_title: Preferanser theme_dark: Mørk theme_light: Lys diff --git a/config/locales/views/settings/nl.yml b/config/locales/views/settings/nl.yml index 05824e565..a74136bbe 100644 --- a/config/locales/views/settings/nl.yml +++ b/config/locales/views/settings/nl.yml @@ -35,6 +35,7 @@ nl: default_period: Standaard Periode default_account_order: Standaard Account Volgorde language: Taal + language_auto: Browsertaal page_title: Voorkeuren theme_dark: Donker theme_light: Licht diff --git a/config/locales/views/settings/pt-BR.yml b/config/locales/views/settings/pt-BR.yml index be3123d45..cc393360a 100644 --- a/config/locales/views/settings/pt-BR.yml +++ b/config/locales/views/settings/pt-BR.yml @@ -34,6 +34,7 @@ pt-BR: general_title: Geral default_period: Período Padrão language: Idioma + language_auto: Idioma do navegador page_title: Preferências theme_dark: Escuro theme_light: Claro diff --git a/config/locales/views/settings/ro.yml b/config/locales/views/settings/ro.yml index 272ff4814..911acc019 100644 --- a/config/locales/views/settings/ro.yml +++ b/config/locales/views/settings/ro.yml @@ -35,6 +35,7 @@ ro: default_period: Perioadă implicită default_account_order: Ordine implicită conturi language: Limbă + language_auto: Limba browserului page_title: Preferințe theme_dark: Întunecat theme_light: Luminos diff --git a/config/locales/views/settings/tr.yml b/config/locales/views/settings/tr.yml index ad4e7ee54..7ad52df99 100644 --- a/config/locales/views/settings/tr.yml +++ b/config/locales/views/settings/tr.yml @@ -19,6 +19,7 @@ tr: general_title: Genel default_period: Varsayılan Dönem language: Dil + language_auto: Tarayıcı dili page_title: Tercihler theme_dark: Koyu theme_light: Açık diff --git a/config/locales/views/settings/zh-CN.yml b/config/locales/views/settings/zh-CN.yml index 06265ba6f..845ae9a0d 100644 --- a/config/locales/views/settings/zh-CN.yml +++ b/config/locales/views/settings/zh-CN.yml @@ -38,6 +38,7 @@ zh-CN: general_subtitle: 配置个人偏好设置 general_title: 通用设置 language: 语言 + language_auto: 浏览器语言 page_title: 偏好设置 theme_dark: 深色模式 theme_light: 浅色模式 diff --git a/config/locales/views/settings/zh-TW.yml b/config/locales/views/settings/zh-TW.yml index 47a69cf58..4d544f479 100644 --- a/config/locales/views/settings/zh-TW.yml +++ b/config/locales/views/settings/zh-TW.yml @@ -35,6 +35,7 @@ zh-TW: default_period: 預設時段 default_account_order: 預設帳戶排序 language: 語言 + language_auto: 瀏覽器語言 page_title: 偏好設定 theme_dark: 深色 theme_light: 淺色 diff --git a/db/migrate/20260124180211_add_locale_to_users.rb b/db/migrate/20260124180211_add_locale_to_users.rb new file mode 100644 index 000000000..e69c471c8 --- /dev/null +++ b/db/migrate/20260124180211_add_locale_to_users.rb @@ -0,0 +1,6 @@ +class AddLocaleToUsers < ActiveRecord::Migration[7.2] + def change + add_column :users, :locale, :string, null: true, default: nil + add_index :users, :locale + end +end diff --git a/db/schema.rb b/db/schema.rb index 84bf07e6f..9b7bcb296 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_23_214127) do +ActiveRecord::Schema[7.2].define(version: 2026_01_24_180211) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1402,9 +1402,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_23_214127) do t.datetime "set_onboarding_goals_at" t.string "default_account_order", default: "name_asc" t.jsonb "preferences", default: {}, null: false + t.string "locale" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" + t.index ["locale"], name: "index_users_on_locale" t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" t.index ["preferences"], name: "index_users_on_preferences", using: :gin end diff --git a/test/controllers/concerns/localize_test.rb b/test/controllers/concerns/localize_test.rb index 29610d6b4..3ba6f376e 100644 --- a/test/controllers/concerns/localize_test.rb +++ b/test/controllers/concerns/localize_test.rb @@ -1,23 +1,55 @@ require "test_helper" class LocalizeTest < ActionDispatch::IntegrationTest - setup do - sign_in users(:family_admin) + test "uses Accept-Language top locale on login when supported" do + get new_session_url, headers: { "Accept-Language" => "fr-CA,fr;q=0.9" } + assert_response :success + assert_select "button", text: /Se connecter/i end - test "uses family locale by default" do - get preferences_onboarding_url + test "falls back to English when Accept-Language is unsupported" do + get new_session_url, headers: { "Accept-Language" => "ru-RU,ru;q=0.9" } + assert_response :success + assert_select "button", text: /Log in/i + end + + test "uses Accept-Language for onboarding when user locale is not set" do + sign_in users(:family_admin) + + get preferences_onboarding_url, headers: { "Accept-Language" => "es-ES,es;q=0.9" } + assert_response :success + assert_select "h1", text: /Configura tus preferencias/i + end + + test "falls back to family locale when Accept-Language is unsupported" do + sign_in users(:family_admin) + + get preferences_onboarding_url, headers: { "Accept-Language" => "ru-RU,ru;q=0.9" } assert_response :success assert_select "h1", text: /Configure your preferences/i end + test "respects user locale override even when Accept-Language differs" do + user = users(:family_admin) + user.update!(locale: "fr") + sign_in user + + get preferences_onboarding_url, headers: { "Accept-Language" => "es-ES,es;q=0.9" } + assert_response :success + assert_select "h1", text: /Configurez vos préférences/i + end + test "switches locale when locale param is provided" do + sign_in users(:family_admin) + get preferences_onboarding_url(locale: "fr") assert_response :success assert_select "h1", text: /Configurez vos préférences/i end test "ignores invalid locale param and uses family locale" do + sign_in users(:family_admin) + get preferences_onboarding_url(locale: "invalid_locale") assert_response :success assert_select "h1", text: /Configure your preferences/i diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index d5158b584..5ca659669 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -21,14 +21,15 @@ class UsersControllerTest < ActionDispatch::IntegrationTest name: "New Family Name", country: "US", date_format: "%m/%d/%Y", - currency: "USD", - locale: "en" - } + currency: "USD" + }, + locale: "es" } } assert_redirected_to settings_profile_url assert_equal "Your profile has been updated.", flash[:notice] + assert_equal "es", @user.reload.locale end test "admin can reset family data" do