December 13, 2019

An Elm application I’m working on presents items with publication timestamps to users around the world. So far, I just printed a slightly mangled ISO-8601 UTC timestamp:

formatTimestamp : String -> String formatTimestamp ts = ts |> String.left (String.length "2019-03-17T05:15") |> String.replace "T" " at "

That worked well enough for a start, but I felt it was time to solve this more properly and give my users localized timestamps.

This article is an account of my quest for a better, timezone aware variant of formatTimestamp . We’ll

meet the ECMAScript Internationalization API

figure out how to write a simple native Elm module

build a binary package database using printf(1)

to trick the Elm compiler into cooperating.

What we’re doing here is most likely not the Elm team’s preferred approach. As such, best to keep this out of the official Elm community channels.

The Internationalization API

The first thing we’ll need to do is to move from stringy timestamps to something more logical. Elm 0.19 talks Unix timestamps, with

type Posix = Posix Int

the number of milliseconds since the epoch. Instead of parsing the timestamps client-side using something like rtfeldman/elm-iso8601-date-strings, I opted to do the conversion in the PostgREST backend:

, published_at + , EXTRACT(EPOCH FROM published_at)*1000 AS published_at_posix

So we’re now looking for a timezone and locale aware function formatTimestamp : Posix -> String . It turns out that browsers have a rather neat interface for this and related topics in the ECMAScript Internationalization API. Here’s a way to do what I want, given a Posix value:

function formatTimestamp(posix) { var dateTimeFormat = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }); return dateTimeFormat.format(posix); }

The undefined first argument to the constructor means to use the user locale. The unspecified timezone in the second options argument means to use the user’s local timezone (I think). Together, this formats Dec 11 15:00:00 UTC 2019 to a pleasing

11/12/2019, 16:00

in my en-GB locale in the Europe/Berlin timezone.

If my locale were, say, en-US or th-u-ca-buddhist , this would show as 12/11/2019, 4:00 PM or 11/12/2562 15:00 respectively. To figure out your local settings, enter the following in your browser console: new Intl.DateTimeFormat().resolvedOptions()

Doing the same in Elm 0.19 turns out to be tricky, however. The time library situation is a bit of a mess, with a very barebones core library elm/time that speaks Unix timestamps, and a variety of other date and time related packages, none of which appear to do the job.

And after all, we have a simple, mostly pure Javascript solution available, so why not just use that?

There’s an 0.18 package vanwagonet/elm-intl wrapping Intl , but attempts to get that functionality into 0.19 were stalled.

Calling native code

The documented approach to interacting with Javascript code from Elm is using ports, which would effectively turn a simple function call into an asynchronous RPC invocation with a ton of scaffolding. That didn’t appear to be an acceptable solution, so I thought I’d apply my recent insight into the Elm tooling to figure out how to use the Javascript API from within Elm. It seemed like a fun challenge, too!

A native module

By following along what vanwagonet/elm-intl and elm/time do for their Javascript interop, I came up with a native module Elm.Kernel.DateTime implemented in the file src/Elm/Kernel/DateTime.js

/* */ function _DateTime_localNumericDateTime() { return new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }); } var _DateTime_format = F2(function (dateTimeFormat, value) { return dateTimeFormat.format(value); });

together with an Elm wrapper module DateTime implemented in src/DateTime.elm :

module DateTime exposing ( DateTimeFormat, localNumericDateTime, format ) import Elm.Kernel.DateTime import Maybe exposing (Maybe) import Time exposing (Posix) type DateTimeFormat = DateTimeFormat localNumericDateTime : DateTimeFormat localNumericDateTime = Elm.Kernel.DateTime.localNumericDateTime () format : DateTimeFormat -> Posix -> String format dateTimeFormat posix = Elm.Kernel.DateTime.format dateTimeFormat (Time.posixToMillis posix)

Walking through the Javascript file, it starts with an empty header comment listing Elm imports (we don’t have any). Then, we declare the function

Elm.Kernel.DateTime.localNumericDateTime

as _DateTime_localNumericDateTime . This is called from Elm with a unit argument. Finally we declare the two-argument function _DateTime_format in curried form using the helper F2 .

Compiling the module

Setting things up naïvely with the following elm.json

{ "type": "package", "name": "robx/elm-datetime", "summary": "Format local dates and times via JavaScript", "license": "BSD-3-Clause", "version": "1.0.0", "exposed-modules": [ "DateTime" ], "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { "elm/core": "1.0.0 <= v < 2.0.0", "elm/time": "1.0.0 <= v < 2.0.0" }, "test-dependencies": {} }

we get an error in elm make :

$ elm make -- BAD MODULE NAME -------------------------------------------- src/DateTime.elm Your DateTime module is trying to import: Elm.Kernel.DateTime But names like that are reserved for internal use. Switch to a name outside of the Elm/Kernel/ namespace.

Bad robx, no cookie. Instead of moving the module out of the Elm namespace, let’s move our package in.

- "name": "robx/elm-datetime", + "name": "elm/my-elm-datetime",

Now elm make is happy:

$ elm make Success! Compiled 1 module.

Here, we should also call elm make --docs=docs.json to check that it’s happy with the state of documentation. Otherwise, compiling an app using this package will fail with an obscure error message. Elm tooling ensures that published package dependencies don’t fail to compile, but we’ll have to bypass some of that later.

A little demo app

To test our function and give us something concrete to try to make work, let’s build a small demo app that merely ticks a clock:

module Demo exposing (main) import Browser import DateTime import Html import Time main : Program () Model Msg main = Browser.document { init = init , view = view , update = update , subscriptions = subscriptions } type alias Model = { time : Maybe Time.Posix } type Msg = Tick Time.Posix init : () -> ( Model, Cmd Msg ) init flags = ( { time = Nothing } , Cmd.none ) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Tick posix -> ( { model | time = Just posix }, Cmd.none ) view : Model -> Browser.Document Msg view model = let format = DateTime.format DateTime.localNumericDateTime in { title = "puzzle" , body = case model.time of Just posix -> [ Html.div [] [ Html.text <| String.fromInt <| Time.posixToMillis <| posix ] , Html.div [] [ Html.text <| format posix ] ] _ -> [ Html.div [] [ Html.text "..." ] ] } subscriptions : Model -> Sub Msg subscriptions model = Time.every 1000 Tick

It tracks the current time in the model by subscribing to Time.every , and displays that next to the formatted version.

But how to build this? Again, we can assemble a naïve elm.json :

{ "type": "application", "source-directories": [ "src" ], "elm-version": "0.19.0", "dependencies": { "direct": { "elm/browser": "1.0.1", "elm/core": "1.0.2", "elm/html": "1.0.0", "elm/time": "1.0.0", "elm/my-elm-datetime": "1.1.0" }, "indirect": { "elm/json": "1.1.3", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.2" } }, "test-dependencies": { "direct": {}, "indirect": {} } }

Naturally, this can’t work because elm/my-elm-datetime doesn’t exist to the elm tool:

$ elm make src/Demo.elm -- CORRUPT CACHE --------------------------------------------------------------- I ran into an unknown package while exploring dependencies: elm/my-elm-datetime [...]

How to get the module into the app?

Of course, we can’t publish to the Elm package database under a name we don’t own, which is where we get to the fun part: The same approach I took when building Elm apps on Guix can help here. In fact, if we were just building on Guix, we could pull in my pretend-elm package quite easily without any extra work. But I’m not regularly developing using Guix, so I wanted to find a shell-based solution to do the necessary environment tweaks to get Elm to play along. What we’ll do is:

fetch dependency archives and unpack them in our own elm home directory. I went with ./elm-stuff/home , which means unpacking e.g. our elm-datetime package to ./elm-stuff/home/.elm/0.19.1/packages/elm/my-elm-datetime , and accordingly for all dependencies in elm.json. generate an elm registry, by using printf(1) to generate binary data call HOME=./elm-stuff/home HTTP_PROXY=. elm make to build.

I collected and wrapped up the various shell snippets involved in this as a bash script, available at robx/shelm. Let’s have a look at some of the core parts:

For regular Elm packages, step 1 may be achieved with jq , curl and tar :

jq -r '.dependencies.direct+.dependencies.indirect | to_entries[] | [.key, .value] | @tsv' | while read package version do { unpack=$(mktemp -d) (cd $unpack && curl -L https://github.com/"$package"/archive/"$version".tar.gz | tar -xz) dest="$ELM_HOME"/0.19.1/packages/"$package" mkdir -p "$dest" mv "$unpack"/* "$dest" rmdir "$dest" done

We collect all dependencies from elm.json , and then just need to do a little bit of careful work to move them to the right place.

It turns out that this code also works just fine for our unpublished package elm/my-elm-datetime . The only thing we need to do is redirect to the real GitHub project robx/elm-datetime . I chose to encode this information in an extra elm.json field:

"dependencies": { "direct": { ... "elm/my-elm-datetime": "1.0.0" }, "locations": { "elm/my-elm-datetime": { "method": "github", "name": "robx/elm-datetime" } } }

A more compact format like "elm/my-elm-datetime": "robx/elm-datetime" worked initially; the present form is a result of overengineering the packaging script. We might tweak our unpacking fragment above to support this as follows:

location=$(jq '.dependencies.locations."'"$package"'.name // "'"$package"'" < elm.json) (cd $unpack && curl -L https://github.com/"$location"/archive/"$version".tar.gz | tar -xz)

For step 2, we list the packages and versions that we’ve just “installed” into the package cache, and write them to Elm’s binary package registry format. We can use printf(1) for this. E.g., integers are encoded as 8 big-endian bytes:

# Haskell binary encoding of integers as 8 bytes big-endian encode_int64() { hex=$(printf "%016x" "$1") printf "\\x${hex:0:2}\\x${hex:2:2}\\x${hex:4:2}\\x${hex:6:2}" printf "\\x${hex:8:2}\\x${hex:10:2}\\x${hex:12:2}\\x${hex:14:2}" }

See the guix discussion for more details.

After fixing various bugs, this works!

$ (cd elm-stuff/home/.elm/0.19.1/packages && ls -d */*/*) elm/browser/1.0.1 elm/my-elm-datetime/1.0.0 elm/core/1.0.2 elm/time/1.0.0 elm/html/1.0.0 elm/url/1.0.0 elm/json/1.1.3 elm/virtual-dom/1.0.2 $ hexdump -C elm-stuff/home/.elm/0.19.1/packages/registry.dat 00000000 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 08 |................| 00000010 03 65 6c 6d 07 62 72 6f 77 73 65 72 01 00 01 00 |.elm.browser....| 00000020 00 00 00 00 00 00 00 03 65 6c 6d 04 63 6f 72 65 |........elm.core| 00000030 01 00 02 00 00 00 00 00 00 00 00 03 65 6c 6d 04 |............elm.| 00000040 68 74 6d 6c 01 00 00 00 00 00 00 00 00 00 00 03 |html............| 00000050 65 6c 6d 04 6a 73 6f 6e 01 01 03 00 00 00 00 00 |elm.json........| 00000060 00 00 00 03 65 6c 6d 0f 6d 79 2d 65 6c 6d 2d 64 |....elm.my-elm-d| 00000070 61 74 65 74 69 6d 65 01 01 00 00 00 00 00 00 00 |atetime.........| 00000080 00 00 03 65 6c 6d 04 74 69 6d 65 01 00 00 00 00 |...elm.time.....| 00000090 00 00 00 00 00 00 03 65 6c 6d 03 75 72 6c 01 00 |.......elm.url..| 000000a0 00 00 00 00 00 00 00 00 00 03 65 6c 6d 0b 76 69 |..........elm.vi| 000000b0 72 74 75 61 6c 2d 64 6f 6d 01 00 02 00 00 00 00 |rtual-dom.......| 000000c0 00 00 00 00 |....| 000000c4 $ HOME=$(pwd)/elm-stuff/home HTTP_PROXY=. elm make --output=demo.js src/Demo.elm Dependencies loaded from local cache. Dependencies ready! Success! Compiled 1 module.

Here’s our app in action:

elm-datetime demo, v1

Making things right

We might stop here, but there’s still an issue with our native DateTime module. While

format : DateTimeFormat -> Posix -> String

itself is a pure function,

localNumericDateTime : DateTimeFormat

is lying when it pretends to be: Calling Intl.DateTime(locale, options) without fully resolved options as we do depends on the environment, and might change between calls, e.g. if the timezone changes.

To model this correctly, we should change it to have a Task type:

localNumericDateTime : Task x DateTimeFormat

Modelling things on Time.here , we change the Javascript module as follows:

--- a/src/Elm/Kernel/DateTime.js +++ b/src/Elm/Kernel/DateTime.js @@ -1,14 +1,21 @@ /* +import Elm.Kernel.Scheduler exposing (binding, succeed) + */ function _DateTime_localNumericDateTime() { - return new Intl.DateTimeFormat(undefined, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric' + return __Scheduler_binding(function(callback) + { + callback(__Scheduler_succeed( + new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric' + }) + )); }); }

Elm.Kernel.Scheduler is the native module behind Task . The Elm-side diff is trivial:

--- a/src/DateTime.elm +++ b/src/DateTime.elm @@ -22,6 +22,7 @@ This module binds to import Elm.Kernel.DateTime import Maybe exposing (Maybe) +import Task exposing (Task) import Time exposing (Posix) @@ -33,7 +34,7 @@ type DateTimeFormat {-| Create a DateTimeFormat using user locale and timezone. -} -localNumericDateTime : DateTimeFormat +localNumericDateTime : Task x DateTimeFormat localNumericDateTime = Elm.Kernel.DateTime.localNumericDateTime ()

And let’s bump the package version:

--- a/elm.json +++ b/elm.json @@ -3,7 +3,7 @@ "name": "elm/my-elm-datetime", "summary": "Format local dates and times via JavaScript", "license": "BSD-3-Clause", - "version": "1.0.0", + "version": "1.1.0", "exposed-modules": [ "DateTime" ],

Getting this into our demo involves performing this task to obtain our formatter at application startup, and keeping it around in the model:

+++ b/demo/elm.json @@ -10,7 +10,7 @@ "elm/core": "1.0.2", "elm/html": "1.0.0", "elm/time": "1.0.0", - "elm/my-elm-datetime": "1.0.0" + "elm/my-elm-datetime": "1.1.0" }, "indirect": { "elm/json": "1.1.3", diff --git a/demo/src/Demo.elm b/demo/src/Demo.elm index fd85793..776b913 100644 --- a/demo/src/Demo.elm +++ b/demo/src/Demo.elm @@ -3,6 +3,7 @@ module Demo exposing (main) import Browser import DateTime import Html +import Task import Time @@ -18,17 +19,19 @@ main = type alias Model = { time : Maybe Time.Posix + , format : Maybe DateTime.DateTimeFormat } type Msg = Tick Time.Posix + | NewFormat DateTime.DateTimeFormat init : () -> ( Model, Cmd Msg ) init flags = - ( { time = Nothing } - , Cmd.none + ( { time = Nothing, format = Nothing } + , Task.perform NewFormat DateTime.localNumericDateTime ) @@ -38,17 +41,20 @@ update msg model = Tick posix -> ( { model | time = Just posix }, Cmd.none ) + NewFormat fmt -> + ( { model | format = Just fmt }, Cmd.none ) + view : Model -> Browser.Document Msg view model = - let - format = - DateTime.format DateTime.localNumericDateTime - in { title = "puzzle" , body = - case model.time of - Just posix -> + case ( model.time, model.format ) of + ( Just posix, Just fmt ) -> + let + format = + DateTime.format fmt + in [ Html.div [] [ Html.text <| String.fromInt <| Time.posixToMillis <| posix ] , Html.div [] [ Html.text <| format posix ] ]

Done:

$ make shelm fetch pruning stale dependency elm/my-elm-datetime-1.0.0 fetching https://github.com/robx/elm-datetime/archive/1.1.0.tar.gz generating /s/elm-datetime/demo/elm-stuff/home/.elm/0.19.1/packages/registry.dat shelm make --output=demo.js src/Demo.elm Dependencies loaded from local cache. Dependencies ready! Success! Compiled 1 module.

Still works! This time around, I used shelm to build the application via make .

elm-datetime demo, v2

You can find the full code for the package and demo app at github.com/robx/elm-datetime, at releases 1.0.0 and 1.1.0. The shelm package manager is available at github.com/robx/shelm.