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-BRβdd/mm/yyyy(e.g.,27/05/2026)enβmm/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-BRanden(Rails app currently supports only these two). - No changes to the persistence format β DB still stores native
date/datetime; form fields still submit ISOyyyy-mm-ddso Rails parses correctly. - No changes to admin email/mailer date strings already templated under
time.formats.devise.*. - No
datetime-localinput rework (no usages found in the current codebase).
Current State
- Rails 8, default locale
:pt-BR, available[:'pt-BR', :en]. User#finance_localeis the source of truth, applied viaaround_action :set_localeinApplicationController.- ~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_fieldusages render native<input type="date">, whose displayed format is browser-locale controlled (not app-controlled). - No
date.formats.*ortime.formats.*keys defined inconfig/locales/en.ymlorconfig/locales/pt-BR.ymlfor general use (only mailer-specific keys exist). rails-i18ngem 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_yearis 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"stringsapp/views/admin/affiliates/show.html.erbapp/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
*.rbhelpers/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 date2026-05-27), assert page contains27/05/2026. - Display, en: same scenario with
finance_locale = "en", assert page contains05/27/2026. - Input, pt-BR: visit
expenses#new, focus the date field, assert the Flatpickr alt input renders the typed value back as27/05/2026after picking 2026-05-27 from the calendar; submit the form; assert the persistedExpense#dateequalsDate.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_fieldattachesdata-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
:shortand: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: truemeans a user can type freely. Flatpickr re-parses againstaltFormat. If a user types only27/05we accept the current year β acceptable default. - Turbo morph: Flatpickr instances should clean up via
disconnect(). The controller handles that. - Existing
:shortcallers: After step 1, the rendered output for:shortwill 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
strftimeis 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(adddate.formats+time.formatsblocks)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(verifylangattribute)- ~21
_form.html.erbpartials withform.date_fieldβlocalized_date_field - ~25β35 view/helper files with raw
strftimeβl(...) - 3 new spec files under
spec/system,spec/helpers,spec/locales
Open Questions
None β proceed to implementation plan.