OldSchool の服薬カレンダーを作る: 服薬タップを信頼できる1日に変換する
月間の服薬カレンダーは1日ごとの単なる色付きドットに見える。その裏にあるイベントモデル、日次ステータスのルール、そして時間厳守率の計算をデバイス上で解説する。
OldSchool のカレンダーは1日につき1つのことだけを示す — 完了なら緑、部分達成なら黄色、未達成なら赤、それに加えてその月の時間厳守率だ。一目で単一の事実のように読める。だがその裏側では、それは決して単一の事実ではない。薬が3種類ある日は、異なるタイミングで届く、独立した最大3つのイベントを生み出す。あるものは即座に対応され、あるものは遅れて対応され、あるものは一度も対応されない。この混沌から一目で信頼できる1つのドットにたどり着くのは小さなモデリングの問題であり、微妙に間違えやすい部分でもある。
日次ステータスを保存したくなる誘惑に抗う
魅力的な近道は DayStatus テーブルだ — カレンダーの日ごとに1行、服用が記録されるたびに更新する status カラムを持つ。これはやってはいけない。導出された値を保存した瞬間、それを同期させ続ける負担を背負うことになる。服用が追加、編集、削除されるたびに、あるいはスケジュールが遡って変更されるたびに、その行も再計算しなければならないことを何かが覚えておく必要がある。コードパスを1つでも見落とせば、カレンダーはそれを生み出したデータと静かに矛盾し始める。
保存する価値があるのはイベントだけだ。
data class DoseEvent(
val id: Long,
val medicationId: Long,
val scheduledAtMillis: Long,
val actedAtMillis: Long?,
val status: DoseStatus, // PENDING, TAKEN, SNOOZED, SKIPPED, MISSED
)
日次ステータスはクエリの結果であり、カレンダーが描画されるたびに新しく計算されるものであって、手作業で正しさを維持しなければならない値では決してない。
イベントから1日の色を導出する
1日はそのイベント群に対する固定の優先順位に従って、正確に1つのステータスに集約される。
fun dayStatus(events: List<DoseEvent>): DayStatus = when {
events.isEmpty() -> DayStatus.NONE
events.all { it.status == DoseStatus.TAKEN } -> DayStatus.COMPLETE
events.none { it.status == DoseStatus.TAKEN } -> DayStatus.MISSED
else -> DayStatus.PARTIAL
}
ここで SKIPPED は意図的に MISSED と同じ扱いになる — スキップされた服用も服用されなかったことに変わりはなく、カレンダーは意図と結果を区別する場所ではない。その区別は日ごとの詳細画面では重要だが、月間の一覧では重要ではない。
Room 側は、日ごとに N+1 回問い合わせるのではなく、1つのグループ化クエリで済む。
@Query("""
SELECT date(scheduledAtMillis / 1000, 'unixepoch', 'localtime') AS day,
status
FROM dose_events
WHERE scheduledAtMillis BETWEEN :monthStart AND :monthEnd
""")
fun eventsByDay(monthStart: Long, monthEnd: Long): List<DayEventRow>
表示中の月全体に対して1回のクエリを実行し、クライアント側で Map<LocalDate, List<DayEventRow>> にグループ化し、グループごとに dayStatus() を適用する。キャッシュするより毎回の再コンポーズで実行するほうが安上がりで、それによってキャッシュ無効化バグの一群をまるごと避けられる。
「服用した」と「時間通り」は同じではない
時間厳守率は、素朴な実装が最初に壊れる場所だ。服用した回数 / 全体 として計算したくなるが、2回のスヌーズの末に6時間遅れて服用した1回は、予定通りに服用した1回と同じに数えるべきではない。OldSchool は後から導出するのではなく、書き込み時点で別個の時間厳守フラグを記録する。
fun markTaken(event: DoseEvent, actedAtMillis: Long): DoseEvent {
val graceMillis = 30 * 60 * 1000L
val onTime = actedAtMillis - event.scheduledAtMillis <= graceMillis
return event.copy(
status = DoseStatus.TAKEN,
actedAtMillis = actedAtMillis,
wasOnTime = onTime,
)
}
30分の猶予枠は、通常のケース — 通知を見て、やっていたことを終えてから数分後に服用済みをタップした — を、朝9時の予定に対して夜9時に服用したケースを優遇することなく吸収する。時間厳守率はこうして count(wasOnTime) / count(TAKEN) になる。チェックボックスが最終的にオンになったかどうかだけを気にする数字ではなく、遅れについて正直な数字だ。
誰もタップしなかった服用
PENDING の服用は自然には解決されない。ユーザーが一度もアプリを開かず、一度も通知アクションをタップしなければ、何かがそれを前に進めない限り、その服用は無期限に PENDING のままだ。そして無期限に PENDING の服用は、1週間後であってもその日全体を COMPLETE にも MISSED にもならない PARTIAL の宙ぶらりんに閉じ込めてしまう。
毎日実行される WorkManager ジョブがまさにこのケースを掃除する。scheduledAtMillis が固定の期間(4時間 — 服用がまだ現実的に「時間通り」とみなせる範囲を超えた長さ)より過去にある PENDING イベントは MISSED に変わる。これはシステム内で唯一、対応するユーザー操作なしに DoseStatus を変更する場所であり、理由はただ1つ — 「何のアクションも取られなかった」ことが、カレンダーが問い続けなければならない疑問ではなく、最終的にカレンダーが描画できる事実になるためだ。
これがカレンダーにもたらすもの
これらはどれもスクリーンショットに写る機能としては現れない。もたらされるのは、「再計算」ボタンを一度も必要とせず、それを生み出したイベントから決してずれないカレンダーだ。ずれる余地がないのは、色が毎回計算されるものであり、保存されるものではないからだ。同じ形は、離散的なイベントをより粗いステータスへと要約するあらゆるダッシュボードに当てはまる。イベントを唯一の真実の源として保持し、導出ルールをそれに対する純粋関数として書き、ユーザー自身では解決してくれるとは期待できない唯一のケース — ユーザーが単に何もしなかったこと — をバックグラウンドの掃除処理に解決させればいい。
// 関連記事
ジャーナルの他の記事
Granynを作る: 銀行ログインなしの家計簿を、3つのテーブルで
Granynが銀行口座を一つも連携せずに、複数通貨での支出を追跡し、定期的な請求を検知する仕組み — その裏にあるRoomスキーマと、そこで生じるトレードオフ。
2026年のローカルファーストなAndroid: SQLite、Room、そしてユーザーデータを端末に残すこと
RoomとSQLiteでローカルファーストなAndroidアプリを作るための2026年版ガイド — スキーマ設計、マイグレーション、WAL、エクスポート、そして同期をいつ足すべきか(いつ足すべきでないか)。
Subly の裏側:サブスクリプション更新日を支えるカレンダー計算
サブスクの次回請求日を予測するのは、月末請求・うるう年・トライアル移行にぶつかるまでは単純に見える。Subly が端末上でどうそれを正しく処理しているかを解説する。