From 5dd1cca580c7c623561f51af047c1cbcd1a6480c Mon Sep 17 00:00:00 2001
From: Braden Shepherdson <braden@metabase.com>
Date: Tue, 5 Nov 2024 09:20:03 -0500
Subject: [PATCH] [util] Add `u.time/local-date`, `local-time` and
 `local-date-time` (#49445)

These are helpful for creating literal dates and times in a
cross-platform way. Useful for generating queries, in particular for
expression and filter values.
---
 src/metabase/util/time.cljc             |  5 ++-
 src/metabase/util/time/impl.clj         | 46 +++++++++++++++++++++
 src/metabase/util/time/impl.cljs        | 53 +++++++++++++++++++++++++
 src/metabase/util/time/impl_common.cljc | 17 ++++++++
 test/metabase/util/time_test.cljc       | 39 ++++++++++++++++++
 5 files changed, 159 insertions(+), 1 deletion(-)

diff --git a/src/metabase/util/time.cljc b/src/metabase/util/time.cljc
index cc3b3858c3c..9cc46d7d269 100644
--- a/src/metabase/util/time.cljc
+++ b/src/metabase/util/time.cljc
@@ -30,7 +30,10 @@
   unit-diff
   truncate
   add
-  format-for-base-type])
+  format-for-base-type
+  local-date
+  local-date-time
+  local-time])
 
 (defn- prep-options [options]
   (merge internal/default-options (u/normalize-map options)))
diff --git a/src/metabase/util/time/impl.clj b/src/metabase/util/time/impl.clj
index 50311ec5aa2..d04448a54b0 100644
--- a/src/metabase/util/time/impl.clj
+++ b/src/metabase/util/time/impl.clj
@@ -222,6 +222,52 @@
   [value]
   (t/local-time value))
 
+;;; ----------------------------------------------- constructors -----------------------------------------------------
+(defn local-time
+  "Constructs a platform time value (eg. Moment, LocalTime) for the given hour and minute, plus optional seconds and
+  milliseconds.
+
+  If called with no arguments, returns the current time."
+  ([]
+   (t/local-time))
+  ([hours minutes]
+   (local-time hours minutes 0 0))
+  ([hours minutes seconds]
+   (local-time hours minutes seconds 0))
+  ([hours minutes seconds millis]
+   (t/local-time hours minutes seconds (* 1000000 millis))))
+
+(defn local-date
+  "Constructs a platform date value (eg. Moment, LocalDate) for the given year, month and day.
+
+  Day is 1-31. January = 1, or you can specify keywords like `:jan`, `:jun`.
+
+  If called with no arguments, returns the current date."
+  ([]
+   (t/local-date))
+  ([year month day]
+   (t/local-date year
+                 (or (common/month-keywords month) month)
+                 day)))
+
+(defn local-date-time
+  "Constructs a platform datetime (eg. Moment, LocalDateTime).
+
+  Accepts either:
+  - no arguments (returns the current datetime)
+  - a local date and local time (see [[local-date]] and [[local-time]]); or
+  - year, month, day, hour, and minute, plus optional seconds and millis."
+  ([]
+   (t/local-date-time))
+  ([a-date a-time]
+   (t/local-date-time a-date a-time))
+  ([year month day hours minutes]
+   (local-date-time (local-date year month day) (local-time hours minutes)))
+  ([year month day hours minutes seconds]
+   (local-date-time (local-date year month day) (local-time hours minutes seconds)))
+  ([year month day hours minutes seconds millis]
+   (local-date-time (local-date year month day) (local-time hours minutes seconds millis))))
+
 ;;; ------------------------------------------------ arithmetic ------------------------------------------------------
 
 (defn unit-diff
diff --git a/src/metabase/util/time/impl.cljs b/src/metabase/util/time/impl.cljs
index a17d33ac582..56cef3a7d4b 100644
--- a/src/metabase/util/time/impl.cljs
+++ b/src/metabase/util/time/impl.cljs
@@ -158,6 +158,59 @@
   [value]
   (moment value parse-time-formats))
 
+;;; ----------------------------------------------- constructors -----------------------------------------------------
+(defn local-time
+  "Constructs a platform time value (eg. Moment, LocalTime) for the given hour and minute, plus optional seconds and
+  milliseconds.
+
+  If called without arguments, returns the current time."
+  ([]
+   ;; Actually a full datetime, but Moment doesn't have freestanding time values.
+   (moment))
+  ([hours minutes]
+   (moment #js {:hours hours, :minutes minutes}))
+  ([hours minutes seconds]
+   (moment #js {:hours hours, :minutes minutes, :seconds seconds}))
+  ([hours minutes seconds millis]
+   (moment #js {:hours hours, :minutes minutes, :seconds seconds, :milliseconds millis})))
+
+(declare truncate)
+
+(defn local-date
+  "Constructs a platform date value (eg. Moment, LocalDate) for the given year, month and day.
+
+  Day is 1-31. January = 1, or you can specify keywords like `:jan`, `:jun`."
+  ([] (truncate (moment) :day))
+  ([year month day]
+   (moment #js {:year  year
+                :day   day
+                ;; Moment uses 0-based months, unlike Metabase.
+                :month (dec (or (common/month-keywords month) month))})))
+
+(defn local-date-time
+  "Constructs a platform datetime (eg. Moment, LocalDateTime).
+
+  Accepts either:
+  - no arguments (current datetime)
+  - a local date and local time (see [[local-date]] and [[local-time]]); or
+  - year, month, day, hour, and minute, plus optional seconds and millis."
+  ([] (moment))
+  ([a-date a-time]
+   (when-not (and (valid? a-date) (valid? a-time))
+     (throw (ex-info "Expected valid Moments for date and time" {:date a-date
+                                                                 :time a-time})))
+   (let [^moment/Moment d (.clone a-date)
+         ^moment/Moment t a-time]
+     (doseq [unit ["hour" "minute" "second" "millisecond"]]
+       (.set d unit (.get t unit)))
+     d))
+  ([year month day hours minutes]
+   (local-date-time (local-date year month day) (local-time hours minutes)))
+  ([year month day hours minutes seconds]
+   (local-date-time (local-date year month day) (local-time hours minutes seconds)))
+  ([year month day hours minutes seconds millis]
+   (local-date-time (local-date year month day) (local-time hours minutes seconds millis))))
+
 ;;; ------------------------------------------------ arithmetic ------------------------------------------------------
 
 (declare unit-diff)
diff --git a/src/metabase/util/time/impl_common.cljc b/src/metabase/util/time/impl_common.cljc
index 34038996b3a..8d8a1b88327 100644
--- a/src/metabase/util/time/impl_common.cljc
+++ b/src/metabase/util/time/impl_common.cljc
@@ -111,3 +111,20 @@
   [time-str]
   (or (second (re-matches (re-pattern (str "(.*?)" (optional offset-part) \$)) time-str))
       time-str))
+
+(def ^:const month-keywords
+  "Mapping of human-friendly keywords to literal month numbers.
+
+  1 = January."
+  {:jan 1
+   :feb 2
+   :mar 3
+   :apr 4
+   :may 5
+   :jun 6
+   :jul 7
+   :aug 8
+   :sep 9
+   :oct 10
+   :nov 11
+   :dec 12})
diff --git a/test/metabase/util/time_test.cljc b/test/metabase/util/time_test.cljc
index dd58ac53e35..8a72e15d48e 100644
--- a/test/metabase/util/time_test.cljc
+++ b/test/metabase/util/time_test.cljc
@@ -14,6 +14,21 @@
   #?(:cljs (moment/utc time-str)
      :clj  (t/offset-date-time time-str)))
 
+(defn- from-local [time-str]
+  #?(:cljs (moment time-str)
+     :clj  (t/local-date-time time-str)))
+
+(defn- from-local-date [time-str]
+  #?(:cljs (moment time-str)
+     :clj  (t/local-date time-str)))
+
+(defn- from-local-time [time-str]
+  #?(:cljs (moment time-str
+                   #js [(.. moment -HTML5_FMT -TIME_MS)
+                        (.. moment -HTML5_FMT -TIME_SECONDS)
+                        (.. moment -HTML5_FMT -TIME)])
+     :clj  (t/local-time time-str)))
+
 (defn- same?
   "True if these two datetimes are equivalent.
   JVM objects are [[=]] but Moment.js values are not, so use the Moment.isSame method in CLJS."
@@ -392,3 +407,27 @@
     :second      "00:00:02"
     :minute      "00:02"
     :hour        "02:00"))
+
+(deftest ^:parallel local-date-test
+  (are [exp-str act] (same? (from-local-date exp-str) act)
+    "2024-11-01" (shared.ut/local-date 2024 11 1)
+    "2000-02-29" (shared.ut/local-date 2000 2 29)
+    "2037-12-31" (shared.ut/local-date 2037 12 31)))
+
+(deftest ^:parallel local-time-test
+  (are [exp-str act] (same? (from-local-time exp-str) act)
+    "12:34:00"     (shared.ut/local-time 12 34)
+    "12:34:56"     (shared.ut/local-time 12 34 56)
+    "12:34:56.789" (shared.ut/local-time 12 34 56 789)))
+
+(deftest ^:parallel local-date-time-test
+  (let [nov4 (shared.ut/local-date 2024 11 4)]
+    (are [exp-str act] (same? (from-local exp-str) act)
+      ;; Local date + local time
+      "2024-11-04T12:34:00"     (shared.ut/local-date-time nov4 (shared.ut/local-time 12 34))
+      "2024-11-04T12:34:56"     (shared.ut/local-date-time nov4 (shared.ut/local-time 12 34 56))
+      "2024-11-04T12:34:56.789" (shared.ut/local-date-time nov4 (shared.ut/local-time 12 34 56 789))
+      ;; Literal values
+      "2024-11-04T12:34:00"     (shared.ut/local-date-time 2024 11 4 12 34)
+      "2024-11-04T12:34:56"     (shared.ut/local-date-time 2024 11 4 12 34 56)
+      "2024-11-04T12:34:56.789" (shared.ut/local-date-time 2024 11 4 12 34 56 789))))
-- 
GitLab