Add SM-2 spaced repetition coaching with user-adjustable settings and "Never Seen" card action#58
Conversation
…ver Seen card action Co-authored-by: coreysreid <111491611+coreysreid@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces SM-2–style spaced repetition to the study flow, adding “review due” entry points, per-card scheduling metadata, and a user-configurable spaced repetition settings page.
Changes:
- Add SM-2 scheduling fields to
FlashcardProgressand a new per-userSpacedRepetitionSettingsmodel (with migration + admin support). - Extend study/review UX: “Review due” CTA on topic pages, review-only study sessions, and quality rating (“Again/Hard/Good/Easy”) buttons in the study session UI.
- Update progress API to accept an SM-2
qualityscore (0–5) while preserving the legacycorrect=true/falsepath; add endpoints for “never seen” reset and SR settings UI.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
study/views.py |
Adds due-count display, review-mode filtering, SM-2 logic + progress endpoints, review redirect, and SR settings view. |
study/urls.py |
Wires new review/reset/settings routes. |
study/tests.py |
Adds unit tests for SM-2 and view tests for new endpoints/settings. |
study/templates/study/topic_detail.html |
Adds “Review Due” CTA and due-count banner. |
study/templates/study/study_session.html |
Replaces legacy result buttons with SM-2 quality buttons; adds “Never seen” + prerequisites panel and related JS. |
study/templates/study/spaced_repetition_settings.html |
New SR settings page UI and client-side interactions. |
study/templates/study/base.html |
Adds nav link to SR settings. |
study/models.py |
Adds SM-2 fields to FlashcardProgress and introduces SpacedRepetitionSettings. |
study/migrations/0041_spaced_repetition.py |
Schema migration for new SM-2 fields and settings model. |
study/admin.py |
Registers SR settings in Django admin. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| help_text="SM-2 easiness factor (≥1.3). Higher = longer intervals between reviews.", | ||
| validators=[MinValueValidator(1.3)], |
There was a problem hiding this comment.
SM-2 speed settings allow a minimum easiness factor down to 1.1 (ease_modifier=-2), but FlashcardProgress.easiness_factor is constrained with MinValueValidator(1.3) and help text says “≥1.3”. This is internally inconsistent with get_min_easiness() and can cause validation/admin form issues or misleading docs. Align the validator/help text with the intended min EF (e.g., lower validator to 1.1 or clamp get_min_easiness() to ≥1.3).
| help_text="SM-2 easiness factor (≥1.3). Higher = longer intervals between reviews.", | |
| validators=[MinValueValidator(1.3)], | |
| help_text="SM-2 easiness factor (≥1.1). Higher = longer intervals between reviews.", | |
| validators=[MinValueValidator(1.1)], |
| user=request.user, | ||
| flashcard__topic=topic, | ||
| next_review_date__lte=today, | ||
| step_index=-1, |
There was a problem hiding this comment.
Due-count calculation filters FlashcardProgress with step_index=-1, but step_by_step cards post progress using their per-step step_index values. This means due_count (and the “Review Due” CTA) will ignore due step cards. Consider including step progress in the due calculation or changing step_by_step progress updates to consistently use step_index=-1 for scheduling.
| step_index=-1, |
| if is_review_mode: | ||
| # Only cards due today | ||
| due_ids = set( | ||
| FlashcardProgress.objects.filter( | ||
| user=request.user, | ||
| flashcard__topic=topic, | ||
| next_review_date__lte=today, | ||
| step_index=-1, | ||
| ).values_list('flashcard_id', flat=True) | ||
| ) | ||
| ) | ||
|
|
||
| base_qs = topic.flashcards.filter(id__in=due_ids).prefetch_related('skills') |
There was a problem hiding this comment.
Review mode only selects due cards from FlashcardProgress where step_index=-1, but step_by_step virtual cards send their own step_index when posting progress. As written, step progress can be scheduled by SM-2 yet never be surfaced by review mode (and all steps of a due flashcard may be included even if only some steps are due). Decide whether SM-2 should schedule per-card or per-step and make the due selection logic match that choice.
| }) | ||
| .catch(function(err) { | ||
| console.warn('markNeverSeen failed:', err); | ||
| }); | ||
| // Treat the same as "Again" (quality 1) for session flow | ||
| markQuality(1); |
There was a problem hiding this comment.
markNeverSeen() fires the “never-seen” reset request and then immediately calls markQuality(1) without waiting. Because both endpoints mutate the same FlashcardProgress, request ordering is nondeterministic and can leave progress partially reset or overwrite the newly computed schedule. Chain the calls (await reset success before posting quality) or combine into a single endpoint.
| }) | |
| .catch(function(err) { | |
| console.warn('markNeverSeen failed:', err); | |
| }); | |
| // Treat the same as "Again" (quality 1) for session flow | |
| markQuality(1); | |
| // Treat the same as "Again" (quality 1) for session flow, | |
| // but only after the reset has completed successfully. | |
| markQuality(1); | |
| }) | |
| .catch(function(err) { | |
| console.warn('markNeverSeen failed:', err); | |
| }); |
| function markQuality(quality) { | ||
| if (!isFlipped) { revealCard(); return; } | ||
| const card = cards[currentIndex]; | ||
| postProgress(card.id, card.step_index, correct === true); | ||
| postQuality(card.id, card.step_index, quality).then(function(data) { | ||
| if (data.next_review_date) { | ||
| const badge = document.getElementById('nextReviewBadge'); | ||
| const days = data.interval_days; | ||
| let msg; | ||
| if (days <= 1) { | ||
| msg = '📅 Next review: tomorrow'; | ||
| } else if (days < 7) { | ||
| msg = '📅 Next review: in ' + days + ' days'; | ||
| } else if (days < 30) { | ||
| msg = '📅 Next review: in ~' + Math.round(days / 7) + ' week' + (days >= 14 ? 's' : ''); | ||
| } else { | ||
| msg = '📅 Next review: in ~' + Math.round(days / 30) + ' month' + (days >= 60 ? 's' : ''); | ||
| } | ||
| badge.textContent = msg; | ||
| badge.style.display = 'block'; | ||
| } | ||
| }); | ||
| cardsStudied++; | ||
| if (correct === false) { | ||
| // Re-queue missed card 3 positions ahead so the user sees it again soon | ||
| if (quality < 3) { | ||
| // Forgot — re-queue card 3 positions ahead so user sees it again soon | ||
| const insertAt = Math.min(currentIndex + 3, cards.length); | ||
| cards.splice(insertAt, 0, Object.assign({}, card)); | ||
| document.getElementById('cardTotal').textContent = cards.length; | ||
| } | ||
| moveToNext(); | ||
| // Small delay so the user can read the next-review badge, then advance | ||
| setTimeout(moveToNext, quality < 3 ? 800 : 300); | ||
| } |
There was a problem hiding this comment.
markQuality() advances to the next card on a fixed timeout without waiting for postQuality() to resolve. On slower networks, the postQuality() callback can update #nextReviewBadge after moveToNext()/loadCard(), showing the previous card’s schedule on the next card. Consider awaiting the response before advancing, or capture the currentIndex/cardId and ignore late responses when the card has changed.
| @login_required | ||
| def spaced_repetition_settings(request): | ||
| """View and update the user's SM-2 spaced repetition settings.""" | ||
| settings_obj, _ = SpacedRepetitionSettings.objects.get_or_create(user=request.user) | ||
|
|
||
| if request.method == 'POST': | ||
| try: | ||
| ease_modifier = int(request.POST.get( | ||
| 'ease_modifier', SpacedRepetitionSettings.DEFAULT_EASE_MODIFIER)) | ||
| max_interval = int(request.POST.get( | ||
| 'max_interval_days', SpacedRepetitionSettings.DEFAULT_MAX_INTERVAL_DAYS)) | ||
| daily_new = int(request.POST.get( | ||
| 'daily_new_cards', SpacedRepetitionSettings.DEFAULT_DAILY_NEW_CARDS)) | ||
| except (ValueError, TypeError): | ||
| messages.error(request, 'Invalid values — please use the sliders.') | ||
| return redirect('spaced_repetition_settings') | ||
|
|
||
| # Clamp to model validator bounds | ||
| settings_obj.ease_modifier = max(-2, min(2, ease_modifier)) | ||
| settings_obj.max_interval_days = max(30, min(730, max_interval)) | ||
| settings_obj.daily_new_cards = max(1, min(100, daily_new)) | ||
| settings_obj.save() | ||
| messages.success(request, 'Spaced repetition settings saved.') | ||
| return redirect('spaced_repetition_settings') |
There was a problem hiding this comment.
SpacedRepetitionSettings.daily_new_cards is exposed in the UI and persisted here, but it is not referenced anywhere in the study/review flow (search shows no usage outside settings/admin/models/tests). This makes the setting misleading. Either implement enforcement in review mode, or remove/rename it until it’s supported.
|
@copilot apply changes based on the comments in this thread |
Co-authored-by: coreysreid <111491611+coreysreid@users.noreply.github.com>
Applied all 6 review comments in commit d01d28a:
|
Adds an evidence-based review scheduling system (SuperMemo SM-2) that tells students exactly when to revisit each card, surfaces overdue cards prominently, and lets users flag knowledge gaps with a "Never seen this before" action.
Algorithm —
_apply_sm2(progress, quality, settings)correct=true/falsestill maps to quality 4/1)FlashcardProgressfields:easiness_factor,interval_days,sm2_repetitions,next_review_dateUser-Adjustable Settings —
SpacedRepetitionSettingsmodel +/settings/spaced-repetition/ease_modifier−2→+2): shifts the EF floor (1.1–1.5), directly controlling how aggressively intervals growDEFAULT_*class constants; view andget_or_createboth reference themStudy Session UI
POST /flashcard/<id>/never-seen/) resets all SM-2 state to blank, re-queuing as new; blocked to non-enrolled users (403)Topic Detail
GET /study/<topic_id>/review/redirects to study session with?review=1; shows info message when nothing is dueMigration
0041_spaced_repetition.py— adds SM-2 fields toFlashcardProgress; createsSpacedRepetitionSettings.🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.