(ns metabase.util.time.impl (:require [java-time.api :as t] [metabase.util.date-2 :as u.date] [metabase.util.time.impl-common :as common]) (:import (java.time.format DateTimeFormatter) (java.util Locale))) | |
(set! *warn-on-reflection* true) | |
(defn- now [] (t/offset-date-time)) | |
Given any value, check if it's a datetime object. ----------------------------------------------- predicates ------------------------------------------------------- | (defn datetime? [value] (or (t/offset-date-time? value) (t/zoned-date-time? value) (t/instant? value))) |
checks if the provided value is a local time value. | (defn time? [value] (t/local-time? value)) |
Given a datetime, check that it's valid. | (defn valid? [value] (or (datetime? value) (t/local-date? value) (t/local-date-time? value) (t/offset-time? value) (t/local-time? value))) |
Does nothing. Just a placeholder in CLJS; the JVM implementation does some real work. | (defn normalize [value] (t/offset-date-time value)) |
Given two platform-specific datetimes, checks if they fall within the same day. | (defn same-day? [d1 d2] (= (t/truncate-to d1 :days) (t/truncate-to d2 :days))) |
True if these two datetimes fall in the same year. | (defn same-year? [d1 d2] (= (t/year d1) (t/year d2))) |
True if these two datetimes fall in the same (year and) month. | (defn same-month? [d1 d2] (and (same-year? d1 d2) (= (t/month d1) (t/month d2)))) |
The first day of the week varies by locale, but Metabase has a setting that overrides it. In JVM, we can just read the setting directly. ---------------------------------------------- information ------------------------------------------------------- | (defn first-day-of-week [] ((requiring-resolve 'metabase.public-settings/start-of-week))) |
The default map of options. | (def default-options {:locale (Locale/getDefault)}) |
------------------------------------------------ to-range -------------------------------------------------------- | (defn- minus-ms [value] (t/minus value (t/millis 1))) |
(defn- apply-offset [value offset-n offset-unit] (t/plus value (case offset-unit :minute (t/minutes offset-n) :hour (t/hours offset-n) :day (t/days offset-n) :week (t/weeks offset-n) :month (t/months offset-n) :year (t/years offset-n) (t/minutes 0)))) | |
(defmethod common/to-range :default [value _] ;; Fallback: Just return a zero-width at the input time. ;; This mimics Moment.js behavior if you `m.startOf("unknown unit")` - it doesn't change anything. [value value]) | |
(defmethod common/to-range :minute [value {:keys [n] :or {n 1}}] (let [start (-> value (t/truncate-to :minutes))] [start (minus-ms (t/plus start (t/minutes n)))])) | |
(defmethod common/to-range :hour [value {:keys [n] :or {n 1}}] (let [start (-> value (t/truncate-to :hours))] [start (minus-ms (t/plus start (t/hours n)))])) | |
(defmethod common/to-range :day [value {:keys [n] :or {n 1}}] (let [start (-> value (t/truncate-to :days))] [start (minus-ms (t/plus start (t/days n)))])) | |
(defmethod common/to-range :week [value {:keys [n] :or {n 1}}] (let [first-day (first-day-of-week) start (-> value (t/truncate-to :days) (t/adjust :previous-or-same-day-of-week first-day))] [start (minus-ms (t/plus start (t/weeks n)))])) | |
(defmethod common/to-range :month [value {:keys [n] :or {n 1}}] (let [value (-> value (t/truncate-to :days) (t/adjust :first-day-of-month))] [value (minus-ms (t/plus value (t/months n)))])) | |
(declare truncate add) | |
(defmethod common/to-range :quarter [value {:keys [n] :or {n 1}}] (let [value (truncate value :quarter)] [value (minus-ms (add value :quarter n))])) | |
(defmethod common/to-range :year [value {:keys [n] :or {n 1}}] (let [value (-> value (t/truncate-to :days) (t/adjust :first-day-of-year))] [value (minus-ms (nth (iterate #(t/adjust % :first-day-of-next-year n) value) n))])) | |
-------------------------------------------- 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. (let [base (try (t/local-date-time value) (catch Exception _ (try (t/local-date value) (catch Exception _ nil))))] (when base (t/offset-date-time base (t/zone-id))))) |
(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 Exception _ nil))] (if (valid? as-default) as-default (let [day (try (t/day-of-week "EEE" value) (catch Exception _ (try (t/day-of-week "EEEE" value) (catch Exception _ (throw (ex-info (str "Failed to coerce '" value "' to day-of-week") {:value value}))))))] (-> (now) (t/truncate-to :days) (t/adjust :previous-or-same-day-of-week :monday) ; Move to ISO start of week. (t/adjust :next-or-same-day-of-week day)))))) ; Then to the specified 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. -------------------------------------------- number->timestamp --------------------------------------------------- | (def ^:private magic-base-date (t/offset-date-time 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. (t/offset-date-time value)) | |
(defmethod common/number->timestamp :minute-of-hour [value _] (-> (now) (t/truncate-to :hours) (t/plus (t/minutes value)))) | |
(defmethod common/number->timestamp :hour-of-day [value _] (-> (now) (t/truncate-to :days) (t/plus (t/hours value)))) | |
(defn- number->timestamp [value day-of-week] (-> (now) (t/adjust :previous-or-same-day-of-week day-of-week) (t/truncate-to :days) (t/plus (t/days (dec value))))) | |
(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. ;; For Java, get the first day of the week from the setting, and offset by `(dec value)` for the current day. (number->timestamp value (first-day-of-week))) | |
(defmethod common/number->timestamp :day-of-week-iso [value _] (number->timestamp value :monday)) | |
(defmethod common/number->timestamp :day-of-month [value _] ;; We force the initial date to be in a month with 31 days. (t/plus magic-base-date (t/days (dec value)))) | |
(defmethod common/number->timestamp :day-of-year [value _] ;; We force the initial date to be in a leap year (2016). (t/plus magic-base-date (t/days (dec value)))) | |
(defmethod common/number->timestamp :week-of-year [value _] (-> (now) (t/truncate-to :days) (t/adjust :first-day-of-year) (t/adjust :previous-or-same-day-of-week (first-day-of-week)) (t/plus (t/weeks (dec value))))) | |
(defmethod common/number->timestamp :month-of-year [value _] (t/offset-date-time (t/year (now)) value 1)) | |
(defmethod common/number->timestamp :quarter-of-year [value _] (let [month (inc (* 3 (dec value)))] (t/offset-date-time (t/year (now)) month 1))) | |
(defmethod common/number->timestamp :year [value _] (t/offset-date-time value 1 1)) | |
Parses a timestamp with Z or a timezone offset at the end. ---------------------------------------------- parsing helpers --------------------------------------------------- | (defn parse-with-zone [value] (t/offset-date-time value)) |
Given a freshly parsed | (defn localize [value] (t/local-date-time value)) |
Parses a time string that has been stripped of any time zone. | (defn parse-time-string [value] (t/local-time value)) |
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. ----------------------------------------------- constructors ----------------------------------------------------- | (defn local-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)))) |
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 If called with no arguments, returns the current date. | (defn local-date ([] (t/local-date)) ([year month day] (t/local-date year (or (common/month-keywords month) month) day))) |
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. | (defn local-date-time ([] (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 ------------------------------------------------------ | |
Return the number of | (defn unit-diff [unit before after] (let [before (cond-> before (string? before) u.date/parse) after (cond-> after (string? after) u.date/parse) ;; you can't use LocalDates in durations I guess, so just convert them LocalDateTimes with time = 0 before (cond-> before (instance? java.time.LocalDate before) (t/local-date-time 0)) after (cond-> after (instance? java.time.LocalDate after) (t/local-date-time 0)) duration (t/duration before after)] (case unit :millisecond (.toMillis duration) :second (.toSeconds duration) :minute (.toMinutes duration) :hour (.toHours duration) :day (.toDays duration) :week (long (/ (unit-diff :day before after) 7)) :month (let [diff-months (- (u.date/extract after :month-of-year) (u.date/extract before :month-of-year)) diff-years (- (u.date/extract after :year) (u.date/extract before :year))] (+ diff-months (* diff-years 12))) :quarter (long (/ (unit-diff :month before after) 3)) :year (- (u.date/extract after :year) (u.date/extract before :year))))) |
Returns the time elapsed between | (defn day-diff [before after] (unit-diff :day before after)) |
(defn- coerce-local-date-time [input] (cond-> input (re-find #"(?:Z|[+-]\d\d(?::?\d\d)?)$" input) (t/offset-date-time) :always (localize))) | |
(def ^:private unit-formats {:day-of-week "EEEE" :day-of-week-iso "EEEE" :month-of-year "MMM" :minute-of-hour "m" :hour-of-day "h a" :day-of-month "d" :day-of-year "D" :week-of-year "w" :quarter-of-year "'Q'Q"}) | |
Formats a date-time value given the temporal extraction unit. If unit is not supported, returns nil. | (defn ^:private format-extraction-unit [t unit ^Locale locale] (when-let [^DateTimeFormatter formatter (some-> unit unit-formats t/formatter (cond-> #_formatter locale (.withLocale locale)))] (.format formatter t))) |
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 | (defn format-unit ([input unit] (format-unit input unit nil)) ([input unit locale] (if (string? input) (let [time? (common/matches-time? input) date? (common/matches-date? input) date-time? (common/matches-date-time? input) t (cond time? (t/local-time input) date? (t/local-date input) date-time? (coerce-local-date-time input))] (if t (or (format-extraction-unit t unit locale) (cond time? (t/format "h:mm a" t) date? (t/format "MMM d, yyyy" t) :else (t/format "MMM d, yyyy, h:mm a" t))) 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 locale) (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? (= (t/year lhs) (t/year rhs)) month-matches? (= (t/month lhs) (t/month rhs)) day-matches? (= (t/day-of-month lhs) (t/day-of-month rhs)) hour-matches? (= (t/format "H" lhs) (t/format "H" rhs)) [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 (t/format lhs-fmt lhs) "–" (t/format rhs-fmt rhs)) (default-format))) (and (common/matches-date? temporal-value-1) (common/matches-date? temporal-value-2)) (let [lhs (t/local-date temporal-value-1) rhs (t/local-date temporal-value-2) year-matches? (= (t/year lhs) (t/year rhs)) month-matches? (= (t/month lhs) (t/month rhs)) [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 (t/format lhs-fmt lhs) "–" (t/format rhs-fmt rhs)) (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 (if (#{:hour :minute} unit) #(t/format "yyyy-MM-dd'T'HH:mm" (t/local-date-time %)) #(str (t/local-date %))) (common/to-range offset-now {:unit unit :n pos-n :offset-n offset-n :offset-unit offset-unit}))] (apply format-diff date-ranges)))) |
Clojure implementation of [[metabase.util.time/truncate]]; basically the same as [[u.date/truncate]] but also handles ISO-8601 strings. | (defn truncate [t unit] (if (string? t) (str (truncate (u.date/parse t) unit)) (u.date/truncate t unit))) |
Clojure implementation of [[metabase.util.time/add]]; basically the same as [[u.date/add]] but also handles ISO-8601 strings. | (defn add [t unit amount] (if (string? t) (str (add (u.date/parse t) unit amount)) (u.date/add t unit amount))) |
Clojure 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 [format (condp #(isa? %2 %1) base-type :type/TimeWithTZ "HH:mm:ss.SSSZ" :type/Time "HH:mm:ss.SSS" :type/DateTimeWithTZ "yyyy-MM-dd'T'HH:mm:ss.SSSZ" :type/DateTime "yyyy-MM-dd'T'HH:mm:ss.SSS" :type/Date "yyyy-MM-dd")] (t/format format t)))) |
Extract a field such as | (defn extract [t unit] (u.date/extract t unit)) |