Skip to content

Commit

Permalink
Add "practice missed words" mode (#89)
Browse files Browse the repository at this point in the history
* Add "practice missed words" mode

\## Motivation

Monkeytype has the possibility of training just the words that you miss
and this seems like a straight forward thing to add.

\## Implementation

Main change is to add a mssed_words item to the Result struct

```rust
pub struct Results {
    pub timing: TimingData,
    pub accuracy: AccuracyData,
    pub missed_words: Vec<String>,
}
```

And on the Results State, listen for 'p' (practice) to start a new test
from missed words.

```rust
state = State::Test(Test::from_missed_words(&result.missed_words));
```

For the `Results` struct I did a very small refactor in the
`From<&Test>` Trait implementation. I moved the calculation of each
result to its own function, to leave the From function easier to follow.

```rust
Self {
    timing: Self::calc_timing(&events),
    accuracy: Self::calc_accuracy(&events),
    missed_words: Self::calc_missed_words(&test),
}
```

* Address PR comments
  • Loading branch information
glazari committed Sep 19, 2023
1 parent fad07b2 commit d1fb17c
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 66 deletions.
19 changes: 18 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ fn main() -> crossterm::Result<()> {
}
}
}
State::Results(_) => match event {
State::Results(ref result) => match event {
Event::Key(KeyEvent {
code: KeyCode::Char('r'),
kind: KeyEventKind::Press,
Expand All @@ -260,6 +260,23 @@ fn main() -> crossterm::Result<()> {
"Couldn't get test contents. Make sure the specified language actually exists.",
)));
}
Event::Key(KeyEvent {
code: KeyCode::Char('p'),
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
..
}) => {
if result.missed_words.is_empty() {
continue;
}
// repeat each missed word 5 times
let mut practice_words: Vec<String> = (result.missed_words)
.iter()
.flat_map(|w| vec![w.clone(); 5])
.collect();
practice_words.shuffle(&mut thread_rng());
state = State::Test(Test::new(practice_words));
}
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
Expand Down
139 changes: 78 additions & 61 deletions src/test/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub struct AccuracyData {
pub struct Results {
pub timing: TimingData,
pub accuracy: AccuracyData,
pub missed_words: Vec<String>,
}

impl From<&Test> for Results {
Expand All @@ -79,67 +80,83 @@ impl From<&Test> for Results {
test.words.iter().flat_map(|w| w.events.iter()).collect();

Self {
timing: {
let mut timing = TimingData {
overall_cps: -1.0,
per_event: Vec::new(),
per_key: HashMap::new(),
};

// map of keys to a two-tuple (total time, clicks) for counting average
let mut keys: HashMap<KeyEvent, (f64, usize)> = HashMap::new();

for win in events.windows(2) {
let event_dur = win[1]
.time
.checked_duration_since(win[0].time)
.map(|d| d.as_secs_f64());

if let Some(event_dur) = event_dur {
timing.per_event.push(event_dur);

let key = keys.entry(win[1].key).or_insert((0.0, 0));
key.0 += event_dur;
key.1 += 1;
}
}

timing.per_key = keys
.into_iter()
.map(|(key, (total, count))| (key, total / count as f64))
.collect();

timing.overall_cps =
timing.per_event.len() as f64 / timing.per_event.iter().sum::<f64>();

timing
},
accuracy: {
let mut acc = AccuracyData {
overall: Fraction::new(0, 0),
per_key: HashMap::new(),
};

events
.iter()
.filter(|event| event.correct.is_some())
.for_each(|event| {
let key = acc
.per_key
.entry(event.key)
.or_insert_with(|| Fraction::new(0, 0));

acc.overall.denominator += 1;
key.denominator += 1;

if event.correct.unwrap() {
acc.overall.numerator += 1;
key.numerator += 1;
}
});

acc
},
timing: calc_timing(&events),
accuracy: calc_accuracy(&events),
missed_words: calc_missed_words(&test),

Check warning on line 85 in src/test/results.rs

View workflow job for this annotation

GitHub Actions / clippy

this expression creates a reference which is immediately dereferenced by the compiler

warning: this expression creates a reference which is immediately dereferenced by the compiler --> src/test/results.rs:85:45 | 85 | missed_words: calc_missed_words(&test), | ^^^^^ help: change this to: `test` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#needless_borrow = note: `#[warn(clippy::needless_borrow)]` on by default
}
}
}

fn calc_timing(events: &[&super::TestEvent]) -> TimingData {
let mut timing = TimingData {
overall_cps: -1.0,
per_event: Vec::new(),
per_key: HashMap::new(),
};

// map of keys to a two-tuple (total time, clicks) for counting average
let mut keys: HashMap<KeyEvent, (f64, usize)> = HashMap::new();

for win in events.windows(2) {
let event_dur = win[1]
.time
.checked_duration_since(win[0].time)
.map(|d| d.as_secs_f64());

if let Some(event_dur) = event_dur {
timing.per_event.push(event_dur);

let key = keys.entry(win[1].key).or_insert((0.0, 0));
key.0 += event_dur;
key.1 += 1;
}
}

timing.per_key = keys
.into_iter()
.map(|(key, (total, count))| (key, total / count as f64))
.collect();

timing.overall_cps = timing.per_event.len() as f64 / timing.per_event.iter().sum::<f64>();

timing
}

fn calc_accuracy(events: &[&super::TestEvent]) -> AccuracyData {
let mut acc = AccuracyData {
overall: Fraction::new(0, 0),
per_key: HashMap::new(),
};

events
.iter()
.filter(|event| event.correct.is_some())
.for_each(|event| {
let key = acc
.per_key
.entry(event.key)
.or_insert_with(|| Fraction::new(0, 0));

acc.overall.denominator += 1;
key.denominator += 1;

if event.correct.unwrap() {
acc.overall.numerator += 1;
key.numerator += 1;
}
});

acc
}

fn calc_missed_words(test: &Test) -> Vec<String> {
let is_missed_word_event = |event: &super::TestEvent| -> bool {
event.correct == Some(false) || event.correct.is_none()
};

test.words
.iter()
.filter(|word| word.events.iter().any(is_missed_word_event))
.map(|word| word.text.clone())
.collect()
}
11 changes: 7 additions & 4 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,13 @@ impl ThemedWidget for &results::Results {
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(res_chunks[0]);

let exit = Span::styled(
"Press 'q' to quit or 'r' for another test.",
theme.results_restart_prompt,
);
let msg = if self.missed_words.is_empty() {
"Press 'q' to quit or 'r' for another test"
} else {
"Press 'q' to quit, 'r' for another test or 'p' to practice missed words"
};

let exit = Span::styled(msg, theme.results_restart_prompt);
buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width);

// Sections
Expand Down

0 comments on commit d1fb17c

Please sign in to comment.