Repository: metabase/metabase
Pull request #54772: Fix offset intervals translations
Author: login: ranquild
Labels: name: no-backport
description: Do not backport this PR to any branch
name: .Team/Querying
Created: 2025-03-05T20:46:19Z | Merged: 2025-03-06T16:40:16Z

Follow up for https://github.com/metabase/metabase/pull/54686

Makes `describe-relative-datetime` use full strings without concatenation. Uses this function instead of hand-written code for the same purpose. 

`describe-relative-datetime` now includes `starting `. It's impossible to translate `starting {0}` and `{1} days from now` separately to make a correct sentence in non-English locales.

There are no user visible changes for the `en` locale, and no way to test it now until `.po` files have the new messages.


Diff:
diff --git a/frontend/src/metabase/querying/filters/utils/dates.ts b/frontend/src/metabase/querying/filters/utils/dates.ts
index e62ae78a3442..613f329c2fa2 100644
--- a/frontend/src/metabase/querying/filters/utils/dates.ts
+++ b/frontend/src/metabase/querying/filters/utils/dates.ts
@@ -284,9 +284,7 @@ export function getDateFilterDisplayName(
       if (offsetValue != null && offsetUnit != null) {
         const prefix = Lib.describeTemporalInterval(value, unit);
         const suffix = Lib.describeRelativeDatetime(offsetValue, offsetUnit);
-        return c(
-          "Describes a relative date interval. Example: Previous 2 months, starting 1 year ago.",
-        ).t`${prefix}, starting ${suffix}`;
+        return `${prefix}, ${suffix}`;
       } else {
         return Lib.describeTemporalInterval(value, unit);
       }
diff --git a/src/metabase/lib/filter.cljc b/src/metabase/lib/filter.cljc
index d02660de1919..f0500ed4fd18 100644
--- a/src/metabase/lib/filter.cljc
+++ b/src/metabase/lib/filter.cljc
@@ -278,19 +278,19 @@
        [:+ _ x [:interval _ n unit]]
        [:relative-datetime _ n2 unit2]
        [:relative-datetime _ 0 _]]
-      (i18n/tru "{0} is in the {1}, starting {2} ago"
+      (i18n/tru "{0} is in the {1}, {2}"
                 (->display-name x)
                 (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval n2 unit2))
-                (inflections/pluralize n (name unit)))
+                (lib.temporal-bucket/describe-relative-datetime (- n) unit))
 
       [:between _
        [:+ _ x [:interval _ n unit]]
        [:relative-datetime _ 0 _]
        [:relative-datetime _ n2 unit2]]
-      (i18n/tru "{0} is in the {1}, starting {2} from now"
+      (i18n/tru "{0} is in the {1}, {2}"
                 (->display-name x)
                 (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval n2 unit2))
-                (inflections/pluralize (abs n) (name unit)))
+                (lib.temporal-bucket/describe-relative-datetime (- n) unit))
 
       [:between _ x y z]
       (i18n/tru "{0} is between {1} and {2}"
@@ -344,14 +344,14 @@
 (defmethod lib.metadata.calculation/display-name-method :relative-time-interval
   [query stage-number [_tag _opts column value bucket offset-value offset-bucket] style]
   (if (neg? offset-value)
-    (i18n/tru "{0} is in the {1}, starting {2} ago"
+    (i18n/tru "{0} is in the {1}, {2}"
               (lib.metadata.calculation/display-name query stage-number column style)
               (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval value bucket))
-              (inflections/pluralize (abs offset-value) (name offset-bucket)))
-    (i18n/tru "{0} is in the {1}, starting {2} from now"
+              (lib.temporal-bucket/describe-relative-datetime offset-value offset-bucket))
+    (i18n/tru "{0} is in the {1}, {2}"
               (lib.metadata.calculation/display-name query stage-number column style)
               (u/lower-case-en (lib.temporal-bucket/describe-temporal-interval value bucket))
-              (inflections/pluralize (abs offset-value) (name offset-bucket)))))
+              (lib.temporal-bucket/describe-relative-datetime offset-value offset-bucket))))
 
 (defmethod lib.metadata.calculation/display-name-method :relative-datetime
   [_query _stage-number [_tag _opts n unit] _style]
diff --git a/src/metabase/lib/temporal_bucket.cljc b/src/metabase/lib/temporal_bucket.cljc
index a397e0668d00..3797ffdebaae 100644
--- a/src/metabase/lib/temporal_bucket.cljc
+++ b/src/metabase/lib/temporal_bucket.cljc
@@ -92,8 +92,7 @@
       (lib.temporal-bucket.util/temporal-interval-tru n "unknown unit"))))
 
 (mu/defn describe-relative-datetime :- ::lib.schema.common/non-blank-string
-  "Get a translated description of a relative datetime interval, ported from
- `frontend/src/metabase-lib/queries/utils/query-time.js`.
+  "Get a translated description of the offset part of a relative datetime interval.
 
   e.g. if the relative interval is `-1 days`, then `n` = `-1` and `unit` = `:day`.
 
@@ -102,19 +101,19 @@
    unit :- [:maybe :keyword]]
   (let [n    (interval-n->int n)
         unit (or unit :day)]
-    (cond
-      (zero? n)
-      (i18n/tru "Now")
-
-      (neg? n)
-      ;; this should legitimately be lowercasing in the user locale. I know system locale isn't necessarily the same
-      ;; thing, but it might be. This will have to do until we have some sort of user-locale lower-case functionality
-      #_{:clj-kondo/ignore [:discouraged-var]}
-      (i18n/tru "{0} {1} ago" (abs n) (str/lower-case (describe-temporal-unit (abs n) unit)))
-
-      :else
-      #_{:clj-kondo/ignore [:discouraged-var]}
-      (i18n/tru "{0} {1} from now" n (str/lower-case (describe-temporal-unit n unit))))))
+    (case (keyword unit)
+      :default     (lib.temporal-bucket.util/relative-datetime-tru n "default period")
+      :millisecond (lib.temporal-bucket.util/relative-datetime-tru n "millisecond")
+      :second      (lib.temporal-bucket.util/relative-datetime-tru n "second")
+      :minute      (lib.temporal-bucket.util/relative-datetime-tru n "minute")
+      :hour        (lib.temporal-bucket.util/relative-datetime-tru n "hour")
+      :day         (lib.temporal-bucket.util/relative-datetime-tru n "day")
+      :week        (lib.temporal-bucket.util/relative-datetime-tru n "week")
+      :month       (lib.temporal-bucket.util/relative-datetime-tru n "month")
+      :quarter     (lib.temporal-bucket.util/relative-datetime-tru n "quarter")
+      :year        (lib.temporal-bucket.util/relative-datetime-tru n "year")
+      ;; else
+      (lib.temporal-bucket.util/relative-datetime-tru n "unknown unit"))))
 
 (defmulti with-temporal-bucket-method
   "Implementation for [[temporal-bucket]]. Implement this to tell [[temporal-bucket]] how to add a bucket to a
diff --git a/src/metabase/lib/temporal_bucket/util.cljc b/src/metabase/lib/temporal_bucket/util.cljc
index b28bd235a46b..8310348f9551 100644
--- a/src/metabase/lib/temporal_bucket/util.cljc
+++ b/src/metabase/lib/temporal_bucket/util.cljc
@@ -19,6 +19,8 @@
   (temporal-interval-tru  4 \"day\" \"Today\" \"Yesterday\" \"Tomorrow\") -> \"Next 4 days\""
   ([n
     unit-message]
+   (assert (string? unit-message)
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
    `(temporal-interval-tru
      ~n
      ~unit-message
@@ -30,26 +32,56 @@
     this-interval-message
     prev-interval-message
     next-interval-message]
+   (assert (every? string? [unit-message
+                            this-interval-message
+                            prev-interval-message
+                            next-interval-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
    `(temporal-interval-tru
      ~n
      ~this-interval-message
      ~prev-interval-message
-     ~(str "Previous {0} " unit-message)
      ~(str "Previous {0} " unit-message "s")
      ~next-interval-message
-     ~(str "Next {0} " unit-message)
      ~(str "Next {0} " unit-message "s")))
   ([n
     this-interval-message
     prev-interval-message
-    prev-single-interval-message
-    prev-plural-interval-message
+    prev-interval-plural-message
     next-interval-message
-    next-single-interval-message
-    next-plural-interval-message]
-   `(cond
-      (zero? ~n) (i18n/tru ~this-interval-message)
-      (= ~n -1)  (i18n/tru ~prev-interval-message)
-      (= ~n 1)   (i18n/tru ~next-interval-message)
-      (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
-      (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+    next-interval-plural-message]
+   (assert (every? string? [this-interval-message
+                            prev-interval-message
+                            prev-interval-plural-message
+                            next-interval-message
+                            next-interval-plural-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
+   `(let [n# ~n]
+      (cond
+        (zero? n#) (i18n/tru ~this-interval-message)
+        (= n# -1)  (i18n/tru ~prev-interval-message)
+        (= n# 1)   (i18n/tru ~next-interval-message)
+        (neg? n#)  (i18n/trun ~prev-interval-message ~prev-interval-plural-message (abs n#))
+        (pos? n#)  (i18n/trun ~next-interval-message ~next-interval-plural-message n#)
+        :else      (throw (ex-info (str "Invalid n: " n#) {:n n#}))))))
+
+(defmacro relative-datetime-tru
+  "Translates the offset part of a relative datetime interval. The macro accepts only compile-time messages. Examples:
+
+  (relative-datetime-tru  0 \"month\") -> \"starting now\"
+  (relative-datetime-tru -1 \"month\") -> \"starting 1 month ago\"
+  (relative-datetime-tru  2 \"month\") -> \"starting 2 months from now\""
+  [n
+   unit-message]
+  (assert (string? unit-message)
+          "[[relative-datetime-tru]] accepts compile-time strings only.")
+  `(let [n# ~n]
+     (cond
+       (neg? n#)
+       (i18n/trun ~(str "starting {0} " unit-message " ago") ~(str "starting {0} " unit-message "s ago") (abs n#))
+
+       (pos? n#)
+       (i18n/trun ~(str "starting {0} " unit-message " from now") ~(str "starting {0} " unit-message "s from now") n#)
+
+       :else
+       (i18n/tru "starting now"))))
diff --git a/test/metabase/lib/temporal_bucket_test.cljc b/test/metabase/lib/temporal_bucket_test.cljc
index 0c6ba68255c9..f8b5c0cf05d1 100644
--- a/test/metabase/lib/temporal_bucket_test.cljc
+++ b/test/metabase/lib/temporal_bucket_test.cljc
@@ -42,19 +42,19 @@
     (testing unit
       (are [n expected] (= expected
                            (lib.temporal-bucket/describe-relative-datetime n unit))
-        -2 "2 days ago"
-        -1 "1 day ago"
-        0  "Now"
-        1  "1 day from now"
-        2  "2 days from now")))
+        -2 "starting 2 days ago"
+        -1 "starting 1 day ago"
+        0  "starting now"
+        1  "starting 1 day from now"
+        2  "starting 2 days from now")))
   (testing "unknown unit"
     (are [n expected] (= expected
                          (lib.temporal-bucket/describe-relative-datetime n :century))
-      -2 "2 century ago"
-      -1 "1 century ago"
-      0  "Now"
-      1  "1 century from now"
-      2  "2 century from now")))
+      -2 "starting 2 unknown units ago"
+      -1 "starting 1 unknown unit ago"
+      0  "starting now"
+      1  "starting 1 unknown unit from now"
+      2  "starting 2 unknown units from now")))
 
 (deftest ^:parallel describe-temporal-unit-test
   (is (= ""


Reviews:
user: login: metamben
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T11:55:02Z
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:03:34Z
user: login: metamben
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:38:29Z
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:47:11Z
user: login: metamben
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:39:03Z

Review comments:
diff_hunk: @@ -53,3 +53,21 @@
       (= ~n 1)   (i18n/tru ~next-interval-message)
       (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
       (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+
+(defmacro relative-datetime-tru
+  "Translates the offset part of a relative datetime interval. The macro accepts only compile-time messages. Examples:
+
+  (relative-datetime-tru  0 \"month\") -> \"starting now\"
+  (relative-datetime-tru -1 \"month\") -> \"starting 1 month ago\"
+  (relative-datetime-tru  2 \"month\") -> \"starting 2 months from now\""
+  [n
+   unit-message]
+  `(cond
+     (neg? ~n)
+     (i18n/trun ~(str "starting {0} " unit-message " ago") ~(str "starting {0} " unit-message "s ago") (abs ~n))
+
+     (pos? ~n)
+     (i18n/trun ~(str "starting {0} " unit-message " from now") ~(str "starting {0} " unit-message "s from now") ~n)
+
+     :else
+     (i18n/tru "starting now")))
path: src/metabase/lib/temporal_bucket/util.cljc
user: login: metamben
body: I suggest
1. checking the precondition that `unit-message` is a string, and
1. evaluating `n` only once.
created_at: 2025-03-06T11:51:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,3 +53,21 @@
       (= ~n 1)   (i18n/tru ~next-interval-message)
       (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
       (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+
+(defmacro relative-datetime-tru
+  "Translates the offset part of a relative datetime interval. The macro accepts only compile-time messages. Examples:
+
+  (relative-datetime-tru  0 \"month\") -> \"starting now\"
+  (relative-datetime-tru -1 \"month\") -> \"starting 1 month ago\"
+  (relative-datetime-tru  2 \"month\") -> \"starting 2 months from now\""
+  [n
+   unit-message]
+  `(cond
+     (neg? ~n)
+     (i18n/trun ~(str "starting {0} " unit-message " ago") ~(str "starting {0} " unit-message "s ago") (abs ~n))
+
+     (pos? ~n)
+     (i18n/trun ~(str "starting {0} " unit-message " from now") ~(str "starting {0} " unit-message "s from now") ~n)
+
+     :else
+     (i18n/tru "starting now")))
path: src/metabase/lib/temporal_bucket/util.cljc
user: login: ranquild
body: @metamben done. Could you take another look, please?
created_at: 2025-03-06T15:03:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,9 +54,39 @@
     next-interval-message
     next-single-interval-message
     next-plural-interval-message]
-   `(cond
-      (zero? ~n) (i18n/tru ~this-interval-message)
-      (= ~n -1)  (i18n/tru ~prev-interval-message)
-      (= ~n 1)   (i18n/tru ~next-interval-message)
-      (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
-      (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+   (assert (every? string? [this-interval-message
+                            prev-interval-message
+                            prev-single-interval-message
+                            prev-plural-interval-message
+                            next-interval-message
+                            next-single-interval-message
+                            next-plural-interval-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
+   `(let [n# ~n]
+      (cond
+        (zero? n#) (i18n/tru ~this-interval-message)
+        (= n# -1)  (i18n/tru ~prev-interval-message)
+        (= n# 1)   (i18n/tru ~next-interval-message)
+        (neg? n#)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs n#))
+        (pos? n#)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message n#)))))
path: src/metabase/lib/temporal_bucket/util.cljc
user: login: metamben
body: When would one want to provide `prev-single-interval-message` and `next-single-interval-message`?

We might want to throw an exception on the else branch rather than returning `nil`.
created_at: 2025-03-06T15:32:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,9 +54,39 @@
     next-interval-message
     next-single-interval-message
     next-plural-interval-message]
-   `(cond
-      (zero? ~n) (i18n/tru ~this-interval-message)
-      (= ~n -1)  (i18n/tru ~prev-interval-message)
-      (= ~n 1)   (i18n/tru ~next-interval-message)
-      (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
-      (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+   (assert (every? string? [this-interval-message
+                            prev-interval-message
+                            prev-single-interval-message
+                            prev-plural-interval-message
+                            next-interval-message
+                            next-single-interval-message
+                            next-plural-interval-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
+   `(let [n# ~n]
+      (cond
+        (zero? n#) (i18n/tru ~this-interval-message)
+        (= n# -1)  (i18n/tru ~prev-interval-message)
+        (= n# 1)   (i18n/tru ~next-interval-message)
+        (neg? n#)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs n#))
+        (pos? n#)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message n#)))))
path: src/metabase/lib/temporal_bucket/util.cljc
user: login: ranquild
body: @metamben fixed - removed unneeded messages, added an exception
created_at: 2025-03-06T15:47:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,26 +32,56 @@
     this-interval-message
     prev-interval-message
     next-interval-message]
+   (assert (every? string? [unit-message
+                            this-interval-message
+                            prev-interval-message
+                            next-interval-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
    `(temporal-interval-tru
      ~n
      ~this-interval-message
      ~prev-interval-message
-     ~(str "Previous {0} " unit-message)
      ~(str "Previous {0} " unit-message "s")
      ~next-interval-message
-     ~(str "Next {0} " unit-message)
      ~(str "Next {0} " unit-message "s")))
   ([n
     this-interval-message
     prev-interval-message
-    prev-single-interval-message
-    prev-plural-interval-message
+    prev-interval-plural-message
     next-interval-message
-    next-single-interval-message
-    next-plural-interval-message]
-   `(cond
-      (zero? ~n) (i18n/tru ~this-interval-message)
-      (= ~n -1)  (i18n/tru ~prev-interval-message)
-      (= ~n 1)   (i18n/tru ~next-interval-message)
-      (neg? ~n)  (i18n/trun ~prev-single-interval-message ~prev-plural-interval-message (abs ~n))
-      (pos? ~n)  (i18n/trun ~next-single-interval-message ~next-plural-interval-message ~n))))
+    next-interval-plural-message]
+   (assert (every? string? [this-interval-message
+                            prev-interval-message
+                            prev-interval-plural-message
+                            next-interval-message
+                            next-interval-plural-message])
+           "[[temporal-interval-tru]] accepts compile-time strings only.")
+   `(let [n# ~n]
+      (cond
+        (zero? n#) (i18n/tru ~this-interval-message)
+        (= n# -1)  (i18n/tru ~prev-interval-message)
+        (= n# 1)   (i18n/tru ~next-interval-message)
+        (neg? n#)  (i18n/trun ~prev-interval-message ~prev-interval-plural-message (abs n#))
+        (pos? n#)  (i18n/trun ~next-interval-message ~next-interval-plural-message n#)
path: src/metabase/lib/temporal_bucket/util.cljc
user: login: metamben
body: Will `prev-interval-message` and `next-interval-message` pass the validations done by `i18n/trun`?
created_at: 2025-03-06T16:38:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-03-05T21:28:19Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/105982_tests-21ce2404-purple)](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/0-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/1-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment&runConclusion=QUARANTINED)


| Flaky Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>scenarios > dashboard warn before leave should warn a user before leaving after adding, removed, moving, or duplicating a tab</code> | Unable to find an element with the text "You're editing this dashboard" within 4 seconds. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment&runConclusion=FAILURE&runId=bfd7b44a-d595-5b9f-978c-439f86d81203) |
<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54772?repo=metabase/metabase&commitHash=21ce240416a4223bc1df594c580b60f1c5ed6375&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-06T15:58:58Z
body: 
  ## e2e tests failed on `21ce240416a4223bc1df594c580b60f1c5ed6375-1`

  [e2e test run](https://github.com/metabase/metabase/actions/runs/13702616220?pr=54772)

  File | Test Name
  ---- | ---------
  `dashboard.cy.spec.js` | (_flaky_) scenarios > dashboard > warn before leave > should warn a user before leaving after adding, removed, moving, or duplicating a tab

author_association: CONTRIBUTOR