Elm TDDしながらelm/timeで日付の変換をする

> この記事は[Elm+Firebaseでチャットアプリを作る](https://qiita.com/ababup1192/items/5f64a5f6f00664ee7981)のエントリーです。
> 前記事 : [Elm TDDしながらチャットコメントの投稿を実装する](https://qiita.com/ababup1192/items/8a8d76838883ed9b09f5)

# この記事で行うこと

この記事では、時間を扱うライブラリ[elm/time](https://package.elm-lang.org/packages/elm/time/latest/)を使って、コメントの投稿日付の実装を行います。結構筋力です。覚悟してください。事前に以下のページに目を通しておくと、良いかもしれません。

- [Elm Guide / コマンドとサブスクリプション](https://guide.elm-lang.jp/effects/)
- [Elm Guide / Time](https://guide.elm-lang.jp/effects/time.html)
- [Elmで副作用を扱う仕組みCmdがとっても良い理由](https://qiita.com/ababup1192/items/2b86ade96651716fc3a8)

## 実装内容

### elm/timeをインストールする

以下のようにして、新しくパッケージを追加します。

```shell 
$ 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を利用して日付を作っていきます。

```Main.elm
toDate : Time.Zone -> Time.Posix -> String
toDate zone time =
    ""
```

テストは以下のようになります。

```Tests.elm
...

, 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

...
```

いつもの失敗です。

```elm
↓ 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としてカスタムタイプで定義されています。欲しい形にするにはパターンマッチです。

```Main.elm
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"
```

冒頭の箇所が一致しました。この調子で、実装を続けていきます。

```elm
↓ 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)。

```Main.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
```

無事テストがグリーンになりました。

```elm
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
```

### 日本式のフォーマットへ

練習で作ったものではありますが、無慈悲にもクライアントに日本風の時間フォーマットに直してくれと言われてしまいました。がんばりましょう。

```Tests.elm
  , 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
            ]
```

そのままではいけませんね？

```elm
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のリファレンスには、[日本の曜日を算出するロジック](https://package.elm-lang.org/packages/elm/time/latest/Time#Weekday)が書かれています。

```Main.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 ->
            "日"
```

無事通りました。この場合もテストは回し続けて少しずつ実行すると良いでしょう。

```elm
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
```

### チャットコメントへ日付の反映

続いて、チャットのコメントに現在時刻を表示しましょう。既にテストは書いてあるので、少し変更を加えます。

```Tests.elm
 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 火曜日" ]
```

時間はまだ何もやっていなかったので失敗します。

```elm 
↓ 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を追加します。それによって変更しなければならない箇所がボロボロ出てきます。コンパイルエラーに従って直していけば、そこまで大変ではありません。

```Main.elm
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
```

テストコードも修正が大きいので、以下のコミットを参考にして修正してみましょう。(このようなときに限ってコミットの粒度が失敗しています。申し訳ありません・・・。)

[コミット](https://github.com/ababup1192/elm-firebase-chat/commit/75b3bfa945a86f54fa0c926eb087d051b7a79a8e)

テストが通りました。

```elm
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で実装をしているのでした。日本時間に合わせます。

<img width="983" alt="スクリーンショット 2019-01-05 18.40.08.png" src="https://qiita-image-store.s3.amazonaws.com/0/48687/72568eb1-ae73-c8f8-48dd-38a477d7cf38.png">

タイムゾーンは、[Time.here](https://package.elm-lang.org/packages/elm/time/latest/Time#here)関数を使うと取得できます。クライアントのタイムゾーンに合わせるためブラウザからタイムゾーンを取得します。副作用のためCmdを利用します。また、タイムゾーンの取得は初回のみで良いため、initでCmdを発生させます。here関数は、Taskで値を返してくるため、[Task.perform](https://package.elm-lang.org/packages/elm/core/latest/Task#perform)関数でCmdに変換して上げる必要があります。コメント投稿時の[Time.now](https://package.elm-lang.org/packages/elm/time/latest/Time#now)も同様ですね。

```Main.elm
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の恩恵をフルに活用してバグのないアプリケーションづくりを目指していきましょう！

<img width="999" alt="スクリーンショット 2019-01-05 18.42.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/48687/e2e48fc4-10d8-b559-44da-38e4ee4e93f3.png">


### コード

現在の[コード](https://github.com/ababup1192/elm-firebase-chat/tree/75b3bfa945a86f54fa0c926eb087d051b7a79a8e)