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 strftime sites only. app/models/**/*.rb strftime calls that build SQL keys, scope conditions, or grouping labels are out of scope (changing them would break queries). Chart series labels that use custom PT_MONTHS constants 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) using Time.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 that I18n.l returns expected strings per locale.
  • test/helpers/application_helper_test.rb β€” test for localized_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 β€” add date.formats, time.formats, date.day_names / month_names.
  • config/importmap.rb β€” pin flatpickr and its Portuguese locale.
  • app/javascript/application.css (or app/assets/stylesheets/application.tailwind.css, whichever the project uses for the authenticated layout) β€” @import the vendored Flatpickr CSS.
  • app/views/layouts/application.html.erb β€” add lang="<%= I18n.locale %>" to <html>.
  • app/helpers/application_helper.rb β€” add localized_date_field.
  • ~21 _form.html.erb partials β€” swap form.date_field β†’ localized_date_field.
  • ~20 view files containing user-facing raw strftime β€” convert to l(...).

Task 1: Locale date/time format keys (TDD)

Files:

  • Modify: config/locales/en.yml (top-level en: map)
  • Modify: config/locales/pt-BR.yml (top-level pt-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 lang attribute

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:46 and :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:50 and app/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) β€” @import the 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.erb partials 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 (check test/fixtures/users.yml).
  • The sign-in helper may already exist as a module. If test/test_helper.rb defines one, use it instead of the inline sign_in_as.
  • The assert_text "27/05/2026" line on expenses_path assumes the expense row prints expense.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 in headed mode via HEADLESS=false bin/rails test ... if supported, or save_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.

  1. Sign in as a pt-BR user β†’ /expenses/new β†’ open the date picker β†’ confirm calendar weekdays read dom seg ter qua qui sex sΓ‘b; pick May 27, 2026 β†’ input shows 27/05/2026; submit β†’ expense persists with date 2026-05-27.
  2. Switch the user's locale to en in /finance_settings (or via console: current_user.update(finance_settings: current_user.finance_settings.merge("locale" => "en"))) β†’ repeat β†’ calendar reads Sun Mon Tue …; input shows 05/27/2026.
  3. Visit one admin page (/admin/users) under each locale and confirm created_at displays 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_field call sites now go through localized_date_field? grep -rn "form\.date_field\|f\.date_field" app/views/ should return zero hits.
  • All user-facing view-level strftime calls 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 test green.
  • pt-BR shows 27/05/2026; en shows 05/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 custom PT_MONTHS constants. They are user-facing but require a deeper i18n pass tied to chart libraries.
  • app/helpers/finance_helper.rb#month_label β€” uses hardcoded PT_BR_MONTHS. Same as above.
  • Email/mailer date strings β€” keep current time.formats.devise.* namespace; revisit if/when emails get translated.