Skip to main content

entracte_lib/scheduler/
chores.rs

1use std::path::Path;
2
3use log::error;
4use serde::Serialize;
5
6use super::types::BreakKind;
7use crate::chores_store::{self, ChoresSnapshot};
8
9/// The user's chore list for the current local day, held in the scheduler
10/// while it runs. Mirrors the `screen_time` pattern: a snapshot loaded at
11/// boot, rolled over when the local day changes, and persisted on mutation.
12///
13/// `rotation` is a monotonically advancing cursor (not an index) so that
14/// editing the list mid-day doesn't reset which chore comes next; the
15/// selector takes it modulo the list length.
16#[derive(Debug, Clone, Default, Serialize)]
17pub struct ChoresState {
18    pub date: String,
19    pub items: Vec<String>,
20    pub rotation: u64,
21    /// Local day the morning prompt last fired (see
22    /// [`ChoresSnapshot::prompted_date`]). `!= date` means "not prompted yet
23    /// today".
24    pub prompted_date: String,
25    /// True once the user has ever saved a non-empty list. Persists across the
26    /// daily rollover (unlike `items`), so the morning prompt only nudges
27    /// people who actually use chores — see [`should_prompt_morning_chores`].
28    pub ever_used_chores: bool,
29}
30
31impl ChoresState {
32    /// Build state from a persisted snapshot. A snapshot from a previous day
33    /// is discarded — a chore post-it is a fresh thing each morning, so we
34    /// never carry yesterday's list into today.
35    pub fn from_snapshot(snap: ChoresSnapshot, today: &str) -> Self {
36        // Migrate stores that predate `ever_used_chores`: a snapshot that
37        // already carries items (today's or yesterday's) is clearly a
38        // chore-user, so treat it as having used chores even if the flag
39        // was default-false.
40        let ever_used_chores = snap.ever_used_chores || !snap.items.is_empty();
41        if snap.date == today {
42            Self {
43                date: snap.date,
44                items: snap.items,
45                rotation: snap.rotation,
46                prompted_date: snap.prompted_date,
47                ever_used_chores,
48            }
49        } else {
50            Self {
51                date: today.to_string(),
52                items: Vec::new(),
53                rotation: 0,
54                prompted_date: String::new(),
55                ever_used_chores,
56            }
57        }
58    }
59
60    /// Convert this state into the on-disk wire format. The inverse of
61    /// `from_snapshot`.
62    pub fn to_snapshot(&self) -> ChoresSnapshot {
63        ChoresSnapshot {
64            date: self.date.clone(),
65            items: self.items.clone(),
66            rotation: self.rotation,
67            prompted_date: self.prompted_date.clone(),
68            ever_used_chores: self.ever_used_chores,
69        }
70    }
71}
72
73/// Reset `state` to an empty list if `today` differs from its stored date.
74/// Returns `true` iff a rollover happened (so the caller can decide whether
75/// to persist).
76pub fn rollover_if_new_day(state: &mut ChoresState, today: &str) -> bool {
77    if state.date != today {
78        state.date = today.to_string();
79        state.items.clear();
80        state.rotation = 0;
81        state.prompted_date = String::new();
82        true
83    } else {
84        false
85    }
86}
87
88/// Earliest local minute-of-day the morning chore prompt may fire. Guards the
89/// all-day work-window case (`work_start = 00:00`) so the prompt lands in the
90/// morning rather than at the post-midnight rollover tick.
91const MORNING_PROMPT_FLOOR_MIN: u32 = 5 * 60;
92
93/// Whether to surface the morning chore prompt this tick. Fires once per
94/// local day — the first time the user is inside their work window (past an
95/// early-morning floor), while today's list is still empty and we haven't
96/// already prompted today. Only nudges users who have *ever* used chores:
97/// the list resets empty every morning, so without this a user who never
98/// touches chores would have Preferences popped open every single work-day
99/// on a permanently-empty list. Pure so the gating is unit-testable without
100/// a scheduler or clock.
101pub fn should_prompt_morning_chores(
102    enabled: bool,
103    in_work_window: bool,
104    now_min: u32,
105    state: &ChoresState,
106    today: &str,
107) -> bool {
108    enabled
109        && state.ever_used_chores
110        && in_work_window
111        && now_min >= MORNING_PROMPT_FLOOR_MIN
112        && state.items.is_empty()
113        && state.prompted_date != today
114}
115
116/// Persist `state` to disk, logging (never panicking) on failure — a chore
117/// list is best-effort, and a write error must not take down the scheduler.
118pub fn persist_chores(path: &Path, state: &ChoresState) {
119    if let Err(e) = chores_store::save(path, &state.to_snapshot()) {
120        error!("chores_store: failed to save {}: {e}", path.display());
121    }
122}
123
124/// Pick the next chore to surface and advance the rotation cursor. Returns
125/// `None` when the list is empty (nothing to nudge). Pure: takes and mutates
126/// plain data so the cycling maths is unit-testable without a store.
127pub fn next_prompt(state: &mut ChoresState) -> Option<String> {
128    if state.items.is_empty() {
129        return None;
130    }
131    let idx = (state.rotation % state.items.len() as u64) as usize;
132    let chosen = state.items[idx].clone();
133    state.rotation = state.rotation.wrapping_add(1);
134    Some(chosen)
135}
136
137/// Resolve the chore nudge for a break. Only **long** breaks draw a chore —
138/// micro breaks are too short to start a task and bedtime is for winding
139/// down. Advances the rotation cursor as a side effect for long breaks with
140/// a non-empty list.
141pub fn prompt_for_break(kind: BreakKind, state: &mut ChoresState) -> Option<String> {
142    match kind {
143        BreakKind::Long => next_prompt(state),
144        BreakKind::Micro | BreakKind::Sleep => None,
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn state_with(items: &[&str], rotation: u64) -> ChoresState {
153        ChoresState {
154            date: "2026-06-11".to_string(),
155            items: items.iter().map(|s| s.to_string()).collect(),
156            rotation,
157            prompted_date: String::new(),
158            // A state built with items is a chore-user; keep the flag in sync
159            // so the rotation / "already has chores" tests act like real use.
160            ever_used_chores: !items.is_empty(),
161        }
162    }
163
164    /// A returning chore-user whose daily list has reset to empty this
165    /// morning: empty today, but `ever_used_chores` is set, so the morning
166    /// prompt should still nudge them. This is the case the gate allows.
167    fn returning_user_empty() -> ChoresState {
168        ChoresState {
169            ever_used_chores: true,
170            ..state_with(&[], 0)
171        }
172    }
173
174    #[test]
175    fn from_snapshot_keeps_today_list() {
176        let snap = ChoresSnapshot {
177            date: "2026-06-11".to_string(),
178            items: vec!["Water the plants".to_string()],
179            rotation: 2,
180            prompted_date: "2026-06-11".to_string(),
181            ever_used_chores: false,
182        };
183        let st = ChoresState::from_snapshot(snap, "2026-06-11");
184        assert_eq!(st.items, vec!["Water the plants".to_string()]);
185        assert_eq!(st.rotation, 2);
186        assert_eq!(st.prompted_date, "2026-06-11");
187        // Migration: a store with items predates the flag but is a chore-user.
188        assert!(st.ever_used_chores);
189    }
190
191    #[test]
192    fn from_snapshot_drops_stale_day() {
193        let snap = ChoresSnapshot {
194            date: "2026-06-10".to_string(),
195            items: vec!["Yesterday's chore".to_string()],
196            rotation: 5,
197            prompted_date: "2026-06-10".to_string(),
198            ever_used_chores: false,
199        };
200        let st = ChoresState::from_snapshot(snap, "2026-06-11");
201        assert_eq!(st.date, "2026-06-11");
202        assert!(st.items.is_empty());
203        assert_eq!(st.rotation, 0);
204        // A stale day's "already prompted" marker must not suppress today's
205        // prompt.
206        assert_eq!(st.prompted_date, "");
207        // ...but "has ever used chores" persists across the day boundary
208        // (migrated here from yesterday's non-empty list), so a returning
209        // user still gets this morning's nudge.
210        assert!(st.ever_used_chores);
211    }
212
213    #[test]
214    fn from_snapshot_preserves_ever_used_across_days() {
215        // A chore-user who cleared their list: empty items, but the flag was
216        // already set. Rolling into a new day keeps the flag even though the
217        // (empty) list is discarded.
218        let snap = ChoresSnapshot {
219            date: "2026-06-10".to_string(),
220            items: vec![],
221            rotation: 0,
222            prompted_date: "2026-06-10".to_string(),
223            ever_used_chores: true,
224        };
225        let st = ChoresState::from_snapshot(snap, "2026-06-11");
226        assert!(st.items.is_empty());
227        assert!(st.ever_used_chores);
228    }
229
230    #[test]
231    fn rollover_clears_on_new_day() {
232        let mut st = state_with(&["a", "b"], 3);
233        st.prompted_date = "2026-06-11".to_string();
234        assert!(rollover_if_new_day(&mut st, "2026-06-12"));
235        assert!(st.items.is_empty());
236        assert_eq!(st.rotation, 0);
237        assert_eq!(st.date, "2026-06-12");
238        assert_eq!(st.prompted_date, "");
239        // The daily rollover must NOT wipe the chore-user flag, or a user with
240        // the app running across midnight would stop getting the morning
241        // nudge every day.
242        assert!(st.ever_used_chores);
243    }
244
245    #[test]
246    fn morning_prompt_fires_for_returning_user_with_empty_list() {
247        let st = returning_user_empty();
248        assert!(should_prompt_morning_chores(
249            true,
250            true,
251            9 * 60,
252            &st,
253            "2026-06-11"
254        ));
255    }
256
257    #[test]
258    fn morning_prompt_skips_when_user_never_used_chores() {
259        // Empty list + never used chores: every other condition is met, but a
260        // user who doesn't use chores must not have Preferences popped open
261        // every morning on a permanently-empty list.
262        let st = state_with(&[], 0);
263        assert!(!st.ever_used_chores);
264        assert!(!should_prompt_morning_chores(
265            true,
266            true,
267            9 * 60,
268            &st,
269            "2026-06-11"
270        ));
271    }
272
273    #[test]
274    fn morning_prompt_skips_when_disabled() {
275        let st = returning_user_empty();
276        assert!(!should_prompt_morning_chores(
277            false,
278            true,
279            9 * 60,
280            &st,
281            "2026-06-11"
282        ));
283    }
284
285    #[test]
286    fn morning_prompt_skips_outside_work_window() {
287        let st = returning_user_empty();
288        assert!(!should_prompt_morning_chores(
289            true,
290            false,
291            9 * 60,
292            &st,
293            "2026-06-11"
294        ));
295    }
296
297    #[test]
298    fn morning_prompt_skips_before_the_morning_floor() {
299        // All-day work window: in_window is true even at 02:00, but the floor
300        // keeps the prompt from firing at the post-midnight rollover.
301        let st = returning_user_empty();
302        assert!(!should_prompt_morning_chores(
303            true,
304            true,
305            2 * 60,
306            &st,
307            "2026-06-11"
308        ));
309        assert!(should_prompt_morning_chores(
310            true,
311            true,
312            MORNING_PROMPT_FLOOR_MIN,
313            &st,
314            "2026-06-11"
315        ));
316    }
317
318    #[test]
319    fn morning_prompt_skips_when_list_already_has_chores() {
320        let st = state_with(&["Water the plants"], 0);
321        assert!(!should_prompt_morning_chores(
322            true,
323            true,
324            9 * 60,
325            &st,
326            "2026-06-11"
327        ));
328    }
329
330    #[test]
331    fn morning_prompt_skips_when_already_prompted_today() {
332        let mut st = returning_user_empty();
333        st.prompted_date = "2026-06-11".to_string();
334        assert!(!should_prompt_morning_chores(
335            true,
336            true,
337            9 * 60,
338            &st,
339            "2026-06-11"
340        ));
341        // …but a new day re-enables it.
342        assert!(should_prompt_morning_chores(
343            true,
344            true,
345            9 * 60,
346            &st,
347            "2026-06-12"
348        ));
349    }
350
351    #[test]
352    fn rollover_noop_same_day() {
353        let mut st = state_with(&["a", "b"], 3);
354        assert!(!rollover_if_new_day(&mut st, "2026-06-11"));
355        assert_eq!(st.items.len(), 2);
356        assert_eq!(st.rotation, 3);
357    }
358
359    #[test]
360    fn next_prompt_empty_is_none() {
361        let mut st = state_with(&[], 0);
362        assert_eq!(next_prompt(&mut st), None);
363        assert_eq!(st.rotation, 0);
364    }
365
366    #[test]
367    fn next_prompt_cycles_through_items() {
368        let mut st = state_with(&["a", "b", "c"], 0);
369        assert_eq!(next_prompt(&mut st).as_deref(), Some("a"));
370        assert_eq!(next_prompt(&mut st).as_deref(), Some("b"));
371        assert_eq!(next_prompt(&mut st).as_deref(), Some("c"));
372        assert_eq!(next_prompt(&mut st).as_deref(), Some("a"));
373        assert_eq!(st.rotation, 4);
374    }
375
376    #[test]
377    fn prompt_for_break_only_long_draws() {
378        let mut st = state_with(&["a", "b"], 0);
379        assert_eq!(prompt_for_break(BreakKind::Micro, &mut st), None);
380        assert_eq!(prompt_for_break(BreakKind::Sleep, &mut st), None);
381        // Neither micro nor sleep advanced the cursor.
382        assert_eq!(st.rotation, 0);
383        assert_eq!(
384            prompt_for_break(BreakKind::Long, &mut st).as_deref(),
385            Some("a")
386        );
387        assert_eq!(st.rotation, 1);
388    }
389
390    #[test]
391    fn persist_chores_swallows_a_save_failure() {
392        // A path whose parent is a regular file can't be created, so the save
393        // fails — `persist_chores` must log and return, never panic.
394        let dir = crate::test_support::temp_dir();
395        let blocker = dir.path().join("blocker");
396        std::fs::write(&blocker, b"x").unwrap();
397        let unwritable = blocker.join("chores.json");
398        persist_chores(&unwritable, &state_with(&["a"], 0));
399    }
400}