Skip to main content

logicaffeine_web/
guide_review.rs

1//! The Syntax Guide's SM-2 deck: recap self-marks and graded exercise results
2//! feed the same spaced-repetition engine as Learn, under a `guide/` key
3//! namespace so the two decks coexist in one `UserProgress` without
4//! collision. Due guide cards surface on the Review page beside Learn cards.
5
6use crate::progress::{ExerciseProgress, SrsData, UserProgress};
7use crate::srs::{calculate_next_review, is_due, sm2_update, ResponseQuality};
8use crate::ui::pages::guide::content::SECTIONS;
9
10pub const GUIDE_RECALL_PREFIX: &str = "guide/recall/";
11pub const GUIDE_EX_PREFIX: &str = "guide/ex/";
12
13/// The progress-map key for a recap question's SRS entry.
14pub fn recall_key(recall_id: &str) -> String {
15    format!("{GUIDE_RECALL_PREFIX}{recall_id}")
16}
17
18/// The progress-map key for a guide exercise's SRS entry.
19pub fn exercise_key(exercise_id: &str) -> String {
20    format!("{GUIDE_EX_PREFIX}{exercise_id}")
21}
22
23/// A due card in the guide deck. Content is looked up from `SECTIONS` at
24/// render time; the card carries the ids and its home section for the
25/// deep-link back into the guide.
26#[derive(Debug, Clone, PartialEq)]
27pub enum GuideCard {
28    Recall {
29        key: String,
30        section_id: String,
31        recall_id: String,
32        question: String,
33        answer: String,
34    },
35    Exercise {
36        key: String,
37        section_id: String,
38        exercise_id: String,
39    },
40}
41
42fn record(progress: &mut UserProgress, key: String, quality: ResponseQuality, today: &str) {
43    let entry = progress
44        .exercises
45        .entry(key.clone())
46        .or_insert_with(|| ExerciseProgress {
47            exercise_id: key,
48            attempts: 0,
49            correct_count: 0,
50            last_attempt: None,
51            srs: SrsData::default(),
52        });
53    entry.attempts += 1;
54    if quality.is_correct() {
55        entry.correct_count += 1;
56    }
57    entry.last_attempt = Some(today.to_string());
58    sm2_update(&mut entry.srs, quality);
59    entry.srs.next_review = Some(calculate_next_review(today, entry.srs.interval));
60    progress.save();
61}
62
63/// A recap reveal + self-mark: quality is the reader's own 0–5 judgment
64/// (the reveal keeps it honest — control of error, not surveillance).
65pub fn record_recall_mark(progress: &mut UserProgress, recall_id: &str, quality: u8, today: &str) {
66    record(
67        progress,
68        recall_key(recall_id),
69        ResponseQuality::from_score(quality),
70        today,
71    );
72}
73
74/// A graded exercise result: quality comes from the grader's verdict
75/// (fail = 1, passed with hints ≈ 3–4, clean pass = 5).
76pub fn record_exercise_result(
77    progress: &mut UserProgress,
78    exercise_id: &str,
79    quality: u8,
80    today: &str,
81) {
82    record(
83        progress,
84        exercise_key(exercise_id),
85        ResponseQuality::from_score(quality),
86        today,
87    );
88}
89
90/// Every guide card that is seeded (the reader has touched it) AND due.
91/// Untouched cards never appear — the deck reviews what you practiced, it
92/// does not nag about what you skipped.
93pub fn due_guide_cards(progress: &UserProgress, today: &str) -> Vec<GuideCard> {
94    let mut due = Vec::new();
95    for section in SECTIONS.iter() {
96        for r in section.recap.iter() {
97            let key = recall_key(r.id);
98            if let Some(entry) = progress.exercises.get(&key) {
99                if is_due(entry.srs.next_review.as_deref(), today) {
100                    due.push(GuideCard::Recall {
101                        key,
102                        section_id: section.id.to_string(),
103                        recall_id: r.id.to_string(),
104                        question: r.question.to_string(),
105                        answer: r.answer.to_string(),
106                    });
107                }
108            }
109        }
110        for e in section.exercises.iter() {
111            let key = exercise_key(e.id);
112            if let Some(entry) = progress.exercises.get(&key) {
113                if is_due(entry.srs.next_review.as_deref(), today) {
114                    due.push(GuideCard::Exercise {
115                        key,
116                        section_id: section.id.to_string(),
117                        exercise_id: e.id.to_string(),
118                    });
119                }
120            }
121        }
122    }
123    due
124}