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