1use chrono::{DateTime, Days, Local, NaiveDate, TimeZone};
9
10use crate::store::Run;
11
12pub const DAILY_GOAL: f64 = 20.0;
14pub const STREAK_MIN: f64 = 10.0;
16pub const DAILY_CAP: f64 = 45.0;
18pub const SESSION_NUDGE: f64 = 15.0;
20pub const SESSION_LIMIT: f64 = 25.0;
22pub const BREAK: f64 = 5.0;
24const UNKNOWN_SECS: f64 = 60.0;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Level {
29 Good,
31 Nudge,
33 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
71pub fn today(now_ms: u64) -> String {
73 day(now_ms).to_string()
74}
75
76fn minutes_on(runs: &[Run], date: NaiveDate) -> f64 {
77 0.0 + runs
79 .iter()
80 .filter(|r| day(r.t) == date)
81 .map(secs)
82 .sum::<f64>()
83 / 60.0
84}
85
86fn 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
108fn 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
140const ACCURACY_DIP: f64 = 3.0;
142const SPEED_DIP: f64 = 0.85;
144
145fn deterioration(session: &[Run]) -> Option<String> {
148 let last = session.last()?;
149 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 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 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}