Construyendo el calendario de adherencia de OldSchool: convertir toques de dosis en un día fiable
Un calendario de adherencia mensual parece un simple punto de color por día. Así es el modelo de eventos, las reglas de estado diario y el cálculo de puntualidad, en el dispositivo.
El calendario de OldSchool muestra una sola cosa por día: verde para completo, ámbar para parcial, rojo para perdido, más un porcentaje de puntualidad para el mes. Se lee como un solo hecho. Por debajo, nunca es un solo hecho — un día con tres medicamentos produce hasta tres eventos independientes, que llegan en momentos distintos, algunos atendidos al instante y otros tarde, algunos nunca atendidos. Pasar de ese desorden a un punto en el que puedas confiar de un vistazo es un pequeño problema de modelado, y es fácil equivocarse de forma sutil.
Resistir la tentación de guardar un estado diario
El atajo tentador es una tabla DayStatus: una fila por día del calendario, con una columna status que actualizas a medida que llegan las dosis. No lo hagas. En el momento en que guardas un valor derivado, asumes la carga de mantenerlo sincronizado — cada vez que se añade, edita o elimina una dosis, o un horario cambia retroactivamente, algo tiene que recordar recalcular también esa fila. Te saltas una sola ruta de código y el calendario acaba en silenciosa contradicción con los datos que lo produjeron.
Lo único que merece la pena persistir es el evento:
data class DoseEvent(
val id: Long,
val medicationId: Long,
val scheduledAtMillis: Long,
val actedAtMillis: Long?,
val status: DoseStatus, // PENDING, TAKEN, SNOOZED, SKIPPED, MISSED
)
El estado del día es un resultado de consulta, calculado de nuevo cada vez que se renderiza el calendario, nunca un valor que haya que mantener correcto a mano.
Derivar el color de un día a partir de sus eventos
Un día se reduce a exactamente un estado, siguiendo una prioridad fija sobre sus eventos:
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 cuenta deliberadamente igual que MISSED aquí — una dosis omitida sigue siendo una dosis no tomada, y el calendario no es el lugar para distinguir la intención del resultado. Esa distinción importa para la vista de detalle del día, no para el vistazo mensual.
En el lado de Room, es una única consulta agrupada en lugar de N+1 búsquedas por día:
@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>
Una sola consulta para todo el mes visible, agrupada en el cliente en Map<LocalDate, List<DayEventRow>>, y luego dayStatus() aplicada por grupo. Suficientemente barata como para ejecutarla en cada recomposición en lugar de cachearla — lo que evita de raíz toda una categoría de errores de invalidación de caché.
”Tomada” no es lo mismo que “a tiempo”
El porcentaje de puntualidad es donde una implementación ingenua se rompe primero. Es tentador calcularlo como tomadas / total — pero una dosis tomada seis horas tarde, tras dos posposiciones, no debería contar igual que una tomada según lo previsto. OldSchool registra un indicador de puntualidad aparte en el momento de escribir, en lugar de derivarlo después:
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,
)
}
Una ventana de gracia de 30 minutos absorbe el caso normal — viste la notificación, terminaste lo que estabas haciendo, tocaste Tomada unos minutos después — sin premiar una dosis tomada a las 9 de la noche para un horario de las 9 de la mañana. El porcentaje de puntualidad es entonces count(wasOnTime) / count(TAKEN), una cifra honesta sobre el retraso en lugar de una que solo se preocupa de si la casilla acabó marcada.
La dosis que nadie tocó
Las dosis PENDING no se resuelven solas. Si el usuario nunca abre la app y nunca toca una acción de notificación, esa dosis se queda en PENDING indefinidamente a menos que algo la haga avanzar — y una dosis indefinidamente PENDING mantendría todo el día fuera tanto de COMPLETE como de MISSED, atrapado en un limbo PARTIAL incluso una semana después.
Una tarea diaria de WorkManager barre exactamente este caso: cualquier evento PENDING cuyo scheduledAtMillis sea anterior a una ventana fija (cuatro horas, más allá del punto en que una dosis todavía puede considerarse realistamente tomada “a tiempo”) pasa a MISSED. Es el único lugar del sistema que modifica un DoseStatus sin una acción de usuario correspondiente — y existe por una sola razón: que “no se tomó ninguna acción” se convierta finalmente en un hecho que el calendario puede mostrar, en lugar de una pregunta que tiene que seguir haciéndose.
Lo que esto le da al calendario
Nada de esto aparece como una función en una captura de pantalla. Lo que aporta es un calendario que nunca necesita un botón de “recalcular” y que nunca se desvía de los eventos que lo produjeron, porque no hay nada de qué desviarse — el color se calcula, no se almacena, cada vez. La misma forma se aplica a cualquier panel que resuma eventos discretos en un estado más general: mantén los eventos como única fuente de verdad, escribe tus reglas de derivación como funciones puras sobre ellos, y deja que un barrido en segundo plano resuelva el único caso que no se puede confiar en que los usuarios resuelvan por sí mismos — lo que simplemente nunca hicieron.
// Lecturas relacionadas
Más del diario
Construir Granyn: un control de presupuesto sin inicio de sesión bancario, en tres tablas
Cómo Granyn controla el gasto en varias divisas y detecta facturas recurrentes sin vincular una sola cuenta bancaria — el esquema Room detrás de todo, y las concesiones que exige.
Android local-first en 2026: SQLite, Room y mantener los datos del usuario en el dispositivo
Una guía de 2026 para construir apps Android local-first con Room y SQLite — diseño de esquemas, migraciones, WAL, exportaciones y cuándo (y cuándo no) añadir sincronización.
Construyendo Subly: las matemáticas de calendario detrás de las fechas de renovación de suscripciones
Predecir la próxima fecha de cobro de una suscripción parece trivial hasta que te topas con la facturación a fin de mes, los años bisiestos y las conversiones de prueba. Así es como Subly lo resuelve, en el dispositivo.