1//! The day's time as hearts: how many quarters of the child's allowance are left, and what the details page says.
2//!
3//! The picture is Zelda's heart containers: [`HEARTS`] of them, each four quarters, depleted from the right.
4//! Everything here is a pure function of numbers the keeper already has; the shell draws and words it.
5
6use crate::limits::Quiet;
7
8/// How many hearts the side panel shows.
9pub const HEARTS: u8 = 3;
10
11/// Quarters in all hearts.
12pub const QUARTERS: u8 = HEARTS * 4;
13
14/// The quarters of the heart row still full, out of [`QUARTERS`], for a day of `allowance_ms` of which `used_ms` is spent.
15///
16/// A quarter is lost as soon as more than the previous quarters' worth is spent, the way a hit takes a quarter at
17/// once. So: nothing spent is all of them; anything spent is at most one fewer than all; time still left is at
18/// least one; nothing left is none. Spent beyond the allowance is none, never negative.
19pub fn heart_quarters(allowance_ms: u64, used_ms: u64) -> u8 {
20    if allowance_ms == 0 || used_ms >= allowance_ms {
21        return 0;
22    }
23    if used_ms == 0 {
24        return QUARTERS;
25    }
26    let total = u64::from(QUARTERS);
27    // Ceiling division, in u128 so a huge allowance cannot overflow the product.
28    let lost = (u128::from(used_ms) * u128::from(total)).div_ceil(u128::from(allowance_ms)) as u64;
29    (total - lost.min(total)).clamp(1, total - 1) as u8
30}
31
32/// What the hearts' details page needs, all of it already worked out by the core. Minutes are whole minutes.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct TimeSheet {
35    /// The day's allowance (the limit and anything the grown-ups added today); `None` when there is no limit.
36    pub allowance_minutes: Option<u32>,
37    /// Minutes left of it, rounded up so thirty seconds left says one; `None` when there is no limit.
38    pub minutes_left: Option<u32>,
39    /// Quarters of the hearts still full, out of [`QUARTERS`]; `None` when there is no limit, since there is
40    /// nothing to deplete and a row of full hearts would say a limit was there.
41    pub quarters: Option<u8>,
42    /// The quiet hours, minutes since midnight, if set.
43    pub quiet: Option<Quiet>,
44    /// Minutes until quiet time next starts; `None` with no quiet hours, or when it is quiet time now.
45    pub minutes_until_quiet: Option<u32>,
46}
47
48impl TimeSheet {
49    /// The sheet for a day: `limit_minutes` and `extra_ms` (what the grown-ups added) make the allowance, `used_ms`
50    /// is spent across every device, `minute_of_day` is her local time.
51    pub fn of(limit_minutes: Option<u32>, extra_ms: u64, used_ms: u64, quiet: Option<Quiet>, minute_of_day: u16) -> TimeSheet {
52        let allowance_ms = limit_minutes.map(|m| u64::from(m) * 60_000 + extra_ms);
53        TimeSheet {
54            allowance_minutes: allowance_ms.map(|a| a.div_ceil(60_000) as u32),
55            minutes_left: allowance_ms.map(|a| a.saturating_sub(used_ms).div_ceil(60_000) as u32),
56            quarters: allowance_ms.map(|a| heart_quarters(a, used_ms)),
57            quiet,
58            minutes_until_quiet: quiet.and_then(|q| q.starts_in(minute_of_day)).map(u32::from),
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    const MIN: u64 = 60_000;
68
69    #[test]
70    fn nothing_spent_is_every_quarter() {
71        assert_eq!(heart_quarters(30 * MIN, 0), QUARTERS);
72    }
73
74    #[test]
75    fn one_minute_spent_already_costs_a_quarter() {
76        assert_eq!(heart_quarters(30 * MIN, MIN), QUARTERS - 1);
77        assert_eq!(heart_quarters(30 * MIN, 1), QUARTERS - 1);
78    }
79
80    #[test]
81    fn a_quarter_is_lost_when_more_than_its_share_is_spent() {
82        // 30 minutes over 12 quarters is 150 seconds a quarter.
83        let q = 150_000;
84        assert_eq!(heart_quarters(30 * MIN, q), QUARTERS - 1, "exactly one quarter's worth spent");
85        assert_eq!(heart_quarters(30 * MIN, q + 1), QUARTERS - 2, "a hair over it");
86        assert_eq!(heart_quarters(30 * MIN, 6 * q), 6, "half spent is half left: two hearts' worth of three");
87    }
88
89    #[test]
90    fn time_still_left_always_shows_a_quarter() {
91        assert_eq!(heart_quarters(30 * MIN, 30 * MIN - 1), 1);
92        assert_eq!(heart_quarters(30 * MIN, 29 * MIN), 1);
93    }
94
95    #[test]
96    fn all_used_and_over_used_are_none() {
97        assert_eq!(heart_quarters(30 * MIN, 30 * MIN), 0);
98        assert_eq!(heart_quarters(30 * MIN, 45 * MIN), 0);
99        assert_eq!(heart_quarters(30 * MIN, u64::MAX), 0);
100    }
101
102    #[test]
103    fn a_huge_allowance_does_not_overflow() {
104        assert_eq!(heart_quarters(u64::MAX, u64::MAX - 1), 1);
105    }
106
107    #[test]
108    fn no_limit_has_nothing_to_deplete() {
109        let s = TimeSheet::of(None, 0, 40 * MIN, None, 600);
110        assert_eq!((s.allowance_minutes, s.minutes_left, s.quarters), (None, None, None));
111    }
112
113    #[test]
114    fn the_sheet_counts_added_time_in_the_allowance() {
115        let s = TimeSheet::of(Some(30), 10 * MIN, 5 * MIN, None, 600);
116        assert_eq!(s.allowance_minutes, Some(40));
117        assert_eq!(s.minutes_left, Some(35));
118        assert_eq!(s.quarters, Some(heart_quarters(40 * MIN, 5 * MIN)));
119    }
120
121    #[test]
122    fn thirty_seconds_left_says_one_minute_and_over_used_says_none() {
123        assert_eq!(TimeSheet::of(Some(30), 0, 30 * MIN - 30_000, None, 0).minutes_left, Some(1));
124        let over = TimeSheet::of(Some(30), 0, 50 * MIN, None, 0);
125        assert_eq!((over.minutes_left, over.quarters), (Some(0), Some(0)));
126    }
127
128    #[test]
129    fn quiet_time_is_counted_from_now() {
130        let q = Quiet::new(19 * 60 + 30, 7 * 60).unwrap();
131        assert_eq!(TimeSheet::of(None, 0, 0, Some(q), 17 * 60 + 30).minutes_until_quiet, Some(120));
132        assert_eq!(TimeSheet::of(None, 0, 0, Some(q), 3 * 60).minutes_until_quiet, None, "it is quiet now");
133        assert_eq!(TimeSheet::of(None, 0, 0, None, 3 * 60).minutes_until_quiet, None);
134    }
135}