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
140/// Accuracy points below the session's best that count as a dip.
141const ACCURACY_DIP: f64 = 3.0;
142/// Share of the session's best speed that counts as a dip.
143const SPEED_DIP: f64 = 0.85;
144
145/// Spots a session getting worse rather than better, which is usually
146/// frustration or tired fingers, and says so kindly.
147fn deterioration(session: &[Run]) -> Option<String> {
148    let last = session.last()?;
149    // compare like with like: recall is faster than words, for instance
150    let same: Vec<&Run> = session
151        .iter()
152        .filter(|r| r.mode == last.mode && r.stage == last.stage)
153        .collect();
154    let take_five = "That's usually frustration or tired fingers talking, not you getting worse. Take five; it'll still be here.";
155
156    if last.mode == "words" && last.stage > 0 && same.len() >= 3 {
157        let misses = same
158            .iter()
159            .rev()
160            .take(3)
161            .all(|r| r.acc < crate::gate::WORDS_ACCURACY);
162        if misses {
163            return Some(format!(
164                "Three in a row under {:.0}%. {take_five}",
165                crate::gate::WORDS_ACCURACY
166            ));
167        }
168    }
169    if same.len() < 4 {
170        return None;
171    }
172    let (earlier, recent) = same.split_at(same.len() - 2);
173    let best_acc = earlier.iter().map(|r| r.acc).fold(f64::MIN, f64::max);
174    let best_wpm = earlier.iter().map(|r| r.wpm).fold(f64::MIN, f64::max);
175    let recent_acc = recent.iter().map(|r| r.acc).sum::<f64>() / 2.0;
176    let recent_wpm = recent.iter().map(|r| r.wpm).sum::<f64>() / 2.0;
177    if best_acc - recent_acc >= ACCURACY_DIP {
178        return Some(format!(
179            "Your accuracy's dipped ({best_acc:.0}% earlier, {recent_acc:.0}% lately). {take_five}"
180        ));
181    }
182    if recent_wpm < best_wpm * SPEED_DIP {
183        return Some(format!(
184            "You've slowed down ({best_wpm:.0} WPM earlier, {recent_wpm:.0} lately). {take_five}"
185        ));
186    }
187    None
188}
189
190pub fn assess(runs: &[Run], now_ms: u64) -> Advice {
191    let today = day(now_ms);
192    let today_minutes = minutes_on(runs, today);
193    let streak = streak(runs, today);
194    let current = session(runs, now_ms);
195    let session_minutes = match (current.first(), current.last()) {
196        (Some(first), Some(last)) => {
197            let began = first.t.saturating_sub((secs(first) * 1000.0) as u64);
198            last.t.saturating_sub(began) as f64 / 60_000.0
199        }
200        _ => 0.0,
201    };
202    let since_last = runs.last().map_or(f64::INFINITY, |r| {
203        now_ms.saturating_sub(r.t) as f64 / 60_000.0
204    });
205    let advice = |level, message: String| Advice {
206        level,
207        message,
208        today_minutes,
209        session_minutes,
210        streak,
211    };
212
213    if session_minutes >= SESSION_LIMIT {
214        let wait = (BREAK - since_last).max(0.5);
215        return advice(
216            Level::Stop,
217            format!(
218                "Break time, babe. {} straight is plenty. Hands off for {}: stretch, water, look out a window.",
219                plural(session_minutes, "minute"),
220                plural(wait, "minute")
221            ),
222        );
223    }
224    if today_minutes >= DAILY_CAP {
225        return advice(
226            Level::Stop,
227            format!(
228                "{} today. Your fingers have clocked off; the rest happens while you sleep. Come back tomorrow.",
229                plural(today_minutes, "minute")
230            ),
231        );
232    }
233    if let Some(message) = deterioration(current) {
234        return advice(Level::Nudge, message);
235    }
236    if session_minutes >= SESSION_NUDGE {
237        return advice(
238            Level::Nudge,
239            format!(
240                "{} in. Finish this drill, then go stretch.",
241                plural(session_minutes, "minute")
242            ),
243        );
244    }
245    if today_minutes >= DAILY_GOAL {
246        return advice(
247            Level::Good,
248            format!(
249                "{} today. Goal smashed. Anything more is a bonus, so keep it short.",
250                plural(today_minutes, "minute")
251            ),
252        );
253    }
254    let left = DAILY_GOAL - today_minutes;
255    let yesterday = today.checked_sub_days(Days::new(1));
256    let skipped = yesterday.is_some_and(|y| minutes_on(runs, y) < STREAK_MIN) && !runs.is_empty();
257    let message = if today_minutes < 0.5 && skipped {
258        format!(
259            "You skipped yesterday. Don't make it a habit: {DAILY_GOAL:.0} minutes today, in short bursts."
260        )
261    } else if today_minutes < 0.5 {
262        format!("Nothing yet today. {DAILY_GOAL:.0} minutes, in two or three short bursts. Go.")
263    } else {
264        format!(
265            "{} today, {} to go. Keep going, babe.",
266            plural(today_minutes, "minute"),
267            plural(left, "minute")
268        )
269    };
270    let message = if streak >= 2 {
271        format!("{message} {streak}-day streak, don't you dare break it.")
272    } else {
273        message
274    };
275    advice(Level::Nudge, message)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    const MIN: u64 = 60_000;
283
284    /// Noon today, local time, in epoch milliseconds.
285    fn noon() -> u64 {
286        Local::now()
287            .date_naive()
288            .and_hms_opt(12, 0, 0)
289            .and_then(|t| Local.from_local_datetime(&t).single())
290            .unwrap()
291            .timestamp_millis() as u64
292    }
293
294    fn run(end_ms: u64, minutes: f64, acc: f64) -> Run {
295        Run {
296            t: end_ms,
297            stage: 1,
298            mode: "words".into(),
299            wpm: 15.0,
300            acc,
301            secs: minutes * 60.0,
302            ..Run::default()
303        }
304    }
305
306    /// Back-to-back runs of `each` minutes, the last ending at `end`.
307    fn block(end: u64, count: u64, each: f64) -> Vec<Run> {
308        let step = (each * 60_000.0) as u64;
309        (0..count)
310            .map(|i| run(end - (count - 1 - i) * step, each, 98.0))
311            .collect()
312    }
313
314    #[test]
315    fn nags_when_nothing_has_happened() {
316        let a = assess(&[], noon());
317        assert_eq!(a.level, Level::Nudge);
318        assert!(a.message.contains("Nothing yet today"));
319    }
320
321    #[test]
322    fn calls_out_a_skipped_day() {
323        let two_days_ago = run(noon() - 48 * 60 * MIN, 15.0, 98.0);
324        let a = assess(&[two_days_ago], noon());
325        assert!(a.message.contains("skipped yesterday"), "{}", a.message);
326    }
327
328    #[test]
329    fn counts_down_to_the_goal() {
330        let runs = block(noon(), 4, 2.0);
331        let a = assess(&runs, noon() + 10 * MIN);
332        assert_eq!(a.level, Level::Nudge);
333        assert!(
334            a.message.contains("8 minutes today, 12 minutes to go"),
335            "{}",
336            a.message
337        );
338    }
339
340    #[test]
341    fn nudges_a_long_session_then_stops_it() {
342        let runs = block(noon(), 8, 2.0);
343        let a = assess(&runs, noon() + MIN);
344        assert_eq!(a.level, Level::Nudge);
345        assert!(a.message.contains("16 minutes in"), "{}", a.message);
346
347        let runs = block(noon(), 13, 2.0);
348        let a = assess(&runs, noon() + MIN);
349        assert_eq!(a.level, Level::Stop);
350        assert!(a.message.contains("Break time"));
351        assert!(a.blocks());
352    }
353
354    #[test]
355    fn a_break_resets_the_session() {
356        let runs = block(noon(), 13, 2.0);
357        let a = assess(&runs, noon() + 6 * MIN);
358        assert_eq!(a.session_minutes, 0.0);
359        assert_eq!(a.level, Level::Good, "{}", a.message);
360    }
361
362    #[test]
363    fn caps_the_day() {
364        let mut runs = block(noon() - 120 * MIN, 12, 2.0);
365        runs.extend(block(noon(), 12, 2.0));
366        let a = assess(&runs, noon() + 10 * MIN);
367        assert_eq!(a.level, Level::Stop);
368        assert!(a.message.contains("48 minutes today"), "{}", a.message);
369    }
370
371    #[test]
372    fn notices_three_misses_in_a_row() {
373        let mut runs = block(noon(), 3, 2.0);
374        for (r, acc) in runs.iter_mut().zip([96.0, 95.0, 96.5]) {
375            r.acc = acc;
376        }
377        let a = assess(&runs, noon() + MIN);
378        assert!(a.message.contains("Three in a row"), "{}", a.message);
379        assert!(a.message.contains("frustration"), "{}", a.message);
380    }
381
382    #[test]
383    fn notices_accuracy_dipping_against_the_session_best() {
384        let mut runs = block(noon(), 4, 2.0);
385        for (r, acc) in runs.iter_mut().zip([98.0, 99.0, 97.0, 94.0]) {
386            r.acc = acc;
387        }
388        let a = assess(&runs, noon() + MIN);
389        assert!(a.message.contains("dipped"), "{}", a.message);
390    }
391
392    #[test]
393    fn notices_slowing_down() {
394        let mut runs = block(noon(), 4, 2.0);
395        for (r, wpm) in runs.iter_mut().zip([20.0, 22.0, 17.0, 17.0]) {
396            r.wpm = wpm;
397        }
398        let a = assess(&runs, noon() + MIN);
399        assert!(a.message.contains("slowed down"), "{}", a.message);
400    }
401
402    #[test]
403    fn a_steady_session_gets_no_dip_warning() {
404        let mut runs = block(noon(), 5, 2.0);
405        for (r, (acc, wpm)) in runs.iter_mut().zip([
406            (97.0, 15.0),
407            (98.0, 16.0),
408            (97.5, 16.5),
409            (98.0, 16.0),
410            (97.2, 15.8),
411        ]) {
412            r.acc = acc;
413            r.wpm = wpm;
414        }
415        assert_eq!(deterioration(&runs), None);
416    }
417
418    #[test]
419    fn counts_a_streak() {
420        let day = 24 * 60 * MIN;
421        let mut runs = Vec::new();
422        for back in (1..=3).rev() {
423            runs.extend(block(noon() - back * day, 6, 2.0));
424        }
425        let a = assess(&runs, noon());
426        assert_eq!(a.streak, 3);
427        assert!(a.message.contains("3-day streak"), "{}", a.message);
428    }
429}