1use std::path::Path;
2
3use log::error;
4use serde::Serialize;
5
6use super::types::BreakKind;
7use crate::chores_store::{self, ChoresSnapshot};
8
9#[derive(Debug, Clone, Default, Serialize)]
17pub struct ChoresState {
18 pub date: String,
19 pub items: Vec<String>,
20 pub rotation: u64,
21 pub prompted_date: String,
25 pub ever_used_chores: bool,
29}
30
31impl ChoresState {
32 pub fn from_snapshot(snap: ChoresSnapshot, today: &str) -> Self {
36 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 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
73pub 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
88const MORNING_PROMPT_FLOOR_MIN: u32 = 5 * 60;
92
93pub 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
116pub 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
124pub 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
137pub 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 ever_used_chores: !items.is_empty(),
161 }
162 }
163
164 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 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 assert_eq!(st.prompted_date, "");
207 assert!(st.ever_used_chores);
211 }
212
213 #[test]
214 fn from_snapshot_preserves_ever_used_across_days() {
215 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 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 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 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 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 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 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}