Skip to main content

logicaffeine_web/
struggle.rs

1//! Struggle Detection Logic
2//!
3//! Detects when a user is struggling with an exercise based on:
4//! - Inactivity (no answer attempt after threshold time)
5//! - Wrong attempts (incorrect answers)
6
7/// Reasons why a user might be struggling
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum StruggleReason {
10    Inactivity,
11    WrongAttempt,
12}
13
14impl StruggleReason {
15    /// Get a message describing the struggle reason
16    pub fn message(&self) -> &'static str {
17        match self {
18            StruggleReason::Inactivity => "Taking your time? Here's a hint to help you along.",
19            StruggleReason::WrongAttempt => "Not quite! Let me help you think through this.",
20        }
21    }
22}
23
24/// Configuration for struggle detection
25#[derive(Debug, Clone, Copy)]
26pub struct StruggleConfig {
27    /// Seconds of inactivity before considering the user stuck
28    pub inactivity_threshold_secs: u64,
29    /// Number of wrong attempts before showing help
30    pub wrong_attempt_threshold: u32,
31}
32
33impl Default for StruggleConfig {
34    fn default() -> Self {
35        Self {
36            inactivity_threshold_secs: 5,
37            wrong_attempt_threshold: 1,
38        }
39    }
40}
41
42/// Tracks struggle state for an exercise
43#[derive(Debug, Clone, Default)]
44pub struct StruggleDetector {
45    pub config: StruggleConfig,
46    pub is_struggling: bool,
47    pub reason: Option<StruggleReason>,
48    pub wrong_attempts: u32,
49    pub inactivity_triggered: bool,
50}
51
52impl StruggleDetector {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    pub fn with_config(config: StruggleConfig) -> Self {
58        Self {
59            config,
60            ..Default::default()
61        }
62    }
63
64    /// Record a wrong attempt - may trigger struggle state
65    pub fn record_wrong_attempt(&mut self) {
66        self.wrong_attempts += 1;
67        if self.wrong_attempts >= self.config.wrong_attempt_threshold {
68            self.is_struggling = true;
69            self.reason = Some(StruggleReason::WrongAttempt);
70        }
71    }
72
73    /// Record a correct attempt - resets inactivity but keeps struggle state for hints
74    pub fn record_correct_attempt(&mut self) {
75        // User got it right - they're no longer struggling
76        self.is_struggling = false;
77        self.inactivity_triggered = false;
78    }
79
80    /// Record user activity (typing, clicking) - resets inactivity timer
81    pub fn record_activity(&mut self) {
82        // Activity resets inactivity detection
83        self.inactivity_triggered = false;
84    }
85
86    /// Called when inactivity threshold is reached
87    pub fn trigger_inactivity(&mut self) {
88        if !self.inactivity_triggered {
89            self.inactivity_triggered = true;
90            self.is_struggling = true;
91            // Only set reason if not already struggling from wrong attempts
92            if self.reason.is_none() {
93                self.reason = Some(StruggleReason::Inactivity);
94            }
95        }
96    }
97
98    /// Reset struggle state (e.g., when moving to next exercise)
99    pub fn reset(&mut self) {
100        self.is_struggling = false;
101        self.reason = None;
102        self.wrong_attempts = 0;
103        self.inactivity_triggered = false;
104    }
105
106    /// Check if we should show hints
107    pub fn should_show_hints(&self) -> bool {
108        self.is_struggling
109    }
110
111    /// Get the current struggle reason
112    pub fn reason(&self) -> Option<StruggleReason> {
113        self.reason
114    }
115
116    /// Get the current struggle reason for display
117    pub fn struggle_message(&self) -> Option<&'static str> {
118        match self.reason {
119            Some(StruggleReason::Inactivity) => Some("Taking your time? Here's a hint to help you along."),
120            Some(StruggleReason::WrongAttempt) => Some("Not quite! Let me help you think through this."),
121            None => None,
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_no_struggle_initially() {
132        let detector = StruggleDetector::new();
133        assert!(!detector.is_struggling);
134        assert!(detector.reason.is_none());
135    }
136
137    #[test]
138    fn test_struggle_after_5s_inactivity() {
139        let mut detector = StruggleDetector::new();
140        assert!(!detector.is_struggling);
141
142        detector.trigger_inactivity();
143
144        assert!(detector.is_struggling);
145        assert_eq!(detector.reason, Some(StruggleReason::Inactivity));
146    }
147
148    #[test]
149    fn test_struggle_after_wrong_attempt() {
150        let mut detector = StruggleDetector::new();
151        assert!(!detector.is_struggling);
152
153        detector.record_wrong_attempt();
154
155        assert!(detector.is_struggling);
156        assert_eq!(detector.reason, Some(StruggleReason::WrongAttempt));
157    }
158
159    #[test]
160    fn test_reset_clears_struggle() {
161        let mut detector = StruggleDetector::new();
162        detector.record_wrong_attempt();
163        assert!(detector.is_struggling);
164
165        detector.reset();
166
167        assert!(!detector.is_struggling);
168        assert!(detector.reason.is_none());
169        assert_eq!(detector.wrong_attempts, 0);
170    }
171
172    #[test]
173    fn test_configurable_threshold() {
174        let config = StruggleConfig {
175            inactivity_threshold_secs: 10,
176            wrong_attempt_threshold: 2,
177        };
178        let mut detector = StruggleDetector::with_config(config);
179
180        // First wrong attempt shouldn't trigger with threshold of 2
181        detector.record_wrong_attempt();
182        assert!(!detector.is_struggling);
183
184        // Second wrong attempt should trigger
185        detector.record_wrong_attempt();
186        assert!(detector.is_struggling);
187    }
188
189    #[test]
190    fn test_inactivity_only_triggers_once() {
191        let mut detector = StruggleDetector::new();
192
193        detector.trigger_inactivity();
194        assert!(detector.inactivity_triggered);
195
196        // Triggering again shouldn't change the reason
197        detector.reason = None;
198        detector.trigger_inactivity();
199        assert!(detector.reason.is_none()); // Didn't set it again
200    }
201
202    #[test]
203    fn test_should_show_hints() {
204        let mut detector = StruggleDetector::new();
205        assert!(!detector.should_show_hints());
206
207        detector.record_wrong_attempt();
208        assert!(detector.should_show_hints());
209    }
210
211    #[test]
212    fn test_struggle_message() {
213        let mut detector = StruggleDetector::new();
214        assert!(detector.struggle_message().is_none());
215
216        detector.trigger_inactivity();
217        assert!(detector.struggle_message().is_some());
218        assert!(detector.struggle_message().unwrap().contains("hint"));
219    }
220}