Saltar al contenido
Todas las entradas

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.

MFKAPPS 5 min de lectura

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.