カレンダー開発の怖い話: 週番号 #TimeTreeアドカレ

これは株式会社TimeTree Advent Calendar 2023の11日目の記事です。

https://qiita.com/advent-calendar/2023/timetree

TimeTreeのiOSエンジニア、@gonseeです。

TimeTreeはカレンダーシェアアプリなので、日々カレンダーにまつわる開発を行っています。カレンダーは我々の生活にあまりにも溶け込んだ存在なので、それをアプリで実装することはそれほど難しくないんじゃないか、と思うかもしれません。しかし実際に開発してみるとすごく複雑で、知っておかないと思わぬバグを仕込んでしまうことになります。そんな、エンジニア視点で「怖い」話をお届けしていきます。

前回は[「タイムゾーン」と「サマータイム」](https://qiita.com/gonsee/items/f26798f841efc338d899)をテーマにお届けしました。今回のテーマは「**週番号**」です。

同じ内容をTimeTreeラヂオでも話しているのでよかったら聞いてみてください。こちらの記事ではコードを交えてより技術的な話をしたいと思います。コードはSwiftによるiOSアプリを前提としています。

https://open.spotify.com/episode/590X9sLWs23NumTGMzfEcm

## 週番号とは

週番号（Week Number）は1年の最初の週からの通し番号です。日本では日常的に使われておらず、カレンダーにも記載されていないのが普通なのであまり馴染みがないですよね。

TimeTreeでもリリース当初は週番号に対応していませんでしたが、ヨーロッパ圏でユーザーが増えていくにつれて多くの要望が来るようになりました。ヨーロッパ圏では日常的に使われていて、なにか日付のやり取りをするときに何週の何曜日という指定の仕方をしたりするそうです。ご要望を受けてTimeTreeでも週番号を表示できるように対応しました。アプリ設定から週番号を表示にするとマンスリーカレンダーの左側に週番号が表示されるようになります。

<table>
<tr><td>アプリ設定画面</td><td>マンスリーカレンダー</td></tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32084/8fa51241-276c-7c10-d780-c4725ab93393.jpeg">
</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32084/39d15a4b-18ff-c163-5480-e607c5df9626.jpeg"></td>
</tr>
</table>

こちらの設定はヨーロッパ圏の国ではデフォルトでオン、それ以外の国ではデフォルトでオフにしています。

## 何が怖いの？

馴染みはないものの週に通し番号を振るだけのことで何が怖いのか、と思いますよね。実は第1週をどこから始めるかの決め方が地域によって違っています。ここではアメリカ式とヨーロッパ式について見ていきます。（イスラム式もありますがTimeTreeが正式に対応していないため割愛します）

- アメリカ式
    - 週の始まりは日曜日
    - 1/1を含む週を第1週とする
- ヨーロッパ式
    - 週の始まりは月曜日
    - その年の最初の木曜日を含む週を第1週とする

アメリカ式はわかりやすいですが、1/1が土曜日だったら第1週が1日しかないみたいなことが起こります。ヨーロッパ式はちょっとわかりにくいですが、月曜始まりだと木曜日が週の真ん中になるので、第1週は過半数の日が含まれることになり合理的な気がします。

なるほどどちらもわかるのですが、問題は、年によってはこの方式の違いによって週番号がずれてしまうということです。

例えば2022年、アメリカ式では1/1が土曜日なので12/26(日)〜1/1(土)が第1週になります。ヨーロッパ式では最初の木曜日が1/6なので、1/3(月)〜1/9(日)が第1週になります。ほぼ1週分ずれてしまうことになります。

ちなみに日本はアメリカ式を採用しています。iOSのデフォルトのカレンダーでも設定で週番号を表示できますが、地域設定日本で見てみるとアメリカ式になっているのが確認できると思います。

## どうやって扱えばいの？

このような状況なので、週番号を自前で計算するようなことはやらないほうがいいでしょう。iOSではFoundationフレームワークを使うことでロケールに応じた適切な週番号を求めることができるようになっています。

以下のコードをPlaygroundなどで動かしてみると実際にロケールによって週番号が変わることが確認できます。

```swift
// 2022/01/04(火)
let date = ISO8601DateFormatter().date(from: "2022-01-04T00:00:00Z")!

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .init(identifier: "UTC")!

// DateComponentsを使う方法
calendar.locale = .init(identifier: "en_US") // アメリカ
print(calendar.component(.weekOfYear, from: date)) // 2

calendar.locale = .init(identifier: "en_GB") // イギリス
print(calendar.component(.weekOfYear, from: date)) // 1

// DateFormatterを使う方法
var dateFormatter = DateFormatter()
dateFormatter.calendar = calendar
dateFormatter.timeZone = .init(identifier: "UTC")
dateFormatter.dateFormat = "w"

dateFormatter.locale = .init(identifier: "en_US") // アメリカ
print(dateFormatter.string(from: date)) // 2

dateFormatter.locale = .init(identifier: "en_GB") // イギリス
print(dateFormatter.string(from: date)) // 1
```

## 日付フォーマットの罠

もうひとつ週番号に関連してはまりがちな罠をご紹介します。`DateFormatter`を使って`Date`型から日付の文字列を得たいとき、「年」については "y" を指定すると思いますが、これを大文字の "Y" にしてしまうと思わぬ不具合につながります。

大文字の "Y" は週番号基準での年を表します。例えば2023年12月31日はアメリカ式では2024年の第1週になるので、「2024」と出力されます。

```swift
let date = ISO8601DateFormatter().date(from: "2023-12-31T00:00:00Z")!

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .init(identifier: "UTC")!

var dateFormatter = DateFormatter()
dateFormatter.calendar = calendar
dateFormatter.timeZone = .init(identifier: "UTC")
dateFormatter.dateFormat = "y 'vs' Y"

dateFormatter.locale = .init(identifier: "en_US") // アメリカ
print(dateFormatter.string(from: date)) // "2023 vs 2024"
```

小文字と大文字の違いなので気づきにくく、年末年始以外は期待通り動くように見えるので非常に厄介です。年末に爆発する時限爆弾、想像しただけでも恐ろしいですね…。

## 終わりに

今回は週番号にまつわるハマりがちなポイントをご紹介しました。日本ではあまり馴染みのないものですが、知っておくことで今後の開発に役立つこともあるのではないかと思います。

こちらを読んでTimeTreeに少しでも興味を持っていただけたなら、ぜひ以下のページもチェックしていただければと思います。カジュアル面談などお気軽にお申し込みください！

https://timetreeapp.com/intl/ja/corporate/careers