Skip to main content

qualia_client_core/wellfair/
med_reminders.rs

1//! Local medication reminder prefs and due-slot computation (Q6 MED-01..13).
2
3use std::fs;
4use std::path::Path;
5
6use chrono::{NaiveTime, Timelike};
7use serde::{Deserialize, Serialize};
8
9use super::journal::JournalEntry;
10
11pub const PREFS_FILE: &str = "wellfair/med_reminder_prefs.json";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct MedReminderPrefs {
15    pub enabled: bool,
16    pub permission_granted: bool,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub permission_granted_at_unix: Option<u32>,
19}
20
21impl Default for MedReminderPrefs {
22    fn default() -> Self {
23        Self {
24            enabled: false,
25            permission_granted: false,
26            permission_granted_at_unix: None,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct DueMedReminder {
33    pub medication_id: String,
34    pub medication_name: String,
35    pub schedule_slot: String,
36    /// Minutes until due (negative = overdue today).
37    pub minutes_until_due: i32,
38}
39
40pub fn load_prefs(storage_root: impl AsRef<Path>) -> MedReminderPrefs {
41    let path = storage_root.as_ref().join(PREFS_FILE);
42    if !path.exists() {
43        return MedReminderPrefs::default();
44    }
45    fs::read_to_string(&path)
46        .ok()
47        .and_then(|s| serde_json::from_str(&s).ok())
48        .unwrap_or_default()
49}
50
51pub fn save_prefs(storage_root: impl AsRef<Path>, prefs: &MedReminderPrefs) -> std::io::Result<()> {
52    let path = storage_root.as_ref().join(PREFS_FILE);
53    if let Some(parent) = path.parent() {
54        fs::create_dir_all(parent)?;
55    }
56    let json =
57        serde_json::to_string_pretty(prefs).map_err(|e| std::io::Error::other(e.to_string()))?;
58    fs::write(&path, json)
59}
60
61fn parse_hhmm(slot: &str) -> Option<NaiveTime> {
62    let parts: Vec<_> = slot.trim().split(':').collect();
63    if parts.len() != 2 {
64        return None;
65    }
66    let h: u32 = parts[0].parse().ok()?;
67    let m: u32 = parts[1].parse().ok()?;
68    NaiveTime::from_hms_opt(h, m, 0)
69}
70
71/// Compute due/overdue slots for active medications from journal summaries.
72pub fn compute_due_reminders(
73    journal: &[JournalEntry],
74    now_local: NaiveTime,
75    window_minutes: i32,
76) -> Vec<DueMedReminder> {
77    let now_mins = (now_local.hour() * 60 + now_local.minute()) as i32;
78    let mut out = Vec::new();
79
80    for entry in journal {
81        if entry.kind != "medication" {
82            continue;
83        }
84        let Some(summary) = &entry.summary else {
85            continue;
86        };
87        let Ok(json) = serde_json::from_str::<serde_json::Value>(summary) else {
88            continue;
89        };
90        if json
91            .get("ceased")
92            .and_then(|v| v.as_bool())
93            .unwrap_or(false)
94        {
95            continue;
96        }
97        let name = json
98            .get("name")
99            .and_then(|v| v.as_str())
100            .unwrap_or("medication")
101            .to_string();
102        let slots = json
103            .get("schedule_times")
104            .and_then(|v| v.as_array())
105            .map(|arr| {
106                arr.iter()
107                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
108                    .collect::<Vec<_>>()
109            })
110            .unwrap_or_default();
111
112        for slot in slots {
113            let Some(t) = parse_hhmm(&slot) else {
114                continue;
115            };
116            let slot_mins = (t.hour() * 60 + t.minute()) as i32;
117            let minutes_until = slot_mins - now_mins;
118            if minutes_until.abs() <= window_minutes {
119                out.push(DueMedReminder {
120                    medication_id: entry.id.clone(),
121                    medication_name: name.clone(),
122                    schedule_slot: slot,
123                    minutes_until_due: minutes_until,
124                });
125            }
126        }
127    }
128
129    out.sort_by_key(|r| r.minutes_until_due);
130    out
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn med_entry(id: &str, summary: &str) -> JournalEntry {
138        JournalEntry {
139            id: id.into(),
140            kind: "medication".into(),
141            asserted_time_unix: 0,
142            evidence_type: "SelfReported".into(),
143            sensitivity: "Restricted".into(),
144            blob_hash: None,
145            source: "test".into(),
146            committed_unix: 0,
147            summary: Some(summary.into()),
148        }
149    }
150
151    #[test]
152    fn prefs_round_trip() {
153        let dir = tempfile::tempdir().unwrap();
154        let mut prefs = MedReminderPrefs::default();
155        prefs.permission_granted = true;
156        prefs.enabled = true;
157        prefs.permission_granted_at_unix = Some(100);
158        save_prefs(dir.path(), &prefs).unwrap();
159        let loaded = load_prefs(dir.path());
160        assert_eq!(loaded, prefs);
161    }
162
163    #[test]
164    fn due_reminder_within_window() {
165        let summary = r#"{"name":"Metformin","schedule_times":["08:00","20:00"],"ceased":false}"#;
166        let journal = vec![med_entry("urn:wellfair:medication:x", summary)];
167        let now = NaiveTime::from_hms_opt(8, 5, 0).unwrap();
168        let due = compute_due_reminders(&journal, now, 30);
169        assert_eq!(due.len(), 1);
170        assert_eq!(due[0].schedule_slot, "08:00");
171        assert_eq!(due[0].minutes_until_due, -5);
172    }
173
174    #[test]
175    fn ceased_medication_excluded() {
176        let summary = r#"{"name":"Old","schedule_times":["08:00"],"ceased":true}"#;
177        let journal = vec![med_entry("urn:wellfair:medication:y", summary)];
178        let now = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
179        assert!(compute_due_reminders(&journal, now, 30).is_empty());
180    }
181}