(ns bbbg.util.time "Utilities for dealing with date/time" (:require [clojure.spec.alpha :as s] [clojure.test.check.generators :as gen] [java-time :as jt]) (:import [java.time LocalDateTime LocalTime OffsetDateTime ZoneId ZoneOffset LocalDate Year] [java.time.format DateTimeFormatter DateTimeParseException] java.util.Calendar org.apache.commons.lang3.time.DurationFormatUtils)) (set! *warn-on-reflection* true) (defprotocol ToOffsetDateTime (->OffsetDateTime [this] "Coerces its argument to a `java.time.OffsetDateTime`")) (extend-protocol ToOffsetDateTime OffsetDateTime (->OffsetDateTime [odt] odt) java.util.Date (->OffsetDateTime [d] (-> d .toInstant (OffsetDateTime/ofInstant (ZoneId/of "UTC"))))) (defprotocol ToLocalTime (->LocalTime [this])) (extend-protocol ToLocalTime LocalTime (->LocalTime [lt] lt) java.sql.Time (->LocalTime [t] (let [^Calendar cal (doto (Calendar/getInstance) (.setTime t))] (LocalTime/of (.get cal Calendar/HOUR_OF_DAY) (.get cal Calendar/MINUTE) (.get cal Calendar/SECOND)))) java.util.Date (->LocalTime [d] (-> d .toInstant (LocalTime/ofInstant (ZoneId/of "UTC"))))) (defn local-time? [x] (satisfies? ToLocalTime x)) (s/def ::local-time (s/with-gen local-time? #(gen/let [hour (gen/choose 0 23) minute (gen/choose 0 59) second (gen/choose 0 59) nanos gen/nat] (LocalTime/of hour minute second nanos)))) (defprotocol ToLocalDate (->LocalDate [this])) (extend-protocol ToLocalDate LocalDate (->LocalDate [ld] ld) java.sql.Date (->LocalDate [sd] (.toLocalDate sd)) java.util.Date (->LocalDate [d] (-> d .toInstant (LocalDate/ofInstant (ZoneId/of "UTC"))))) (defn local-date? [x] (satisfies? ToLocalDate x)) (s/def ::local-date (s/with-gen local-date? #(gen/let [year (gen/choose Year/MIN_VALUE Year/MAX_VALUE) day (gen/choose 1 (if (.isLeap (Year/of year)) 366 365))] (LocalDate/ofYearDay year day)))) (extend-protocol Inst OffsetDateTime (inst-ms* [zdt] (inst-ms* (.toInstant zdt))) LocalDateTime (inst-ms* [^LocalDateTime ldt] (inst-ms* (.toInstant ldt ZoneOffset/UTC)))) (let [formatter DateTimeFormatter/ISO_OFFSET_DATE_TIME] (defn ^OffsetDateTime parse-iso-8601 "Parse s as an iso-8601 datetime, returning nil if invalid" [^String s] (try (OffsetDateTime/parse s formatter) (catch DateTimeParseException _ nil))) (defn format-iso-8601 "Format dt, which can be an OffsetDateTime or java.util.Date, as iso-8601" [dt] (some->> dt ->OffsetDateTime (.format formatter)))) (let [formatter DateTimeFormatter/ISO_TIME] (defn parse-iso-8601-time "Parse s as an iso-8601 timestamp, returning nil if invalid" [^String s] (try (LocalTime/parse s formatter) (catch DateTimeParseException _ nil))) (defn format-iso-8601-time "Format lt, which can be a LocalTime or java.sql.Time, as an iso-8601 formatted timestamp without a date." [lt] (some->> lt ->LocalTime (.format formatter)))) (defmethod print-dup LocalTime [t w] (binding [*out* w] (print "#local-time ") (print (str "\"" (format-iso-8601-time t) "\"")))) (defmethod print-method LocalTime [t w] (print-dup t w)) (let [formatter DateTimeFormatter/ISO_LOCAL_DATE] (defn parse-iso-8601-date "Parse s as an iso-8601 date, returning nil if invalid" [^String s] (try (LocalDate/parse s formatter) (catch DateTimeParseException _ nil))) (defn format-iso-8601-date "Format lt, which can be a LocalDate, as an iso-8601 formatted date without a timestamp." [lt] (some->> lt ->LocalDate (.format formatter)))) (defmethod print-dup LocalDate [t w] (binding [*out* w] (print "#local-date ") (print (str "\"" (format-iso-8601-date t) "\"")))) (defmethod print-method LocalDate [t w] (print-dup t w)) (defn ^String human-format-duration "Human-format the given duration" [^java.time.Duration dur] (DurationFormatUtils/formatDurationWords (Math/abs (.toMillis dur)) true true)) (comment (human-format-duration (jt/hours 5)) (human-format-duration (jt/plus (jt/hours 5) (jt/minutes 7))) )