İçeriğe geç
Tüm yazılar

OldSchool'un düzen takvimini inşa etmek: doz dokunuşlarını güvenilir bir güne dönüştürmek

Aylık bir düzen takvimi, her gün için tek renkli bir nokta gibi görünür. İşte arkasındaki olay modeli, gün durumu kuralları ve zamanında alım matematiği, cihaz üzerinde.

MFKAPPS 4 dk okuma

OldSchool’un takvimi her gün için tek bir şey gösterir: tamamlandı için yeşil, kısmi için sarı, kaçırıldı için kırmızı, artı ay için bir zamanında alım yüzdesi. Bir bakışta tek bir gerçek gibi okunur. Altında, bu hiçbir zaman tek bir gerçek değildir — üç ilaçlı bir gün, farklı zamanlarda gelen, bazıları anında tepki alan, bazıları geç tepki alan, bazıları hiç tepki almayan en fazla üç bağımsız olay üretir. Bu karmaşadan bir bakışta güvenebileceğiniz bir noktaya gitmek küçük bir modelleme sorunudur ve ince şekillerde yanlış yapmak kolaydır.

Bir gün durumu saklama isteğine direnin

Cazip kısayol bir DayStatus tablosudur: her takvim günü için bir satır, dozlar geldikçe güncellediğiniz bir status sütunuyla. Bunu yapmayın. Türetilmiş bir değeri sakladığınız an, onu senkron tutma yükümlülüğünü de üstlenirsiniz — bir doz eklendiğinde, düzenlendiğinde, silindiğinde ya da bir program geriye dönük değiştirildiğinde, bir şeyin o satırı da yeniden hesaplaması gerektiğini hatırlaması gerekir. Bir kod yolunu kaçırın ve takvim, onu üreten veriyle sessizce çelişmeye başlar.

Saklamaya değer tek şey olaydır:

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

Gün durumu, her takvim çizildiğinde yeniden hesaplanan, elle doğru tutulması gereken bir değer olmayan bir sorgu sonucudur.

Bir günün rengini olaylarından türetmek

Bir gün, olayları üzerinde sabit bir öncelik sırasına göre tam olarak tek bir duruma eşlenir:

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 burada bilerek MISSED ile aynı sayılıyor — bir atlama yine de alınmamış bir dozdur ve takvim, niyeti sonuçtan ayırt edecek yer değildir. Bu ayrım günün detay görünümü için önemlidir, aylık bakış için değil.

Room tarafı, gün başına N+1 sorgu yerine tek bir gruplanmış sorgudur:

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

Görünen ay için tek bir sorgu, istemci tarafında Map<LocalDate, List<DayEventRow>> içine gruplanır, ardından her grup için dayStatus() uygulanır. Her yeniden oluşturmada çalıştırmayı göze alacak kadar ucuz — bu da önbellek geçersizleştirme hatalarının bütün bir kategorisinden ücretsiz olarak kaçınmak anlamına gelir.

”Alındı” ile “zamanında” aynı şey değil

Zamanında alım yüzdesi, saf bir uygulamanın ilk kırıldığı yerdir. Bunu alınan / toplam olarak hesaplamak cazip gelir — ama iki erteleme sonrası altı saat geç alınan bir doz, programda alınmış bir dozla aynı sayılmamalı. OldSchool, ayrı bir zamanında bayrağını sonradan türetmek yerine yazma anında takip eder:

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 dakikalık bir tolerans penceresi, sıradan durumu — bildirimi gördünüz, yaptığınız işi bitirdiniz, birkaç dakika sonra Alındı’ya dokundunuz — bir 9 sabah programı için 9 akşam alınan bir dozu ödüllendirmeden kapsar. Zamanında alım yüzdesi böylece count(wasOnTime) / count(TAKEN) olur — kutunun sonunda işaretlenip işaretlenmediğine değil, gecikmeye dair dürüst bir sayı.

Kimsenin dokunmadığı doz

PENDING dozlar kendi kendilerini çözmez. Kullanıcı uygulamayı hiç açmaz ve bir bildirim eylemine hiç dokunmazsa, bir şey onu ileriye taşımadığı sürece o doz sonsuza kadar PENDING’de kalır — ve sonsuza kadar PENDING bir doz, günün hem COMPLETE hem de MISSED dışında kalmasına neden olur, bir hafta sonra bile PARTIAL arafında sıkışıp kalır.

Günlük bir WorkManager işi tam olarak bunu tarar: scheduledAtMillis’i sabit bir pencereden (dört saat, bir dozun gerçekçi olarak hâlâ “programında” alınabileceği noktanın ötesi) daha geçmişte olan herhangi bir PENDING olay MISSED’e çevrilir. Bu, sistemde karşılık gelen bir kullanıcı eylemi olmadan bir DoseStatus’u değiştiren tek yerdir — ve tek bir sebeple var: “hiçbir eylem alınmadı” sonunda takvimin çizebileceği bir gerçeğe dönüşsün, sürekli sorması gereken bir soru olarak kalmasın diye.

Bunun takvime kazandırdığı

Bunların hiçbiri bir ekran görüntüsünde bir özellik olarak görünmez. Kazandırdığı şey, hiçbir zaman bir “yeniden hesapla” düğmesine ihtiyaç duymayan ve onu üreten olaylardan asla sapmayan bir takvimdir, çünkü sapacak bir şey yoktur — renk her seferinde hesaplanır, saklanmaz. Aynı biçim, ayrık olayları daha kaba bir duruma özetleyen her panoya uygulanır: olayları tek doğruluk kaynağı olarak tutun, türetme kurallarınızı bunlar üzerinde saf fonksiyonlar olarak yazın ve arka planda çalışan bir tarama, kullanıcıların kendi başlarına çözmesine güvenilemeyen tek durumu — hiç yapmadıkları şeyi — çözsün.