qualia_client_core/wellfair/
med_reminders.rs1use 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 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
71pub 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}