リリースノート

振り返りと声かけを足す — 週カレンダーと、やることごとの時刻リマインダー

1.1.3 で足したのは2つです。やることごとに時刻を決めて知らせる 時刻リマインダー と、1週間の達成を面で見せる がんばりカレンダー

どちらも、連続達成ストリークとリマインド通知 で作った仕組みに相乗りする形で実装しました。設計の中身は、その 相乗りをどこまで許して、どこから分けるか の線引きです。

時刻はどのモデルに持たせるか

当初の想定は、やることの雛形(テンプレート)に時刻を持たせる案でした。追加のたびに時刻を選ばずに済むので、入力は楽になります。

これは採れませんでした。雛形は、その後の機能追加で 家族をまたいで共有されるカタログ になっていて、個人ごとの時刻を持てる場所ではないためです。

そこで、時刻は個々のやること側に持たせました。根拠は既存の配置です。繰り返しの定義(曜日指定・毎月X日)はすでに個々のやること側へ移してあり、日次の繰り越しはやることを id ごとコピーします。時刻をここに置けば、繰り返しの扱いと繰り越しの扱いに自動的に乗ります。

一般化すると、共有される定義と個人の設定は同じモデルに同居できない。そして、すでに個人側へ移した属性があるなら、新しい個人設定はその隣に置くのが一貫します。

予約 ID は散らさず、レンジで管理する

端末のローカル通知は整数 ID で予約・取り消しします。やることは増減するので、一覧が変わるたびに予約を貼り直します。貼り直すには、前回ぶんを消す必要があります。

素直に見えるのは、やることの ID をハッシュして採番する案です。

// 却下した案: タスク ID から採番する
final id = base + task.id.hashCode % range;  // やることが増えると衝突する

これは採れません。衝突したとき、別のやることの予約を上書きします。通知は鳴らなかったことを利用者が確かめられないので、上書きされた1件は失われたまま残ります。

採用したのは、上限 × 日数の固定レンジをまるごと消してから、並び順で機械的に採番する 形です。

// 予約 ID = ベース + 並び順 * 日数 + 日オフセット
// 貼り直しのたびにレンジ全体を消すので、前回ぶんが残らない
for (var id = base; id < base + maxTasks * daysAhead; id++) {
  await sink.cancel(id);
}

予約対象に上限を設けているのは、上限がないと「消すべき ID の範囲」が決まらない からです。無制限に採番できる設計は、取り消しができない設計とほぼ同義です。

夜の「できたリマインド」とは、ID のレンジも通知チャンネルも分けました。レンジを分けるのは、片方の貼り直しがもう片方の予約を消さないため。チャンネルを分けるのは、端末側で音や重要度を別々に切れるようにするためです。こども端末では後者が効きます。

予約しない条件を純粋関数に出す

予約するかどうかの判定は、副作用を持たない関数に切り出しました。除外するのは、時刻が未設定・その日に表示されないやること(曜日や毎月X日の指定)・当日ぶんで達成済み・発火時刻がすでに過ぎている、の4つです。

翌日以降の日については、達成済みでも予約します。まだ確定していないからです。「いま達成済み」を未来の日に投影しない という区別が必要になります。

ここで1つ、購読するデータを足しました。当日カードを供給する状態は曜日でフィルタ済みで、翌日以降にだけ表示されるやることが落ちています。予約側は各日の表示条件を自分で見るので、フィルタ前を流す供給元を別に用意して購読しました。

一般化すると、今日の表示用に整形したデータを、未来を扱う処理に流用しない。フィルタは表示の都合で、予約の都合とは一致しません。

カレンダーは判定を共有し、表示都合の状態だけ足す

がんばりカレンダーは、1日ぶんの達成判定と「その日に有効なやること」の抽出を、ストリークの実装からそのまま使っています。

別実装にすると「連続しているのに×が付く」形の食い違いが起きます。同じ意味論を2箇所に書かない のは、実装量の話ではなく、利用者に見える矛盾を作らないための判断です。

一方で、カレンダーには元の判定結果に乗らない状態が2つありました。当日(まだ日が終わっていないので、未完了でも「途切れ」とは言わない)と、今週のこれから(実績が無いのではなく、まだ来ていない)です。これは表示側の enum に足しました。

enum DayStatus { achieved, broken, skip, inProgress, future }

ドメインの判定に表示都合の状態を混ぜず、表示側で足す。当日を中立に扱う意味論はストリークと共通なので、判定の共有はそのまま保てます。

判定は共有できても、取得範囲は用途ごと

データの取り方は分けました。ストリークは直近90日ぶんのウィンドウを持っていますが、これを使い回すと、そこから遡った週を見られません。カレンダーは週ごとの範囲クエリにしました。

範囲指定はドキュメント ID の比較で成立するので、追加のインデックスが要りません。日付そのものをドキュメント ID にしてある設計が効いています。

一般化すると、集計のために作ったウィンドウは、閲覧の要求を満たさない。共有できるのは判定ロジックで、取得範囲は用途ごとに決めます。

画面の面積は、機能ごとの予算ではない

当初の想定は、ホームに週のストリップを埋め込む案でした。開かずに見えるほうが振り返りは起きやすくなります。

ただ、直前のリリースでヘッダーを圧縮して、やることの表示領域を確保したばかりでした(並び順をどこに持つか、機能にどうたどり着かせるか で書いた作業です)。そこに1段足すと、直前の判断と相反します。

そこでホームには、既存の状態表示1行に導線を1つ足すだけにして、振り返りは専用画面に置きました。対象はおとなのみです。こどものホームは、今日のやることに集中させます。

直前のリリースで取った領域を、次の機能で取り返さない。画面の面積は機能ごとに割り当てられた予算ではなく、画面全体で1つの予算です。新機能を足すたびにホームが伸びるなら、どこかで配分の判断をしていません。

色は配色スキームのスロットから取りました。ブランド色を直接置くと、紙色の地とダークテーマの双方でコントラストを個別に確認することになります。

まとめ

  • 共有される定義と個人の設定は同じモデルに同居できない。既に個人側へ移した属性の隣に置く
  • 予約 ID は散らさずレンジで管理する。上限がないと「消すべき範囲」が決まらない
  • 相乗りする通知は、ID レンジとチャンネルを分ける。片方の貼り直しで他方が消えない
  • 今日の表示用に整形したデータを、未来を扱う処理に流用しない
  • 同じ意味論を2箇所に書かない。表示都合の状態は表示側で足す
  • 判定は共有できても、取得範囲は用途ごとに決める
  • 画面の面積は機能ごとの予算ではない。直前に確保した領域を次の機能で取り返さない