diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-12 23:42:23 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-12 23:42:23 +0200 |
| commit | cf93acb8ad44ba9cd486e8f6457d9fd9fbc041cc (patch) | |
| tree | 71ebf9abed3fe2b96262443e33da767315a5f7d2 | |
| parent | d320f8509751a886adb3ca723ebfafccaa8c191b (diff) | |
Add a locale-aware relative-time helper; drop stale locale overrides
Node.recently_changed and the new dashboard need "X ago" rendered
correctly in both locales. Rails' own distance_of_time_in_words/
time_ago_in_words can't do this on their own -- the scope option
changes which translation key is looked up, not the underlying
grammar, and German's "vor" requires dative case, which is a
different word form than the nominative plural Rails computes by
default. relative_time_phrase/relative_distance is a small, from-
scratch bucketing helper instead, with an explicit, hand-checked
translation table per unit per locale.
Also removes de.yml's datetime.distance_in_words and activerecord.
errors blocks. Both used the old {{count}}/{{model}} interpolation
syntax the current i18n gem no longer recognizes -- it silently
leaves the literal placeholder text in the rendered output rather
than erroring, which is why this went unnoticed until now. Both were
shadowing rails-i18n's own current, correct translations for exactly
these keys; deleting the local override lets the gem's version take
over instead of maintaining a second, broken copy.
| -rw-r--r-- | app/helpers/datetime_helper.rb | 53 | ||||
| -rw-r--r-- | config/locales/de.yml | 70 | ||||
| -rw-r--r-- | config/locales/en.yml | 4 | ||||
| -rw-r--r-- | test/models/helpers/datetime_helper_test.rb | 19 |
4 files changed, 80 insertions, 66 deletions
diff --git a/app/helpers/datetime_helper.rb b/app/helpers/datetime_helper.rb new file mode 100644 index 0000000..8497b1c --- /dev/null +++ b/app/helpers/datetime_helper.rb | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | module DatetimeHelper | ||
| 2 | |||
| 3 | def relative_distance(time, now = Time.current) | ||
| 4 | seconds = (now - time).abs | ||
| 5 | |||
| 6 | case seconds | ||
| 7 | when 0...90 | ||
| 8 | [1, :minute] | ||
| 9 | when 90...45.minutes | ||
| 10 | [(seconds / 60).round, :minute] | ||
| 11 | when 45.minutes...24.hours | ||
| 12 | [(seconds / 1.hour).round, :hour] | ||
| 13 | when 24.hours...30.days | ||
| 14 | [(seconds / 1.day).round, :day] | ||
| 15 | when 30.days...365.days | ||
| 16 | [(seconds / 30.days).round, :month] | ||
| 17 | else | ||
| 18 | [(seconds / 365.days).round, :year] | ||
| 19 | end | ||
| 20 | end | ||
| 21 | |||
| 22 | RELATIVE_TIME_UNIT_FORMS = { | ||
| 23 | en: { | ||
| 24 | minute: { one: "minute", other: "minutes" }, | ||
| 25 | hour: { one: "hour", other: "hours" }, | ||
| 26 | day: { one: "day", other: "days" }, | ||
| 27 | month: { one: "month", other: "months" }, | ||
| 28 | year: { one: "year", other: "years" }, | ||
| 29 | }, | ||
| 30 | de: { | ||
| 31 | # "other" here is the DATIVE plural needed after "vor" -- not the | ||
| 32 | # nominative plural you'd use standing alone ("3 Tage" vs "vor 3 | ||
| 33 | # Tagen"). All five happen to be the nominative plural + "n", since | ||
| 34 | # none of them already end in -n or -s -- a property of these five | ||
| 35 | # specific words, not a rule to extend to new units without checking. | ||
| 36 | minute: { one: "Minute", other: "Minuten" }, | ||
| 37 | hour: { one: "Stunde", other: "Stunden" }, | ||
| 38 | day: { one: "Tag", other: "Tagen" }, | ||
| 39 | month: { one: "Monat", other: "Monaten" }, | ||
| 40 | year: { one: "Jahr", other: "Jahren" }, | ||
| 41 | }, | ||
| 42 | }.freeze | ||
| 43 | |||
| 44 | def relative_time_phrase(time, now = Time.current) | ||
| 45 | count, unit = relative_distance(time, now) | ||
| 46 | locale = I18n.locale.to_sym | ||
| 47 | forms = RELATIVE_TIME_UNIT_FORMS.fetch(locale, RELATIVE_TIME_UNIT_FORMS[:en]) | ||
| 48 | word = forms.fetch(unit).fetch(count == 1 ? :one : :other) | ||
| 49 | |||
| 50 | locale == :de ? "vor #{count} #{word}" : "#{count} #{word} ago" | ||
| 51 | end | ||
| 52 | |||
| 53 | end | ||
diff --git a/config/locales/de.yml b/config/locales/de.yml index 1f2222c..550ccb3 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -14,6 +14,10 @@ de: | |||
| 14 | open_days_label: "Offene Tage" | 14 | open_days_label: "Offene Tage" |
| 15 | upcoming_events: "Weitere Termine" | 15 | upcoming_events: "Weitere Termine" |
| 16 | open_today: "Das Chaos, lokal und heute offen" | 16 | open_today: "Das Chaos, lokal und heute offen" |
| 17 | published: "veröffentlicht" | ||
| 18 | published_by: "veröffentlicht durch %{editor}" | ||
| 19 | editor_self: "du" | ||
| 20 | publisher_self: "dich" | ||
| 17 | date: | 21 | date: |
| 18 | formats: | 22 | formats: |
| 19 | default: "%d.%m.%Y" | 23 | default: "%d.%m.%Y" |
| @@ -29,7 +33,6 @@ de: | |||
| 29 | - :day | 33 | - :day |
| 30 | - :month | 34 | - :month |
| 31 | - :year | 35 | - :year |
| 32 | |||
| 33 | time: | 36 | time: |
| 34 | formats: | 37 | formats: |
| 35 | default: "%A, %e. %B %Y, %H:%M Uhr" | 38 | default: "%A, %e. %B %Y, %H:%M Uhr" |
| @@ -41,42 +44,6 @@ de: | |||
| 41 | am: "vormittags" | 44 | am: "vormittags" |
| 42 | pm: "nachmittags" | 45 | pm: "nachmittags" |
| 43 | 46 | ||
| 44 | datetime: | ||
| 45 | distance_in_words: | ||
| 46 | half_a_minute: 'eine halbe Minute' | ||
| 47 | less_than_x_seconds: | ||
| 48 | zero: 'weniger als 1 Sekunde' | ||
| 49 | one: 'weniger als 1 Sekunde' | ||
| 50 | other: 'weniger als {{count}} Sekunden' | ||
| 51 | x_seconds: | ||
| 52 | one: '1 Sekunde' | ||
| 53 | other: '{{count}} Sekunden' | ||
| 54 | less_than_x_minutes: | ||
| 55 | zero: 'weniger als 1 Minute' | ||
| 56 | one: 'weniger als eine Minute' | ||
| 57 | other: 'weniger als {{count}} Minuten' | ||
| 58 | x_minutes: | ||
| 59 | one: '1 Minute' | ||
| 60 | other: '{{count}} Minuten' | ||
| 61 | about_x_hours: | ||
| 62 | one: 'etwa 1 Stunde' | ||
| 63 | other: 'etwa {{count}} Stunden' | ||
| 64 | x_days: | ||
| 65 | one: '1 Tag' | ||
| 66 | other: '{{count}} Tage' | ||
| 67 | about_x_months: | ||
| 68 | one: 'etwa 1 Monat' | ||
| 69 | other: 'etwa {{count}} Monate' | ||
| 70 | x_months: | ||
| 71 | one: '1 Monat' | ||
| 72 | other: '{{count}} Monate' | ||
| 73 | about_x_years: | ||
| 74 | one: 'etwa 1 Jahr' | ||
| 75 | other: 'etwa {{count}} Jahre' | ||
| 76 | over_x_years: | ||
| 77 | one: 'mehr als 1 Jahr' | ||
| 78 | other: 'mehr als {{count}} Jahre' | ||
| 79 | |||
| 80 | number: | 47 | number: |
| 81 | format: | 48 | format: |
| 82 | precision: 2 | 49 | precision: 2 |
| @@ -105,35 +72,6 @@ de: | |||
| 105 | sentence_connector: "und" | 72 | sentence_connector: "und" |
| 106 | skip_last_comma: true | 73 | skip_last_comma: true |
| 107 | 74 | ||
| 108 | activerecord: | ||
| 109 | errors: | ||
| 110 | template: | ||
| 111 | header: | ||
| 112 | one: 1 error prohibited this {{model}} from being saved | ||
| 113 | other: "{{count}} errors prohibited this {{model}} from being saved" | ||
| 114 | body: "There were problems with the following fields:" | ||
| 115 | |||
| 116 | messages: | ||
| 117 | inclusion: "is not included in the list" | ||
| 118 | exclusion: "is reserved" | ||
| 119 | invalid: "is invalid" | ||
| 120 | confirmation: "doesn't match confirmation" | ||
| 121 | accepted: "must be accepted" | ||
| 122 | empty: "can't be empty" | ||
| 123 | blank: "can't be blank" | ||
| 124 | too_long: "is too long (maximum is {{count}} characters)" | ||
| 125 | too_short: "is too short (minimum is {{count}} characters)" | ||
| 126 | wrong_length: "is the wrong length (should be {{count}} characters)" | ||
| 127 | taken: "has already been taken" | ||
| 128 | not_a_number: "is not a number" | ||
| 129 | greater_than: "must be greater than {{count}}" | ||
| 130 | greater_than_or_equal_to: "must be greater than or equal to {{count}}" | ||
| 131 | equal_to: "must be equal to {{count}}" | ||
| 132 | less_than: "must be less than {{count}}" | ||
| 133 | less_than_or_equal_to: "must be less than or equal to {{count}}" | ||
| 134 | odd: "must be odd" | ||
| 135 | even: "must be even" | ||
| 136 | |||
| 137 | will_paginate: | 75 | will_paginate: |
| 138 | previous_label: "← Zurück" | 76 | previous_label: "← Zurück" |
| 139 | previous_aria_label: "Vorherige Seite" | 77 | previous_aria_label: "Vorherige Seite" |
diff --git a/config/locales/en.yml b/config/locales/en.yml index 59c7304..73271ed 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -15,6 +15,10 @@ en: | |||
| 15 | open_days_label: "Open days" | 15 | open_days_label: "Open days" |
| 16 | upcoming_events: "Upcoming events" | 16 | upcoming_events: "Upcoming events" |
| 17 | open_today: "Open today" | 17 | open_today: "Open today" |
| 18 | published: "published" | ||
| 19 | published_by: "published by %{editor}" | ||
| 20 | publisher_self: "you" | ||
| 21 | editor_self: "you" | ||
| 18 | 22 | ||
| 19 | time: | 23 | time: |
| 20 | formats: | 24 | formats: |
diff --git a/test/models/helpers/datetime_helper_test.rb b/test/models/helpers/datetime_helper_test.rb new file mode 100644 index 0000000..a24e5d3 --- /dev/null +++ b/test/models/helpers/datetime_helper_test.rb | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | require 'test_helper' | ||
| 2 | |||
| 3 | class DatetimeHelperTest < ActionView::TestCase | ||
| 4 | test "relative_time_phrase pluralizes correctly in English and German" do | ||
| 5 | travel_to Time.zone.parse("2026-07-12 12:00:00") do | ||
| 6 | I18n.with_locale(:en) do | ||
| 7 | assert_equal "1 day ago", relative_time_phrase(1.day.ago) | ||
| 8 | assert_equal "3 days ago", relative_time_phrase(3.days.ago) | ||
| 9 | end | ||
| 10 | I18n.with_locale(:de) do | ||
| 11 | assert_equal "vor 1 Tag", relative_time_phrase(1.day.ago) | ||
| 12 | assert_equal "vor 3 Tagen", relative_time_phrase(3.days.ago) | ||
| 13 | assert_equal "vor 1 Monat", relative_time_phrase(30.days.ago) | ||
| 14 | assert_equal "vor 2 Monaten", relative_time_phrase(45.days.ago) | ||
| 15 | assert_equal "vor 2 Jahren", relative_time_phrase(2.years.ago) | ||
| 16 | end | ||
| 17 | end | ||
| 18 | end | ||
| 19 | end | ||
