Aller au contenu
Tous les articles

Construire le calendrier d'observance d'OldSchool : transformer des prises en un jour fiable

Un calendrier d'observance mensuel ressemble à un simple point coloré par jour. Voici le modèle d'événements, les règles de statut journalier et le calcul de ponctualité, sur l'appareil.

MFKAPPS 5 min de lecture

Le calendrier d’OldSchool montre une seule chose par jour : vert pour complété, ambre pour partiel, rouge pour manqué, plus un pourcentage de ponctualité pour le mois. Ça se lit comme un fait unique. En dessous, ce n’est jamais un fait unique — un jour avec trois médicaments produit jusqu’à trois événements indépendants, arrivant à des heures différentes, certains traités immédiatement et d’autres tardivement, certains jamais traités du tout. Passer de ce désordre à un point auquel on peut faire confiance d’un coup d’œil est un petit problème de modélisation, et il est facile de s’y tromper subtilement.

Résister à l’envie de stocker un statut journalier

Le raccourci tentant est une table DayStatus : une ligne par jour du calendrier, avec une colonne status mise à jour au fil des prises. Ne faites pas ça. Dès l’instant où vous stockez une valeur dérivée, vous héritez de la charge de la garder synchronisée — chaque fois qu’une prise est ajoutée, modifiée, supprimée, ou qu’un horaire change rétroactivement, quelque chose doit se souvenir de recalculer cette ligne aussi. Ratez un seul chemin de code et le calendrier finit par contredire silencieusement les données qui l’ont produit.

Le seul élément qui mérite d’être persisté, c’est l’événement :

data class DoseEvent(
    val id: Long,
    val medicationId: Long,
    val scheduledAtMillis: Long,
    val actedAtMillis: Long?,
    val status: DoseStatus, // PENDING, TAKEN, SNOOZED, SKIPPED, MISSED
)

Le statut journalier est un résultat de requête, recalculé à chaque affichage du calendrier, jamais une valeur qu’il faut maintenir correcte à la main.

Dériver la couleur d’un jour à partir de ses événements

Un jour se ramène à exactement un statut, selon une priorité fixe sur ses événements :

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 compte volontairement comme MISSED ici — une prise sautée reste une prise non prise, et le calendrier n’est pas l’endroit pour distinguer l’intention du résultat. Cette distinction compte pour la vue détaillée du jour, pas pour le coup d’œil mensuel.

Côté Room, c’est une seule requête groupée plutôt que N+1 lectures par jour :

@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>

Une seule requête pour tout le mois visible, groupée côté client en Map<LocalDate, List<DayEventRow>>, puis dayStatus() appliquée par groupe. Assez peu coûteux pour être exécuté à chaque recomposition plutôt que mis en cache — ce qui évite d’emblée toute une catégorie de bugs d’invalidation de cache.

« Pris » n’est pas la même chose que « à l’heure »

Le pourcentage de ponctualité est l’endroit où une implémentation naïve casse en premier. Il est tentant de le calculer comme pris / total — mais une prise faite six heures en retard, après deux reports, ne devrait pas compter comme une prise faite à l’heure prévue. OldSchool suit un indicateur de ponctualité distinct au moment de l’écriture, plutôt que de le dériver après coup :

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,
    )
}

Une fenêtre de tolérance de 30 minutes absorbe le cas ordinaire — vous avez vu la notification, terminé ce que vous étiez en train de faire, appuyé sur Pris quelques minutes plus tard — sans récompenser une prise faite à 21h pour un horaire de 9h. Le pourcentage de ponctualité devient alors count(wasOnTime) / count(TAKEN), un chiffre honnête sur le retard plutôt qu’un chiffre qui se soucie seulement de savoir si la case a fini par être cochée.

La prise que personne n’a validée

Les prises PENDING ne se résolvent pas d’elles-mêmes. Si l’utilisateur n’ouvre jamais l’app et n’appuie jamais sur une action de notification, cette prise reste indéfiniment PENDING à moins que quelque chose ne la fasse avancer — et une prise indéfiniment PENDING empêcherait la journée entière d’être soit COMPLETE, soit MISSED, coincée dans les limbes de PARTIAL même une semaine plus tard.

Une tâche WorkManager quotidienne balaie exactement ce cas : toute prise PENDING dont le scheduledAtMillis est plus ancien qu’une fenêtre fixe (quatre heures, au-delà du point où une prise peut encore raisonnablement être considérée « à l’heure ») bascule vers MISSED. C’est le seul endroit du système qui modifie un DoseStatus sans action utilisateur correspondante — et il existe pour une seule raison : que « aucune action prise » devienne finalement un fait que le calendrier peut afficher, au lieu d’une question qu’il doit continuer à se poser.

Ce que ça apporte au calendrier

Rien de tout cela n’apparaît comme une fonctionnalité dans une capture d’écran. Ce que ça apporte, c’est un calendrier qui n’a jamais besoin d’un bouton « recalculer » et qui ne dérive jamais des événements qui l’ont produit, parce qu’il n’y a rien à dériver — la couleur est calculée, jamais stockée, à chaque fois. Le même schéma s’applique à tout tableau de bord qui résume des événements discrets en un statut plus grossier : gardez les événements comme unique source de vérité, écrivez vos règles de dérivation comme des fonctions pures sur ces événements, et laissez un balayage en arrière-plan résoudre le seul cas que les utilisateurs ne peuvent pas être censés résoudre eux-mêmes — ce qu’ils n’ont tout simplement jamais fait.