use chrono::{Duration, Utc};
use super::card::Card;
use super::deck::Deck;
use super::State;
pub const MASTERED_FACTOR_THRESHOLD: f64 = 2500.0;
pub const MASTERED_INTERVAL_THRESHOLD_DAYS: f64 = 21.0;
pub const FORGOTTEN_FACTOR_THRESHOLD: f64 = 1500.0;
pub const ALL_DECKS_LABEL: &str = "all";
#[derive(Debug, Clone, PartialEq)]
pub struct DeckStats {
pub name: String,
pub due: usize,
pub due_within_a_week: usize,
pub mastered: usize,
pub forgotten: usize,
pub total: usize,
}
impl DeckStats {
pub(crate) fn empty(name: &str) -> Self {
Self {
name: name.to_string(),
due: 0,
due_within_a_week: 0,
mastered: 0,
forgotten: 0,
total: 0,
}
}
fn count(&mut self, card: &Card, thresholds: &Thresholds) {
if card.is_due() {
self.due += 1;
} else if is_due_within_a_week(card) {
self.due_within_a_week += 1;
}
if thresholds.is_mastered(card) {
self.mastered += 1;
}
if thresholds.is_forgotten(card) {
self.forgotten += 1;
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Thresholds {
pub factor_mastered: f64,
pub interval_mastered: f64,
pub factor_forgotten: f64,
}
impl Thresholds {
pub fn defaults() -> Self {
Self {
factor_mastered: MASTERED_FACTOR_THRESHOLD,
interval_mastered: MASTERED_INTERVAL_THRESHOLD_DAYS,
factor_forgotten: FORGOTTEN_FACTOR_THRESHOLD,
}
}
pub fn for_deck(deck: &Deck) -> Self {
let d = Self::defaults();
Self {
factor_mastered: deck.factor_mastered_threshold.unwrap_or(d.factor_mastered),
interval_mastered: deck
.interval_mastered_threshold
.unwrap_or(d.interval_mastered),
factor_forgotten: deck.factor_forgotten_threshold.unwrap_or(d.factor_forgotten),
}
}
fn is_mastered(&self, card: &Card) -> bool {
card.revision_settings.memorisation_factor >= self.factor_mastered
&& card.revision_settings.interval >= self.interval_mastered
}
fn is_forgotten(&self, card: &Card) -> bool {
card.revision_settings.interval > 0.0
&& card.revision_settings.memorisation_factor <= self.factor_forgotten
}
}
fn is_due_within_a_week(card: &Card) -> bool {
let due = card.revision_settings.due;
let now = Utc::now();
due > now && due <= now + Duration::days(7)
}
impl State {
pub fn deck_stats(&self) -> Vec<DeckStats> {
let mut rows: Vec<DeckStats> = self
.decks
.values()
.map(|deck| {
let thresholds = Thresholds::for_deck(deck);
let mut stats = DeckStats::empty(&deck.name);
stats.total = deck.card_paths.len();
for path in &deck.card_paths {
if let Some(card) = self.cards.get(path) {
stats.count(card, &thresholds);
}
}
stats
})
.collect();
rows.sort_by(|a, b| a.name.cmp(&b.name));
rows
}
pub fn all_due_stats(&self) -> DeckStats {
let thresholds = Thresholds::defaults();
let mut stats = DeckStats::empty(ALL_DECKS_LABEL);
stats.total = self.cards.len();
for card in self.cards.values() {
stats.count(card, &thresholds);
}
stats
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use crate::state::card::revision_settings::RevisionSettings;
use crate::state::deck;
use chrono::DateTime;
fn make_card(
path: &str,
decks: &[&str],
due: DateTime<Utc>,
factor: f64,
interval: f64,
) -> Card {
Card::new(
path.to_string(),
decks.iter().map(|s| s.to_string()).collect(),
"q".to_string(),
"a".to_string(),
RevisionSettings::new(due, interval, factor),
)
}
fn make_state(cards: Vec<Card>) -> State {
let decks = deck::many_from_cards(&cards);
State::new(
crate::state::card::parser::ParsingConfig::default(),
cards,
decks,
)
}
fn default_factor() -> f64 {
1300.0
}
#[test]
fn deck_stats_returns_one_row_per_deck_sorted_by_name() {
let now = Utc::now();
let state = make_state(vec![
make_card("c.md", &["c"], now, 0.0, default_factor()),
make_card("a.md", &["a"], now, 0.0, default_factor()),
make_card("b.md", &["b"], now, 0.0, default_factor()),
]);
let rows = state.deck_stats();
assert_eq!(3, rows.len());
assert_eq!(
vec!["a", "b", "c"],
rows.iter().map(|r| r.name.clone()).collect::<Vec<_>>()
);
}
#[test]
fn deck_stats_counts_currently_due_cards() {
let past = Utc::now() - Duration::days(1);
let future = Utc::now() + Duration::days(30);
let state = make_state(vec![
make_card("a.md", &["x"], past, 0.0, 1300.0),
make_card("b.md", &["x"], past, 0.0, 1300.0),
make_card("c.md", &["x"], future, 0.0, 1300.0),
]);
let rows = state.deck_stats();
let x = rows.iter().find(|r| r.name == "x").unwrap();
assert_eq!(2, x.due);
assert_eq!(3, x.total);
}
#[test]
fn deck_stats_counts_due_within_a_week_excluding_currently_due() {
let now = Utc::now();
let state = make_state(vec![
make_card("past.md", &["x"], now - Duration::days(1), 0.0, 1300.0),
make_card("plus_one.md", &["x"], now + Duration::days(1), 0.0, 1300.0),
make_card("plus_five.md", &["x"], now + Duration::days(5), 0.0, 1300.0),
make_card(
"plus_eight.md",
&["x"],
now + Duration::days(8),
0.0,
1300.0,
),
]);
let rows = state.deck_stats();
let x = rows.iter().find(|r| r.name == "x").unwrap();
assert_eq!(1, x.due, "only the past-due card counts as currently due");
assert_eq!(
2, x.due_within_a_week,
"plus_one and plus_five are within a week"
);
}
#[test]
fn deck_stats_classifies_mastered_cards() {
let future = Utc::now() + Duration::days(30);
let state = make_state(vec![
make_card("mastered.md", &["x"], future, 3000.0, 30.0),
make_card("not_yet.md", &["x"], future, 2400.0, 30.0),
make_card("short_interval.md", &["x"], future, 3000.0, 14.0),
]);
let rows = state.deck_stats();
let x = rows.iter().find(|r| r.name == "x").unwrap();
assert_eq!(
1, x.mastered,
"only the high-factor + long-interval card is mastered"
);
}
#[test]
fn deck_stats_classifies_forgotten_cards() {
let future = Utc::now() + Duration::days(30);
let state = make_state(vec![
make_card("untouched.md", &["x"], future, 1300.0, 0.0),
make_card("struggling.md", &["x"], future, 1300.0, 0.5),
make_card("doing_well.md", &["x"], future, 2000.0, 5.0),
]);
let rows = state.deck_stats();
let x = rows.iter().find(|r| r.name == "x").unwrap();
assert_eq!(
1, x.forgotten,
"only the reviewed-but-still-at-floor card classifies as forgotten"
);
}
#[test]
fn deck_stats_honors_per_deck_factor_mastered_override() {
let future = Utc::now() + Duration::days(30);
let card = make_card("c.md", &["x"], future, 2400.0, 30.0);
let mut state = make_state(vec![card]);
if let Some(deck) = state.decks.get_mut("x") {
deck.factor_mastered_threshold = Some(2000.0);
}
let rows = state.deck_stats();
let x = rows.iter().find(|r| r.name == "x").unwrap();
assert_eq!(1, x.mastered, "card crosses the per-deck override");
}
#[test]
fn all_due_stats_dedupes_cards_in_multiple_decks() {
let past = Utc::now() - Duration::days(1);
let state = make_state(vec![
make_card("shared.md", &["a", "b"], past, 0.0, 1300.0),
make_card("only_a.md", &["a"], past, 0.0, 1300.0),
]);
let all = state.all_due_stats();
assert_eq!(2, all.total, "shared card counted once, not twice");
assert_eq!(2, all.due);
}
}