Skip to main content

very_normal/
coach.rs

1//! The coach: bossy about doing enough, and just as bossy about stopping.
2//!
3//! Short, frequent sessions teach fingers faster than long ones, and tired
4//! sloppy reps teach sloppy habits. So: a daily goal, a nudge to wrap up a
5//! long session, a forced break after a very long one, a daily cap, and a
6//! warning when accuracy slides.
7
8use chrono::{DateTime, Days, Local, NaiveDate, TimeZone};
9
10use crate::store::Run;
11
12/// Minutes a day to aim for.
13pub const DAILY_GOAL: f64 = 20.0;
14/// Minutes a day that count toward a streak.
15pub const STREAK_MIN: f64 = 10.0;
16/// Minutes a day after which the coach sends you away.
17pub const DAILY_CAP: f64 = 45.0;
18/// Minutes of continuous practice before a nudge to wrap up.
19pub const SESSION_NUDGE: f64 = 15.0;
20/// Minutes of continuous practice before a forced break.
21pub const SESSION_LIMIT: f64 = 25.0;
22/// A pause this long (minutes) ends a session, and is the break the coach asks for.
23pub const BREAK: f64 = 5.0;
24/// Old runs carry no duration; count each as this many seconds.
25const UNKNOWN_SECS: f64 = 60.0;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Level {
29    /// Doing well.
30    Good,
31    /// Keep going, or wrap up soon.
32    Nudge,
33    /// Stop: starting another drill needs an override.
34    Stop,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub struct Advice {
39    pub level: Level,
40    pub message: String,
41    pub today_minutes: f64,
42    pub session_minutes: f64,
43    pub streak: u32,
44}
45
46impl Advice {
47    pub fn blocks(&self) -> bool {
48        self.level == Level::Stop
49    }
50}
51
52fn secs(run: &Run) -> f64 {
53    if run.secs > 0.0 {
54        run.secs
55    } else {
56        UNKNOWN_SECS
57    }
58}
59
60fn local(ms: u64) -> DateTime<Local> {
61    Local
62        .timestamp_millis_opt(ms as i64)
63        .single()
64        .unwrap_or_else(Local::now)
65}
66
67fn day(ms: u64) -> NaiveDate {
68    local(ms).date_naive()
69}
70
71/// Today's local date as YYYY-MM-DD.
72pub fn today(now_ms: u64) -> String {
73    day(now_ms).to_string()
74}
75
76fn minutes_on(runs: &[Run], date: NaiveDate) -> f64 {
77    // an empty f64 sum is -0.0, which prints as "-0"
78    0.0 + runs
79        .iter()
80        .filter(|r| day(r.t) == date)
81        .map(secs)
82        .sum::<f64>()
83        / 60.0
84}
85
86/// Days in a row with at least `STREAK_MIN` minutes, counting back from today
87/// (or from yesterday, if today hasn't got there yet).
88fn streak(runs: &[Run], today: NaiveDate) -> u32 {
89    let mut date = if minutes_on(runs, today) >= STREAK_MIN {
90        today
91    } else {
92        match today.checked_sub_days(Days::new(1)) {
93            Some(d) => d,
94            None => return 0,
95        }
96    };
97    let mut n = 0;
98    while minutes_on(runs, date) >= STREAK_MIN {
99        n += 1;
100        match date.checked_sub_days(Days::new(1)) {
101            Some(d) => date = d,
102            None => break,
103        }
104    }
105    n
106}
107
108/// The runs in the current session: back from the latest run while the gaps
109/// between them stay under the break length. Empty once you've had a break.
110fn session(runs: &[Run], now_ms: u64) -> &[Run] {
111    let gap_ms = (BREAK * 60_000.0) as u64;
112    let Some(last) = runs.last() else {
113        return &runs[..0];
114    };
115    if now_ms.saturating_sub(last.t) >= gap_ms {
116        return &runs[..0];
117    }
118    let mut start = runs.len() - 1;
119    while start > 0 {
120        let began = runs[start]
121            .t
122            .saturating_sub((secs(&runs[start]) * 1000.0) as u64);
123        if began.saturating_sub(runs[start - 1].t) >= gap_ms {
124            break;
125        }
126        start -= 1;
127    }
128    &runs[start..]
129}
130
131fn plural(n: f64, word: &str) -> String {
132    let n = n.round().max(0.0) as i64;
133    if n == 1 {
134        format!("1 {word}")
135    } else {
136        format!("{n} {word}s")
137    }
138}
139
140pub fn assess(runs: &[Run], now_ms: u64) -> Advice {
141    let today = day(now_ms);
142    let today_minutes = minutes_on(runs, today);
143    let streak = streak(runs, today);
144    let current = session(runs, now_ms);
145    let session_minutes = match (current.first(), current.last()) {
146        (Some(first), Some(last)) => {
147            let began = first.t.saturating_sub((secs(first) * 1000.0) as u64);
148            last.t.saturating_sub(began) as f64 / 60_000.0
149        }
150        _ => 0.0,
151    };
152    let since_last = runs.last().map_or(f64::INFINITY, |r| {
153        now_ms.saturating_sub(r.t) as f64 / 60_000.0
154    });
155    let advice = |level, message: String| Advice {
156        level,
157        message,
158        today_minutes,
159        session_minutes,
160        streak,
161    };
162
163    if session_minutes >= SESSION_LIMIT {
164        let wait = (BREAK - since_last).max(0.5);
165        return advice(
166            Level::Stop,
167            format!(
168                "Break time, babe. {} straight is plenty. Hands off for {}: stretch, water, look out a window.",
169                plural(session_minutes, "minute"),
170                plural(wait, "minute")
171            ),
172        );
173    }
174    if today_minutes >= DAILY_CAP {
175        return advice(
176            Level::Stop,
177            format!(
178                "{} today. Your fingers have clocked off; the rest happens while you sleep. Come back tomorrow.",
179                plural(today_minutes, "minute")
180            ),
181        );
182    }
183    if current.len() >= 3 {
184        let last3: Vec<f64> = current.iter().rev().take(3).rev().map(|r| r.acc).collect();
185        if last3[0] > last3[1] && last3[1] > last3[2] && last3[2] < 92.0 {
186            return advice(
187                Level::Nudge,
188                format!(
189                    "Accuracy's sliding ({:.0}% → {:.0}%). Sloppy reps teach sloppy fingers. Take five.",
190                    last3[0], last3[2]
191                ),
192            );
193        }
194    }
195    if session_minutes >= SESSION_NUDGE {
196        return advice(
197            Level::Nudge,
198            format!(
199                "{} in. Finish this drill, then go stretch.",
200                plural(session_minutes, "minute")
201            ),
202        );
203    }
204    if today_minutes >= DAILY_GOAL {
205        return advice(
206            Level::Good,
207            format!(
208                "{} today. Goal smashed. Anything more is a bonus, so keep it short.",
209                plural(today_minutes, "minute")
210            ),
211        );
212    }
213    let left = DAILY_GOAL - today_minutes;
214    let yesterday = today.checked_sub_days(Days::new(1));
215    let skipped = yesterday.is_some_and(|y| minutes_on(runs, y) < STREAK_MIN) && !runs.is_empty();
216    let message = if today_minutes < 0.5 && skipped {
217        format!(
218            "You skipped yesterday. Don't make it a habit: {DAILY_GOAL:.0} minutes today, in short bursts."
219        )
220    } else if today_minutes < 0.5 {
221        format!("Nothing yet today. {DAILY_GOAL:.0} minutes, in two or three short bursts. Go.")
222    } else {
223        format!(
224            "{} today, {} to go. Keep going, babe.",
225            plural(today_minutes, "minute"),
226            plural(left, "minute")
227        )
228    };
229    let message = if streak >= 2 {
230        format!("{message} {streak}-day streak, don't you dare break it.")
231    } else {
232        message
233    };
234    advice(Level::Nudge, message)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    const MIN: u64 = 60_000;
242
243    /// Noon today, local time, in epoch milliseconds.
244    fn noon() -> u64 {
245        Local::now()
246            .date_naive()
247            .and_hms_opt(12, 0, 0)
248            .and_then(|t| Local.from_local_datetime(&t).single())
249            .unwrap()
250            .timestamp_millis() as u64
251    }
252
253    fn run(end_ms: u64, minutes: f64, acc: f64) -> Run {
254        Run {
255            t: end_ms,
256            stage: 1,
257            mode: "words".into(),
258            wpm: 15.0,
259            acc,
260            secs: minutes * 60.0,
261        }
262    }
263
264    /// Back-to-back runs of `each` minutes, the last ending at `end`.
265    fn block(end: u64, count: u64, each: f64) -> Vec<Run> {
266        let step = (each * 60_000.0) as u64;
267        (0..count)
268            .map(|i| run(end - (count - 1 - i) * step, each, 98.0))
269            .collect()
270    }
271
272    #[test]
273    fn nags_when_nothing_has_happened() {
274        let a = assess(&[], noon());
275        assert_eq!(a.level, Level::Nudge);
276        assert!(a.message.contains("Nothing yet today"));
277    }
278
279    #[test]
280    fn calls_out_a_skipped_day() {
281        let two_days_ago = run(noon() - 48 * 60 * MIN, 15.0, 98.0);
282        let a = assess(&[two_days_ago], noon());
283        assert!(a.message.contains("skipped yesterday"), "{}", a.message);
284    }
285
286    #[test]
287    fn counts_down_to_the_goal() {
288        let runs = block(noon(), 4, 2.0);
289        let a = assess(&runs, noon() + 10 * MIN);
290        assert_eq!(a.level, Level::Nudge);
291        assert!(
292            a.message.contains("8 minutes today, 12 minutes to go"),
293            "{}",
294            a.message
295        );
296    }
297
298    #[test]
299    fn nudges_a_long_session_then_stops_it() {
300        let runs = block(noon(), 8, 2.0);
301        let a = assess(&runs, noon() + MIN);
302        assert_eq!(a.level, Level::Nudge);
303        assert!(a.message.contains("16 minutes in"), "{}", a.message);
304
305        let runs = block(noon(), 13, 2.0);
306        let a = assess(&runs, noon() + MIN);
307        assert_eq!(a.level, Level::Stop);
308        assert!(a.message.contains("Break time"));
309        assert!(a.blocks());
310    }
311
312    #[test]
313    fn a_break_resets_the_session() {
314        let runs = block(noon(), 13, 2.0);
315        let a = assess(&runs, noon() + 6 * MIN);
316        assert_eq!(a.session_minutes, 0.0);
317        assert_eq!(a.level, Level::Good, "{}", a.message);
318    }
319
320    #[test]
321    fn caps_the_day() {
322        let mut runs = block(noon() - 120 * MIN, 12, 2.0);
323        runs.extend(block(noon(), 12, 2.0));
324        let a = assess(&runs, noon() + 10 * MIN);
325        assert_eq!(a.level, Level::Stop);
326        assert!(a.message.contains("48 minutes today"), "{}", a.message);
327    }
328
329    #[test]
330    fn notices_sliding_accuracy() {
331        let mut runs = block(noon(), 3, 2.0);
332        runs[0].acc = 97.0;
333        runs[1].acc = 94.0;
334        runs[2].acc = 89.0;
335        let a = assess(&runs, noon() + MIN);
336        assert!(a.message.contains("sliding"), "{}", a.message);
337    }
338
339    #[test]
340    fn counts_a_streak() {
341        let day = 24 * 60 * MIN;
342        let mut runs = Vec::new();
343        for back in (1..=3).rev() {
344            runs.extend(block(noon() - back * day, 6, 2.0));
345        }
346        let a = assess(&runs, noon());
347        assert_eq!(a.streak, 3);
348        assert!(a.message.contains("3-day streak"), "{}", a.message);
349    }
350}