q=1 * 2 (Hard) -> q=3 * 3 (Good) -> q=4 * 4 (Easy) -> q=5 */ public function processReview(User $user, Vocabulary $vocab, int $grade) { // Map UI Grade to SM-2 Quality // Mapping: 1->1, 2->3, 3->4, 4->5 $qualityMap = [1 => 1, 2 => 3, 3 => 4, 4 => 5]; $q = $qualityMap[$grade] ?? 3; // Find existing review or create new $review = SrsReview::firstOrNew([ 'user_id' => $user->id, 'vocabulary_id' => $vocab->id ]); if (!$review->exists) { // Defaults $review->ease_factor = 2.5; $review->interval = 0; $review->repetitions = 0; $review->status = 'new'; } // SM-2 Algorithm Implementation if ($q >= 3) { // Correct response if ($review->repetitions == 0) { $review->interval = 1; } elseif ($review->repetitions == 1) { $review->interval = 6; } else { $review->interval = round($review->interval * $review->ease_factor); } $review->repetitions++; $review->status = $review->interval > 21 ? 'graduated' : 'review'; } else { // Incorrect response $review->repetitions = 0; $review->interval = 1; $review->status = 'learning'; } // Update Ease Factor // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) $review->ease_factor = $review->ease_factor + (0.1 - (5 - $q) * (0.08 + (5 - $q) * 0.02)); if ($review->ease_factor < 1.3) { $review->ease_factor = 1.3; } $review->last_review_at = Carbon::now(); $review->next_review_at = Carbon::now()->addDays($review->interval); $review->save(); // Track Activity (XP and Streak) $this->trackActivity($user, $grade); return $review; } public function trackActivity(User $user, int $grade) { $today = Carbon::today(); $lastActivity = $user->last_activity_at ? Carbon::parse($user->last_activity_at)->startOfDay() : null; // Award XP (Easy gives slightly more?) // Let's just do 5 XP per card for now. $user->xp_points += 5; if (!$lastActivity) { // First activity ever $user->current_streak = 1; } elseif ($today->isSameDay($lastActivity)) { // Already active today, do nothing to streak } elseif ($today->diffInDays($lastActivity) === 1) { // Consecutive day $user->current_streak += 1; } else { // Streak broken $user->current_streak = 1; } $user->last_activity_at = Carbon::now(); $user->save(); } public function getDueReviews(User $user, int $limit = 20) { return SrsReview::where('user_id', $user->id) ->where('next_review_at', '<=', Carbon::now()) ->with(['vocabulary']) ->orderBy('next_review_at', 'asc') ->limit($limit) ->get(); } public function getNewCards(User $user, int $limit = 5) { // Get vocabularies NOT in srs_reviews $reviewedIds = SrsReview::where('user_id', $user->id)->pluck('vocabulary_id'); return Vocabulary::whereNotIn('id', $reviewedIds) // Ideally filter by User's Level (e.g. N5) // ->where('level', $user->level ?? 'N5') ->limit($limit) ->get(); } }