Building OldSchool's adherence calendar: turning dose taps into a day you can trust
A monthly adherence calendar looks like a single colored dot per day. Here's the event model, the day-status rules, and the on-time math behind it, on-device.
OldSchool’s calendar shows one thing per day: green for completed, amber for partial, red for missed, plus an on-time percentage for the month. It reads as a single fact. Underneath, it’s never one fact — a day with three medications produces up to three independent events, arriving at different times, some acted on immediately and some acted on late, some never acted on at all. Getting from that mess to a dot you can trust at a glance is a small modeling problem, and it’s easy to get subtly wrong.
Resist the urge to store a day status
The tempting shortcut is a DayStatus table: one row per calendar day, with a status column you update as doses come in. Don’t do this. The moment you store a derived value, you own keeping it in sync — every time a dose is added, edited, deleted, or a schedule changes retroactively, something has to remember to recompute that row too. Miss one code path and the calendar quietly disagrees with the data that produced it.
The event is the only thing worth persisting:
data class DoseEvent(
val id: Long,
val medicationId: Long,
val scheduledAtMillis: Long,
val actedAtMillis: Long?,
val status: DoseStatus, // PENDING, TAKEN, SNOOZED, SKIPPED, MISSED
)
Day status is a query result, computed fresh every time the calendar renders, never a value that has to be kept correct by hand.
Deriving a day’s color from its events
A day maps to exactly one status, following a fixed precedence over its events:
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 deliberately counts the same as MISSED here — a skip is still a dose that wasn’t taken, and the calendar isn’t the place to distinguish intent from outcome. That distinction matters for the day’s detail view, not for the monthly glance.
The Room side of this is a single grouped query rather than N+1 lookups per day:
@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>
One query for the whole visible month, grouped client-side into Map<LocalDate, List<DayEventRow>>, then dayStatus() applied per group. Cheap enough to run on every recomposition instead of caching it — which sidesteps a whole category of cache-invalidation bugs for free.
”Taken” isn’t the same as “on time”
The on-time percentage is where a naive implementation breaks first. It’s tempting to compute it as taken / total — but a dose taken six hours late, after two snoozes, shouldn’t count the same as one taken on schedule. OldSchool tracks a separate on-time flag at write time, not derived after the fact:
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,
)
}
A 30-minute grace window absorbs the ordinary case — you saw the notification, finished what you were doing, tapped Taken a few minutes later — without rewarding a dose taken at 9pm for a 9am schedule. The on-time percentage is then count(wasOnTime) / count(TAKEN), a number that’s honest about lateness instead of one that only cares whether the box eventually got checked.
The dose nobody tapped
PENDING doses don’t resolve themselves. If the user never opens the app and never taps a notification action, that dose sits at PENDING forever unless something moves it forward — and an indefinitely PENDING dose would keep the whole day out of both COMPLETE and MISSED, stuck in PARTIAL limbo even a week later.
A daily WorkManager job sweeps for exactly this: any PENDING event whose scheduledAtMillis is more than a fixed window in the past (four hours, past the point a dose realistically still gets taken “on schedule”) gets flipped to MISSED. It’s the only place in the system that mutates a DoseStatus without a corresponding user action, and it exists for one reason — so that “no action taken” eventually becomes a fact the calendar can render, instead of a question it has to keep asking.
What this buys the calendar
None of this shows up as a feature in a screenshot. What it buys is a calendar that never needs a “recalculate” button and never drifts from the events that produced it, because there’s nothing to drift — the color is computed, not stored, every single time. The same shape applies to any dashboard that summarizes discrete events into a coarser status: keep the events as the only source of truth, write your derivation rules as pure functions over them, and let a background sweep resolve the one case users can’t be relied on to resolve themselves — the thing they simply never did.
// Related reading
More from the journal
Building Granyn: a budget tracker with no bank login, in three tables
How Granyn tracks spending across currencies and catches recurring bills without linking a single bank account — the Room schema and the trade-offs behind it.
Local-first Android in 2026: SQLite, Room, and keeping user data on the device
A 2026 guide to building local-first Android apps with Room and SQLite — schema design, migrations, WAL, exports, and when (and when not) to add sync.
Building Subly: the calendar math behind subscription renewal dates
Predicting a subscription's next charge date sounds trivial until you hit month-end billing, leap years, and trial conversions. Here's how Subly gets it right, on-device.