Locale-Aware Date Formats Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Render every user-facing date in the format expected by the active locale (dd/mm/yyyy for pt-BR, mm/dd/yyyy for en) β for both display sites and form inputs.
Architecture: Two layers. (1) Server-side displays read locale formats from config/locales/*.yml via I18n.l. All raw view-level strftime calls are converted to l(...). (2) Form inputs use a Stimulus controller wrapping Flatpickr; the native <input type="date"> is enhanced with a locale-formatted alt input while the submitted value stays ISO yyyy-mm-dd. The active locale flows from User#finance_locale (already wired via ApplicationController#set_locale) and is exposed to JS via <html lang>.
Tech Stack: Rails 8 / Hotwire (Stimulus 3, importmap), Tailwind, Flatpickr 4.6.x, Minitest (system + integration tests).
Spec: docs/superpowers/specs/2026-05-27-locale-aware-date-formats-design.md
Scope notes:
- View-layer
strftimesites only.app/models/**/*.rbstrftimecalls that build SQL keys, scope conditions, or grouping labels are out of scope (changing them would break queries). Chart series labels that use customPT_MONTHSconstants in analytics calculators are also out of scope β they need a separate i18n pass. - Two clock displays (
shared/admin/_footer_nav.html.erb:21,shared/admin/_navbar.html.erb:14) usingTime.current.strftime("%H:%Mβ¦")are time-of-day clocks, not date displays β out of scope.
File Structure
New files:
app/javascript/controllers/date_picker_controller.jsβ Stimulus controller that wraps<input type="date">with Flatpickr, picking format/locale from<html lang>.app/assets/stylesheets/vendor/flatpickr.cssβ vendored Flatpickr base stylesheet (so we can keep importmap-only JS and skip a CSS pin).test/i18n/date_formats_test.rbβ unit test thatI18n.lreturns expected strings per locale.test/helpers/application_helper_test.rbβ test forlocalized_date_field(create if absent; append a test method if it already exists).test/system/locale_dates_test.rbβ system test exercising display + input for both locales.
Modified files:
config/locales/en.yml,config/locales/pt-BR.ymlβ adddate.formats,time.formats,date.day_names/month_names.config/importmap.rbβ pinflatpickrand its Portuguese locale.app/javascript/application.css(orapp/assets/stylesheets/application.tailwind.css, whichever the project uses for the authenticated layout) β@importthe vendored Flatpickr CSS.app/views/layouts/application.html.erbβ addlang="<%= I18n.locale %>"to<html>.app/helpers/application_helper.rbβ addlocalized_date_field.- ~21
_form.html.erbpartials β swapform.date_fieldβlocalized_date_field. - ~20 view files containing user-facing raw
strftimeβ convert tol(...).
Task 1: Locale date/time format keys (TDD)
Files:
- Modify:
config/locales/en.yml(top-levelen:map) - Modify:
config/locales/pt-BR.yml(top-levelpt-BR:map) -
Test:
test/i18n/date_formats_test.rb(new) - Step 1: Write the failing test
Create test/i18n/date_formats_test.rb:
require "test_helper"
class DateFormatsTest < ActiveSupport::TestCase
D = Date.new(2026, 5, 27)
T = Time.utc(2026, 5, 27, 14, 30)
test "pt-BR formats dates dd/mm/yyyy by default" do
I18n.with_locale(:"pt-BR") do
assert_equal "27/05/2026", I18n.l(D)
assert_equal "27/05", I18n.l(D, format: :short)
assert_equal "27 de maio de 2026", I18n.l(D, format: :long)
end
end
test "en formats dates mm/dd/yyyy by default" do
I18n.with_locale(:en) do
assert_equal "05/27/2026", I18n.l(D)
assert_equal "May 27", I18n.l(D, format: :short)
assert_equal "May 27, 2026", I18n.l(D, format: :long)
end
end
test "pt-BR formats time with 24h and dd/mm" do
I18n.with_locale(:"pt-BR") do
assert_equal "27/05/2026 14:30", I18n.l(T)
end
end
test "en formats time with 12h and mm/dd" do
I18n.with_locale(:en) do
assert_equal "05/27/2026 02:30 PM", I18n.l(T)
end
end
end
- Step 2: Run the test to verify it fails
Run: bin/rails test test/i18n/date_formats_test.rb -v
Expected: 4 failures β I18n::MissingTranslationData for date.formats.default / time.formats.default (or wrong default output).
- Step 3: Add date/time format keys to
en.yml
Open config/locales/en.yml. Immediately under en: (before any existing nested keys), insert:
date:
formats:
default: "%m/%d/%Y"
short: "%b %d"
long: "%B %d, %Y"
month_year: "%B %Y"
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"
- Step 4: Add date/time format keys to
pt-BR.yml
Open config/locales/pt-BR.yml. Immediately under pt-BR: (before any existing nested keys), insert:
date:
formats:
default: "%d/%m/%Y"
short: "%d/%m"
long: "%d de %B de %Y"
month_year: "%B de %Y"
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"
- Step 5: Run the test to verify it passes
Run: bin/rails test test/i18n/date_formats_test.rb -v
Expected: 4 runs, 0 failures.
- Step 6: Commit
git add config/locales/en.yml config/locales/pt-BR.yml test/i18n/date_formats_test.rb
git commit -m "i18n: define date/time formats for pt-BR and en"
Task 2: Add lang attribute to layout
The Stimulus controller (Task 4) needs to read the active locale from document.documentElement.lang. Right now <html> has no lang attribute.
Files:
-
Modify:
app/views/layouts/application.html.erb(line 2) -
Step 1: Read the current
<html>line
Verify the current opening tag matches:
<html class="bg-[#f1f5f9] dark:bg-[#0d1117] overflow-hidden h-dvh">
- Step 2: Add
langattribute
Change to:
<html lang="<%= I18n.locale %>" class="bg-[#f1f5f9] dark:bg-[#0d1117] overflow-hidden h-dvh">
- Step 3: Verify via a quick integration test
Run an existing system or integration test (e.g., bin/rails test test/system/ or any controller test) to make sure the layout still renders. If a system test exists for a logged-in page, view the source to confirm lang="pt-BR" appears on <html>. Otherwise this is verified end-to-end in Task 6.
- Step 4: Commit
git add app/views/layouts/application.html.erb
git commit -m "layout: emit I18n.locale as html lang attribute"
Task 3: Convert view-level strftime calls to I18n.l
The full audit (view-layer only) yields the conversions below. Apply them all, then run the test suite, then commit once.
Mapping rules used:
"%b %d, %Y"βl(date, format: :long)(full date display)"%B %d, %Y"βl(date, format: :long)"%b %d, %Y at %I:%M %p"/"%B %d, %Y at %I:%M %p"βl(datetime, format: :long)(time portion now locale-controlled)"%b %d"βl(date, format: :short)"%d/%m/%Y"βl(date)(uses:default)"%d/%m/%Y Γ s %H:%M"/"%d/%m %H:%M"βl(datetime)(uses time:default)
Files to modify (with the exact edits):
- Step 1:
app/views/admin/audit_log/index.html.erb:37
Before:
<%= l(event.created_at, format: :short) rescue event.created_at.strftime("%b %d %H:%M") %>
After:
<%= l(event.created_at, format: :short) %>
The rescue was a safety net for missing keys; with Task 1 done, the key always exists.
- Step 2:
app/views/admin/affiliates/show.html.erb:37
Before: <%= @affiliate.created_at.strftime("%b %d, %Y") %>
After: <%= l(@affiliate.created_at.to_date, format: :long) %>
- Step 3:
app/views/admin/users/_show.html.erb:41
Before: <%= user.created_at.strftime("%b %d, %Y at %I:%M %p") %>
After: <%= l(user.created_at, format: :long) %>
- Step 4:
app/views/admin/users/_user_row.html.erb:46and:78
Before (both): <%= user.confirmed_at.strftime("%b %d, %Y") %> and <%= user.created_at.strftime("%b %d, %Y") %>
After (both): <%= l(user.confirmed_at.to_date, format: :long) %> and <%= l(user.created_at.to_date, format: :long) %>
- Step 5:
app/views/admin/users/_detail_lifecycle.html.erb:19
Before:
<div class="β¦"><%= l(e[:at].to_date, format: :short) rescue e[:at].strftime("%b %d") %></div>
After:
<div class="β¦"><%= l(e[:at].to_date, format: :short) %></div>
- Step 6:
app/views/admin/announcements/_announcement_row.html.erb:30
Before: <%= announcement.created_at.strftime("%b %d, %Y") %>
After: <%= l(announcement.created_at.to_date, format: :long) %>
- Step 7:
app/views/admin/announcements/show.html.erb(lines 17, 23, 34)
Line 17 before: Published <%= @announcement.published_at.strftime("%b %d, %Y at %I:%M %p") %>
Line 17 after: Published <%= l(@announcement.published_at, format: :long) %>
Line 23 before: Expires: <%= @announcement.expires_at.strftime("%b %d, %Y") %>
Line 23 after: Expires: <%= l(@announcement.expires_at.to_date, format: :long) %>
Line 34 before: on <%= @announcement.created_at.strftime("%B %d, %Y") %>
Line 34 after: on <%= l(@announcement.created_at.to_date, format: :long) %>
- Step 8:
app/views/devise/registrations/edit.html.erb(lines 198, 202)
Line 198 before: <%= resource.created_at.strftime("%d/%m/%Y") %>
Line 198 after: <%= l(resource.created_at.to_date) %>
Line 202 before: <%= resource.current_sign_in_at&.strftime("%d/%m/%Y Γ s %H:%M") || "β" %>
Line 202 after: <%= resource.current_sign_in_at ? l(resource.current_sign_in_at) : "β" %>
- Step 9:
app/views/countdowns/index.html.erb(lines 49, 115)
Both before: <%= countdown.target_date.strftime("%b %d, %Y") %>
Both after: <%= l(countdown.target_date, format: :long) %>
- Step 10:
app/views/todo_boards/_archive.html.erb:31
Before: <%= card.archived_at&.strftime("%b %d, %Y") %>
After: <%= card.archived_at ? l(card.archived_at.to_date, format: :long) : nil %>
- Step 11:
app/views/todo_cards/show.html.erb:50andapp/views/todo_cards/_card.html.erb:60
show.html.erb:50 before: <span><%= @card.due_date.strftime("%B %d, %Y") %></span>
show.html.erb:50 after: <span><%= l(@card.due_date, format: :long) %></span>
_card.html.erb:60 before: <%= card.due_date.strftime("%b %d") %>
_card.html.erb:60 after: <%= l(card.due_date, format: :short) %>
- Step 12:
app/views/liquidity/index.html.erb(lines 158, 163)
Line 158 before: Liquidez: <%= inv.liquidity_date.strftime("%d/%m/%Y") %>
Line 158 after: <%= t("liquidity.row.liquidity_label") %>: <%= l(inv.liquidity_date) %>
If liquidity.row.liquidity_label does not already exist, do not add it β instead leave the literal Liquidez: text (the current view is already hardcoded Portuguese, so we are not adding new strings in this plan). Use:
Line 158 after (final): Liquidez: <%= l(inv.liquidity_date) %>
Line 163 before: Venc: <%= inv.maturity_date.strftime("%d/%m/%Y") %>
Line 163 after: Venc: <%= l(inv.maturity_date) %>
- Step 13:
app/views/investments/_investment_row.html.erb(lines 29, 61, 70)
Line 29 before: <%= investment.purchase_date&.strftime("%d/%m/%Y") %> Β· <%= investment.institution %>
Line 29 after: <%= investment.purchase_date ? l(investment.purchase_date) : nil %> Β· <%= investment.institution %>
Line 61 before: <%= investment.liquidity_date.strftime("%d/%m/%Y") %>
Line 61 after: <%= l(investment.liquidity_date) %>
Line 70 before: <%= investment.maturity_date.strftime("%d/%m/%Y") %>
Line 70 after: <%= l(investment.maturity_date) %>
- Step 14:
app/views/investments/_timeline_tab.html.erb:67
Before: Vence: <%= inv.maturity_date.strftime("%d/%m/%Y") %>
After: Vence: <%= l(inv.maturity_date) %>
- Step 15:
app/views/investments/_liquidez_tab.html.erb(lines 151, 156)
Line 151 before: Liquidez: <%= inv.liquidity_date.strftime("%d/%m/%Y") %>
Line 151 after: Liquidez: <%= l(inv.liquidity_date) %>
Line 156 before: Venc: <%= inv.maturity_date.strftime("%d/%m/%Y") %>
Line 156 after: Venc: <%= l(inv.maturity_date) %>
- Step 16:
app/views/gamer_dashboard/_stats_panel.html.erb:87
Before: title="<%= Date.parse(date).strftime('%b %d') %>: <%= xp %> XP">
After: title="<%= l(Date.parse(date), format: :short) %>: <%= xp %> XP">
(Line 184 is xp.created_at.strftime("%H:%M") β a time-of-day stamp, not a date. Leave it alone.)
- Step 17:
app/views/habits/show.html.erb:61
Before:
<span class="text-[10px] text-gray-500"><%= I18n.l(date, format: "%a") rescue date.strftime("%a") %></span>
After:
<span class="text-[10px] text-gray-500"><%= I18n.l(date, format: "%a") %></span>
(The :%a format token is interpreted against the locale's abbr_day_names table, which we defined in Task 1.)
- Step 18:
app/views/finance_settings/show.html.erb:259
Before:
(current_user.exchange_rate_updated_at.to_date == Date.current ? "Hoje, #{current_user.exchange_rate_updated_at.strftime('%H:%M')}" : l(current_user.exchange_rate_updated_at, format: :short)) :
After (only the strftime call changes β leave the rest):
(current_user.exchange_rate_updated_at.to_date == Date.current ? "Hoje, #{current_user.exchange_rate_updated_at.strftime('%H:%M')}" : l(current_user.exchange_rate_updated_at, format: :short)) :
This is unchanged: %H:%M is a time-of-day-only fragment, already locale-neutral, so leave it. Skip this step.
- Step 19:
app/views/finance_settings/show.html.erb:197
Before: <span class="β¦"><%= Time.current.strftime("%d/%m %H:%M") %></span>
After: <span class="β¦"><%= l(Time.current, format: :short) %></span>
- Step 20:
app/views/accounts/index.html.erb(lines 116, 117)
Line 116 before: <span class="β¦"><%= registry.date.strftime('%d') %></span>
Line 116 after: <span class="β¦"><%= registry.date.strftime('%d') %></span> β leave unchanged. %d is a zero-padded day number, identical in both locales.
Line 117 before: <span class="β¦"><%= registry.date.strftime('%b/%y') %></span>
Line 117 after: <span class="β¦"><%= l(registry.date, format: "%b/%y") %></span>
Skip the %d-only line. Edit the %b/%y line.
- Step 21: Run the full test suite
Run: bin/rails test
Expected: all tests still pass. The locale formats test from Task 1 should still pass. No other tests should break.
- Step 22: Manual spot check (optional, fast)
Boot the dev server (bin/dev or bin/rails s), log in, visit /countdowns, /investments, /admin/users (if admin user) and confirm dates render in dd/mm/yyyy for the default pt-BR locale.
- Step 23: Commit
git add app/views/
git commit -m "i18n: convert user-facing strftime calls to I18n.l"
Task 4: Install Flatpickr and create the Stimulus controller
Files:
- Modify:
config/importmap.rb - Create:
app/assets/stylesheets/vendor/flatpickr.css(vendored) - Modify:
app/assets/stylesheets/application.tailwind.css(or whichever stylesheet is loaded by the authenticated layout) β@importthe vendored file -
Create:
app/javascript/controllers/date_picker_controller.js - Step 1: Pin Flatpickr in importmap
Add to config/importmap.rb (immediately after the existing apexcharts pin, since these are also app-only):
pin "flatpickr", to: "https://ga.jspm.io/npm:[email protected]/dist/esm/index.js", preload: "application"
pin "flatpickr/l10n/pt", to: "https://ga.jspm.io/npm:[email protected]/dist/esm/l10n/pt.js", preload: "application"
Restart any running dev server so the importmap reloads (or the next request will reload it automatically).
- Step 2: Vendor Flatpickr CSS
Create app/assets/stylesheets/vendor/flatpickr.css with the contents of https://cdn.jsdelivr.net/npm/[email protected]/dist/flatpickr.min.css. Use curl:
mkdir -p app/assets/stylesheets/vendor
curl -fsSL https://cdn.jsdelivr.net/npm/[email protected]/dist/flatpickr.min.css \
-o app/assets/stylesheets/vendor/flatpickr.css
- Step 3: Import the vendored CSS into the authenticated stylesheet
Find the app's main stylesheet entry point. Likely candidates: app/assets/stylesheets/application.tailwind.css, app/assets/stylesheets/application.css, or app/javascript/application.css. Open it and add to the top (after any @import "tailwindcss"; directive):
@import "vendor/flatpickr.css";
If the project uses Propshaft, the relative import resolves under app/assets/stylesheets/. If it uses cssbundling, place the file under wherever tailwind.config.js / postcss.config.js expects.
To find the right file, run:
ls app/assets/stylesheets/*.css app/javascript/*.css 2>/dev/null
grep -l "@import" app/assets/stylesheets/*.css 2>/dev/null
- Step 4: Create the Stimulus controller
Create app/javascript/controllers/date_picker_controller.js:
import { Controller } from "@hotwired/stimulus"
import flatpickr from "flatpickr"
import { Portuguese } from "flatpickr/l10n/pt"
export default class extends Controller {
connect() {
const locale = (document.documentElement.lang || "pt-BR").toLowerCase()
const isPt = locale.startsWith("pt")
this.fp = flatpickr(this.element, {
dateFormat: "Y-m-d",
altInput: true,
altFormat: isPt ? "d/m/Y" : "m/d/Y",
allowInput: true,
locale: isPt ? Portuguese : "default",
})
}
disconnect() {
this.fp?.destroy()
}
}
Note: eagerLoadControllersFrom("controllers", application) in app/javascript/controllers/index.js already auto-registers any new file matching *_controller.js. No manual registration needed.
- Step 5: Smoke test in the browser
Boot the dev server. Open browser DevTools console, navigate to any page that currently has a date_field (e.g., /expenses/new). In the console, manually attach the controller to test:
document.querySelector('input[type="date"]').setAttribute('data-controller', 'date-picker')
The input should be replaced with a Flatpickr widget. Cancel and revert any console-only changes.
- Step 6: Commit
git add config/importmap.rb app/assets/stylesheets/vendor/flatpickr.css \
app/assets/stylesheets/application.tailwind.css \
app/javascript/controllers/date_picker_controller.js
git commit -m "feat: add flatpickr-based date picker stimulus controller"
(Adjust the second-to-last path to the actual stylesheet you edited in Step 3.)
Task 5: Add localized_date_field helper and sweep all date_field call sites
Files:
- Modify:
app/helpers/application_helper.rb - Create:
test/helpers/application_helper_test.rb(or append if it exists) -
Modify: 21
_form.html.erbpartials listed below. - Step 1: Write the failing helper test
If test/helpers/application_helper_test.rb does not exist, create it with:
require "test_helper"
class ApplicationHelperTest < ActionView::TestCase
test "localized_date_field attaches the date-picker stimulus controller" do
form = form_with(model: OpenStruct.new(due_on: Date.new(2026, 5, 27)),
url: "#", scope: :record, local: true) do |f|
concat localized_date_field(f, :due_on, class: "input")
end
rendered = form.to_s
assert_match %r{data-controller="[^"]*\bdate-picker\b}, rendered
assert_match %r{class="input"}, rendered
assert_match %r{type="date"}, rendered
end
test "localized_date_field preserves caller-provided data-controller values" do
form = form_with(model: OpenStruct.new(due_on: nil),
url: "#", scope: :record, local: true) do |f|
concat localized_date_field(f, :due_on, data: { controller: "other" })
end
rendered = form.to_s
assert_match %r{data-controller="other date-picker"}, rendered
end
end
(If test/helpers/application_helper_test.rb already exists, just append the two test "..." blocks above to its class body.)
- Step 2: Run the test to verify it fails
Run: bin/rails test test/helpers/application_helper_test.rb -v
Expected: 2 errors β NoMethodError: undefined method 'localized_date_field'.
- Step 3: Implement the helper
Open app/helpers/application_helper.rb. Add (inside the module):
def localized_date_field(form, attribute, **options)
existing = options.dig(:data, :controller)
controllers = [existing, "date-picker"].compact.uniq.join(" ")
options[:data] = (options[:data] || {}).merge(controller: controllers)
form.date_field(attribute, **options)
end
- Step 4: Run the test to verify it passes
Run: bin/rails test test/helpers/application_helper_test.rb -v
Expected: 2 runs, 0 failures.
- Step 5: Sweep
form.date_fieldβlocalized_date_field
For each file below, replace the bare method call. Use the existing options as-is.
| File | Line(s) |
|---|---|
app/views/construction_phases/_form.html.erb |
51, 58 |
app/views/activity_logs/_form.html.erb |
23 |
app/views/birthdays/_form.html.erb |
23 |
app/views/construction_expenses/_form.html.erb |
34 |
app/views/admin/management/_filter_shell.html.erb |
7, 12 |
app/views/habits/_form.html.erb |
23 |
app/views/admin/leaderboards/show.html.erb |
13 |
app/views/expenses/_form.html.erb |
60 |
app/views/construction_projects/_form.html.erb |
63, 70 |
app/views/investments/_form.html.erb |
64, 69, 74 |
app/views/goals/_form.html.erb |
150 |
app/views/balance_registries/_form.html.erb |
27 |
app/views/todo_cards/_form.html.erb |
53 |
app/views/debt_payments/_form.html.erb |
20 |
app/views/debts/_form.html.erb |
116, 121 |
In each, replace form.date_field or f.date_field with localized_date_field(form, β¦) or localized_date_field(f, β¦). The form-builder receiver becomes the first positional argument; everything else (attribute name + options hash) follows unchanged.
Example transformation for app/views/expenses/_form.html.erb:60:
Before:
<%= form.date_field :date,
class: "modal-input w-full rounded-lg px-3 py-2 β¦" %>
After:
<%= localized_date_field form, :date,
class: "modal-input w-full rounded-lg px-3 py-2 β¦" %>
For app/views/admin/management/_filter_shell.html.erb:7,12 the receiver is f (a Ransack search form). Same transformation: f.date_field :created_at_gteq, β¦ β localized_date_field f, :created_at_gteq, β¦.
- Step 6: Run the full test suite
Run: bin/rails test
Expected: all green (no previously passing test should regress; the helper test passes; the locale formats test still passes).
- Step 7: Commit
git add app/helpers/application_helper.rb \
test/helpers/application_helper_test.rb \
app/views/
git commit -m "feat: route date inputs through localized_date_field"
Task 6: End-to-end system test (display + input, both locales)
This locks in the user-facing behavior across the full stack (controller locale switch β view rendering β JS picker β form submission).
Files:
-
Create:
test/system/locale_dates_test.rb -
Step 1: Inspect existing system test setup
Run:
cat test/application_system_test_case.rb
ls test/system/ | head
Confirm the base class uses headless Chrome (or similar). If a fixture user exists, note its identifier; otherwise use a fixture loaded by the test directly.
- Step 2: Write the failing system test
Create test/system/locale_dates_test.rb:
require "application_system_test_case"
class LocaleDatesTest < ApplicationSystemTestCase
setup do
@user = users(:one) # adjust to a real fixture key; see Step 1
end
test "pt-BR user sees dd/mm/yyyy on expense detail" do
@user.finance_settings = (@user.finance_settings || {}).merge("locale" => "pt-BR")
@user.save!
expense = Expense.create!(user: @user, date: Date.new(2026, 5, 27),
description: "CafΓ©", amount: 10)
sign_in_as(@user)
visit edit_expense_path(expense)
# Form alt-input (Flatpickr) shows locale format
assert_selector "input.flatpickr-input + input[value='27/05/2026']"
# Display elsewhere (page header, etc.)
visit expenses_path
assert_text "27/05/2026"
end
test "en user sees mm/dd/yyyy on expense detail" do
@user.finance_settings = (@user.finance_settings || {}).merge("locale" => "en")
@user.save!
expense = Expense.create!(user: @user, date: Date.new(2026, 5, 27),
description: "Coffee", amount: 10)
sign_in_as(@user)
visit edit_expense_path(expense)
assert_selector "input.flatpickr-input + input[value='05/27/2026']"
visit expenses_path
assert_text "05/27/2026"
end
private
def sign_in_as(user)
visit new_user_session_path
fill_in "Email", with: user.email
fill_in "Password", with: "password" # matches fixture password
click_on "Sign in"
end
end
Notes:
users(:one)β replace with the actual fixture key used in this project (checktest/fixtures/users.yml).- The sign-in helper may already exist as a module. If
test/test_helper.rbdefines one, use it instead of the inlinesign_in_as. -
The
assert_text "27/05/2026"line onexpenses_pathassumes the expense row printsexpense.date. If the index does not render the date directly, change the assertion to an expense show page or a page known to display the date. - Step 3: Run the test to verify it fails or errors meaningfully
Run: bin/rails test test/system/locale_dates_test.rb -v
Possible failure modes (and the right fix for each):
- Fixture key wrong β update
users(:one). - Sign-in flow doesn't match β use the project's actual helper or path.
-
assert_selector "input.flatpickr-input + input[value=...]"fails β confirm the controller mounted (open the test inheadedmode viaHEADLESS=false bin/rails test ...if supported, orsave_screenshot). - Step 4: Iterate until both tests pass
Adjust assertions to match the actual rendered DOM. Do not lower the bar β both locales must show the correct format in both display and input.
- Step 5: Run the full test suite
Run: bin/rails test
Expected: full green.
- Step 6: Manual smoke check (recommended)
Boot the dev server.
- Sign in as a pt-BR user β
/expenses/newβ open the date picker β confirm calendar weekdays readdom seg ter qua qui sex sΓ‘b; pick May 27, 2026 β input shows27/05/2026; submit β expense persists with date2026-05-27. - Switch the user's locale to
enin/finance_settings(or via console:current_user.update(finance_settings: current_user.finance_settings.merge("locale" => "en"))) β repeat β calendar readsSun Mon Tue β¦; input shows05/27/2026. - Visit one admin page (
/admin/users) under each locale and confirmcreated_atdisplays match.
- Step 7: Commit
git add test/system/locale_dates_test.rb
git commit -m "test: cover locale-aware date display and input end-to-end"
Self-review checklist (run before handing off)
- All 21
form.date_fieldcall sites now go throughlocalized_date_field?grep -rn "form\.date_field\|f\.date_field" app/views/should return zero hits. - All user-facing view-level
strftimecalls converted?grep -rn "strftime" app/views/ | grep -v "%H:%M\|^app/views/accounts/index.html.erb:59\|^app/views/accounts/index.html.erb:116"should be empty (the time-of-day clocks and the day-number-only fragment are the documented exceptions). bin/rails testgreen.- pt-BR shows
27/05/2026; en shows05/27/2026. Manual smoke confirmed.
Out of scope follow-ups (do not do as part of this plan)
- Chart series labels in
app/models/user/analytics_calculator.rb,app/models/dashboard/data_aggregator.rb,app/models/analytics/retention.rbβ currently use customPT_MONTHSconstants. They are user-facing but require a deeper i18n pass tied to chart libraries. app/helpers/finance_helper.rb#month_labelβ uses hardcodedPT_BR_MONTHS. Same as above.- Email/mailer date strings β keep current
time.formats.devise.*namespace; revisit if/when emails get translated.