सामग्री पर जाएं
सभी पोस्ट

OldSchool का एडहेरेंस कैलेंडर बनाना: दवा के टैप्स को भरोसेमंद दिन में बदलना

एक महीने का एडहेरेंस कैलेंडर हर दिन के लिए बस एक रंगीन डॉट जैसा दिखता है। जानिए इसके पीछे का इवेंट मॉडल, दिन के स्टेटस के नियम, और समय-पालन का गणित, वह भी डिवाइस पर।

MFKAPPS 5 मिनट पढ़ना

OldSchool का कैलेंडर हर दिन के लिए एक ही चीज़ दिखाता है: पूरा होने के लिए हरा, आंशिक के लिए एम्बर, छूटे होने के लिए लाल, साथ ही महीने के लिए एक समय-पालन प्रतिशत। यह एक नज़र में एक अकेले तथ्य जैसा दिखता है। इसके नीचे, यह कभी भी एक अकेला तथ्य नहीं होता — तीन दवाओं वाला दिन तीन अलग-अलग समय पर आने वाले, कुछ तुरंत निपटाए गए और कुछ देर से, और कुछ कभी न निपटाए गए, ऐसे तीन तक स्वतंत्र इवेंट पैदा कर सकता है। इस उलझन से एक नज़र में भरोसा किए जा सकने वाले एक डॉट तक पहुंचना एक छोटी मॉडलिंग समस्या है, और इसमें सूक्ष्म रूप से ग़लती करना आसान है।

दिन का स्टेटस सेव करने के लालच से बचें

लुभावना शॉर्टकट है एक DayStatus टेबल: कैलेंडर के हर दिन के लिए एक रो, जिसका status कॉलम आप दवाएं आने पर अपडेट करते हैं। ऐसा मत करें। जिस पल आप एक व्युत्पन्न (derived) वैल्यू सेव करते हैं, उसी पल आप उसे सिंक में बनाए रखने का बोझ भी उठा लेते हैं — जब भी कोई दवा जोड़ी जाए, बदली जाए, हटाई जाए, या कोई शेड्यूल पीछे जाकर बदला जाए, कुछ न कुछ को यह भी याद रखना होगा कि उस रो को भी फिर से गिना जाए। एक भी कोड पाथ चूक जाएं, और कैलेंडर उस डेटा से चुपचाप असहमत होने लगेगा जिसने उसे बनाया था।

सेव करने लायक इकलौती चीज़ इवेंट है:

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

दिन का स्टेटस एक क्वेरी का नतीजा है, जो कैलेंडर के हर बार रेंडर होने पर नए सिरे से गिना जाता है — कभी भी हाथ से सही बनाए रखने वाली वैल्यू नहीं।

इवेंट्स से दिन का रंग निकालना

एक दिन अपने इवेंट्स पर एक तय प्राथमिकता के हिसाब से ठीक एक स्टेटस में सिमट जाता है:

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 को जान-बूझकर MISSED जैसा ही गिना जाता है — छोड़ी गई दवा भी आख़िरकार न ली गई दवा ही है, और कैलेंडर इरादे और नतीजे में फ़र्क़ करने की जगह नहीं है। यह फ़र्क़ दिन के डिटेल व्यू के लिए मायने रखता है, महीने भर की एक नज़र के लिए नहीं।

Room वाला हिस्सा हर दिन के लिए N+1 क्वेरी करने के बजाय एक ही ग्रुप्ड क्वेरी है:

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

दिख रहे पूरे महीने के लिए एक क्वेरी, क्लाइंट साइड पर Map<LocalDate, List<DayEventRow>> में ग्रुप की जाती है, फिर हर ग्रुप पर dayStatus() लगाया जाता है। इतना सस्ता कि इसे कैश करने के बजाय हर रीकम्पोज़िशन पर चला दिया जाए — जिससे कैश इनवैलिडेशन बग्स की पूरी एक श्रेणी मुफ़्त में टल जाती है।

“ली गई” और “समय पर” एक चीज़ नहीं हैं

समय-पालन प्रतिशत वह जगह है जहां एक भोला-भाला इम्प्लीमेंटेशन सबसे पहले टूटता है। इसे ली गईं / कुल के रूप में गिनना लुभावना लगता है — पर दो बार स्नूज़ के बाद छह घंटे देर से ली गई एक दवा को समय पर ली गई दवा जितना नहीं गिना जाना चाहिए। OldSchool बाद में निकालने के बजाय लिखने के समय ही एक अलग समय-पालन फ्लैग दर्ज करता है:

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 मिनट की मोहलत सामान्य स्थिति को समेट लेती है — आपने नोटिफिकेशन देखा, जो कर रहे थे वह पूरा किया, कुछ मिनट बाद ली गई पर टैप किया — बिना सुबह 9 बजे के शेड्यूल के लिए रात 9 बजे ली गई दवा को इनाम दिए। समय-पालन प्रतिशत तब count(wasOnTime) / count(TAKEN) बन जाता है — देरी के बारे में ईमानदार एक आंकड़ा, न कि सिर्फ़ यह देखने वाला कि बॉक्स आख़िर में चेक हुआ या नहीं।

वह दवा जिसे किसी ने नहीं टैप किया

PENDING दवाएं अपने आप हल नहीं होतीं। अगर यूज़र कभी ऐप नहीं खोलता और कभी किसी नोटिफिकेशन एक्शन पर टैप नहीं करता, तो जब तक कुछ उसे आगे नहीं बढ़ाता, वह दवा अनिश्चित काल तक PENDING बनी रहती है — और अनिश्चित काल तक PENDING रहने वाली दवा पूरे दिन को COMPLETE और MISSED दोनों से बाहर रखेगी, एक हफ़्ते बाद भी PARTIAL के बीच में अटका हुआ।

एक रोज़ाना चलने वाला WorkManager जॉब ठीक इसी मामले को साफ़ करता है: कोई भी PENDING इवेंट जिसका scheduledAtMillis एक तय विंडो (चार घंटे — यानी वह बिंदु जिसके बाद किसी दवा को असल में “समय पर” माना जाना मुश्किल है) से ज़्यादा पुराना है, MISSED में बदल दिया जाता है। यह सिस्टम में इकलौती जगह है जो बिना किसी उससे मेल खाते यूज़र एक्शन के DoseStatus बदलती है — और यह एक ही वजह से मौजूद है: ताकि “कोई एक्शन नहीं लिया गया” आख़िरकार वह तथ्य बन जाए जिसे कैलेंडर दिखा सके, न कि वह सवाल जो उसे बार-बार पूछते रहना पड़े।

यह कैलेंडर को क्या देता है

इसमें से कुछ भी किसी स्क्रीनशॉट में एक फ़ीचर की तरह नज़र नहीं आता। जो मिलता है वह एक ऐसा कैलेंडर है जिसे कभी “फिर से गणना करें” बटन की ज़रूरत नहीं पड़ती और जो उन इवेंट्स से कभी नहीं भटकता जिन्होंने उसे बनाया — क्योंकि भटकने के लिए कुछ है ही नहीं, रंग हर बार गिना जाता है, सेव नहीं किया जाता। यही तरीका हर उस डैशबोर्ड पर लागू होता है जो अलग-अलग इवेंट्स को एक मोटे स्टेटस में समेटता है: इवेंट्स को सच्चाई का इकलौता स्रोत बनाए रखें, अपने व्युत्पत्ति (derivation) के नियमों को उन पर शुद्ध फ़ंक्शंस की तरह लिखें, और एक बैकग्राउंड स्वीप को उस इकलौते मामले को हल करने दें जिसे यूज़र्स से खुद हल करवाने की उम्मीद नहीं की जा सकती — वह चीज़ जो उन्होंने बस कभी की ही नहीं।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 5 मिनट पढ़ना

Granyn बनाना: बिना बैंक लॉगिन वाला बजट ट्रैकर, बस तीन टेबल में

Granyn एक भी बैंक अकाउंट जोड़े बिना कई मुद्राओं में खर्च कैसे ट्रैक करता है और आवर्ती बिलों को कैसे पकड़ता है — इसके पीछे का Room स्कीमा, और इससे जुड़े समझौते।

#android #engineering #privacy
MFKAPPS 8 मिनट पढ़ना

2026 में लोकल-फर्स्ट Android: SQLite, Room, और यूज़र डेटा को डिवाइस पर ही रखना

Room और SQLite के साथ लोकल-फर्स्ट Android ऐप बनाने की 2026 गाइड — स्कीमा डिज़ाइन, माइग्रेशन, WAL, एक्सपोर्ट, और सिंक कब जोड़ें (और कब नहीं)।

#android #engineering #privacy
MFKAPPS 5 मिनट पढ़ना

Subly बनाना: सब्सक्रिप्शन रिन्यूअल तारीखों के पीछे का कैलेंडर गणित

किसी सब्सक्रिप्शन की अगली चार्ज तारीख का अनुमान लगाना तब तक साधारण लगता है जब तक आप महीने के अंत की बिलिंग, लीप ईयर और ट्रायल कन्वर्शन से नहीं टकराते। यहाँ बताया गया है कि Subly इसे डिवाइस पर ही कैसे सही करता है।

#android #engineering #subscriptions