Edited at

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


この記事は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を利用して日付を作っていきます。


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

...


いつもの失敗です。

 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"


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

 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


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

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
]


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

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から 201911 00:00 火曜日

"Jan 1,2019,00:00:00"

Expect.equal

"2019年1月1日 00:00 火曜日"

気合を入れた結果がこちらになります。面白いことにElmのリファレンスには、日本の曜日を算出するロジックが書かれています。


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 ->
"日"


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

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 火曜日" ]

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

 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


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

コミット

テストが通りました。

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

スクリーンショット 2019-01-05 18.40.08.png

タイムゾーンは、Time.here関数を使うと取得できます。クライアントのタイムゾーンに合わせるためブラウザからタイムゾーンを取得します。副作用のためCmdを利用します。また、タイムゾーンの取得は初回のみで良いため、initでCmdを発生させます。here関数は、Taskで値を返してくるため、Task.perform関数でCmdに変換して上げる必要があります。コメント投稿時の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の恩恵をフルに活用してバグのないアプリケーションづくりを目指していきましょう!

スクリーンショット 2019-01-05 18.42.16.png


コード

現在のコード