#![doc = simple_mermaid::mermaid!("../diagrams/statistics_architecture.mmd")]
use std::collections::HashMap;
pub use web_time::{Duration, Instant};
use crate::{
CharacterResult, State, Timestamp, Word,
config::Configuration,
math::{Accuracy, Consistency, Ipm, Wpm},
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Input {
pub timestamp: Timestamp,
pub char: char,
pub result: CharacterResult,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Measurement {
pub timestamp: Timestamp,
pub wpm: Wpm,
pub ipm: Ipm,
pub accuracy: Accuracy,
pub consistency: Consistency,
}
impl Measurement {
pub fn new(
timestamp: Timestamp,
input_len: usize,
previous_measurements: &[Measurement],
input_history: &[Input],
adds: usize,
errors: usize,
corrections: usize,
) -> Self {
let minutes = timestamp / 60.0;
let wpm = Wpm::calculate(input_history.len(), errors, corrections, minutes);
let ipm = Ipm::calculate(adds, input_history.len(), minutes);
let accuracy = Accuracy::calculate(input_len, errors, corrections);
let all_wpm_measurements: Vec<Wpm> = previous_measurements
.iter()
.map(|m| m.wpm)
.chain(std::iter::once(wpm))
.collect();
let consistency = Consistency::calculate(&all_wpm_measurements);
Self {
timestamp,
wpm,
ipm,
accuracy,
consistency,
}
}
}
#[derive(Default, Debug, Clone)]
pub struct CounterData {
pub char_errors: HashMap<char, usize>,
pub word_errors: HashMap<Word, usize>,
pub adds: usize,
pub deletes: usize,
pub errors: usize,
pub corrects: usize,
pub corrections: usize,
pub wrong_deletes: usize,
}
#[derive(Debug, Clone)]
pub struct Statistics {
pub wpm: Wpm,
pub ipm: Ipm,
pub accuracy: Accuracy,
pub consistency: Consistency,
pub duration: Duration,
pub measurements: Vec<Measurement>,
pub input_history: Vec<Input>,
pub counters: CounterData,
pub input_length: usize,
pub missing_characters: usize,
}
#[derive(Default, Debug, Clone)]
pub struct TempStatistics {
pub measurements: Vec<Measurement>,
pub input_history: Vec<Input>,
pub counters: CounterData,
last_measurement: Option<Timestamp>,
}
impl TempStatistics {
pub fn update(
&mut self,
char: char,
result: CharacterResult,
input_len: usize,
elapsed: Duration,
config: &Configuration,
) {
let timestamp = elapsed.as_secs_f64();
self.update_from_result(char, result, timestamp);
if self.should_take_measurement(timestamp, config.measurement_interval_seconds) {
self.take_measurement(timestamp, input_len);
}
}
fn should_take_measurement(&self, current_timestamp: Timestamp, interval_seconds: f64) -> bool {
match self.last_measurement {
Some(last_timestamp) => current_timestamp - last_timestamp >= interval_seconds,
None => current_timestamp >= interval_seconds,
}
}
fn take_measurement(&mut self, timestamp: Timestamp, input_len: usize) {
let measurement = Measurement::new(
timestamp,
input_len,
&self.measurements,
&self.input_history,
self.counters.adds,
self.counters.errors,
self.counters.corrections,
);
self.measurements.push(measurement);
self.last_measurement = Some(timestamp);
}
fn update_from_result(&mut self, char: char, result: CharacterResult, timestamp: Timestamp) {
match result {
CharacterResult::Deleted(state) => {
self.counters.deletes += 1;
if matches!(state, State::Correct | State::Corrected) {
self.counters.wrong_deletes += 1
}
}
CharacterResult::Wrong => {
self.counters.errors += 1;
self.counters.adds += 1;
*self.counters.char_errors.entry(char).or_insert(0) += 1;
}
CharacterResult::Corrected => {
self.counters.corrections += 1;
self.counters.adds += 1;
}
CharacterResult::Correct => {
self.counters.corrects += 1;
self.counters.adds += 1;
}
}
self.input_history.push(Input {
timestamp,
char,
result,
});
}
pub fn finalize(
mut self,
duration: Duration,
text_length: usize,
current_position: usize,
) -> Statistics {
let total_time = duration.as_secs_f64();
self.take_measurement(total_time, current_position);
let missing_characters = text_length.saturating_sub(current_position);
let Self {
measurements,
input_history,
counters,
..
} = self;
let Measurement {
wpm,
ipm,
accuracy,
consistency,
..
} = measurements.last().copied().unwrap();
Statistics {
wpm,
ipm,
accuracy,
consistency,
duration,
measurements,
input_history,
counters,
input_length: text_length,
missing_characters,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_input_length_and_missing_characters() {
let mut temp_stats = TempStatistics::default();
let config = Configuration::default();
temp_stats.update(
'h',
CharacterResult::Correct,
1,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'e',
CharacterResult::Correct,
2,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'x',
CharacterResult::Wrong,
3,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'x',
CharacterResult::Deleted(crate::State::Wrong),
2,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'l',
CharacterResult::Corrected,
3,
Duration::from_secs(1),
&config,
);
let target_text_length = 5;
let current_position = 3;
let stats =
temp_stats.finalize(Duration::from_secs(1), target_text_length, current_position);
assert_eq!(
stats.input_length, 5,
"input_length should be the target text length"
);
assert_eq!(
stats.missing_characters, 2,
"missing_characters should be 2 (still need to type 'lo')"
);
assert_eq!(
stats.input_history.len(),
5,
"input_history should contain all 5 keystrokes"
);
}
#[test]
fn test_missing_characters_with_no_errors() {
let mut temp_stats = TempStatistics::default();
let config = Configuration::default();
temp_stats.update(
'h',
CharacterResult::Correct,
1,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'i',
CharacterResult::Correct,
2,
Duration::from_secs(1),
&config,
);
let target_text_length = 2;
let current_position = 2; let stats =
temp_stats.finalize(Duration::from_secs(1), target_text_length, current_position);
assert_eq!(
stats.input_length, 2,
"input_length should be target text length"
);
assert_eq!(
stats.input_history.len(),
2,
"input_history should contain 2 keystrokes"
);
assert_eq!(
stats.missing_characters, 0,
"missing_characters should be 0 when fully typed"
);
}
#[test]
fn test_missing_characters_partial_completion() {
let mut temp_stats = TempStatistics::default();
let config = Configuration::default();
temp_stats.update(
'h',
CharacterResult::Correct,
1,
Duration::from_secs(0),
&config,
);
temp_stats.update(
'e',
CharacterResult::Correct,
2,
Duration::from_secs(1),
&config,
);
let target_text_length = 5; let current_position = 2; let stats =
temp_stats.finalize(Duration::from_secs(1), target_text_length, current_position);
assert_eq!(
stats.input_length, 5,
"input_length should be target text length"
);
assert_eq!(
stats.input_history.len(),
2,
"input_history should contain 2 keystrokes"
);
assert_eq!(
stats.missing_characters, 3,
"missing_characters should be 3 (still need 'llo')"
);
}
#[test]
fn test_missing_characters_no_typing() {
let temp_stats = TempStatistics::default();
let target_text_length = 10;
let current_position = 0; let stats =
temp_stats.finalize(Duration::from_secs(0), target_text_length, current_position);
assert_eq!(
stats.input_length, 10,
"input_length should be target text length"
);
assert_eq!(
stats.input_history.len(),
0,
"input_history should be empty"
);
assert_eq!(
stats.missing_characters, 10,
"missing_characters should equal target length when nothing typed"
);
}
}