CLJS implementation of the time utilities on top of Moment.js. See [[metabase.util.time]] for the public interface. | (ns metabase.util.time.impl (:require ["moment" :as moment] [metabase.util.time.impl-common :as common])) |
(defn- now [] (moment)) | |
Given any value, check if it's a (possibly invalid) Moment. ----------------------------------------------- predicates ------------------------------------------------------- | (defn datetime? [value] (and value (moment/isMoment value))) |
checks if the provided value is a local time value. | (defn time? [value] (moment/isMoment value)) |
Given a Moment, check that it's valid. | (defn valid? [value] (and (datetime? value) (.isValid ^moment/Moment value))) |
Does nothing. Just a placeholder in CLJS; the JVM implementation does some real work. | (defn normalize [value] value) |
Given two platform-specific datetimes, checks if they fall within the same day. | (defn same-day? [^moment/Moment d1 ^moment/Moment d2] (.isSame d1 d2 "day")) |
True if these two datetimes fall in the same (year and) month. | (defn same-month? [^moment/Moment d1 ^moment/Moment d2] (.isSame d1 d2 "month")) |
True if these two datetimes fall in the same year. | (defn same-year? [^moment/Moment d1 ^moment/Moment d2] (.isSame d1 d2 "year")) |
The first day of the week varies by locale, but Metabase has a setting that overrides it. In CLJS, Moment is already configured with that setting. ---------------------------------------------- information ------------------------------------------------------- | (defn first-day-of-week
[]
(nth [:sunday :monday :tuesday :wednesday :thursday :friday :saturday]
(.firstDayOfWeek (moment/localeData)))) |
The default map of options - empty in CLJS. | (def default-options
{}) |
------------------------------------------------ to-range -------------------------------------------------------- | (defn- apply-offset [^moment/Moment value offset-n offset-unit] (.add (moment value) offset-n (name offset-unit))) |
(defmethod common/to-range :default [^moment/Moment value {:keys [n unit]}]
(let [^moment/Moment c1 (.clone value)
^moment/Moment c2 (.clone value)
^moment/Moment adjusted (if (> n 1)
(.add c2 (dec n) (name unit))
c2)]
[(.startOf c1 (name unit))
(.endOf adjusted (name unit))])) | |
NB: Only the :default for to-range is needed in CLJS, since Moment's startOf and endOf methods are doing the work. | |
-------------------------------------------- string->timestamp --------------------------------------------------- | (defmethod common/string->timestamp :default [value _] ;; Best effort to parse this unknown string format, as a local zoneless datetime, then treating it as UTC. (moment/utc value moment/ISO_8601)) |
(defmethod common/string->timestamp :day-of-week [value options]
;; Try to parse as a regular timestamp; if that fails then try to treat it as a weekday name and adjust from
;; the current time.
(let [as-default (try ((get-method common/string->timestamp :default) value options)
(catch js/Error _ nil))]
(if (valid? as-default)
as-default
(-> (now)
(.isoWeekday value)
(.startOf "day"))))) | |
Some of the date coercions are relative, and not directly involved with any particular month. To avoid errors we need to use a reference date that is (a) in a month with 31 days,(b) in a leap year. This uses 2016-01-01 for the purpose. This is a function that returns fresh values, since Moments are mutable. -------------------------------------------- number->timestamp --------------------------------------------------- | (defn- magic-base-date [] (moment "2016-01-01")) |
(defmethod common/number->timestamp :default [value _] ;; If no unit is given, or the unit is not recognized, try to parse the number as year number, returning the timestamp ;; for midnight UTC on January 1. (moment/utc value moment/ISO_8601)) | |
(defmethod common/number->timestamp :minute-of-hour [value _] (.. (now) (minute value) (startOf "minute"))) | |
(defmethod common/number->timestamp :hour-of-day [value _] (.. (now) (hour value) (startOf "hour"))) | |
(defmethod common/number->timestamp :day-of-week [value _] ;; Metabase uses 1 to mean the start of the week, based on the Metabase setting for the first day of the week. ;; Moment uses 0 as the first day of the week in its configured locale. (.. (now) (weekday (dec value)) (startOf "day"))) | |
(defmethod common/number->timestamp :day-of-week-iso [value _] (.. (now) (isoWeekday value) (startOf "day"))) | |
(defmethod common/number->timestamp :day-of-month [value _] ;; We force the initial date to be in a month with 31 days. (.. (magic-base-date) (date value) (startOf "day"))) | |
(defmethod common/number->timestamp :day-of-year [value _] ;; We force the initial date to be in a leap year (2016). (.. (magic-base-date) (dayOfYear value) (startOf "day"))) | |
(defmethod common/number->timestamp :week-of-year [value _] (.. (now) (week value) (startOf "week"))) | |
(defmethod common/number->timestamp :month-of-year [value _] (.. (now) (month (dec value)) (startOf "month"))) | |
(defmethod common/number->timestamp :quarter-of-year [value _] (.. (now) (quarter value) (startOf "quarter"))) | |
(defmethod common/number->timestamp :year [value _] (.. (now) (year value) (startOf "year"))) | |
Parses a timestamp with Z or a timezone offset at the end. This requires a different API call from timestamps without time zones in CLJS. ---------------------------------------------- parsing helpers --------------------------------------------------- | (defn parse-with-zone [value] (moment/parseZone value)) |
Given a freshly parsed absolute Moment, convert it to a local one. | (defn localize [value] (.local value)) |
(def ^:private parse-time-formats
#js ["HH:mm:ss.SSS[Z]"
"HH:mm:ss.SSS"
"HH:mm:ss"
"HH:mm"]) | |
Parses a time string that has been stripped of any time zone. | (defn parse-time-string [value] (moment value parse-time-formats)) |
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. ----------------------------------------------- constructors ----------------------------------------------------- | (defn local-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) | |
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 | (defn local-date
([] (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))}))) |
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. | (defn local-date-time
([] (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) | |
Returns the time elapsed between | (defn day-diff [before after] (unit-diff :day before after)) |
(defn- coerce-local-date-time [input]
(-> input
common/drop-trailing-time-zone
(moment/utc moment/ISO_8601))) | |
Formats a date-time value given the temporal extraction unit. If unit is not supported, returns nil. | (defn ^:private format-extraction-unit
[t unit]
(case unit
:day-of-week (.format t "dddd")
:day-of-week-iso (.format t "dddd")
:month-of-year (.format t "MMM")
:minute-of-hour (.format t "m")
:hour-of-day (.format t "h A")
:day-of-month (.format t "D")
:day-of-year (.format t "DDD")
:week-of-year (.format t "w")
:quarter-of-year (.format t "[Q]Q")
nil)) |
Formats a temporal-value (iso date/time string, int for extraction units) given the temporal-bucketing unit. If unit is nil, formats the full date/time. Time input formatting is only defined with time units. | (defn format-unit
;; This third argument is needed for the JVM side; it can be ignored here.
([input unit _locale] (format-unit input unit))
([input unit]
(if (string? input)
(let [time? (common/matches-time? input)
date? (common/matches-date? input)
date-time? (common/matches-date-time? input)
t (cond
;; Anchor to an arbitrary date since time inputs are only defined for
;; :hour-of-day and :minute-of-hour.
time? (moment/utc (str "2023-01-01T" input) moment/ISO_8601)
(or date? date-time?) (coerce-local-date-time input))]
(if (and t (.isValid t))
(or
(format-extraction-unit t unit)
(cond
time? (.format t "h:mm A")
date? (.format t "MMM D, YYYY")
date-time? (.format t "MMM D, YYYY, h:mm A")))
input))
(if (= unit :hour-of-day)
(str (cond (zero? input) "12" (<= input 12) input :else (- input 12)) " " (if (<= input 11) "AM" "PM"))
(or
(format-extraction-unit (common/number->timestamp input {:unit unit}) unit)
(str input)))))) |
Formats a time difference between two temporal values. Drops redundant information. | (defn format-diff
[temporal-value-1 temporal-value-2]
(let [default-format #(str (format-unit temporal-value-1 nil)
" – "
(format-unit temporal-value-2 nil))]
(cond
(some (complement string?) [temporal-value-1 temporal-value-2])
(default-format)
(= temporal-value-1 temporal-value-2)
(format-unit temporal-value-1 nil)
(and (common/matches-time? temporal-value-1)
(common/matches-time? temporal-value-2))
(default-format)
(and (common/matches-date-time? temporal-value-1)
(common/matches-date-time? temporal-value-2))
(let [lhs (coerce-local-date-time temporal-value-1)
rhs (coerce-local-date-time temporal-value-2)
year-matches? (= (.format lhs "YYYY") (.format rhs "YYYY"))
month-matches? (= (.format lhs "MMM") (.format rhs "MMM"))
day-matches? (= (.format lhs "D") (.format rhs "D"))
hour-matches? (= (.format lhs "HH") (.format rhs "HH"))
[lhs-fmt rhs-fmt] (cond
(and year-matches? month-matches? day-matches? hour-matches?)
["MMM D, YYYY, h:mm A " " h:mm A"]
(and year-matches? month-matches? day-matches?)
["MMM D, YYYY, h:mm A " " h:mm A"]
year-matches?
["MMM D, h:mm A " " MMM D, YYYY, h:mm A"])]
(if lhs-fmt
(str (.format lhs lhs-fmt) "–" (.format rhs rhs-fmt))
(default-format)))
(and (common/matches-date? temporal-value-1)
(common/matches-date? temporal-value-2))
(let [lhs (moment/utc temporal-value-1 moment/ISO_8601)
rhs (moment/utc temporal-value-2 moment/ISO_8601)
year-matches? (= (.format lhs "YYYY") (.format rhs "YYYY"))
month-matches? (= (.format lhs "MMM") (.format rhs "MMM"))
[lhs-fmt rhs-fmt] (cond
(and year-matches? month-matches?)
["MMM D" "D, YYYY"]
year-matches?
["MMM D " " MMM D, YYYY"])]
(if lhs-fmt
(str (.format lhs lhs-fmt) "–" (.format rhs rhs-fmt))
(default-format)))
:else
(default-format)))) |
Given a | (defn format-relative-date-range
([n unit offset-n offset-unit opts]
(format-relative-date-range (now) n unit offset-n offset-unit opts))
([t n unit offset-n offset-unit {:keys [include-current]}]
(let [offset-now (cond-> t
(neg? n) (apply-offset n unit)
(and (pos? n) (not include-current)) (apply-offset 1 unit)
(and offset-n offset-unit) (apply-offset offset-n offset-unit))
pos-n (cond-> (abs n)
include-current inc)
date-ranges (map #(.format % (if (#{:hour :minute} unit) "YYYY-MM-DDTHH:mm" "YYYY-MM-DD"))
(common/to-range offset-now
{:unit unit
:n pos-n
:offset-n offset-n
:offset-unit offset-unit}))]
(apply format-diff date-ranges)))) |
(def ^:private temporal-formats
{:offset-date-time {:regex common/offset-datetime-regex
:formats #js ["YYYY-MM-DDTHH:mm:ss.SSS[Z]"
"YYYY-MM-DDTHH:mm:ss[Z]"
"YYYY-MM-DDTHH:mm[Z]"
"YYYY-MM-DDTHH[Z]"]}
:local-date-time {:regex common/local-datetime-regex
:formats #js ["YYYY-MM-DDTHH:mm:ss.SSS"
"YYYY-MM-DDTHH:mm:ss"
"YYYY-MM-DDTHH:mm"
"YYYY-MM-DDTHH"]}
:local-date {:regex common/local-date-regex
:formats #js ["YYYY-MM-DD"
"YYYY-MM"
"YYYY"]}
:offset-time {:regex common/offset-time-regex
:formats #js ["HH:mm:ss.SSS[Z]"
"HH:mm:ss[Z]"
"HH:mm[Z]"
"HH[Z]"]}
:local-time {:regex common/local-time-regex
:formats #js ["HH:mm:ss.SSS"
"HH:mm:ss"
"HH:mm"
"HH"]}}) | |
(defn- iso-8601->moment+type
[s]
(some (fn [[value-type {:keys [regex formats]}]]
(when (re-matches regex s)
(let [parsed (moment/parseZone s formats #_strict? true)]
(when (.isValid parsed)
[parsed value-type]))))
temporal-formats)) | |
(defmulti ^:private moment+type->iso-8601
{:arglists '([moment+type])}
(fn [[_t value-type]]
value-type)) | |
(defmethod moment+type->iso-8601 :offset-date-time
[[^moment/Moment t _value-type]]
(let [format-string (cond
(pos? (.milliseconds t)) "YYYY-MM-DDTHH:mm:ss.SSS[Z]"
(pos? (.seconds t)) "YYYY-MM-DDTHH:mm:ss[Z]"
:else "YYYY-MM-DDTHH:mm[Z]")]
(.format t format-string))) | |
(defmethod moment+type->iso-8601 :local-date-time
[[^moment/Moment t _value-type]]
(let [format-string (cond
(pos? (.milliseconds t)) "YYYY-MM-DDTHH:mm:ss.SSS"
(pos? (.seconds t)) "YYYY-MM-DDTHH:mm:ss"
:else "YYYY-MM-DDTHH:mm")]
(.format t format-string))) | |
(defmethod moment+type->iso-8601 :local-date [[^moment/Moment t _value-type]] (.format t "YYYY-MM-DD")) | |
(defmethod moment+type->iso-8601 :offset-time
[[^moment/Moment t _value-type]]
(let [format-string (cond
(pos? (.milliseconds t)) "HH:mm:ss.SSS[Z]"
(pos? (.seconds t)) "HH:mm:ss[Z]"
:else "HH:mm[Z]")]
(.format t format-string))) | |
(defmethod moment+type->iso-8601 :local-time
[[^moment/Moment t _value-type]]
(let [format-string (cond
(pos? (.milliseconds t)) "HH:mm:ss.SSS"
(pos? (.seconds t)) "HH:mm:ss"
:else "HH:mm")]
(.format t format-string))) | |
(defn- ->moment ^moment/Moment [t]
(if (instance? js/Date t)
(moment/utc t)
t)) | |
Return the number of | (defn unit-diff
[unit before after]
(let [^moment/Moment before (if (string? before)
(first (iso-8601->moment+type before))
(->moment before))
^moment/Moment after (if (string? after)
(first (iso-8601->moment+type after))
(->moment after))]
(.diff after before (name unit)))) |
ClojureScript implementation of [[metabase.util.time/truncate]]; supports both Moment.js instances and ISO-8601 strings. | (defn truncate
[t unit]
(if (string? t)
(let [[t value-type] (iso-8601->moment+type t)
t (truncate t unit)]
(moment+type->iso-8601 [t value-type]))
(let [^moment/Moment t (->moment t)]
(.startOf t (name unit))))) |
ClojureScript implementation of [[metabase.util.time/add]]; supports both Moment.js instances and ISO-8601 strings. | (defn add
[t unit amount]
(if (string? t)
(let [[t value-type] (iso-8601->moment+type t)
t (add t unit amount)]
(moment+type->iso-8601 [t value-type]))
(let [^moment/Moment t (->moment t)]
(.add t amount (name unit))))) |
ClojureScript implementation of [[metabase.util.time/format-for-base-type]]; format a temporal value as an ISO-8601
string appropriate for a value of the given | (defn format-for-base-type
[t base-type]
(if (string? t)
t
(let [t (->moment t)
value-type (condp #(isa? %2 %1) base-type
:type/TimeWithTZ :offset-time
:type/Time :local-time
:type/DateTimeWithTZ :offset-date-time
:type/DateTime :local-date-time
:type/Date :local-date)]
(moment+type->iso-8601 [t value-type])))) |
Extract a field such as | (defn extract
[^moment/Moment t unit]
(case unit
:second-of-minute (.second t)
:minute-of-hour (.minute t)
:hour-of-day (.hour t)
:day-of-week (inc (.weekday t)) ;; `weekday` is 0-6, where 0 corresponds to the first day of week
:day-of-week-iso (.isoWeekday t)
:day-of-month (.date t)
:day-of-year (.dayOfYear t)
:week-of-year (.week t)
:month-of-year (inc (.month t)) ;; `month` is 0-11
:quarter-of-year (.quarter t)
:year (.year t))) |