Skip to content

feat(07): Spaced repetition study system (SM-2) - #49

Merged
tpaulshippy merged 22 commits into
mainfrom
feature/roadmap-07-spaced-repetition
Sep 12, 2026
Merged

tpaulshippy merged 22 commits into
mainfrom
feature/roadmap-07-spaced-repetition

Conversation

@tpaulshippy

@tpaulshippy tpaulshippy commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Roadmap 07: Spaced Repetition Study

Implements #59.

What changed

  • Backend: SM-2 scheduling algorithm (srs.py), due_at/interval/ease/reps/lapses fields on Flashcard, FlashcardReview log model, POST .../review.json endpoint, GET .../study_queue.json with due filtering, deck annotations (due_count, last_studied_at)
  • Frontend: Study mode with card flip animation, Again/Hard/Good/Easy ratings with 1d/3d/7d hints, progress tracking, completion summary, due badges on deck list
  • Scheduling: Again→relearn (4h), Hard→interval×1.2, Good→1d→6d→interval×ease, Easy→×1.3 bonus; ease floored at 1.3
  • Tests: 126 backend (44 new incl. table-driven SM-2 tests + timezone-aware due filtering), 55 frontend, e2e walkthrough
  • Fixes since review: .json format-suffix actions now accept the format kwarg (study_queue/review 500d live); corrected /study_queue/.json/study_queue.json URLs

Demo

  1. Seed: manage.py seed_e2e_spaced_repetition → 8 Cell Bio cards (6 due)
  2. Open deck → Study (6) → flip cards → rate Again/Hard/Good/Easy
  3. Completion: summary shows reviewed count, next due time
  4. Re-open: badge vanishes, study says 'Nothing due 🎉'

Evidence

Deck list

Study card front

Flipped card with ratings

Session complete

pr49-spaced.mp4

@tpaulshippy tpaulshippy changed the title feat(07): Spaced repetition study system feat(07): Spaced repetition study system (SM-2) Aug 25, 2026
tpaulshippy pushed a commit that referenced this pull request Aug 26, 2026
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
tpaulshippy pushed a commit that referenced this pull request Aug 26, 2026
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
tpaulshippy pushed a commit that referenced this pull request Sep 3, 2026
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-07-spaced-repetition branch from 00fdddd to 7c9bec8 Compare September 3, 2026 14:07
@tpaulshippy
tpaulshippy changed the base branch from main to feature/roadmap-06-streaming-chat September 3, 2026 14:07
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-06-streaming-chat branch from a82ec39 to ad636c8 Compare September 3, 2026 15:14
tpaulshippy pushed a commit that referenced this pull request Sep 3, 2026
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-07-spaced-repetition branch from 7c9bec8 to 8263b37 Compare September 3, 2026 15:15
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-06-streaming-chat branch from 87666c1 to 8bb3f48 Compare September 3, 2026 15:32
tpaulshippy pushed a commit that referenced this pull request Sep 3, 2026
Screenshot shows the flashcards deck list with Biology 101 (12 cards) and World History (8 cards).
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-07-spaced-repetition branch from 4457aaa to ad6fc3f Compare September 3, 2026 15:32
tpaulshippy and others added 12 commits September 3, 2026 15:45
…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.
@tpaulshippy
tpaulshippy force-pushed the feature/roadmap-07-spaced-repetition branch from ad6fc3f to 47faf0e Compare September 3, 2026 15:45
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Automated review — backwards-compat + tests

Backwards-compat: COMPATIBLE (one intended behavior change). All scheduling fields nullable/defaulted; old {front,back} payloads still validate; no renames. Pre-existing cards become immediately due (due_at defaults to now) — intended, call it out. Old study_queue callers get due-only results by default — intended.
Tests: GOOD (verified). test_srs.py + test_spaced_repetition_api.py 44 passed; test_flashcard_api.py 30 passed; test_chat.py 12 passed, same head. .json format-suffix 500 from PR body is addressed (format=None accepted).

MUST FIX

None blocking. Two should-fix items below are the closest.

SHOULD FIX

  1. serializers/flashcard_serializer.py:27-28,51-52 + viewsets/flashcard_viewset.py:125-127due_count/last_studied_at exist only on annotated querysets; perform_create returns unannotated instance → create response may serialize wrong/error. allow_null/re-fetch annotated instance (same pre-existing pattern as card_count, now 2 more fields ride on it).
  2. services/srs.py:hard — never advances reps, so all-hard cards loop and a later good still hits the reps==0 -> 1d branch. Increment reps on hard or document.

NICE TO HAVE

  1. No review idempotency — double-tap applies SM-2 twice. Client debounce or dedupe window.
  2. limit=0/-5 silently clamps to 1 while non-int 400s — inconsistent.
  3. (process) drive-bys in this PR (02-pin-reauth.e2e.js selector, package.json --watchman=false) — split out next time.

Base automatically changed from feature/roadmap-06-streaming-chat to main September 8, 2026 06:07
- 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)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 function apply_sm2, add FlashcardReview log model, and expose review + study_queue API 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.

Comment thread front/e2e/07-spaced-repetition.e2e.js Outdated
Comment thread back/bots/models/flashcard_review.py
Comment thread back/bots/viewsets/flashcard_viewset.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.deck is a SlugRelatedField that dereferences the FK for each object. Starting from deck.flashcards.all() leaves that relation uncached, so a full queue performs an extra query per card. Use select_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). Study then 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

Comment thread back/bots/serializers/flashcard_serializer.py
Comment thread back/bots/viewsets/flashcard_viewset.py Outdated
Comment thread front/api/flashcards.ts Outdated
Comment thread front/app/flashcards/study.tsx Outdated
Comment thread back/bots/management/commands/seed_e2e_spaced_repetition.py
Comment thread front/app/flashcards/study.tsx Outdated
Comment thread front/app/flashcards/study.tsx Outdated
Comment thread front/e2e/07-spaced-repetition.e2e.js
Comment thread front/e2e/07-spaced-repetition.e2e.js Outdated
- 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 1 because 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

  • FlashcardSerializer serializes the deck slug for every queue item, but this reverse-manager queryset does not select_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

  • requestRaw rethrows UnauthorizedError after refresh failure, but this catch treats it as an ordinary load error and never invokes the repository's handleUnauthorized flow. 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 UnauthorizedError is converted into “Failed to save your review,” leaving the user in the session with an invalid token. Call handleUnauthorized(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

Comment thread front/e2e/07-spaced-repetition.e2e.js Outdated
Comment thread front/e2e/07-spaced-repetition.e2e.js Outdated
Comment thread front/app/flashcards/study.tsx
- 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 FlashcardReview rows. After rerunning the documented seed, the cards look fresh but retain every prior rating, so consumers of profile.flashcard_reviews see 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

  • Profile supports soft deletion, and the profiles API only returns rows with deleted_at=None (back/bots/viewsets/profile_viewset.py:38-42). If a prior run or cleanup soft-deletes E2E Test Profile, get_or_create finds that row but leaves it deleted, so this seed reports success while getSeedState cannot find the profile and the E2E flow fails. Reactivate an existing row when seeding (for example, use update_or_create with defaults={'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. Because Flashcard.front is not unique, a duplicate of one of the eight known fronts survives; get_or_create then 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 BotViewSet excludes deleted_at rows (back/bots/viewsets/bot_viewset.py:20-25). If E2E Test Bot already exists but is soft-deleted, get_or_create returns it without reactivating it; getSeedState then may inject an unrelated bot or hit bots.results[0] with no result. Seed the bot with update_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 .json routes used by the frontend would therefore pass the required test suite; add .json requests 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.yaml still has no study_queue or review path and its Flashcard/Deck schemas omit the scheduling and annotation fields; front/__mocks__/handlers.ts also has no handlers for these requests. The repository's OpenAPI contract guidance requires updating those artifacts when viewsets or serializers change, otherwise /api/docs and mock-backed consumers remain on the pre-SRS contract.
    @action(detail=True, methods=['get'], url_path='study_queue')

front/app/flashcards/study.tsx:370

  • backfaceVisibility only 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 until isFlipped (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 tintColor and Again's #d9534f. The light theme defines tintColor as #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
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Addressed all 9 suppressed comments from the latest Copilot review (c77e5c4) — each verified valid before fixing:

Seed determinism (4)

  • Review rows now cleared on reseed: the deck's cards are recreated each run, cascading to FlashcardReview. Verified live (planted review + duplicate + soft-deleted rows → 8 cards, 0 reviews).
  • Soft-deleted E2E Test Profile reactivated via update_or_create(defaults={deleted_at: None}).
  • Duplicate fronts can't survive: full recreate replaces the upsert loop.
  • Soft-deleted E2E Test Bot reactivated the same way; e2e helper now selects the bot by name with a hard fail.

API coverage + contract (2)

  • New backend tests hit /review.json and /study_queue.json?mode=all&limit=2 directly.
  • Regenerated front/api/schema.yaml per OPENAPI_CONTRACT_TESTING.md (now includes the review/study_queue paths, scheduling fields, deck annotations) and added MSW handlers for both endpoints.

Study screen (3)

  • Card faces hidden from VoiceOver/TalkBack until shown (accessibilityElementsHidden + importantForAccessibility).
  • Rating/solid buttons use new studyRating/studyAgain theme colors (light #00708c/#b52a25, dark tint/#b52a25) — all ≥4.5:1 with white text (old #00a4c9 was 2.9, #d9534f 3.9).
  • Completion shows correct rate per 07 — Spaced Repetition Study #59 ('X% correct (Y of Z)', correct = anything but Again), with tests for 100% and 50%.

Verified: backend 83 passed, ruff clean, eslint 0 errors, tsc clean, study suite 7 passed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • resetFlip can race the spring started in flipCard: the rating buttons become tappable before that spring necessarily finishes, and setValue(0) does not stop the in-flight animation. A quick rating can therefore leave the next card visually flipped even though isFlipped is false. Stop the animation before resetting the value.
    flipAnim.setValue(0);

front/app/flashcards/study.tsx:107

  • requestRaw deliberately rethrows UnauthorizedError, 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 existing handleUnauthorized flow 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 with handleUnauthorized(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()
Comment thread front/api/schema.yaml
name: deck_pk
schema:
type: string
type: integer
Comment thread front/api/schema.yaml
content:
application/json:
schema:
$ref: '#/components/schemas/Flashcard'
Comment thread front/api/schema.yaml
Query params:
mode: 'due' (default) only cards due now, or 'all'
limit: max cards returned (default 50, capped at 200)
parameters:
Comment thread front/api/schema.yaml
content:
application/json:
schema:
$ref: '#/components/schemas/Deck'
Comment thread front/app/flashcards.tsx
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
@tpaulshippy

Copy link
Copy Markdown
Owner Author

Addressed the 3 new suppressed comments from the latest Copilot review (224a2ae), all verified valid:

  • Flip raceresetFlip now calls flipAnim.stopAnimation() before setValue(0), so a quick rating can't leave the next card visually flipped while isFlipped is false.
  • Swallowed auth errors (queue + all-mode) — both catches now await handleUnauthorized(error, router) first (the established chatList pattern), so expired sessions redirect to login instead of rendering the load-error card.
  • Swallowed auth error (rating) — same routing before the generic save alert, so an expired session can't strand the user on the study screen.

Added a study-screen test proving expired queue loads delegate to handleUnauthorized with no error card. Verified: eslint 0 errors, tsc clean, study+deck suites 12 passed.

@tpaulshippy
tpaulshippy merged commit 9fb2e70 into main Sep 12, 2026
3 checks passed
@tpaulshippy
tpaulshippy deleted the feature/roadmap-07-spaced-repetition branch September 12, 2026 14:58
tpaulshippy added a commit that referenced this pull request Sep 12, 2026
- Take FlashcardReview model (PR #49) alongside HtmlPage
- Renumber htmlpage migration 0050 -> 0051 on top of main's 0050
tpaulshippy added a commit that referenced this pull request Sep 12, 2026
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.
tpaulshippy added a commit that referenced this pull request Sep 13, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants