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
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 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 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}