この記事はElm+Firebaseでチャットアプリを作るのエントリーです。
前記事 : Elm TDDしながらチャットコメントの投稿を実装する
この記事で行うこと
この記事では、時間を扱うライブラリelm/timeを使って、コメントの投稿日付の実装を行います。結構筋力です。覚悟してください。事前に以下のページに目を通しておくと、良いかもしれません。
実装内容
elm/timeをインストールする
以下のようにして、新しくパッケージを追加します。
$ npx elm install elm/time
I found it in your elm.json file, but in the "indirect" dependencies.
Should I move it into "direct" dependencies for more general use? [Y/n]: y
Dependencies loaded from local cache.
Dependencies ready!
Posixから日付フォーマットの実装をする
Elmにおいて時間はTimeモジュールの関数を用いて扱われますが、日付のフォーマットをしてくれるライブラリは標準には備わっていません。そのためサードパーティのライブラリを探すか、自身で実装していくしかありません。ここは明確にElmが好きであるかどうかが分かれる部分かもしれません。ライブラリに楽に任せて実装をしていきたいか。それともライブラリの依存を避けてアプリケーションの管理を楽にするかです。一長一短だと思いますし日付のフォーマットに関しては、サードパーティを利用するかライブラリを自分で作ってしまう方が良い気がします。
せっかくなので、今回はTDDをしながら自身で実装をしていきましょう。タイムゾーンとPosixを利用して日付を作っていきます。
toDate : Time.Zone -> Time.Posix -> String
toDate zone time =
    ""
テストは以下のようになります。
...
, describe "toDate" <|
            [ test "Posixから Jan 1,2019,00:00:00" <|
                \_ ->
                    let
                        actual =
                            toDate utc (Time.millisToPosix 1546300800000)
                        expect =
                            "Jan 1,2019,00:00:00"
                    in
                    Expect.equal expect actual
...
いつもの失敗です。
↓ Tests
↓ The Main module
↓ toDate
✗ Posixから Jan 1,2018,00:00:00
    ""
    ╷
    │ Expect.equal
    ╵
    "Jan 1,2018,00:00:00"
TEST RUN FAILED
Duration: 194 ms
Passed:   11
Failed:   1
今回の実装は文字列を徐々に作っていくものなので一気に結果を作るよりも、頻繁にテストを走らせて、結果を少しずつ合わせていくほうが良いでしょう。さっそく筋力です。月はTime.Monthとしてカスタムタイプで定義されています。欲しい形にするにはパターンマッチです。
toDate : Zone -> Posix -> String
toDate zone time =
    let
        month =
            Time.toMonth zone time |> omissionMonth
    in
    month
omissionMonth : Time.Month -> String
omissionMonth month =
    case month of
        Jan ->
            "Jan"
        Feb ->
            "Feb"
        Mar ->
            "Mar"
        Apr ->
            "Apr"
        May ->
            "May"
        Jun ->
            "Jun"
        Jul ->
            "Jul"
        Aug ->
            "Aug"
        Sep ->
            "Sep"
        Oct ->
            "Oct"
        Nov ->
            "Nov"
        Dec ->
            "Dec"
冒頭の箇所が一致しました。この調子で、実装を続けていきます。
↓ Tests
↓ The Main module
↓ toDate
✗ Posixから Jan 1,2018,00:00:00
    "Jan"
    ╷
    │ Expect.equal
    ╵
    "Jan 1,2018,00:00:00"
TEST RUN FAILED
Duration: 169 ms
Passed:   11
Failed:   1
流れは同じなので、最終実装結果がこちらになります。時間に関する部分は'0'埋め2桁の処理を施す必要があります。行数はありますが、とても素直な実装です(これぞElm)。
toDate : Zone -> Posix -> String
toDate zone time =
    let
        padZero2 =
            String.padLeft 2 '0'
        month =
            Time.toMonth zone time |> omissionMonth
        day =
            Time.toDay zone time |> String.fromInt
        year =
            Time.toYear zone time |> String.fromInt
        hour =
            Time.toHour zone time |> String.fromInt |> padZero2
        minutes =
            Time.toMinute zone time |> String.fromInt |> padZero2
        seconds =
            Time.toSecond zone time |> String.fromInt |> padZero2
    in
    month ++ " " ++ day ++ "," ++ year ++ "," ++ hour ++ ":" ++ minutes ++ ":" ++ seconds
無事テストがグリーンになりました。
Running 12 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 68792130650241 /Users/abab/Desktop/elm-firebase-chat/tests/Tests.elm
TEST RUN PASSED
Duration: 172 ms
Passed:   12
Failed:   0
日本式のフォーマットへ
練習で作ったものではありますが、無慈悲にもクライアントに日本風の時間フォーマットに直してくれと言われてしまいました。がんばりましょう。
  , describe "toDate" <|
            [ test "Posixから 2019年1月1日 00:00 火曜日" <|
                \_ ->
                    let
                        actual =
                            toDate utc (Time.millisToPosix 1546300800000)
                        expect =
                            "2019年1月1日 00:00 火曜日"
                    in
                    Expect.equal expect actual
            ]
そのままではいけませんね?
Running 12 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 121127996793637 /Users/abab/Desktop/elm-firebase-chat/tests/Tests.elm
↓ Tests
↓ The Main module
↓ toDate
✗ Posixから 2019年1月1日 00:00 火曜日
    "Jan 1,2019,00:00:00"
    ╷
    │ Expect.equal
    ╵
    "2019年1月1日 00:00 火曜日"
気合を入れた結果がこちらになります。面白いことにElmのリファレンスには、日本の曜日を算出するロジックが書かれています。
toDate : Zone -> Posix -> String
toDate zone time =
    let
        padZero2 =
            String.padLeft 2 '0'
        month =
            Time.toMonth zone time |> toMonthNumber
        day =
            Time.toDay zone time |> String.fromInt
        year =
            Time.toYear zone time |> String.fromInt
        hour =
            Time.toHour zone time |> String.fromInt |> padZero2
        minutes =
            Time.toMinute zone time |> String.fromInt |> padZero2
        week =
            Time.toWeekday zone time |> toJapaneseWeekday
    in
    year ++ "年" ++ month ++ "月" ++ day ++ "日 " ++ hour ++ ":" ++ minutes ++ " " ++ week ++ "曜日"
toMonthNumber : Time.Month -> String
toMonthNumber month =
    case month of
        Jan ->
            "1"
        Feb ->
            "2"
        Mar ->
            "3"
        Apr ->
            "4"
        May ->
            "5"
        Jun ->
            "6"
        Jul ->
            "7"
        Aug ->
            "8"
        Sep ->
            "9"
        Oct ->
            "10"
        Nov ->
            "11"
        Dec ->
            "12"
toJapaneseWeekday : Weekday -> String
toJapaneseWeekday weekday =
    case weekday of
        Mon ->
            "月"
        Tue ->
            "火"
        Wed ->
            "水"
        Thu ->
            "木"
        Fri ->
            "金"
        Sat ->
            "土"
        Sun ->
            "日"
無事通りました。この場合もテストは回し続けて少しずつ実行すると良いでしょう。
Running 12 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 317161368220161 /Users/abab/Desktop/elm-firebase-chat/tests/Tests.elm
TEST RUN PASSED
Duration: 187 ms
Passed:   12
Failed:   0
チャットコメントへ日付の反映
続いて、チャットのコメントに現在時刻を表示しましょう。既にテストは書いてあるので、少し変更を加えます。
 test "コメントしたのは、「Tanaka Jiro」で、現在時刻が表示されるはずだ。" <|
                \_ ->
                    meComment
                        |> Query.fromHtml
                        |> Query.find [ Selector.class "media-body" ]
                        |> Query.find [ Selector.tag "h4" ]
                        |> Query.has [ Selector.text "Tanaka Jiro Date: 2019年1月1日 00:00 火曜日" ]
時間はまだ何もやっていなかったので失敗します。
↓ Tests
↓ The Main module
↓ mediaView
✗ コメントしたのは、「Tanaka Jiro」で、現在時刻が表示されるはずだ。
    ▼ Query.fromHtml
        <div class="media">
            <div class="media-body media-part">
                <h4 class="media-heading">
                    Tanaka Jiro Date:2018/12/29
                </h4>
                <div>
                    田中のコメントです。
                </div>
            </div>
            <div class="media-right media-part">
                <a class="icon-rounded" href="#">
                    T
                </a>
            </div>
        </div>
    ▼ Query.find [ class "media-body" ]
        1)  <div class="media-body media-part">
                <h4 class="media-heading">
                    Tanaka Jiro Date:2018/12/29
                </h4>
                <div>
                    田中のコメントです。
                </div>
            </div>
    ▼ Query.find [ tag "h4" ]
        1)  <h4 class="media-heading">
                Tanaka Jiro Date:2018/12/29
            </h4>
    ▼ Query.has [ text "Tanaka Jiro Date: 2019年1月1日 00:00 火曜日" ]
    ✗ has text "Tanaka Jiro Date: 2019年1月1日 00:00 火曜日"
TEST RUN FAILED
Duration: 211 ms
Passed:   11
Failed:   1
コメントにPosixを追加し、ModelにZoneを追加します。それによって変更しなければならない箇所がボロボロ出てきます。コンパイルエラーに従って直していけば、そこまで大変ではありません。
type alias Comment =
    { user : User, content : String, postTime : Posix }
type alias Model =
    { me : User
    , content : String
    , comments : List Comment
    , zone : Zone
    }
init : () -> ( Model, Cmd Msg )
init _ =
    ( { me = tanaka
      , content = ""
      , comments =
            [ Comment suzuki "新年明けましておめでとうございます。" (Time.millisToPosix 1546300800000)
            ]
      , zone =
            Time.utc
      }
    , Cmd.none
    )
type Msg
    | UpdateContent String
    | SendContent
    | AddComment Posix
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ me, content, comments } as model) =
    case msg of
        UpdateContent c ->
            ( { model | content = c }, Cmd.none )
        SendContent ->
            ( model, Cmd.batch [ Task.perform AddComment Time.now ] )
        AddComment postTime ->
            ( updateSendContent postTime model, Cmd.none )
updateSendContent : Posix -> Model -> Model
updateSendContent postTime ({ me, content, comments } as model) =
    if String.isEmpty (String.trim content) then
        model
    else
        { model
            | comments = Comment me content postTime :: comments
            , content = ""
        }
mediaView : User -> Zone -> Comment -> Html Msg
mediaView me zone { user, content, postTime } =
    let
        mediaBody =
            div [ class "media-body media-part" ]
                [ h4 [ class "media-heading" ] [ text <| user.name ++ " Date: " ++ toDate zone postTime ]
                , div [] [ text content ]
                ]
        mediaChildren =
            if user == me then
                [ mediaBody
                , div [ class "media-right media-part" ]
                    [ a [ href "#", class "icon-rounded" ] [ text <| nameInitial user ]
                    ]
                ]
            else
                [ div [ class "media-left media-part" ]
                    [ a [ href "#", class "icon-rounded" ] [ text <| nameInitial user ]
                    ]
                , mediaBody
                ]
    in
    div [ class "media" ] mediaChildren
テストコードも修正が大きいので、以下のコミットを参考にして修正してみましょう。(このようなときに限ってコミットの粒度が失敗しています。申し訳ありません・・・。)
テストが通りました。
Running 12 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 260045809256763 /Users/abab/Desktop/elm-firebase-chat/tests/Tests.elm
TEST RUN PASSED
Duration: 195 ms
Passed:   12
Failed:   0
実装が完了したので、立ち上げてコメントをしてみましょう!あれ? 時間が9時間ほどズレていますね。ああ、UTCで実装をしているのでした。日本時間に合わせます。
 
タイムゾーンは、Time.here関数を使うと取得できます。クライアントのタイムゾーンに合わせるためブラウザからタイムゾーンを取得します。副作用のためCmdを利用します。また、タイムゾーンの取得は初回のみで良いため、initでCmdを発生させます。here関数は、Taskで値を返してくるため、Task.perform関数でCmdに変換して上げる必要があります。コメント投稿時のTime.nowも同様ですね。
type Msg
    = AdjustTimeZone Zone
    | UpdateContent String
    | SendContent
    | AddComment Posix
init : () -> ( Model, Cmd Msg )
init _ =
    ( { me = tanaka
      , content = ""
      , comments =
            [ Comment suzuki "新年明けましておめでとうございます。" (Time.millisToPosix 1546300800000)
            ]
      , zone =
            Time.utc
      }
    , Task.perform AdjustTimeZone Time.here
    )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ me, content, comments } as model) =
    case msg of
        AdjustTimeZone zone ->
            ( { model | zone = zone }, Cmd.none )
        UpdateContent c ->
            ( { model | content = c }, Cmd.none )
        SendContent ->
            ( model, Task.perform AddComment Time.now )
        AddComment postTime ->
            ( updateSendContent postTime model, Cmd.none )
無事、日本時間で表示がされました。今回時間を扱うときにZoneやCmd、また日付の変換に実装が必要となりました。面倒と感じたでしょうか? これは2つのポイント、副作用の分離と人間時間と実装の分離を徹底した結果となります。冒頭に貼った記事にもある通り、副作用が分離されていることで、時刻に関するテストを書くのは簡単で、実行時には正しい結果が表示されることに確信が持てました。また、タイムゾーンを強制することによって、実装で扱う時間の扱いやすさと表示するときの扱いやすさを両立しています。手軽に時間を扱えることはとても良いことですが、それ故にバグを生み出しやすいのです(実際に私は時間関係のプログラミングで良い思い出がありませんでした・・・)。面倒な分、正確な実装をできるElmの恩恵をフルに活用してバグのないアプリケーションづくりを目指していきましょう!
 
コード
現在のコード
