Skip to main content

very_normal/
store.rs

1//! Progress on disk. The shape matches the web tutor's backup text, so a
2//! backup copied out of the artifact imports as it is.
3
4use 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    /// Keyed by stage number as a string.
17    #[serde(default)]
18    pub manual: BTreeMap<String, Manual>,
19    /// Stage number, then the character, both as strings.
20    #[serde(default)]
21    pub letters: BTreeMap<String, BTreeMap<String, LetterStat>>,
22    /// Stage number, then two characters typed one after the other: how long
23    /// the second took after the first, and how often it was missed there.
24    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
25    pub pairs: BTreeMap<String, BTreeMap<String, LetterStat>>,
26    /// The local date of the last warm-up, as YYYY-MM-DD.
27    #[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    /// Milliseconds since the Unix epoch.
34    pub t: u64,
35    pub stage: usize,
36    pub mode: String,
37    pub wpm: f64,
38    pub acc: f64,
39    /// How long the drill took; missing from older runs and web tutor backups.
40    #[serde(default)]
41    pub secs: f64,
42    /// This run's timings and misses by character, so a key's speed can be
43    /// followed over time. Missing from older runs.
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    pub letters: BTreeMap<String, LetterStat>,
46    /// Wrong keys, as "wanted>typed", with how often.
47    #[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    /// A baseline typed in by hand, for stage 0.
60    #[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
83/// `$VERY_NORMAL_DATA`, or `~/.local/share/very-normal/progress.json`.
84pub 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    /// Writes to a temporary file first so a crash never leaves half a file.
110    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    /// Average of the last three stage-0 real-text runs, or the number typed in.
146    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    /// Saves a run, adding its letters (and the drill's pairs) to the stage's totals.
164    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    /// Folds another progress file (such as a web tutor backup) into this one.
181    /// Runs are deduplicated by time, stage and drill; ticked boxes stay ticked;
182    /// letter timings are only taken for stages that have none yet, so importing
183    /// the same backup twice doesn't double them.
184    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}