Skip to content

Latest commit

 

History

History
377 lines (278 loc) · 14.9 KB

README.md

File metadata and controls

377 lines (278 loc) · 14.9 KB

deja-fu

CircleCI cljdoc badge Clojars Project

Lightweight ClojureScript local time/date library

“What?” said Lobsang, into the mat. “You said none of the monks knew deja-fu!”
“I never taught it to 'em, that's why!” said Lu-Tze. “Promise not to harm me, would you? Thank you so very much! Submit?”
“You never told me you knew it!” Lu-Tze's knees, rammed into the secret pressure points, were turning Lobsang's arms into powerless lumps of flesh.
“I may be old but I'm not daft!” Lu-Tze shouted. “You don't think I'd give away a trick like that, do you?”
— Terry Pratchet, Thief of Time

Installation

To use the latest release, add the following to your deps.edn (Clojure CLI)

com.lambdaisland/deja-fu {:mvn/version "1.6.65"}

or add the following to your project.clj (Leiningen)

[com.lambdaisland/deja-fu "1.6.65"]

Rationale

This library is the result of a particular set of insights and design constraints. The main insight is that it is valuable to have distinct types for distinct concepts like a "calendar date" vs "wall time" vs a "date-time / timestamp". On the JVM we have known this for some time, thanks to Joda-Time and later the java.time API (aka JSR-310).

The equivalent in the JavaScript/ClojureScript world is js-joda, which is used by the ClojureScript version of juxt/tick, or the upcoming TC39 Temporal API. Eventually we can expect Temporal to become part of browsers and other JS runtimes, but until then, a hefty polyfill is needed, significantly blowing up build size. The same is true of js-joda.

For applications where dealing with time is not enough of their core business to justify these large dependencies, a lighter alternative is needed. This is where deja-fu comes in. It builds upon the Google Closure library's goog.date.Date and goog.date.DateTime, and adds a third "time" type, thus providing representations for three concepts.

  • a date
  • a time without time zone
  • a date+time without time zone

For these it provides a limited but flexible and highly idiomatic ClojureScript API.

Note that none of them carry time zone information. It is up to the programmer to decide how to deal with time zones. Either make sure all times are in the user's (browser's) time zone, or in the server's time zone, or in UTC, and convert accordingly on the edges. Or track time+zone explicitly. We do provide a basic API for doing time zone offset shifts, and for querying the time zone offset of the browser running the app.

The API that deja-fu provides is by no means as comprehensive as java.time, js-joda, or Temporal. Instead we provide basic primitives for parsing, formatting, and manipulating times in a way that hopefully feels intuitive and convenient for Clojure programmers. It tries to be Good Enough while retaining a limited footprint and paving over some of the quirkiness of dealing with dates and times in a JavaScript world.

Getting started

We provide a single namespace, lambdaisland.deja-fu. All examples below call functions from that namespace.

(ns my-ns
  (:require [lambdaisland.deja-fu :as fu]))
  
(fu/local-time)

For brevity we have omitted the fu/ prefix in the rest of the README.

Types

Reader / printer syntax deja-fu type equivalent JDK type
#time/date "2020-10-10" goog.date.Date java.time.LocalDate
#time/time "05:30:45" lambdaisland.deja-fu/LocalTime java.time.LocalTime
#time/date-time "2020-10-07T12:16:41.761088" goog.date.DateTime java.time.LocalDateTime

deja-fu includes a data_readers.cljs file, which provides support for tagged literals in code, and it registers print and pretty-print handlers for printing values of these types as tagged literals.

For each type there's a constructor that takes either the individual elements as positional arguments, or no args to get the current date/time/datetime. Each type also has a parse function that takes a string.

;;;;;;;;;;;;;;;;;;; Time
;; Current time
(local-time) ;; => #time/time "09:33:53.048000"

;; Hours / Minutes / Seconds / Nanos
(local-time 10 15 59 123000000) ;; => #time/time "10:15:59.123"
(local-time 10 15 59 123456789) ;; => #time/time "10:15:59.123456789"

;; Parse time
(parse-local-time "10:15:59.123456789") ;; => #time/time "10:15:59.123456789"

;;;;;;;;;;;;;;;;;;; Date
;; Current date
(local-date);; => #time/date "2021-06-30"

;; Year / Month / Day
(local-date 2021 6 30) ;; => #time/date "2021-06-30"
(parse-local-date "2021-06-30");; => #time/date "2021-06-30"

(local-date-time);; => #time/date-time "2021-06-30T09:47:09.191"

;;;;;;;;;;;;;;;;;;; DateTime
;; Year / Month / Day / Hours / Minutes / Seconds / Nanos
(local-date-time 2021 6 30 10 15 59 123456789);; => #time/date-time "2021-06-30T10:15:59.123"
;; See note on millis vs nanos

;; Seconds / Nanos are optional
(local-date-time 2021 6 30 10 15) ;; => #time/date-time "2021-06-30T10:15"

;; Parse date+time
(parse-local-date-time "2021-06-30T10:15:59.123") ;; => #time/date-time "2021-06-30T10:15:59.123"

Keyword access

The killer feature of Deja-fu is that all three types implement several built-in ClojureScript protocols, making them behave much like regular maps or records.

(keys (local-time)) ;; => (:hours :minutes :seconds :nanos :millis)
(:seconds (local-time)) ;; => 16
(assoc (local-date-time) :hours 10 :minutes 20 :seconds 30) ;; => #time/date-time "2021-06-30T10:20:30.529"
(update (local-date-time) :year inc) ;; => #time/date-time "2022-06-30T10:17:24.561"

(let [{:keys [hours minutes]} (local-time)]
  (str "It is " minutes " past " hours))
;; => "It is 18 past 10"

You can destructure, update certain fields, etc.

Converting between types

We provide the following conversion methods

(defprotocol Conversions
  (epoch-ms [obj] "Milliseconds since January 1, 1970")
  (to-local-date [obj] "Date part of a date or date-time")
  (to-local-time [obj] "Time part of a date-time or local time")
  (add-interval [obj values] "Add an interval to the date/time")
  (with-date [obj date] "Set the date part of a DateTime")
  (with-time [obj time] "Set the time part of a DateTime"))

to-local-date / to-local-time simply truncate a date-time to either the date or the time part. When called on a #time/date or #time/time respectively they are idempotent.

(to-local-date (local-date-time)) ;; => #time/date "2021-06-30"
(to-local-time (local-date-time)) ;; => #time/time "10:33:49.267"
(to-local-date (local-date)) ;; => #time/date "2021-06-30"
(to-local-time (local-time)) ;; => #time/time "10:35:07.546"

with-date / with-time combines two date/time objects, retaining the date part of one, and the time part of the other.

(with-time (local-date) (local-time));; => #time/date-time "2021-06-30T10:33:27.691"

(with-time (local-date-time) (local-time));; => #time/date-time "2021-06-30T10:33:22.432"

add-interval takes a map with :years / :months / :days, etc.

(add-interval (local-date) {:years 5 :days 3}) ;; => #time/date "2026-07-03"
(add-interval (local-time) {:minutes 5}) ;; => #time/time "10:42:08.239" 

epoch-ms returns a UNIX timestamp with millisecond precision, i.e., the number of milliseconds since January 1, 1970.

Formatting

For formatting we rely on the Google Closure library, see the docstring of lambdaisland.deja-fu/format for valid patterns. Without a pattern, format will use standard ISO formatting.

(format (local-date-time)) ;; => "2021-06-30T10:39:18.423"
(format (local-date-time) "dd. MMMM yyyy") ;; => "30. June 2021"

Note that the Google Closure library contains many locale-specific patterns under goog.i18n.DateTimePatterns_*.

(ns my-ns
  (:require [lambdaisland.deja-fu :as fu]
            [goog.i18n.DateTimePatterns_de :as DateTimePatterns_de]))
            
(fu/format date DateTimePatterns_de/MONTH_DAY_YEAR_MEDIUM)

Assorted API

  • current-time-millis Get the current UNIX timestamp
  • today Get the current day
  • millis-between / minutes-between / days-between Get the interval between two times/dates/date-times in milliseconds, minutes, or days.
  • browser-time-zone-offset Get the offset between the browser's timezone and UTC

To get the current UTC time you can use

(add-interval (local-time) {:minutes (browser-timezone-offset)})

Caveats

Nanoseconds vs Milliseconds

The deja-fu API generally works with nanoseconds, for instance, the constructors above take nanoseconds. However, goog.date.DateTime, being based on js/Date, only has millisecond precision. This means nanosecond values are truncated to millisecond precision.

Our own lambdaisland.deja-fu/LocalTime type does not have this limitation; it has full nanosecond precision.

For keyword access we provide both :millis and :nanos, as a convenience.

We may try to address this in a future version, by tacking a separate "nanoseconds" field onto goog.date.DateTime. As long as you stick to deja-fu APIs this change should be transparent, and all code should work as before, except that values no longer get truncated. However, when using the goog.date.DateTime API directly, it will not be aware of the nanosecond field, and will continue to provide values truncated to the millisecond.

Piggieback printing

When printing deja-fu values via nREPL and piggieback, they may come out looking a little off.

;; Expected
#time/time "10:15:59.123"

;; Actual
#time/time10:15:59.123

This is an issue with how Piggieback handles tagged literals it doesn't know how to read in Clojure. If you have data-readers set up on the Clojure side for reading time/time, time/date, and time/date-time, then this will not be an issue. Note that you'll have to set these up yourself, since using time-literals is not an option in this case, see the next point.

Deja-fu with Transit

You may want to use Transit to send deja-fu times over the wire in your JSON payloads, such as the out-of-the-box behavior with ShadowCLJS. Here is an example of how this is done for goog.date.Dates, where the above table shows them to be the under-the-hood types for the deja-fu local-date type. For other types consult the table.

(ns deja-foo
  (:require [cognitect.transit :as transit]
            [lambdaisland.deja-fu :as fu]
            [goog.date.Date]))

(let [auth-write-handler {:handlers {goog.date.Date
                                     (transit/write-handler
                                      (constantly "LocalDate")
                                      fu/format)}}
      auth-read-handlers  {:handlers {"LocalDate"
                                      (transit/read-handler
                                       fu/parse-local-date)}}
      treader (transit/reader :json auth-read-handlers)
      twriter (transit/writer :json auth-write-handler) ]
  (->> (fu/local-date)
       (transit/write twriter)
       (transit/read treader)))
  ;; => #time/date "2021-10-11"

Incompatibility with other libraries

deja-fu provides its own data-readers for #time/time, #time/date and #time/date-time. This means it conflicts with time-literals, which provides these for both clj and cljs, but is based on js-joda. If you are using deja-fu, you should not be using js-joda (and vice versa).

Other libraries that are incompatible because they rely on js-joda or time-literals or both are cljs.java-time, cljc.java-time, and tick.

References

  • This spreadsheet contains an overview of time types provided by different libraries and platforms, and some notes on different Clojure and ClojureScript options.

Lambda Island Open Source

 

deja-fu is part of a growing collection of quality Clojure libraries created and maintained by the fine folks at Gaiwan.

Pay it forward by becoming a backer on our Open Collective, so that we may continue to enjoy a thriving Clojure ecosystem.

You can find an overview of our projects at lambdaisland/open-source.

 

 

Contributing

Everyone has a right to submit patches to deja-fu, and thus become a contributor.

Contributors MUST

  • adhere to the LambdaIsland Clojure Style Guide
  • write patches that solve a problem. Start by stating the problem, then supply a minimal solution. *
  • agree to license their contributions as MPL 2.0.
  • not break the contract with downstream consumers. **
  • not break the tests.

Contributors SHOULD

  • update the CHANGELOG and README.
  • add tests for new functionality.

If you submit a pull request that adheres to these rules, then it will almost certainly be merged immediately. However some things may require more consideration. If you add new dependencies, or significantly increase the API surface, then we need to decide if these changes are in line with the project's goals. In this case you can start by writing a pitch, and collecting feedback on it.

* This goes for features too, a feature needs to solve a problem. State the problem it solves, then supply a minimal solution.

** As long as this project has not seen a public release (i.e. is not on Clojars) we may still consider making breaking changes, if there is consensus that the changes are justified.

License

Copyright © 2021-2022 Arne Brasseur and Contributors

Licensed under the term of the Mozilla Public License 2.0, see LICENSE.