Locale-Aware Date Formats (pt-BR / en)

Status: Draft Date: 2026-05-27 Locale source: User#finance_locale (already wired via ApplicationController#set_locale)

Goal

Make every date in the app render in the format expected by the user's locale:

  • pt-BRdd/mm/yyyy (e.g., 27/05/2026)
  • enmm/dd/yyyy (e.g., 05/27/2026)

Cover both display (anywhere a date is rendered) and input (every form.date_field).

Non-Goals

  • No new locales beyond pt-BR and en (Rails app currently supports only these two).
  • No changes to the persistence format — DB still stores native date/datetime; form fields still submit ISO yyyy-mm-dd so Rails parses correctly.
  • No changes to admin email/mailer date strings already templated under time.formats.devise.*.
  • No datetime-local input rework (no usages found in the current codebase).

Current State

  • Rails 8, default locale :pt-BR, available [:'pt-BR', :en].
  • User#finance_locale is the source of truth, applied via around_action :set_locale in ApplicationController.
  • ~85 view sites already use l(date, format: :short|:long) (locale-aware).
  • ~69 view/helper/model sites use raw strftime(...) with hardcoded formats — mix of English ("%b %d, %Y") and Brazilian ("%d/%m/%Y").
  • 21 form.date_field usages render native <input type="date">, whose displayed format is browser-locale controlled (not app-controlled).
  • No date.formats.* or time.formats.* keys defined in config/locales/en.yml or config/locales/pt-BR.yml for general use (only mailer-specific keys exist).
  • rails-i18n gem is not installed — we own all locale data.

Design

1. Define locale date/time formats

Add to config/locales/en.yml:

en:
  date:
    formats:
      default: "%m/%d/%Y"        # 05/27/2026
      short:   "%b %d"           # May 27
      long:    "%B %d, %Y"       # May 27, 2026
      month_year: "%B %Y"        # May 2026
    abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
    day_names:      [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
    abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
    month_names:      [~, January, February, March, April, May, June, July, August, September, October, November, December]
  time:
    formats:
      default: "%m/%d/%Y %I:%M %p"
      short:   "%b %d, %I:%M %p"
      long:    "%B %d, %Y %I:%M %p"

Add to config/locales/pt-BR.yml:

pt-BR:
  date:
    formats:
      default: "%d/%m/%Y"         # 27/05/2026
      short:   "%d/%m"            # 27/05
      long:    "%d de %B de %Y"   # 27 de maio de 2026
      month_year: "%B de %Y"      # maio de 2026
    abbr_day_names: [dom, seg, ter, qua, qui, sex, sáb]
    day_names:      [domingo, segunda, terça, quarta, quinta, sexta, sábado]
    abbr_month_names: [~, jan, fev, mar, abr, mai, jun, jul, ago, set, out, nov, dez]
    month_names:      [~, janeiro, fevereiro, março, abril, maio, junho, julho, agosto, setembro, outubro, novembro, dezembro]
  time:
    formats:
      default: "%d/%m/%Y %H:%M"
      short:   "%d/%m, %H:%M"
      long:    "%d de %B de %Y, %H:%M"

Notes:

  • pt-BR conventionally uses lowercase month/day names — we follow that.
  • pt-BR uses 24h time; en uses 12h %I:%M %p.
  • :month_year is a custom format used by some dashboards; included so we have a single canonical key.

2. Convert display sites

Replace every raw strftime in views/helpers with l(date) (uses :default) or l(date, format: :short|:long|:month_year). Audit existing l(...) calls to verify the chosen format still reads well in both locales.

Heuristic for picking the right format:

  • Long-form / standalone display (e.g., show pages, hero subtitles) → :long
  • Compact / list contexts (rows, sidebars, timeline labels) → :short
  • Tabular / filter values, fallback → :default

In-scope files (from the scan; not exhaustive):

  • app/views/admin/users/* — several hardcoded "%b %d, %Y" and "%B %d, %Y at %I:%M %p"
  • app/views/admin/announcements/* — multiple "%b %d, %Y" strings
  • app/views/admin/affiliates/show.html.erb
  • app/views/devise/registrations/edit.html.erb — hardcoded "%d/%m/%Y"
  • app/views/liquidity/index.html.erb — hardcoded "%d/%m/%Y"
  • app/views/todo_boards/_archive.html.erb"%b %d, %Y"
  • All *.rb helpers/models that build user-facing date strings (full grep during execution).

3. Locale-aware date input via Stimulus + Flatpickr

Native <input type="date"> displays in the browser/OS locale, which we cannot override from the server. To make inputs match the app locale we wrap them with Flatpickr (≈20 KB minified, MIT, zero deps), driven by a Stimulus controller.

Library install (via importmap):

# config/importmap.rb
pin "flatpickr", to: "https://ga.jspm.io/npm:[email protected]/dist/flatpickr.js"
pin "flatpickr/pt", to: "https://ga.jspm.io/npm:[email protected]/dist/l10n/pt.js"

Flatpickr CSS goes into app/assets/stylesheets/application.tailwind.css (or wherever app CSS is currently composed) — vendor a copy of flatpickr.min.css and import it once, so we control styling under Tailwind.

New Stimulus controller app/javascript/controllers/date_picker_controller.js:

import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr"
import { Portuguese } from "flatpickr/dist/l10n/pt.js"

export default class extends Controller {
  connect() {
    const locale = document.documentElement.lang || "pt-BR"
    const isPt = locale.startsWith("pt")

    this.fp = flatpickr(this.element, {
      dateFormat: "Y-m-d",                       // wire format (Rails-friendly)
      altInput: true,                            // visible input shows altFormat
      altFormat: isPt ? "d/m/Y" : "m/d/Y",
      allowInput: true,                          // typing is allowed
      locale: isPt ? Portuguese : "default",
    })
  }

  disconnect() {
    this.fp?.destroy()
  }
}

Helper to keep view code clean — add to ApplicationHelper:

def localized_date_field(form, attribute, **opts)
  opts[:data] = (opts[:data] || {}).merge(controller: [opts.dig(:data, :controller), "date-picker"].compact.join(" "))
  form.date_field(attribute, **opts)
end

Then sweep the 21 form.date_field usages → localized_date_field. This keeps the swap site-local and reversible.

Layout requirement: <html lang="<%= I18n.locale %>"> already needs to be set in app/views/layouts/application.html.erb. Verify during execution; add if missing.

4. Tests

System spec spec/system/locale_dates_spec.rb:

  • Display, pt-BR: sign in as a user with finance_locale = "pt-BR", visit a known page that renders a date (e.g., an expense detail seeded with date 2026-05-27), assert page contains 27/05/2026.
  • Display, en: same scenario with finance_locale = "en", assert page contains 05/27/2026.
  • Input, pt-BR: visit expenses#new, focus the date field, assert the Flatpickr alt input renders the typed value back as 27/05/2026 after picking 2026-05-27 from the calendar; submit the form; assert the persisted Expense#date equals Date.new(2026, 5, 27).
  • Input, en: same, expect alt input to show 05/27/2026.

Helper spec (spec/helpers/application_helper_spec.rb):

  • localized_date_field attaches data-controller="date-picker" and preserves any caller-provided controllers.

Unit-level locale config check (spec/locales/dates_spec.rb):

  • I18n.with_locale(:'pt-BR') { I18n.l(Date.new(2026,5,27)) } == "27/05/2026"
  • I18n.with_locale(:en) { I18n.l(Date.new(2026,5,27)) } == "05/27/2026"
  • Same for :short and :long.

5. Rollout

  • Single PR / single implementation plan.
  • Manual smoke-check before merge: log in as both a pt-BR user and an en user, exercise expenses/birthdays/goals forms (cover create + edit), spot-check 3–4 admin pages and 3–4 user dashboards.

Risk / Edge Cases

  • Year edge in 2-digit Flatpickr typing: allowInput: true means a user can type freely. Flatpickr re-parses against altFormat. If a user types only 27/05 we accept the current year — acceptable default.
  • Turbo morph: Flatpickr instances should clean up via disconnect(). The controller handles that.
  • Existing :short callers: After step 1, the rendered output for :short will change in both locales (currently uses Rails built-in defaults — "May 27" and "27 de Mai" respectively). This is the intended outcome, but worth eyeballing during smoke.
  • Mailer/email templates: Out of scope unless a hardcoded strftime is found in mailer views during the sweep; if so, convert it the same way.

File Touch List (approximate)

  • config/locales/en.yml, config/locales/pt-BR.yml (add date.formats + time.formats blocks)
  • config/importmap.rb (pin flatpickr)
  • app/javascript/controllers/index.js (register controller if not auto-registered)
  • app/javascript/controllers/date_picker_controller.js (new)
  • app/helpers/application_helper.rb (localized_date_field)
  • app/assets/stylesheets/... (vendor flatpickr.css)
  • app/views/layouts/application.html.erb (verify lang attribute)
  • ~21 _form.html.erb partials with form.date_fieldlocalized_date_field
  • ~25–35 view/helper files with raw strftimel(...)
  • 3 new spec files under spec/system, spec/helpers, spec/locales

Open Questions

None — proceed to implementation plan.