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    /// The local date of the last warm-up, as YYYY-MM-DD.
23    #[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    /// Milliseconds since the Unix epoch.
30    pub t: u64,
31    pub stage: usize,
32    pub mode: String,
33    pub wpm: f64,
34    pub acc: f64,
35    /// How long the drill took; missing from older runs and web tutor backups.
36    #[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    /// A baseline typed in by hand, for stage 0.
49    #[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
72/// `$VERY_NORMAL_DATA`, or `~/.local/share/very-normal/progress.json`.
73pub 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    /// Writes to a temporary file first so a crash never leaves half a file.
99    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    /// Average of the last three stage-0 real-text runs, or the number typed in.
135    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    /// Folds another progress file (such as a web tutor backup) into this one.
158    /// Runs are deduplicated by time, stage and drill; ticked boxes stay ticked;
159    /// letter timings are only taken for stages that have none yet, so importing
160    /// the same backup twice doesn't double them.
161    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}