1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
11pub struct Progress {
12 #[serde(default)]
13 pub stage: usize,
14 #[serde(default)]
15 pub runs: Vec<Run>,
16 #[serde(default)]
18 pub manual: BTreeMap<String, Manual>,
19 #[serde(default)]
21 pub letters: BTreeMap<String, BTreeMap<String, LetterStat>>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub warmed_up: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Run {
29 pub t: u64,
31 pub stage: usize,
32 pub mode: String,
33 pub wpm: f64,
34 pub acc: f64,
35 #[serde(default)]
37 pub secs: f64,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct Manual {
42 #[serde(default)]
43 pub oryx: bool,
44 #[serde(default)]
45 pub vim: bool,
46 #[serde(default)]
47 pub workday: bool,
48 #[serde(default)]
50 pub wpm: f64,
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
54pub struct LetterStat {
55 pub n: u64,
56 pub miss: u64,
57 pub ms: f64,
58}
59
60impl LetterStat {
61 pub fn add(&mut self, other: &LetterStat) {
62 self.n += other.n;
63 self.miss += other.miss;
64 self.ms += other.ms;
65 }
66
67 pub fn average_ms(&self) -> Option<f64> {
68 (self.n > 0).then(|| self.ms / self.n as f64)
69 }
70}
71
72pub fn default_path() -> PathBuf {
74 if let Some(p) = std::env::var_os("VERY_NORMAL_DATA") {
75 return PathBuf::from(p);
76 }
77 let home = std::env::var_os("HOME")
78 .map(PathBuf::from)
79 .unwrap_or_default();
80 home.join(".local/share/very-normal/progress.json")
81}
82
83impl Progress {
84 pub fn load(path: &Path) -> Result<Progress> {
85 match std::fs::read_to_string(path) {
86 Ok(text) => {
87 Progress::parse(&text).with_context(|| format!("reading {}", path.display()))
88 }
89 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Progress::default()),
90 Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
91 }
92 }
93
94 pub fn parse(text: &str) -> Result<Progress> {
95 Ok(serde_json::from_str(text)?)
96 }
97
98 pub fn save(&self, path: &Path) -> Result<()> {
100 if let Some(dir) = path.parent() {
101 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
102 }
103 let tmp = path.with_extension("json.tmp");
104 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
105 std::fs::rename(&tmp, path).with_context(|| format!("writing {}", path.display()))?;
106 Ok(())
107 }
108
109 pub fn manual(&self, stage: usize) -> Manual {
110 self.manual
111 .get(&stage.to_string())
112 .cloned()
113 .unwrap_or_default()
114 }
115
116 pub fn manual_mut(&mut self, stage: usize) -> &mut Manual {
117 self.manual.entry(stage.to_string()).or_default()
118 }
119
120 pub fn letters(&self, stage: usize) -> BTreeMap<String, LetterStat> {
121 self.letters
122 .get(&stage.to_string())
123 .cloned()
124 .unwrap_or_default()
125 }
126
127 pub fn runs_for(&self, stage: usize, mode: &str) -> Vec<&Run> {
128 self.runs
129 .iter()
130 .filter(|r| r.stage == stage && r.mode == mode)
131 .collect()
132 }
133
134 pub fn baseline(&self) -> Option<f64> {
136 let real = self.runs_for(0, "real");
137 if real.is_empty() {
138 let typed = self.manual(0).wpm;
139 return (typed > 0.0).then_some(typed);
140 }
141 let last: Vec<_> = real.iter().rev().take(3).collect();
142 Some(last.iter().map(|r| r.wpm).sum::<f64>() / last.len() as f64)
143 }
144
145 pub fn record(&mut self, run: Run, letters: &BTreeMap<String, LetterStat>) {
146 let stage = self.letters.entry(run.stage.to_string()).or_default();
147 for (ch, stat) in letters {
148 stage.entry(ch.clone()).or_default().add(stat);
149 }
150 self.runs.push(run);
151 if self.runs.len() > 2000 {
152 let excess = self.runs.len() - 2000;
153 self.runs.drain(..excess);
154 }
155 }
156
157 pub fn merge(&mut self, other: Progress) -> usize {
162 let before = self.runs.len();
163 for run in other.runs {
164 let dup = self
165 .runs
166 .iter()
167 .any(|r| r.t == run.t && r.stage == run.stage && r.mode == run.mode);
168 if !dup {
169 self.runs.push(run);
170 }
171 }
172 self.runs.sort_by_key(|r| r.t);
173 for (stage, m) in other.manual {
174 let mine = self.manual.entry(stage).or_default();
175 mine.oryx |= m.oryx;
176 mine.vim |= m.vim;
177 mine.workday |= m.workday;
178 if mine.wpm <= 0.0 {
179 mine.wpm = m.wpm;
180 }
181 }
182 for (stage, letters) in other.letters {
183 self.letters.entry(stage).or_insert(letters);
184 }
185 self.stage = self.stage.max(other.stage);
186 self.runs.len() - before
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 const BACKUP: &str = r#"{"stage":1,"runs":[
195 {"t":1794400000000,"stage":0,"mode":"real","wpm":34,"acc":96.5},
196 {"t":1794400100000,"stage":0,"mode":"real","wpm":36,"acc":97},
197 {"t":1794500000000,"stage":1,"mode":"words","wpm":13,"acc":98.2}],
198 "manual":{"0":{"oryx":true,"wpm":0}},
199 "letters":{"1":{"e":{"n":10,"miss":2,"ms":5200}}}}"#;
200
201 #[test]
202 fn reads_a_web_tutor_backup() {
203 let p = Progress::parse(BACKUP).unwrap();
204 assert_eq!(p.stage, 1);
205 assert_eq!(p.runs.len(), 3);
206 assert!(p.manual(0).oryx);
207 assert_eq!(p.baseline(), Some(35.0));
208 assert_eq!(p.letters(1)["e"].average_ms(), Some(520.0));
209 }
210
211 #[test]
212 fn a_typed_baseline_counts_until_there_are_runs() {
213 let mut p = Progress::default();
214 assert_eq!(p.baseline(), None);
215 p.manual_mut(0).wpm = 30.0;
216 assert_eq!(p.baseline(), Some(30.0));
217 }
218
219 #[test]
220 fn merging_the_same_backup_twice_changes_nothing() {
221 let mut p = Progress::default();
222 assert_eq!(p.merge(Progress::parse(BACKUP).unwrap()), 3);
223 let once = p.clone();
224 assert_eq!(p.merge(Progress::parse(BACKUP).unwrap()), 0);
225 assert_eq!(p, once);
226 }
227
228 #[test]
229 fn saves_and_loads() {
230 let dir = std::env::temp_dir().join(format!("very-normal-test-{}", std::process::id()));
231 let path = dir.join("progress.json");
232 let p = Progress::parse(BACKUP).unwrap();
233 p.save(&path).unwrap();
234 assert_eq!(Progress::load(&path).unwrap(), p);
235 assert_eq!(
236 Progress::load(&dir.join("missing.json")).unwrap(),
237 Progress::default()
238 );
239 std::fs::remove_dir_all(dir).unwrap();
240 }
241}