feat(07): Spaced repetition study system (SM-2) - #49
Conversation
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
00fdddd to
7c9bec8
Compare
a82ec39 to
ad636c8
Compare
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
7c9bec8 to
8263b37
Compare
87666c1 to
8bb3f48
Compare
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
4457aaa to
ad6fc3f
Compare
…endpoints, deck annotations
Implements docs/roadmap/07-spaced-repetition-study.md backend sections:
- Flashcard scheduling fields (due_at, interval_days, ease, reps, lapses,
last_reviewed_at) with due_at=now default so new cards are studyable
immediately, including agent-created ones
- Pure apply_sm2(card, rating, now) in bots/services/srs.py (Again/Hard/
Good/Easy), unit-tested and swappable; no math in the viewset
- POST /api/decks/{deck_id}/flashcards/{id}/review/ reschedules via srs.py
and writes a FlashcardReview log row
- GET /api/decks/{deck_id}/study_queue/?mode=due|all&limit=N ordered by
due_at asc (nulls last)
- Deck list/detail serializers gain due_count + last_studied_at annotations
- FlashcardViewSet now scopes lookups to the requesting user's decks and
requires IsAuthenticated, so foreign decks 404 like every other resource
- seed_e2e_spaced_repetition management command (idempotent) for Detox runs
…ngs, due badges
Implements docs/roadmap/07-spaced-repetition-study.md frontend sections:
- flashcards/study.tsx: study queue via GET study_queue?mode=due with
'Study all anyway' fallback, rotateY flip animation, rating row
(Again/Hard/Good/Easy with interval hints) revealed after the flip,
review POST per rating, haptics (warning on Again, light on others),
session summary (reviewed count, Again count, 'Next due in X'), testIDs
on all new interactive elements
- flashcards.tsx list rows: red 'N due' badge + 'Last studied X ago' line
- flashcards/deck.tsx: Study button label 'Study (N)' from deck.due_count,
passes mode=due; study-button testID
- api/flashcards.ts: scheduling field types, fetchStudyQueue,
reviewFlashcard
- drawer/menu button testIDs for e2e navigation
- typecheck fixes for pre-existing errors in __mocks__/handlers.ts and
__tests__/api/{apiClient,bots,profiles,aiModels}.test.ts so tsc is clean;
new fetchStudyQueue/reviewFlashcard tests in flashcards.test.ts
- package.json: run jest with --watchman=false (local watchman daemon broken)
…ngs, due badges
Implements docs/roadmap/07-spaced-repetition-study.md frontend sections:
- flashcards/study.tsx: study queue via GET study_queue?mode=due with
'Study all anyway' fallback, rotateY flip animation, rating row
(Again/Hard/Good/Easy with interval hints) revealed after the flip,
review POST per rating, haptics (warning on Again, light on others),
session summary (reviewed count, Again count, 'Next due in X'), testIDs
on all new interactive elements
- flashcards.tsx list rows: red 'N due' badge + 'Last studied X ago' line
(deck-row-* / deck-due-badge-* testIDs)
- flashcards/deck.tsx: 'Study (N)' button from deck.due_count, mode=due
param, study-button testID
- api/flashcards.ts: scheduling field types, fetchStudyQueue,
reviewFlashcard, Deck.due_count/last_studied_at
- drawer-item-flashcards + drawer-menu-button testIDs for e2e navigation
- new fetchStudyQueue/reviewFlashcard tests in flashcards.test.ts
Note: these changes were previously stashed by an external checkpoint tool
(stash@{0}, commit e64f5ad) mid-session; restored from that stash.
front/e2e/07-spaced-repetition.e2e.js models chatImageUpload.e2e.js: JWT auth against API_BASE/token/, profile/bot/deck fetched from the seed data, AsyncStorage injected via simctl, then drawer -> Flashcards -> 'Cell Bio' deck -> Study -> six cards rated Again/Hard/Good/Easy -> session-complete summary -> Done. Verifies next-due state afterwards: study_queue?mode=due returns [], the deck shows the nothing-due state with 'Study all anyway', and the list row's red due badge disappears. Header comment documents the idempotent seed_e2e_spaced_repetition management command and env vars. back-button testID added to shared BackButton for navigation.
- Backend: remove unused studied variable, fix import ordering - Frontend: replace useRef with useState for Animated.Value and Date.now() - Frontend: add jest globals to eslint config
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
The extra slash before .json (/study_queue/.json, /review/.json) does not match the DRF format-suffix routes (/study_queue.json, /review.json) or this file's own convention, so study mode and ratings 404. Use the suffix form.
…idence DRF passes format='json' to custom actions; study_queue and review rejected it with TypeError (500), so study mode showed 'Nothing due' against a live backend. Accept format=None (matching DRF convention). Evidence: real study session — card front, flipped back with Again/Hard/Good/Easy + interval hints, completion summary. Also fix /.json endpoint URLs and guard web notif crash.
ad6fc3f to
47faf0e
Compare
Automated review — backwards-compat + testsBackwards-compat: COMPATIBLE (one intended behavior change). All scheduling fields nullable/defaulted; old MUST FIXNone blocking. Two should-fix items below are the closest. SHOULD FIX
NICE TO HAVE
|
- serializers: default due_count=0, card_count=0, last_studied_at=None so deck create responses include annotation fields - srs: hard now increments reps so all-hard cards progress and later good skips learning steps - study_queue: limit<1 returns 400 instead of silently clamping to 1 - study UI: don't advance on failed review, reset rating flag in finally, functional again-count (double-tap guard)
There was a problem hiding this comment.
🟡 Changes recommended
There are a couple of concrete correctness issues (e2e URL typo and a broken __str__ on FlashcardReview) that can cause test/runtime failures and should be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements Roadmap 07 spaced-repetition study (SM-2 style) across backend scheduling, review logging, API endpoints, and a new frontend study mode so decks can be studied by “due” cards with Again/Hard/Good/Easy ratings and progress/completion UI.
Changes:
- Backend: add SM-2 scheduling fields to
Flashcard, implement pure scheduling functionapply_sm2, addFlashcardReviewlog model, and exposereview+study_queueAPI actions plus deck list annotations (due_count,last_studied_at). - Frontend: add study-mode UI with flip animation + rating buttons, deck due badges / last-studied text, and API helpers for
study_queue+review. - Tests/tooling: add backend unit/API tests, frontend tests, e2e flow + deterministic seed command.
File summaries
| File | Description |
|---|---|
| front/package.json | Disables Watchman in Jest test script. |
| front/e2e/07-spaced-repetition.e2e.js | Adds Detox e2e walkthrough for study flow using seeded backend state. |
| front/app/flashcards/study.tsx | Reworks study UI to fetch due queue, flip card, rate, and show completion summary. |
| front/app/flashcards/deck.tsx | Adds “Study (N)” label and testID for study button. |
| front/app/flashcards.tsx | Shows deck due badge + “last studied” metadata and adds testIDs for deck rows. |
| front/app/tests/study-test.tsx | Updates study tests to use study queue + review API and new UI flow. |
| front/app/tests/deck-test.tsx | Updates deck detail test for new study route params. |
| front/api/flashcards.ts | Adds types + client functions for study_queue and review endpoints and scheduling fields. |
| front/tests/api/flashcards.test.ts | Adds API tests/mocks covering fetchStudyQueue and reviewFlashcard. |
| back/bots/viewsets/flashcard_viewset.py | Adds review action on flashcards, study_queue action on decks, and deck annotations for due/last-studied. |
| back/bots/tests/test_srs.py | Adds table-driven unit tests for the pure SM-2 scheduling function. |
| back/bots/tests/test_spaced_repetition_api.py | Adds integration tests for review + study queue + deck annotations. |
| back/bots/services/srs.py | Implements pure SM-2-like scheduling function and constants. |
| back/bots/serializers/flashcard_serializer.py | Serializes scheduling fields and deck due/last-studied annotations. |
| back/bots/models/flashcard.py | Adds scheduling fields (due_at/interval/ease/reps/lapses/last_reviewed_at). |
| back/bots/models/flashcard_review.py | Adds review log model for rating history. |
| back/bots/models/init.py | Exports FlashcardReview from models package. |
| back/bots/migrations/0050_flashcard_due_at_flashcard_ease_and_more.py | Migrates scheduling fields + creates FlashcardReview table. |
| back/bots/management/commands/seed_e2e_spaced_repetition.py | Adds deterministic seed command for the spaced repetition e2e flow. |
Review details
- Files reviewed: 19/25 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain in scheduling writes, concurrent reviews, and failed review handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
back/bots/viewsets/flashcard_viewset.py:210
- The queue can return up to 200 cards, but
FlashcardSerializer.deckis aSlugRelatedFieldthat dereferences the FK for each object. Starting fromdeck.flashcards.all()leaves that relation uncached, so a full queue performs an extra query per card. Useselect_related('deck')here to keep queue loading bounded.
queryset = deck.flashcards.all()
if mode == 'due':
queryset = queryset.filter(due_at__lte=timezone.now())
queryset = queryset.order_by(
front/api/flashcards.ts:159
request()converts every non-401/network failure into its fallback[](see the shared request helper).Studythen treats that array as a successful empty queue and renders “Nothing due”, so a 500/offline response is presented as caught up. This endpoint needs to surface failure and the screen needs a separate error state instead of conflating it with an empty queue.
request<Flashcard[]>(
`/decks/${deckId}/study_queue.json?mode=${mode}&limit=${limit}`,
{ method: "GET" },
[]
);
- Files reviewed: 19/19 changed files
- Comments generated: 9
- Review effort level: Lite
- Serializer: scheduling fields read-only, mutated only via review (logged) - Review: transaction.atomic + select_for_update against lost updates - Seed: delete stale cards so reruns converge to exactly 8 - API: fetchStudyQueue/reviewFlashcard throw on failure (no silent empty/null) - Study: load-error state with retry; null review never advances; hints derived per-card from SM-2 state - E2E: select E2E Test Profile by name; assert response.ok and array body
…usly Fixes frontend lint error react-hooks/set-state-in-effect: loader lives inside the effect and touches state only after await; retry resets flags in the event handler and bumps reloadKey.
There was a problem hiding this comment.
🟡 Changes recommended
Critical E2E failures and unresolved frontend authentication and error-handling issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
back/bots/tests/test_spaced_repetition_api.py:341
- The test name says an unstudied deck has zero due cards, but the assertion is
1because new cards are intentionally due immediately. Rename the test so its name matches the scheduling behavior and does not mislead future changes.
def test_unstudied_deck_has_zero_due_count_and_no_last_studied(self, auth_client, deck):
back/bots/viewsets/flashcard_viewset.py:212
FlashcardSerializerserializes thedeckslug for every queue item, but this reverse-manager queryset does notselect_related('deck'). With the 200-card cap, serializing a queue can issue one extra database query per card; select the related deck on this queryset to avoid an N+1 on each study session.
queryset = deck.flashcards.all()
front/app/flashcards/study.tsx:106
requestRawrethrowsUnauthorizedErrorafter refresh failure, but this catch treats it as an ordinary load error and never invokes the repository'shandleUnauthorizedflow. An expired session will remain on this study screen showing Retry instead of clearing credentials and redirecting to login; handle unauthorized errors separately before setting the generic error state.
} catch (error) {
Sentry.captureException(error);
setLoadError(true);
} finally {
front/app/flashcards/study.tsx:187
- The review path has the same authentication failure bug: a rethrown
UnauthorizedErroris converted into “Failed to save your review,” leaving the user in the session with an invalid token. CallhandleUnauthorized(error, router)before showing the save alert so expired sessions are cleared and redirected consistently.
} catch (error) {
Sentry.captureException(error);
Alert.alert("Error", "Failed to save your review");
return;
- Files reviewed: 19/19 changed files
- Comments generated: 3
- Review effort level: Lite
- E2E: scope deck lookup to E2E Test Profile via ?profileId= - E2E: use header-back-button testID (shared BackButton default) - Study: failed study-all-anyway sets loadError instead of fake empty queue
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate issues remain in seed reset behavior, API route coverage, and the study summary.
Review details
Suppressed comments (9)
back/bots/management/commands/seed_e2e_spaced_repetition.py:93
- Resetting these card fields does not reset the new
FlashcardReviewrows. After rerunning the documented seed, the cards look fresh but retain every prior rating, so consumers ofprofile.flashcard_reviewssee duplicated/unbounded activity and the seed is not deterministic. Clear the review rows for this deck (or recreate the cards) when resetting it.
# Reset scheduling every run so repeated seeds converge.
card.back = back
card.order = order
card.ease = 2.5
card.interval_days = 0
card.reps = 0
card.lapses = 0
card.last_reviewed_at = None
back/bots/management/commands/seed_e2e_spaced_repetition.py:55
Profilesupports soft deletion, and the profiles API only returns rows withdeleted_at=None(back/bots/viewsets/profile_viewset.py:38-42). If a prior run or cleanup soft-deletesE2E Test Profile,get_or_createfinds that row but leaves it deleted, so this seed reports success whilegetSeedStatecannot find the profile and the E2E flow fails. Reactivate an existing row when seeding (for example, useupdate_or_createwithdefaults={'deleted_at': None}).
profile, _ = Profile.objects.get_or_create(user=user, name='E2E Test Profile')
back/bots/management/commands/seed_e2e_spaced_repetition.py:75
- The cleanup only removes cards whose front is absent from
CARDS. BecauseFlashcard.frontis not unique, a duplicate of one of the eight known fronts survives;get_or_createthen reuses one copy and the deck still has more than eight cards, so the documented idempotent reset can leave extra due cards in the study queue. Deduplicate each known front (or clear/recreate the seed deck's cards) before the upsert loop.
Flashcard.objects.filter(deck=deck).exclude(
front__in=[front for front, _, _ in CARDS]
).delete()
back/bots/management/commands/seed_e2e_spaced_repetition.py:62
- Bots are also soft-deleted and
BotViewSetexcludesdeleted_atrows (back/bots/viewsets/bot_viewset.py:20-25). IfE2E Test Botalready exists but is soft-deleted,get_or_createreturns it without reactivating it;getSeedStatethen may inject an unrelated bot or hitbots.results[0]with no result. Seed the bot withupdate_or_create(..., defaults={'ai_model': ai_model, 'deleted_at': None})(and preferably select it by name in the E2E helper).
bot, _ = Bot.objects.get_or_create(
user=user,
name='E2E Test Bot',
defaults={'ai_model': ai_model},
)
back/bots/viewsets/flashcard_viewset.py:74
- The format-suffix fix is only exercised by the Detox spec: all backend API tests call
/review/and/study_queue/, while CI runs pytest rather than Detox. A regression in the.jsonroutes used by the frontend would therefore pass the required test suite; add.jsonrequests for both actions (including the queue query parameters).
def review(self, request, deck_pk=None, flashcardId=None, format=None):
back/bots/viewsets/flashcard_viewset.py:195
- Please regenerate the checked-in API contract for this new action.
front/api/schema.yamlstill has nostudy_queueorreviewpath and itsFlashcard/Deckschemas omit the scheduling and annotation fields;front/__mocks__/handlers.tsalso has no handlers for these requests. The repository's OpenAPI contract guidance requires updating those artifacts when viewsets or serializers change, otherwise/api/docsand mock-backed consumers remain on the pre-SRS contract.
@action(detail=True, methods=['get'], url_path='study_queue')
front/app/flashcards/study.tsx:370
backfaceVisibilityonly hides pixels; the answer text under this always-mounted animated face remains exposed to VoiceOver/TalkBack before the user flips the card. Hide this face from accessibility untilisFlipped(and expose it after the flip), otherwise the front→reveal interaction is bypassed for screen-reader users.
<Animated.View
style={[
styles.cardFace,
{ transform: [{ rotateY: backRotate }] },
]}
>
front/app/flashcards/study.tsx:393
- These controls render white 15/12px text on
tintColorand Again's#d9534f. The light theme definestintColoras#00a4c9(front/constants/Colors.ts:6,13), and both combinations are below WCAG AA contrast for the primary rating controls, making the labels and hints difficult to read. Use darker theme-specific button backgrounds or a contrasting text color.
style={({ pressed }) => [
styles.ratingButton,
{ backgroundColor: rating === "again" ? "#d9534f" : tintColor },
pressed && styles.ratingButtonPressed,
front/app/flashcards/study.tsx:294
- The completion summary reports the total reviewed and Again count, but never displays the correct-ish rate required by issue #59's success criteria. Add an explicit percentage (and define which ratings count as correct) so the session outcome is visible rather than inferred from the raw counts.
<ThemedText style={styles.completionSubtitle}>
You reviewed {cards.length} card{cards.length === 1 ? "" : "s"}.
</ThemedText>
{againCount > 0 ? (
<ThemedText style={[styles.completionStat, { color: iconColor }]}>
- Files reviewed: 19/19 changed files
- Comments generated: 0 new
- Review effort level: Lite
- Seed: recreate deck cards each run (clears reviews via cascade, no dupes); reactivate soft-deleted profile/bot - Tests: .json suffix coverage for review + study_queue with params - Contract: regenerate schema.yaml (review/study_queue paths); add MSW handlers - Study: hide card faces from screen readers until shown; AA-passing button colors; correct-rate summary per #59 - E2E: select bot by name
|
Addressed all 9 suppressed comments from the latest Copilot review (c77e5c4) — each verified valid before fixing: Seed determinism (4)
API coverage + contract (2)
Study screen (3)
Verified: backend 83 passed, ruff clean, eslint 0 errors, tsc clean, study suite 7 passed. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved schema, authentication handling, performance, and study-animation issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
front/app/flashcards/study.tsx:145
resetFlipcan race the spring started inflipCard: the rating buttons become tappable before that spring necessarily finishes, andsetValue(0)does not stop the in-flight animation. A quick rating can therefore leave the next card visually flipped even thoughisFlippedis false. Stop the animation before resetting the value.
flipAnim.setValue(0);
front/app/flashcards/study.tsx:107
requestRawdeliberately rethrowsUnauthorizedError, but this catch treats every error as a queue outage and swallows it. If the access and refresh tokens expire while opening study mode, the existinghandleUnauthorizedflow is never called, so the user remains on a dead screen instead of being redirected to login; handle/rethrow auth errors before setting the load-error state (and apply the same handling to the all-mode catch).
} catch (error) {
Sentry.captureException(error);
setLoadError(true);
front/app/flashcards/study.tsx:192
- The review API also propagates
UnauthorizedError, but this catch swallows it and shows “Failed to save your review”. An expired session can therefore leave the user stuck on the study screen without the app's established logout/redirect flow; handle auth errors withhandleUnauthorized(error, router)before showing the generic save error.
} catch (error) {
Sentry.captureException(error);
Alert.alert("Error", "Failed to save your review");
return;
- Files reviewed: 22/22 changed files
- Comments generated: 6
- Review effort level: Lite
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
|
|
||
| queryset = deck.flashcards.all() |
| name: deck_pk | ||
| schema: | ||
| type: string | ||
| type: integer |
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: '#/components/schemas/Flashcard' |
| Query params: | ||
| mode: 'due' (default) only cards due now, or 'all' | ||
| limit: max cards returned (default 50, capped at 200) | ||
| parameters: |
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: '#/components/schemas/Deck' |
| testID={`deck-due-badge-${item.deck_id}`} | ||
| style={[styles.countBadge, { backgroundColor: "#e0525226" }]} | ||
| > | ||
| <ThemedText style={[styles.dueBadgeText, { color: "#d9534f" }]}> |
- Study catches route UnauthorizedError to handleUnauthorized (queue, all-mode, rating) instead of error card / save alert - resetFlip stops the spring before setValue so the next card can't render flipped
|
Addressed the 3 new suppressed comments from the latest Copilot review (224a2ae), all verified valid:
Added a study-screen test proving expired queue loads delegate to |
- Take FlashcardReview model (PR #49) alongside HtmlPage - Renumber htmlpage migration 0050 -> 0051 on top of main's 0050
Post-#49 merge CI failed on 'shows the current kid chip' (5s waitFor timeout). Same tree passed branch CI; local reruns pass/fail intermittently. Cold-start cost exceeds the default timeout under parallel-suite load; assertions unchanged.
Resolves merge conflicts + drift from main (PR #78 admin reset, Study Materials screens, #49 review batch 2): - back migration 0051 collision: PR69's 0051_device_notify_study_due renumbered to 0055 (linear, after main's 0054) - flashcard review(): keep main's select_for_update row lock - fetchStudyQueue: unify on main's throw contract; thrown error carries HTTP status so reminder taps still redirect on 404 (sibling deck) while offline/5xx show the error card - study.tsx: main's loadError/retry + handleUnauthorized UX as base, PR69 reminder fallback (empty or 404 + source=reminder -> deck list) re-applied; hint tests updated to main's previewHint copy - drawer: Study Materials (all) + Activity (parents) from main, with PR69 teen-safe Settings (/settings) vs parent Settings; menu uses main's replace navigation
Roadmap 07: Spaced Repetition Study
Implements #59.
What changed
srs.py),due_at/interval/ease/reps/lapses fields on Flashcard,FlashcardReviewlog model,POST .../review.jsonendpoint,GET .../study_queue.jsonwith due filtering, deck annotations (due_count, last_studied_at)1d/3d/7dhints, progress tracking, completion summary, due badges on deck list.jsonformat-suffix actions now accept theformatkwarg (study_queue/review 500d live); corrected/study_queue/.json→/study_queue.jsonURLsDemo
manage.py seed_e2e_spaced_repetition→ 8 Cell Bio cards (6 due)Evidence
pr49-spaced.mp4