開発Tips

「続いている」を数える — 連続達成ストリークとリマインド通知の設計

こども向けの「やること」アプリ「ぽんっ!」(旧称:でいりんぐ)に、連続達成ストリークと、その日のやることが残っているときに知らせるできたリマインド通知を実装しました。

習慣化を支えるアプリで「何日続いているか」を見せるのは定番ですが、実装してみると、決めるべきことのほとんどは表示部分ではなく、連続の定義と、いつ鳴らすかの状態管理にありました。その設計を残しておきます。

何を作ったか

  • こども画面: 連続日数のバッジ。その日のやることを全部終えた瞬間に、控えめな祝祭の演出が入る
  • おとな画面: タブのヘッダーに読み取り専用の表示(連続日数と最長記録)
  • できたリマインド: 設定した時刻に、その日のやることがまだ残っていれば通知する(既定は 20:00・こども端末のみ)
  • 最長記録は端末をまたいで残る

まず「連続」の定義を決める

設計で時間をかけたのはここです。「毎日達成した日数」と言葉にするのは簡単ですが、実際のカレンダーには次のような日が混ざります。

  • やることが 1 つも設定されていない日
  • 曜日設定や「毎月X日」の指定によって、その日には何も出ない日
  • まだ終わっていないが、日も終わっていない当日

これらを「未達成」に倒すと、土日にやることを出さない設定にしている家庭では、月曜に連続が切れてしまうという理不尽な挙動になります。かといって全部「達成」にすると、何もしていない日が連続を伸ばしてしまい、数字の意味が失われます。

そこで 1 日の判定を 3 種類に分けました。

判定 条件 連続への影響
達成 その日に出るやることが 1 件以上あり、すべて完了 伸ばす
途切れ その日に出るやることが 1 件以上あり、未完了が残る リセット
スキップ その日に出るやることが 0 件(未設定・曜日外・日付指定外) 数えないが、切らない

スキップを「橋渡し」として扱うのが要点です。数えないので数字は水増しされませんが、連続も切れません。「やることが無い日」はユーザーの設定どおりの正常な状態であって、失敗ではないからです。

当日だけは例外で、未完了が残っていても途切れ扱いにしません。日が終わるまでは進行中であり、朝の時点で「連続が途切れました」と表示するのは事実に反します。当日は中立として扱い、その日の終わりを待ちます。

判定の「その日に出るやること」は、やることが持つ繰り返し設定(曜日・毎月X日・末日)から日付ごとに求めています。ここは表示側のフィルタと同じ判定関数を使い回しました。表示と集計で「その日に出るはずだったもの」の解釈がずれると、画面には何も出ていないのに連続が切れる、といった説明のつかない挙動になります。

// 1日ぶんの判定。カードの日付に対して繰り返し設定を適用し、
// 「その日に出るはずだったやること」だけを見る
DayStatus statusOf(DailyCard card) {
  final date = parseDate(card.date);
  final active = card.tasks.where((t) => t.showsOn(
        weekday: date.weekday,
        dayOfMonth: date.day,
        isLastDayOfMonth: isLastDay(date),
      ));

  if (active.isEmpty) return DayStatus.skip;      // 橋渡し
  if (active.every((t) => t.isDone)) return DayStatus.achieved;
  return DayStatus.broken;
}

計算そのものは、日付のリストと「今日」を受け取って結果を返す純粋関数に切り出しました。ネットワークもストレージも触らないので、境界条件(日付の切り替わり、橋渡しの連鎖、記録なしの状態)をユニットテストで直接押さえられます。日付はすべて日本時間で解決し、サーバ側の日次判定と同じ意味論に揃えています。

取得コストを、表示に必要なぶんだけに抑える

連続日数を出すには過去の記録が要りますが、全期間を取得する設計にはしませんでした。使い続けるほど取得量が増える形は、起動のたびに重くなっていきます。

代わりに段階的な取得にしています。

  1. まず直近 90 日を 1 回のクエリで取得する。大半のケースはこれで起点まで届く
  2. 連続がウィンドウの最も古い日まで途切れずに続いていたら、まだ起点が確定していないので、範囲を広げてもう一度だけ計算する
  3. 上限に達したら数え切らず「365+」として打ち切る

3 番目は、ユーザー体験として 400 日と 365 日を区別する必要が無いという判断です。1 年続いている人にとって意味があるのは「1 年以上続いている」であって、正確な日数ではありません。ここで正確さを追うと、取得量が青天井になるだけでした。

「起点が確定していない」ことを計算結果に持たせておき、呼び出し側が範囲を広げるか打ち切るかを決める形にしています。計算側は範囲の判断を知らずに済みます。

当日はライブ、過去は取得済み

ストリークは、その日のやることをチェックした瞬間に更新されてほしい値です。一方、過去の記録は取り直す必要がありません。

そこで、当日のぶんは購読中のデータを使い、過去のぶんだけ取得する構成にしました。チェックを付けると当日のデータが流れてきて再計算が走り、表示が即座に変わります。日付が変わったときも、日付を保持している側を購読しているので自動で計算し直されます。

最長記録は、ウィンドウの外でも残す

ここまでの計算だと、最長記録は「取得した範囲内で観測できた最長」でしかありません。1 年前に 100 日続けた記録は、90 日ウィンドウからは見えなくなります。

そこで最長記録だけは、ユーザーごとのメタ領域に単調増加で保存しています。計算値のほうが大きければ書き戻し、保存値のほうが大きければそちらを採用する、という単純な合流です。

このとき、保存や取得に失敗しても表示は壊さない方針にしました。記録の保存は体験の中核ではなく、失敗しても計算値で表示は成立します。ここで例外を投げてストリーク表示ごと消えるほうが、ユーザーにとっては損失が大きいと判断しました。

// 最長記録の合流。失敗しても計算値にフォールバックする
var best = computed.best;
try {
  final persisted = await metaRepository.getBest(uid);
  if (persisted > best) {
    best = persisted;                      // 保存されている記録のほうが長い
  } else if (computed.best > persisted) {
    await metaRepository.updateBest(uid, computed.best);  // 更新された
  }
} catch (_) {
  // 保存層が使えなくても、表示は計算値で成立させる
}

リマインド通知は「繰り返し」ではなく「貼り直し」で作る

できたリマインドは、毎日同じ時刻に鳴らせばよさそうに見えますが、実際に鳴らしたいのはその日のやることがまだ残っているときだけです。夕方までに全部終えた日に「まだ残っています」と鳴るのは、体験としてかなり悪い部類に入ります。

つまり通知は状態に依存するので、OS の繰り返し予約とは相性がよくありません。そこで、単発の予約を「今日+翌 2 日ぶん」貼り直す方式にしました。

  • 予約 ID は日付のオフセットごとに固定にしておき、貼り直しのたびに上書きする(多重予約が積み上がらない)
  • 貼り直しはまず全スロットのキャンセルから始める。設定を切ったときや達成したときに、確実に消える
  • 当日は達成済みならスロットを空けたままにする。翌日以降は未確定なので予約しておく
  • すでに過ぎた時刻はスキップする

翌 2 日ぶんまで先に予約しておくのは、アプリを開かない日を跨いでも鳴るようにするためです。貼り直しのきっかけはアプリ内のイベント(やることの達成、通知設定の変更)なので、予約が今日ぶんだけだと、開かなかった翌日は無音になります。

Future<void> reschedule({
  required bool enabled,
  required int minuteOfDay,
  required bool achievedToday,
}) async {
  // 設定変更・達成で確実に消えるよう、まず全スロットを消す
  for (var offset = 0; offset < daysAhead; offset++) {
    await sink.cancel(baseId + offset);
  }
  if (!enabled) return;

  for (var offset = 0; offset < daysAhead; offset++) {
    if (offset == 0 && achievedToday) continue;   // 今日はもう終わっている
    final when = at(dayOffset: offset, minuteOfDay: minuteOfDay);
    if (!when.isAfter(now)) continue;             // 過ぎた時刻は予約しない
    await sink.schedule(id: baseId + offset, when: when, /* ... */);
  }
}

予約先はインターフェースに切っておき、テストでは記録用の実装に差し替えています。「達成済みなら今日は予約しない」「設定オフなら全部消える」「過去時刻は飛ばす」といった判断は、OS を触らずに検証できる場所に置きたい部分です。

通知が実際に届くための3つの手当て

予約を組み立てるだけでは、端末や OS の事情で届かないことがあります。ここは 3 点に絞って対応しました。

1. Android の再起動。OS が再起動すると、アプリが入れた予約は失われます。利用しているローカル通知ライブラリが起動時の再スケジュールに対応しているので、マニフェストに受信側の宣言と RECEIVE_BOOT_COMPLETED を追加しました。iOS は OS 側が予約を保持するので、この手当ては不要です。

2. 正確なアラームを要求しない。Android には分単位で正確に発火させる仕組みがありますが、専用の権限が必要になります。できたリマインドは「夕方ごろに気づかせる」通知であって、20:00 ちょうどである必然性はありません。そこで権限が不要な緩い予約を選びました。精度と引き換えに、ユーザーに追加の許可を求めずに済みます。用途に必要な精度を見極めて、要求する権限を減らすほうが得だと判断しています。

3. 通知許可を聞くタイミング。こども端末には、それまで通知許可を求める導線がありませんでした。かといって起動直後に聞くと、機能を使うかどうかも分からない段階でのプロンプトになります。そこで、実際に予約を入れる時に一度だけ要求する形にしました。リマインドをオフのまま使う人には、最後まで聞かれません。

誰の端末で鳴らすかを決める

このリマインドはこども端末で、こども自身のストリークに対してだけ動かしています。おとな側には、サーバ側から届く見守り通知が別にあり、そこにローカルのリマインドを重ねると同じ趣旨の通知が二重になります。

「機能としては両方で動かせる」場合でも、通知は増やすほど価値が下がる領域です。既存の通知経路と役割が重なっていないかを先に確認して、重なるほうは足さない判断をしました。

設定を足すときの後方互換

リマインドの有効・無効と時刻は、既存の通知設定に項目を追加する形で持たせています。当然ながら、すでに保存されている設定にはその項目がありません。

読み出し時に既定値(有効・20:00)で補うようにして、移行処理は入れていません。既定値が「その機能を使い始める人にとって妥当な値」であれば、移行は不要になります。逆に、既定値が妥当でないなら、そもそも既定値の選び方を見直したほうがよい、という考え方をしています。

まとめ

  • ストリークの設計で難しいのは表示ではなく連続の定義。「やることが無い日」を橋渡しにして、当日は中立にする
  • 集計と表示で同じ判定関数を使う。ここがずれると説明のつかない挙動になる
  • 過去データの取得は段階的に。上限を決めて打ち切る割り切りが効く。表示に必要な精度を先に決める
  • 状態に依存する通知は繰り返し予約ではなく、固定スロットへの貼り直しで組む
  • 権限は用途に必要な精度から逆算して減らす。許可を聞くのはその機能を実際に使う時でよい
  • 補助的な永続化は best-effort に倒し、失敗しても中核の表示は成立させる

「続いている」という感覚を数字にするのは、集計としては素朴ですが、ユーザーの生活の形(休みの日、やることが無い日)をどう扱うかの判断が詰まっている部分でした。