Разработка календаря приверженности OldSchool: превращаем отметки о приёме в надёжный день
Месячный календарь приверженности выглядит как одна цветная точка на день. Вот модель событий, правила дневного статуса и математика своевременности — всё на устройстве.
Календарь OldSchool показывает одну вещь на день: зелёный — для завершённого, янтарный — для частичного, красный — для пропущенного, плюс процент своевременности за месяц. На первый взгляд это выглядит как единый факт. Под капотом это никогда не единый факт — день с тремя лекарствами порождает до трёх независимых событий, приходящих в разное время: одни обработаны сразу, другие с опозданием, третьи не обработаны вовсе. Пройти путь от этого беспорядка до точки, которой можно доверять с одного взгляда, — небольшая задача моделирования, и в ней легко ошибиться незаметным образом.
Не поддавайтесь искушению хранить дневной статус
Соблазнительный короткий путь — таблица DayStatus: одна строка на календарный день, с колонкой status, которую вы обновляете по мере поступления приёмов. Не делайте так. В тот момент, когда вы сохраняете производное значение, вы берёте на себя обязанность держать его синхронизированным — каждый раз, когда приём добавляется, редактируется, удаляется, или расписание меняется задним числом, что-то должно вспомнить пересчитать и эту строку тоже. Пропустите хотя бы один путь в коде — и календарь начнёт молча противоречить данным, которые его породили.
Единственное, что стоит хранить, — это событие:
data class DoseEvent(
val id: Long,
val medicationId: Long,
val scheduledAtMillis: Long,
val actedAtMillis: Long?,
val status: DoseStatus, // PENDING, TAKEN, SNOOZED, SKIPPED, MISSED
)
Дневной статус — это результат запроса, вычисляемый заново при каждом рендере календаря, а не значение, которое приходится вручную поддерживать правильным.
Вычисление цвета дня из его событий
День сводится ровно к одному статусу по фиксированному приоритету среди его событий:
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 обращений на каждый день:
@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>
Один запрос на весь видимый месяц, сгруппированный на стороне клиента в Map<LocalDate, List<DayEventRow>>, затем dayStatus() применяется к каждой группе. Достаточно дёшево, чтобы выполнять при каждой рекомпозиции вместо кеширования — что сразу избавляет от целой категории багов, связанных с инвалидацией кеша.
«Принято» — не то же самое, что «вовремя»
Процент своевременности — то место, где наивная реализация ломается первой. Заманчиво вычислить его как принято / всего — но приём, сделанный на шесть часов позже после двух отсрочек, не должен считаться так же, как приём точно по расписанию. 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-приём удерживал бы весь день вне и COMPLETE, и MISSED, застрявшим в лимбе PARTIAL даже неделю спустя.
Ежедневная задача WorkManager вычищает именно этот случай: любое событие PENDING, чей scheduledAtMillis старше фиксированного окна (четыре часа — за пределами того момента, когда приём ещё реалистично можно считать сделанным «вовремя»), переводится в MISSED. Это единственное место в системе, которое меняет DoseStatus без соответствующего действия пользователя — и существует оно по одной причине: чтобы «действие не было предпринято» в итоге стало фактом, который календарь может отобразить, а не вопросом, который ему приходится задавать снова и снова.
Что это даёт календарю
Ничто из этого не проявляется как фича на скриншоте. Что это даёт — так это календарь, которому никогда не нужна кнопка «пересчитать» и который никогда не расходится с событиями, породившими его, потому что расходиться попросту не с чем — цвет вычисляется, а не хранится, каждый раз заново. Та же схема применима к любой панели, которая сводит дискретные события к более грубому статусу: храните события как единственный источник истины, пишите правила вычисления как чистые функции над ними, и позвольте фоновой проверке разрешить единственный случай, на самостоятельное разрешение которого пользователями рассчитывать нельзя — то, что они просто никогда не сделали.
// По теме
Ещё из журнала
Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.
Как устроен Subly: календарная математика за датами продления подписок
Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.