Skip to main content

entracte_lib/scheduler/
screen_time.rs

1use std::path::Path;
2
3use log::error;
4use serde::Serialize;
5
6use crate::screen_time_store::{self, ScreenTimeSnapshot};
7
8/// Cumulative active-screen time for the current local day, plus the
9/// epoch of the most recent wind-down reminder (so we don't re-nag
10/// every tick after the budget is crossed).
11#[derive(Debug, Clone, Default, Serialize)]
12pub struct ScreenTimeState {
13    pub date: String,
14    pub seconds: u64,
15    pub last_reminder_epoch_secs: Option<u64>,
16}
17
18impl ScreenTimeState {
19    /// Build state from a persisted snapshot. If the snapshot is from a
20    /// previous day, the counter resets — we don't carry yesterday's
21    /// usage into today.
22    pub fn from_snapshot(snap: ScreenTimeSnapshot, today: &str) -> Self {
23        if snap.date == today {
24            Self {
25                date: snap.date,
26                seconds: snap.seconds,
27                last_reminder_epoch_secs: snap.last_reminder_epoch_secs,
28            }
29        } else {
30            Self {
31                date: today.to_string(),
32                seconds: 0,
33                last_reminder_epoch_secs: None,
34            }
35        }
36    }
37
38    /// Convert this state into the on-disk wire format. The inverse of
39    /// `from_snapshot`.
40    pub fn to_snapshot(&self) -> ScreenTimeSnapshot {
41        ScreenTimeSnapshot {
42            date: self.date.clone(),
43            seconds: self.seconds,
44            last_reminder_epoch_secs: self.last_reminder_epoch_secs,
45        }
46    }
47}
48
49/// Mutate `state` to a fresh-day baseline if `today` differs from its
50/// stored date. Returns `true` iff a rollover happened (so the caller
51/// can decide whether to persist).
52pub fn rollover_if_new_day(state: &mut ScreenTimeState, today: &str) -> bool {
53    if state.date != today {
54        state.date = today.to_string();
55        state.seconds = 0;
56        state.last_reminder_epoch_secs = None;
57        true
58    } else {
59        false
60    }
61}
62
63/// Decide whether to fire the daily-budget reminder this tick. Returns
64/// `true` when the feature is on, the budget is non-zero, the counter
65/// has crossed the budget, and either no reminder has fired yet today
66/// or the snooze window has elapsed (`remind_again_secs == 0` means
67/// fire once per day only).
68pub fn should_remind_screen_time(
69    enabled: bool,
70    counter_secs: u64,
71    budget_secs: u64,
72    last_reminder_epoch_secs: Option<u64>,
73    remind_again_secs: u64,
74    now_epoch_secs: u64,
75) -> bool {
76    if !enabled || budget_secs == 0 {
77        return false;
78    }
79    if counter_secs < budget_secs {
80        return false;
81    }
82    match last_reminder_epoch_secs {
83        None => true,
84        Some(_) if remind_again_secs == 0 => false,
85        Some(prev) => now_epoch_secs.saturating_sub(prev) >= remind_again_secs,
86    }
87}
88
89/// Atomically write `state` to disk. Called every tick that the
90/// counter changes, plus on rollover and after firing a reminder.
91pub fn persist_screen_time(path: &Path, state: &ScreenTimeState) {
92    let snap = state.to_snapshot();
93    if let Err(e) = screen_time_store::save(path, &snap) {
94        error!("screen_time_store: failed to save {}: {e}", path.display());
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn should_remind_screen_time_disabled_never_fires() {
104        assert!(!should_remind_screen_time(
105            false, 28_800, 28_800, None, 3600, 1_000_000
106        ));
107    }
108
109    #[test]
110    fn should_remind_screen_time_under_budget_does_not_fire() {
111        assert!(!should_remind_screen_time(
112            true, 28_739, 28_800, None, 3600, 1_000_000
113        ));
114    }
115
116    #[test]
117    fn should_remind_screen_time_just_crossed_fires_first_time() {
118        assert!(should_remind_screen_time(
119            true, 28_800, 28_800, None, 3600, 1_000_000
120        ));
121    }
122
123    #[test]
124    fn should_remind_screen_time_increment_by_one_minute_crosses_budget() {
125        let budget_secs: u64 = 480 * 60;
126        let counter_before: u64 = budget_secs - 60;
127        assert!(!should_remind_screen_time(
128            true,
129            counter_before,
130            budget_secs,
131            None,
132            3600,
133            1_000_000
134        ));
135        let counter_after = counter_before + 60;
136        assert!(should_remind_screen_time(
137            true,
138            counter_after,
139            budget_secs,
140            None,
141            3600,
142            1_000_000
143        ));
144    }
145
146    #[test]
147    fn should_remind_screen_time_snooze_blocks_repeat() {
148        let budget_secs: u64 = 480 * 60;
149        let now: u64 = 1_000_000;
150        let ten_min_ago = now - 600;
151        assert!(!should_remind_screen_time(
152            true,
153            budget_secs + 120,
154            budget_secs,
155            Some(ten_min_ago),
156            3600,
157            now
158        ));
159    }
160
161    #[test]
162    fn should_remind_screen_time_after_snooze_fires_again() {
163        let budget_secs: u64 = 480 * 60;
164        let now: u64 = 1_000_000;
165        let an_hour_ago = now - 3600;
166        assert!(should_remind_screen_time(
167            true,
168            budget_secs + 120,
169            budget_secs,
170            Some(an_hour_ago),
171            3600,
172            now
173        ));
174    }
175
176    #[test]
177    fn should_remind_screen_time_zero_remind_again_fires_only_once() {
178        let budget_secs: u64 = 480 * 60;
179        let now: u64 = 1_000_000;
180        assert!(should_remind_screen_time(
181            true,
182            budget_secs,
183            budget_secs,
184            None,
185            0,
186            now
187        ));
188        assert!(!should_remind_screen_time(
189            true,
190            budget_secs + 7200,
191            budget_secs,
192            Some(now - 7200),
193            0,
194            now
195        ));
196    }
197
198    #[test]
199    fn should_remind_screen_time_zero_budget_disabled_path() {
200        assert!(!should_remind_screen_time(
201            true, 99_999, 0, None, 3600, 1_000_000
202        ));
203    }
204
205    #[test]
206    fn rollover_resets_counter_at_midnight() {
207        let mut st = ScreenTimeState {
208            date: "2026-05-15".into(),
209            seconds: 28_800,
210            last_reminder_epoch_secs: Some(1_000_000),
211        };
212        let rolled = rollover_if_new_day(&mut st, "2026-05-16");
213        assert!(rolled);
214        assert_eq!(st.date, "2026-05-16");
215        assert_eq!(st.seconds, 0);
216        assert!(st.last_reminder_epoch_secs.is_none());
217    }
218
219    #[test]
220    fn rollover_noop_when_same_day() {
221        let mut st = ScreenTimeState {
222            date: "2026-05-15".into(),
223            seconds: 1234,
224            last_reminder_epoch_secs: Some(99),
225        };
226        let rolled = rollover_if_new_day(&mut st, "2026-05-15");
227        assert!(!rolled);
228        assert_eq!(st.seconds, 1234);
229        assert_eq!(st.last_reminder_epoch_secs, Some(99));
230    }
231
232    #[test]
233    fn screen_time_state_from_snapshot_keeps_today_data() {
234        let snap = ScreenTimeSnapshot {
235            date: "2026-05-15".into(),
236            seconds: 500,
237            last_reminder_epoch_secs: Some(42),
238        };
239        let st = ScreenTimeState::from_snapshot(snap, "2026-05-15");
240        assert_eq!(st.seconds, 500);
241        assert_eq!(st.last_reminder_epoch_secs, Some(42));
242    }
243
244    #[test]
245    fn screen_time_state_from_snapshot_resets_on_stale_date() {
246        let snap = ScreenTimeSnapshot {
247            date: "2026-05-14".into(),
248            seconds: 28_800,
249            last_reminder_epoch_secs: Some(42),
250        };
251        let st = ScreenTimeState::from_snapshot(snap, "2026-05-15");
252        assert_eq!(st.date, "2026-05-15");
253        assert_eq!(st.seconds, 0);
254        assert!(st.last_reminder_epoch_secs.is_none());
255    }
256}