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.
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.
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?
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.
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. ourelm-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:
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
.
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.