diff --git a/back/bots/management/commands/seed_e2e_spaced_repetition.py b/back/bots/management/commands/seed_e2e_spaced_repetition.py new file mode 100644 index 00000000..499bce08 --- /dev/null +++ b/back/bots/management/commands/seed_e2e_spaced_repetition.py @@ -0,0 +1,113 @@ +"""Seed deterministic data for the spaced repetition Detox e2e flow. + +Creates (idempotently): + - parent user e2e-test-user / testpassword123 + - one profile and one bot for that user + - deck "Cell Bio" with 8 cards: 6 due (3 overdue + 3 brand new) and + 2 not-yet-due + +Re-running resets the deck's scheduling state to this exact layout, so the +e2e suite always starts from the same queue. + +Usage: python manage.py seed_e2e_spaced_repetition +""" + +from datetime import timedelta + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from django.utils import timezone + +from bots.models import AiModel, Bot, Deck, Flashcard, Profile + +USERNAME = 'e2e-test-user' +PASSWORD = 'testpassword123' +DECK_NAME = 'Cell Bio' + +# front -> scheduling state at seed time +CARDS = [ + # Due now (overdue): the e2e run rates these. + ('What is anaphase?', 'Sister chromatids separate and move to opposite poles', 'due'), + ('Define osmosis', 'Diffusion of water across a semipermeable membrane', 'due'), + ('What organelle makes ATP?', 'The mitochondrion', 'due'), + # Not due yet: must be skipped by the due queue. + ('What is mitosis?', 'Cell division producing two identical daughter cells', 'future'), + ('Function of ribosomes', 'Protein synthesis', 'future'), + # Never reviewed, due immediately by default. + ('What is a cell membrane?', 'The phospholipid bilayer enclosing the cell', 'new'), + ('Define photosynthesis', 'Conversion of light energy into chemical energy (glucose)', 'new'), + ('What does DNA stand for?', 'Deoxyribonucleic acid', 'new'), +] + + +class Command(BaseCommand): + help = 'Seed idempotent e2e data for spaced repetition study flows' + + def handle(self, *args, **options): + user, user_created = User.objects.get_or_create( + username=USERNAME, + defaults={'email': f'{USERNAME}@example.com'}, + ) + if user_created or not user.check_password(PASSWORD): + user.set_password(PASSWORD) + user.save() + + profile, _ = Profile.objects.update_or_create( + user=user, + name='E2E Test Profile', + # A previous run may have soft-deleted this profile; the + # profiles API hides deleted rows, so reactivate it. + defaults={'deleted_at': None}, + ) + + ai_model = AiModel.objects.order_by('pk').first() + bot_defaults = {'deleted_at': None} + if ai_model is not None: + bot_defaults['ai_model'] = ai_model + bot, _ = Bot.objects.update_or_create( + user=user, + name='E2E Test Bot', + # Same soft-delete reactivation as the profile above. + defaults=bot_defaults, + ) + + deck, _ = Deck.objects.get_or_create( + profile=profile, + name=DECK_NAME, + defaults={'description': 'Seeded deck for spaced repetition e2e tests'}, + ) + + now = timezone.now() + # Recreate the deck's cards from scratch every run: this removes + # stale/duplicate cards (front is not unique, so upserting cannot + # converge) and cascades to the cards' FlashcardReview rows, so + # repeated seeds converge to exactly these eight cards with no + # leftover activity. + Flashcard.objects.filter(deck=deck).delete() + for order, (front, back, state) in enumerate(CARDS): + card_kwargs = { + 'deck': deck, + 'front': front, + 'back': back, + 'order': order, + 'ease': 2.5, + 'interval_days': 0, + 'reps': 0, + 'lapses': 0, + 'last_reviewed_at': None, + } + if state == 'due': + card_kwargs['due_at'] = now - timedelta(days=1) + elif state == 'future': + card_kwargs['due_at'] = now + timedelta(days=7) + else: # new + card_kwargs['due_at'] = now + Flashcard.objects.create(**card_kwargs) + + due_count = Flashcard.objects.filter(deck=deck, due_at__lte=now).count() + self.stdout.write(self.style.SUCCESS( + f"E2E seed complete: user={user.username} (created={user_created}) " + f"profile_id={profile.profile_id} bot_id={bot.bot_id} " + f"deck_id={deck.deck_id} cards={Flashcard.objects.filter(deck=deck).count()} " + f"due_now={due_count}" + )) diff --git a/back/bots/migrations/0050_flashcard_due_at_flashcard_ease_and_more.py b/back/bots/migrations/0050_flashcard_due_at_flashcard_ease_and_more.py new file mode 100644 index 00000000..36ece43e --- /dev/null +++ b/back/bots/migrations/0050_flashcard_due_at_flashcard_ease_and_more.py @@ -0,0 +1,59 @@ +# Generated by Django 6.0.4 on 2026-08-25 21:12 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bots', '0049_profile_photo_filename'), + ] + + operations = [ + migrations.AddField( + model_name='flashcard', + name='due_at', + field=models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now, null=True), + ), + migrations.AddField( + model_name='flashcard', + name='ease', + field=models.FloatField(default=2.5), + ), + migrations.AddField( + model_name='flashcard', + name='interval_days', + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name='flashcard', + name='lapses', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='flashcard', + name='last_reviewed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='flashcard', + name='reps', + field=models.PositiveIntegerField(default=0), + ), + migrations.CreateModel( + name='FlashcardReview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.CharField(choices=[('again', 'Again'), ('hard', 'Hard'), ('good', 'Good'), ('easy', 'Easy')], max_length=8)), + ('reviewed_at', models.DateTimeField(auto_now_add=True)), + ('flashcard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='bots.flashcard')), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flashcard_reviews', to='bots.profile')), + ], + options={ + 'ordering': ['reviewed_at'], + 'indexes': [models.Index(fields=['profile', 'reviewed_at'], name='bots_flashc_profile_c2f806_idx'), models.Index(fields=['flashcard', '-reviewed_at'], name='bots_flashc_flashca_f96eaf_idx')], + }, + ), + ] diff --git a/back/bots/models/__init__.py b/back/bots/models/__init__.py index a92a6c66..46d1f095 100644 --- a/back/bots/models/__init__.py +++ b/back/bots/models/__init__.py @@ -4,6 +4,7 @@ from .deck import Deck from .device import Device from .flashcard import Flashcard +from .flashcard_review import FlashcardReview from .message import Message from .profile import Profile from .safety_event import SafetyEvent @@ -17,6 +18,7 @@ 'Deck', 'Device', 'Flashcard', + 'FlashcardReview', 'Message', 'Profile', 'RevenueCatWebhookEvent', diff --git a/back/bots/models/flashcard.py b/back/bots/models/flashcard.py index 36d13965..1e9fd287 100644 --- a/back/bots/models/flashcard.py +++ b/back/bots/models/flashcard.py @@ -1,6 +1,7 @@ import uuid from django.db import models +from django.utils import timezone class Flashcard(models.Model): @@ -9,6 +10,13 @@ class Flashcard(models.Model): front = models.TextField() back = models.TextField() order = models.PositiveIntegerField(default=0) + # Spaced repetition scheduling (SM-2 style). New cards are due immediately. + due_at = models.DateTimeField(null=True, blank=True, db_index=True, default=timezone.now) + interval_days = models.FloatField(default=0) + ease = models.FloatField(default=2.5) + reps = models.PositiveIntegerField(default=0) + lapses = models.PositiveIntegerField(default=0) + last_reviewed_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/back/bots/models/flashcard_review.py b/back/bots/models/flashcard_review.py new file mode 100644 index 00000000..e6d07ec0 --- /dev/null +++ b/back/bots/models/flashcard_review.py @@ -0,0 +1,31 @@ +from django.db import models + + +class FlashcardReview(models.Model): + """One row per rating a learner gives a card during study. + + Enables streaks and parent activity summaries ("studied 20 cards") + without scanning scheduling fields on the cards themselves. + """ + + RATING_CHOICES = [ + ('again', 'Again'), + ('hard', 'Hard'), + ('good', 'Good'), + ('easy', 'Easy'), + ] + + flashcard = models.ForeignKey('Flashcard', on_delete=models.CASCADE, related_name='reviews') + profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='flashcard_reviews') + rating = models.CharField(max_length=8, choices=RATING_CHOICES) + reviewed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['reviewed_at'] + indexes = [ + models.Index(fields=['profile', 'reviewed_at']), + models.Index(fields=['flashcard', '-reviewed_at']), + ] + + def __str__(self): + return f"{self.flashcard_id}: {self.rating} at {self.reviewed_at}" diff --git a/back/bots/serializers/flashcard_serializer.py b/back/bots/serializers/flashcard_serializer.py index 5da3426d..da11a717 100644 --- a/back/bots/serializers/flashcard_serializer.py +++ b/back/bots/serializers/flashcard_serializer.py @@ -14,12 +14,25 @@ class FlashcardSerializer(serializers.ModelSerializer): class Meta: model = Flashcard - fields = ['id', 'flashcard_id', 'deck', 'front', 'back', 'order', 'created_at', 'updated_at'] + fields = [ + 'id', 'flashcard_id', 'deck', 'front', 'back', 'order', + 'due_at', 'interval_days', 'ease', 'reps', 'lapses', + 'last_reviewed_at', 'created_at', 'updated_at', + ] + # Scheduling is owned by the review endpoint: POST/PATCH payloads + # cannot set these directly, so every schedule change has a + # FlashcardReview log row. Still serialized on output. + read_only_fields = [ + 'due_at', 'interval_days', 'ease', 'reps', 'lapses', + 'last_reviewed_at', + ] class DeckSerializer(serializers.ModelSerializer): flashcards = FlashcardSerializer(many=True, read_only=True) - card_count = serializers.IntegerField(read_only=True, source='flashcard_count') + card_count = serializers.IntegerField(read_only=True, source='flashcard_count', default=0) + due_count = serializers.IntegerField(read_only=True, default=0) + last_studied_at = serializers.DateTimeField(read_only=True, allow_null=True, default=None) profile = serializers.SlugRelatedField( queryset=Profile.objects.all(), slug_field='profile_id', @@ -33,12 +46,21 @@ class DeckSerializer(serializers.ModelSerializer): class Meta: model = Deck - fields = ['id', 'deck_id', 'profile', 'chat', 'name', 'description', 'flashcards', 'card_count', 'created_at', 'updated_at'] + fields = [ + 'id', 'deck_id', 'profile', 'chat', 'name', 'description', + 'flashcards', 'card_count', 'due_count', 'last_studied_at', + 'created_at', 'updated_at', + ] class DeckListSerializer(serializers.ModelSerializer): - card_count = serializers.IntegerField(read_only=True, source='flashcard_count') + card_count = serializers.IntegerField(read_only=True, source='flashcard_count', default=0) + due_count = serializers.IntegerField(read_only=True, default=0) + last_studied_at = serializers.DateTimeField(read_only=True, allow_null=True, default=None) class Meta: model = Deck - fields = ['id', 'deck_id', 'name', 'description', 'card_count', 'created_at', 'updated_at'] \ No newline at end of file + fields = [ + 'id', 'deck_id', 'name', 'description', 'card_count', + 'due_count', 'last_studied_at', 'created_at', 'updated_at', + ] \ No newline at end of file diff --git a/back/bots/services/srs.py b/back/bots/services/srs.py new file mode 100644 index 00000000..01be7f30 --- /dev/null +++ b/back/bots/services/srs.py @@ -0,0 +1,97 @@ +"""Spaced repetition scheduling (SM-2 style) for flashcard reviews. + +The math lives here as a pure function so it is unit-testable and +swappable β€” viewsets must not inline scheduling logic. +""" +from datetime import timedelta + +from django.utils import timezone + +RATINGS = ('again', 'hard', 'good', 'easy') + +# Minimum ease factor (classic SM-2 floor). +MIN_EASE = 1.3 +# Default ease for new cards. +DEFAULT_EASE = 2.5 +# First interval (days) after learning a new card with Good/Good. +FIRST_INTERVAL_DAYS = 1 +# Second interval (days). +SECOND_INTERVAL_DAYS = 6 +# Interval (days) after an "Again" lapse; ~4 hours so the card comes +# back the same day without re-entering the current session queue. +LAPSE_INTERVAL_DAYS = 1 / 6 +# Ease adjustments per rating. +EASE_DELTA = { + 'again': -0.2, + 'hard': -0.15, + 'good': 0.0, + 'easy': +0.15, +} +# Multiplier applied to the "good" interval for an "easy" rating. +EASY_BONUS = 1.3 +# Multiplier for a "hard" review of a card already in review. +HARD_MULTIPLIER = 1.2 + + +def apply_sm2(card, rating, now=None): + """Return updated scheduling fields for `card` after a review. + + Pure: does not mutate or save `card`. Accepts any object exposing + ``interval_days``, ``ease``, ``reps`` and ``lapses`` attributes + (a Flashcard works directly). Returns a dict with: + + due_at, interval_days, ease, reps, lapses, last_reviewed_at + + Sketch: + again: reset reps, bump lapses, short same-day interval, + ease down (floored at MIN_EASE) + hard: interval = max(1, interval * 1.2), ease down, reps += 1 + good: 1 day -> 6 days -> interval * ease; reps += 1 + easy: like good * 1.3, ease up; reps += 1 + """ + if rating not in RATINGS: + raise ValueError(f"Invalid rating: {rating!r}. Expected one of {RATINGS}") + + now = now or timezone.now() + + interval_days = float(getattr(card, 'interval_days', 0) or 0) + ease = float(getattr(card, 'ease', DEFAULT_EASE)) + reps = int(getattr(card, 'reps', 0) or 0) + lapses = int(getattr(card, 'lapses', 0) or 0) + + if rating == 'again': + # Lapse: relearn from scratch. + reps = 0 + lapses += 1 + interval_days = LAPSE_INTERVAL_DAYS + elif rating == 'hard': + interval_days = max(FIRST_INTERVAL_DAYS, interval_days * HARD_MULTIPLIER) + reps += 1 + elif rating == 'good': + if reps == 0: + interval_days = FIRST_INTERVAL_DAYS + elif reps == 1: + interval_days = SECOND_INTERVAL_DAYS + else: + interval_days = interval_days * ease + reps += 1 + else: # easy + if reps == 0: + interval_days = FIRST_INTERVAL_DAYS + elif reps == 1: + interval_days = SECOND_INTERVAL_DAYS + else: + interval_days = interval_days * ease + interval_days *= EASY_BONUS + reps += 1 + + ease = max(MIN_EASE, ease + EASE_DELTA[rating]) + + return { + 'due_at': now + timedelta(days=interval_days), + 'interval_days': interval_days, + 'ease': ease, + 'reps': reps, + 'lapses': lapses, + 'last_reviewed_at': now, + } diff --git a/back/bots/tests/test_spaced_repetition_api.py b/back/bots/tests/test_spaced_repetition_api.py new file mode 100644 index 00000000..3edc3a04 --- /dev/null +++ b/back/bots/tests/test_spaced_repetition_api.py @@ -0,0 +1,379 @@ +from datetime import datetime, timedelta + +import pytest +from django.contrib.auth.models import User +from django.utils import timezone +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from bots.models import Deck, Flashcard, FlashcardReview, Profile + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def test_user(db): + return User.objects.create_user( + username='srs-user', + email='srs@example.com', + password='testpass123', + ) + + +@pytest.fixture +def other_user(db): + return User.objects.create_user( + username='other-srs-user', + email='other-srs@example.com', + password='testpass123', + ) + + +@pytest.fixture +def test_profile(test_user): + return Profile.objects.create(user=test_user, name='SRS Profile') + + +@pytest.fixture +def auth_client(api_client, test_user): + refresh = RefreshToken.for_user(test_user) + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}') + return api_client + + +@pytest.fixture +def deck(test_profile, db): + return Deck.objects.create(profile=test_profile, name='Cell Bio') + + +def make_card(deck, order=0, **scheduling): + return Flashcard.objects.create( + deck=deck, + front=f'Front {order}', + back=f'Back {order}', + order=order, + **scheduling, + ) + + +@pytest.mark.django_db +class TestReviewEndpoint: + def test_review_good_updates_scheduling(self, auth_client, deck): + card = make_card(deck) + before = timezone.now() + + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'good'}, + ) + + assert response.status_code == 200 + data = response.json() + due_at = datetime.fromisoformat(data['due_at']) + assert data['reps'] == 1 + assert data['interval_days'] == 1 + assert due_at > before + + card.refresh_from_db() + assert card.reps == 1 + assert card.interval_days == 1 + assert card.last_reviewed_at is not None + assert card.due_at is not None + + def test_review_again_increments_lapses_and_resets_reps(self, auth_client, deck): + card = make_card(deck, reps=3, interval_days=10) + + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'again'}, + ) + + assert response.status_code == 200 + data = response.json() + assert data['lapses'] == 1 + assert data['reps'] == 0 + # Due again within hours (same-day relearn). + assert datetime.fromisoformat(data['due_at']) < timezone.now() + timedelta(days=1) + @pytest.mark.parametrize('rating', ['again', 'hard', 'good', 'easy']) + def test_review_accepts_all_ratings(self, auth_client, deck, rating): + card = make_card(deck) + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': rating}, + ) + assert response.status_code == 200 + assert response.json()['last_reviewed_at'] is not None + + def test_review_invalid_rating_returns_400(self, auth_client, deck): + card = make_card(deck) + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'ok'}, + ) + assert response.status_code == 400 + card.refresh_from_db() + assert card.last_reviewed_at is None + + def test_review_missing_rating_returns_400(self, auth_client, deck): + card = make_card(deck) + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {}, + ) + assert response.status_code == 400 + + def test_review_other_users_deck_returns_404(self, api_client, other_user, deck): + refresh = RefreshToken.for_user(other_user) + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}') + card = make_card(deck) + + response = api_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'good'}, + ) + assert response.status_code == 404 + card.refresh_from_db() + assert card.last_reviewed_at is None + + def test_review_requires_authentication(self, api_client, deck): + card = make_card(deck) + response = api_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'good'}, + ) + assert response.status_code == 401 + + def test_review_creates_review_log(self, auth_client, test_profile, deck): + card = make_card(deck) + auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review/', + {'rating': 'easy'}, + ) + review = FlashcardReview.objects.get(flashcard=card) + assert review.rating == 'easy' + assert review.profile == test_profile + assert review.reviewed_at is not None + + def test_review_json_format_suffix(self, auth_client, deck): + # The frontend calls the .json routes; cover them directly. + card = make_card(deck) + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/review.json', + {'rating': 'good'}, + ) + assert response.status_code == 200 + assert response.json()['reps'] == 1 + assert FlashcardReview.objects.filter(flashcard=card).count() == 1 + + +@pytest.mark.django_db +class TestStudyQueue: + def test_new_cards_appear_in_due_queue(self, auth_client, deck): + # Default due_at=now makes fresh cards immediately studyable. + card = make_card(deck) + response = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/') + assert response.status_code == 200 + results = response.json() + assert [c['flashcard_id'] for c in results] == [str(card.flashcard_id)] + + def test_due_mode_excludes_future_cards(self, auth_client, deck): + overdue = make_card(deck, order=0, due_at=timezone.now() - timedelta(days=2)) + future = make_card(deck, order=1, due_at=timezone.now() + timedelta(days=3)) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/').json() + + ids = [c['flashcard_id'] for c in results] + assert str(overdue.flashcard_id) in ids + assert str(future.flashcard_id) not in ids + + def test_all_mode_includes_future_cards(self, auth_client, deck): + overdue = make_card(deck, order=0, due_at=timezone.now() - timedelta(days=2)) + future = make_card(deck, order=1, due_at=timezone.now() + timedelta(days=3)) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?mode=all').json() + + ids = [c['flashcard_id'] for c in results] + assert set(ids) == {str(overdue.flashcard_id), str(future.flashcard_id)} + + def test_queue_ordered_by_due_at_ascending(self, auth_client, deck): + later = make_card(deck, order=0, due_at=timezone.now() + timedelta(hours=2)) + sooner = make_card(deck, order=1, due_at=timezone.now() - timedelta(hours=2)) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?mode=all').json() + + assert [c['flashcard_id'] for c in results] == [ + str(sooner.flashcard_id), + str(later.flashcard_id), + ] + + def test_null_due_at_sorts_last_in_all_mode(self, auth_client, deck): + scheduled = make_card(deck, order=0, due_at=timezone.now() - timedelta(days=1)) + unscheduled = make_card(deck, order=1, due_at=None) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?mode=all').json() + + assert [c['flashcard_id'] for c in results] == [ + str(scheduled.flashcard_id), + str(unscheduled.flashcard_id), + ] + # Null-due cards are not "due", so they stay out of due mode. + due_results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/').json() + assert [c['flashcard_id'] for c in due_results] == [str(scheduled.flashcard_id)] + + def test_limit_param(self, auth_client, deck): + for i in range(5): + make_card(deck, order=i, due_at=timezone.now() - timedelta(days=1)) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?limit=3').json() + assert len(results) == 3 + + def test_invalid_mode_returns_400(self, auth_client, deck): + response = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?mode=bogus') + assert response.status_code == 400 + + def test_invalid_limit_returns_400(self, auth_client, deck): + make_card(deck) + for bad in ('0', '-5', 'abc'): + response = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/?limit={bad}') + assert response.status_code == 400 + + def test_queue_due_filter_respects_timezone(self, auth_client, deck): + """due_at values are timezone-aware (stored UTC); the due filter must + compare correctly no matter which zone was used to construct them. + Without freeze_time we pin cards far in the past/future instead.""" + from datetime import datetime + + try: + from zoneinfo import ZoneInfo + tz = ZoneInfo('America/New_York') + except ImportError: # pragma: no cover + import pytz + tz = pytz.timezone('America/New_York') + + long_overdue = make_card( + deck, order=0, + due_at=datetime(2020, 1, 1, 12, 0, tzinfo=tz), # 17:00 UTC + ) + far_future = make_card( + deck, order=1, + due_at=datetime(2099, 6, 15, 9, 0, tzinfo=tz), + ) + + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/').json() + ids = [c['flashcard_id'] for c in results] + assert ids == [str(long_overdue.flashcard_id)] + assert str(far_future.flashcard_id) not in ids + + def test_study_queue_other_users_deck_returns_404(self, api_client, other_user, deck): + refresh = RefreshToken.for_user(other_user) + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {refresh.access_token}') + response = api_client.get(f'/api/decks/{deck.deck_id}/study_queue/') + assert response.status_code == 404 + + def test_study_queue_requires_authentication(self, api_client, deck): + response = api_client.get(f'/api/decks/{deck.deck_id}/study_queue/') + assert response.status_code == 401 + + def test_review_str_does_not_raise(self, deck): + card = make_card(deck) + review = FlashcardReview.objects.create( + flashcard=card, profile=deck.profile, rating='good' + ) + assert str(review) == f"{card.pk}: good at {review.reviewed_at}" + + +@pytest.mark.django_db +class TestSchedulingFieldsReadOnly: + def test_create_ignores_scheduling_fields(self, auth_client, deck): + response = auth_client.post( + f'/api/decks/{deck.deck_id}/flashcards/', + {'front': 'Q', 'back': 'A', 'ease': 9.9, 'reps': 10, + 'interval_days': 99, 'lapses': 5}, + format='json', + ) + assert response.status_code == 201 + data = response.json() + assert data['ease'] == 2.5 + assert data['reps'] == 0 + assert data['interval_days'] == 0 + assert data['lapses'] == 0 + + def test_update_ignores_scheduling_fields(self, auth_client, deck): + card = make_card(deck) + response = auth_client.patch( + f'/api/decks/{deck.deck_id}/flashcards/{card.flashcard_id}/', + {'ease': 9.9, 'reps': 10, 'due_at': '2099-01-01T00:00:00Z'}, + format='json', + ) + assert response.status_code == 200 + card.refresh_from_db() + assert card.ease == 2.5 + assert card.reps == 0 + + def test_study_queue_includes_scheduling_fields(self, auth_client, deck): + make_card(deck, ease=2.1, reps=4, lapses=1, interval_days=12.5) + results = auth_client.get(f'/api/decks/{deck.deck_id}/study_queue/').json() + card = results[0] + for field in ('due_at', 'interval_days', 'ease', 'reps', 'lapses', 'last_reviewed_at'): + assert field in card + assert card['ease'] == 2.1 + assert card['reps'] == 4 + + def test_study_queue_json_format_suffix_with_params(self, auth_client, deck): + for i in range(3): + make_card(deck, order=i, due_at=timezone.now() - timedelta(days=1)) + response = auth_client.get( + f'/api/decks/{deck.deck_id}/study_queue.json?mode=all&limit=2' + ) + assert response.status_code == 200 + assert len(response.json()) == 2 + + +@pytest.mark.django_db +class TestDeckAnnotations: + def test_deck_list_includes_due_count_and_last_studied(self, auth_client, test_profile, deck): + make_card( + deck, order=0, + due_at=timezone.now() + timedelta(days=2), + last_reviewed_at=timezone.now() - timedelta(hours=2), + ) + make_card(deck, order=1, due_at=timezone.now() - timedelta(hours=1)) + + response = auth_client.get(f'/api/decks.json?profileId={test_profile.profile_id}') + assert response.status_code == 200 + row = next(d for d in response.json()['results'] if d['deck_id'] == str(deck.deck_id)) + + assert row['due_count'] == 1 + last_studied = datetime.fromisoformat(row['last_studied_at']) + assert abs((timezone.now() - last_studied).total_seconds()) < 3 * 3600 + + def test_deck_detail_includes_due_count(self, auth_client, deck): + make_card(deck, order=0, due_at=timezone.now() - timedelta(hours=1)) + make_card(deck, order=1, due_at=timezone.now() + timedelta(days=1)) + + response = auth_client.get(f'/api/decks/{deck.deck_id}/') + assert response.status_code == 200 + assert response.json()['due_count'] == 1 + + def test_unstudied_deck_has_zero_due_count_and_no_last_studied(self, auth_client, deck): + # Freshly-created cards are immediately due (default due_at=now). + make_card(deck) + response = auth_client.get(f'/api/decks/{deck.deck_id}/') + data = response.json() + assert data['due_count'] == 1 + assert data['last_studied_at'] is None + + def test_deck_create_returns_annotation_defaults(self, auth_client, test_profile): + response = auth_client.post( + '/api/decks.json', + {'name': 'Fresh', 'profile': str(test_profile.profile_id)}, + format='json', + ) + assert response.status_code == 201 + data = response.json() + assert data['card_count'] == 0 + assert data['due_count'] == 0 + assert data['last_studied_at'] is None diff --git a/back/bots/tests/test_srs.py b/back/bots/tests/test_srs.py new file mode 100644 index 00000000..c4e6f3ce --- /dev/null +++ b/back/bots/tests/test_srs.py @@ -0,0 +1,159 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone as dj_timezone + +from bots.services.srs import ( + DEFAULT_EASE, + EASY_BONUS, + FIRST_INTERVAL_DAYS, + LAPSE_INTERVAL_DAYS, + MIN_EASE, + SECOND_INTERVAL_DAYS, + apply_sm2, +) + + +class FakeCard: + """Minimal stand-in for Flashcard; apply_sm2 only reads attributes.""" + + def __init__(self, interval_days=0, ease=DEFAULT_EASE, reps=0, lapses=0): + self.interval_days = interval_days + self.ease = ease + self.reps = reps + self.lapses = lapses + + +NOW = dj_timezone.now().replace(microsecond=0) + + +def expected_due(interval_days, now=NOW): + return now + timedelta(days=interval_days) + + +class TestApplySm2NewCard: + """Table of ratings applied to a brand-new card (reps=0).""" + + def test_new_card_good(self): + fields = apply_sm2(FakeCard(), 'good', NOW) + assert fields['interval_days'] == FIRST_INTERVAL_DAYS + assert fields['reps'] == 1 + assert fields['lapses'] == 0 + assert fields['ease'] == DEFAULT_EASE + assert fields['due_at'] == expected_due(FIRST_INTERVAL_DAYS) + assert fields['last_reviewed_at'] == NOW + + def test_new_card_easy_is_longer_than_good(self): + good = apply_sm2(FakeCard(), 'good', NOW) + easy = apply_sm2(FakeCard(), 'easy', NOW) + assert easy['interval_days'] == FIRST_INTERVAL_DAYS * EASY_BONUS + assert easy['interval_days'] > good['interval_days'] + assert easy['ease'] == DEFAULT_EASE + 0.15 + + def test_new_card_hard_gets_minimum_one_day(self): + fields = apply_sm2(FakeCard(), 'hard', NOW) + assert fields['interval_days'] == 1 # max(1, 0 * 1.2) + assert fields['reps'] == 1 + assert fields['ease'] == pytest.approx(DEFAULT_EASE - 0.15) + + def test_new_card_again_lapses(self): + fields = apply_sm2(FakeCard(), 'again', NOW) + assert fields['reps'] == 0 + assert fields['lapses'] == 1 + assert fields['interval_days'] == LAPSE_INTERVAL_DAYS + # Same-day relearn: due again in a few hours, well before tomorrow. + assert fields['due_at'] == expected_due(LAPSE_INTERVAL_DAYS) + assert fields['due_at'] < NOW + timedelta(days=1) + assert fields['ease'] == pytest.approx(DEFAULT_EASE - 0.2) + + +class TestApplySm2Progression: + """Good/Good stepping and longer-interval behaviour.""" + + def test_second_good_six_days(self): + card = FakeCard(interval_days=1, reps=1) + fields = apply_sm2(card, 'good', NOW) + assert fields['interval_days'] == SECOND_INTERVAL_DAYS + assert fields['reps'] == 2 + + def test_third_good_uses_ease(self): + card = FakeCard(interval_days=6, ease=2.5, reps=2) + fields = apply_sm2(card, 'good', NOW) + assert fields['interval_days'] == 6 * 2.5 + assert fields['reps'] == 3 + + def test_easy_multiplies_good_interval(self): + card = FakeCard(interval_days=6, ease=2.5, reps=2) + fields = apply_sm2(card, 'easy', NOW) + assert fields['interval_days'] == pytest.approx(6 * 2.5 * EASY_BONUS) + + def test_hard_grows_interval_slowly(self): + card = FakeCard(interval_days=10, ease=2.5, reps=3) + fields = apply_sm2(card, 'hard', NOW) + assert fields['interval_days'] == 10 * 1.2 + + def test_hard_increments_reps(self): + card = FakeCard(interval_days=10, reps=3) + fields = apply_sm2(card, 'hard', NOW) + assert fields['reps'] == 4 + + def test_hard_then_good_leaves_learning_steps(self): + first = apply_sm2(FakeCard(), 'hard', NOW) + assert first['reps'] == 1 + second = apply_sm2(FakeCard(**{ + 'interval_days': first['interval_days'], + 'ease': first['ease'], + 'reps': first['reps'], + 'lapses': 0, + }), 'good', NOW) + assert second['interval_days'] == SECOND_INTERVAL_DAYS + assert second['reps'] == 2 + + +class TestApplySm2Lapse: + def test_again_resets_progress(self): + card = FakeCard(interval_days=30, ease=2.5, reps=5, lapses=0) + fields = apply_sm2(card, 'again', NOW) + # Learning state resets: next Good starts over at 1 day. + assert fields['reps'] == 0 + assert fields['lapses'] == 1 + restarted = apply_sm2(FakeCard(**{ + 'interval_days': fields['interval_days'], + 'ease': fields['ease'], + 'reps': fields['reps'], + 'lapses': fields['lapses'], + }), 'good', NOW) + assert restarted['interval_days'] == FIRST_INTERVAL_DAYS + + +class TestEaseFloor: + @pytest.mark.parametrize('rating,delta', [ + ('again', -0.2), + ('hard', -0.15), + ]) + def test_ease_never_drops_below_min(self, rating, delta): + card = FakeCard(ease=MIN_EASE) + fields = apply_sm2(card, rating, NOW) + assert fields['ease'] == MIN_EASE + + def test_repeated_again_keeps_floor(self): + card = FakeCard(ease=1.35) + first = apply_sm2(card, 'again', NOW) + second = apply_sm2(FakeCard(ease=first['ease'], lapses=1), 'again', NOW) + assert second['ease'] == MIN_EASE + + +class TestInvalidInput: + @pytest.mark.parametrize('rating', ['', 'Again', 'GOOD', 'ok', None, 3]) + def test_invalid_rating_raises(self, rating): + with pytest.raises(ValueError): + apply_sm2(FakeCard(), rating, NOW) + + +class TestPurity: + def test_does_not_mutate_card(self): + card = FakeCard(interval_days=6, ease=2.5, reps=2) + apply_sm2(card, 'good', NOW) + assert card.interval_days == 6 + assert card.reps == 2 + assert card.ease == 2.5 diff --git a/back/bots/viewsets/flashcard_viewset.py b/back/bots/viewsets/flashcard_viewset.py index 5920e7f4..8beb1015 100644 --- a/back/bots/viewsets/flashcard_viewset.py +++ b/back/bots/viewsets/flashcard_viewset.py @@ -1,26 +1,35 @@ import uuid -from django.db.models import Count, Max -from rest_framework import viewsets - -from bots.models import Deck, Flashcard, Profile +from django.db import transaction +from django.db.models import Count, F, Max, Q +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from bots.models import Deck, Flashcard, FlashcardReview, Profile from bots.permissions import IsOwner from bots.serializers import DeckListSerializer, DeckSerializer, FlashcardSerializer +from bots.services import srs from bots.tokens import delegated_profile_from_auth, is_teen_delegated from bots.viewsets.mixins import get_object_by_uuid_or_id class FlashcardViewSet(viewsets.ModelViewSet): - permission_classes = [IsOwner] + permission_classes = [IsAuthenticated, IsOwner] serializer_class = FlashcardSerializer queryset = Flashcard.objects.all() lookup_field = "flashcard_id" lookup_url_kwarg = "flashcardId" - def _deck_queryset(self): - """Decks visible to this request: scoped to the claimed profile for - teen-delegated sessions so they cannot reach a sibling's decks.""" - queryset = Deck.objects.all() + def _owned_decks(self): + """Decks visible to this request: owned by the requesting user, and + scoped to the claimed profile for teen-delegated sessions so they + cannot reach a sibling's decks.""" + if not self.request.user.is_authenticated: + return Deck.objects.none() + queryset = Deck.objects.filter(profile__user=self.request.user) delegated_profile = delegated_profile_from_auth(self.request.auth, self.request.user) if delegated_profile is not None: queryset = queryset.filter(profile=delegated_profile) @@ -31,7 +40,9 @@ def _deck_queryset(self): def get_queryset(self): deck_id = self.kwargs['deck_pk'] - deck = get_object_by_uuid_or_id(self._deck_queryset(), 'deck_id', deck_id) + # Scope to decks owned by the requesting user so foreign decks 404 + # instead of leaking existence. + deck = get_object_by_uuid_or_id(self._owned_decks(), 'deck_id', deck_id) self.check_object_permissions(self.request, deck) @@ -41,7 +52,7 @@ def get_object(self): lookup_field_value = self.kwargs[self.lookup_url_kwarg] deck_pk = self.kwargs['deck_pk'] - deck = get_object_by_uuid_or_id(self._deck_queryset(), 'deck_id', deck_pk) + deck = get_object_by_uuid_or_id(self._owned_decks(), 'deck_id', deck_pk) flashcard = get_object_by_uuid_or_id( Flashcard.objects.filter(deck=deck), 'flashcard_id', lookup_field_value ) @@ -52,16 +63,46 @@ def get_object(self): def perform_create(self, serializer): deck_id = self.kwargs['deck_pk'] - deck = get_object_by_uuid_or_id(self._deck_queryset(), 'deck_id', deck_id) + deck = get_object_by_uuid_or_id(self._owned_decks(), 'deck_id', deck_id) self.check_object_permissions(self.request, deck) max_order = Flashcard.objects.filter(deck=deck).aggregate(Max('order'))['order__max'] or -1 serializer.save(deck=deck, order=max_order + 1) + @action(detail=True, methods=['post'], url_path='review') + def review(self, request, deck_pk=None, flashcardId=None, format=None): + """Rate a card (again|hard|good|easy) and reschedule it via SM-2.""" + rating = request.data.get('rating') + if rating not in srs.RATINGS: + return Response( + {'rating': f"Invalid rating. Expected one of: {', '.join(srs.RATINGS)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + flashcard = self.get_object() + # Lock the row so concurrent reviews serialize: without this, + # two simultaneous POSTs could both schedule from the same + # reps/ease and the later save would silently drop the first. + flashcard = Flashcard.objects.select_for_update().get(pk=flashcard.pk) + + updates = srs.apply_sm2(flashcard, rating) + for field, value in updates.items(): + setattr(flashcard, field, value) + flashcard.save(update_fields=list(updates.keys()) + ['updated_at']) + + FlashcardReview.objects.create( + flashcard=flashcard, + profile=flashcard.deck.profile, + rating=rating, + ) + + return Response(FlashcardSerializer(flashcard).data) + class DeckViewSet(viewsets.ModelViewSet): - permission_classes = [IsOwner] + permission_classes = [IsAuthenticated, IsOwner] serializer_class = DeckSerializer queryset = Deck.objects.all() @@ -85,7 +126,12 @@ def get_queryset(self): except ValueError: queryset = queryset.none() - return queryset.annotate(flashcard_count=Count('flashcards')).order_by('-created_at') + now = timezone.now() + return queryset.annotate( + flashcard_count=Count('flashcards'), + due_count=Count('flashcards', filter=Q(flashcards__due_at__lte=now)), + last_studied_at=Max('flashcards__last_reviewed_at'), + ).order_by('-created_at') def get_serializer_class(self): if self.action == 'list': @@ -145,3 +191,40 @@ def perform_update(self, serializer): def perform_destroy(self, instance): instance.delete() + + @action(detail=True, methods=['get'], url_path='study_queue') + def study_queue(self, request, pk=None, format=None): + """Cards to study, ordered by due_at ascending (nulls last). + + Query params: + mode: 'due' (default) only cards due now, or 'all' + limit: max cards returned (default 50, capped at 200) + """ + deck = self.get_object() + + mode = request.query_params.get('mode', 'due') + if mode not in ('due', 'all'): + return Response( + {'mode': "Invalid mode. Expected 'due' or 'all'"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + queryset = deck.flashcards.all() + if mode == 'due': + queryset = queryset.filter(due_at__lte=timezone.now()) + + queryset = queryset.order_by( + F('due_at').asc(nulls_last=True), 'order', 'created_at' + ) + + try: + limit = int(request.query_params.get('limit', 50)) + except (TypeError, ValueError): + return Response({'limit': 'Invalid limit'}, status=status.HTTP_400_BAD_REQUEST) + if limit < 1: + return Response({'limit': 'Invalid limit'}, status=status.HTTP_400_BAD_REQUEST) + limit = min(limit, 200) + queryset = queryset[:limit] + + serializer = FlashcardSerializer(queryset, many=True) + return Response(serializer.data) diff --git a/front/__mocks__/handlers.ts b/front/__mocks__/handlers.ts index 70c6293e..b85d32a4 100644 --- a/front/__mocks__/handlers.ts +++ b/front/__mocks__/handlers.ts @@ -129,6 +129,51 @@ export const handlers = [ return HttpResponse.json({}, { status: 204 }); }), + // Spaced repetition study queue + review + http.get('/api/decks/:deck_pk/study_queue.json', async () => { + await delay(200); + return HttpResponse.json([ + { + id: 10, + flashcard_id: '660e8400-e29b-41d4-a716-446655440010', + deck: '550e8400-e29b-41d4-a716-446655440001', + front: 'What is anaphase?', + back: 'Sister chromatids separate', + order: 0, + due_at: '2024-01-01T00:00:00Z', + interval_days: 0, + ease: 2.5, + reps: 0, + lapses: 0, + last_reviewed_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ]); + }), + + http.post('/api/decks/:deck_pk/flashcards/:id/review.json', async ({ request, params }) => { + await delay(200); + const body = (await request.json()) as Record; + const id = params.id as string; + return HttpResponse.json({ + id: 10, + flashcard_id: id, + deck: '550e8400-e29b-41d4-a716-446655440001', + front: 'What is anaphase?', + back: 'Sister chromatids separate', + order: 0, + due_at: '2024-01-04T00:00:00Z', + interval_days: body.rating === 'again' ? 0.1667 : 1, + ease: 2.5, + reps: body.rating === 'again' ? 0 : 1, + lapses: body.rating === 'again' ? 1 : 0, + last_reviewed_at: '2024-01-03T09:00:00Z', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-03T09:00:00Z', + }); + }), + // Profile endpoints http.get('/api/profiles.json', async () => { await delay(200); diff --git a/front/__tests__/api/flashcards.test.ts b/front/__tests__/api/flashcards.test.ts index 237dfe0b..69468c9a 100644 --- a/front/__tests__/api/flashcards.test.ts +++ b/front/__tests__/api/flashcards.test.ts @@ -4,7 +4,10 @@ import { createDeck, fetchDeck, deleteDeck, + fetchStudyQueue, + reviewFlashcard, } from '../../api/flashcards'; +import { apiClient } from '../../api/apiClient'; // Mock the apiClient to return paginated responses matching OpenAPI schema jest.mock('../../api/apiClient', () => ({ @@ -132,7 +135,75 @@ jest.mock('../../api/apiClient', () => ({ return Promise.resolve({ ok: true }); } } - + + if (url.includes('/study_queue')) { + return Promise.resolve({ + ok: true, + data: [ + { + id: 10, + flashcard_id: '660e8400-e29b-41d4-a716-446655440010', + deck: '550e8400-e29b-41d4-a716-446655440001', + front: 'What is anaphase?', + back: 'Sister chromatids separate', + order: 0, + due_at: '2024-01-01T00:00:00Z', + interval_days: 0, + ease: 2.5, + reps: 0, + lapses: 0, + last_reviewed_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 11, + flashcard_id: '660e8400-e29b-41d4-a716-446655440011', + deck: '550e8400-e29b-41d4-a716-446655440001', + front: 'Define osmosis', + back: 'Water diffusion across a membrane', + order: 1, + due_at: '2024-01-02T00:00:00Z', + interval_days: 1, + ease: 2.35, + reps: 1, + lapses: 0, + last_reviewed_at: '2024-01-01T12:00:00Z', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + }, + ], + }); + } + + if (url.match(/\/decks\/[^/]+\/flashcards\/[^/]+\/review/)) { + if (method === 'POST') { + const body = options.body ? JSON.parse(options.body) : {}; + const intervals: Record = { again: 0.1667, hard: 1, good: 1, easy: 1.3 }; + return Promise.resolve({ + ok: true, + data: { + id: 10, + flashcard_id: url.match( + /\/decks\/[^/]+\/flashcards\/([^/]+)\/review/ + )?.[1], + deck: '550e8400-e29b-41d4-a716-446655440001', + front: 'What is anaphase?', + back: 'Sister chromatids separate', + order: 0, + due_at: '2024-01-04T00:00:00Z', + interval_days: intervals[body.rating as string] ?? 0, + ease: body.rating === 'again' ? 2.3 : body.rating === 'easy' ? 2.65 : 2.5, + reps: body.rating === 'again' ? 0 : 1, + lapses: body.rating === 'again' ? 1 : 0, + last_reviewed_at: '2024-01-03T09:00:00Z', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-03T09:00:00Z', + }, + }); + } + } + return Promise.resolve({ ok: true, data: null }); }), })); @@ -236,4 +307,66 @@ describe('Flashcards API', () => { expect(response).toBe(true); }); }); + + describe('fetchStudyQueue', () => { + it('should default to due mode', async () => { + const queue = await fetchStudyQueue(testDeckId); + + expect(Array.isArray(queue)).toBe(true); + expect(queue.length).toBe(2); + expect(queue[0].front).toBe('What is anaphase?'); + }); + + it('should return cards with scheduling fields', async () => { + const queue = await fetchStudyQueue(testDeckId, 'all'); + + expect(queue[0]).toHaveProperty('due_at'); + expect(queue[0]).toHaveProperty('interval_days'); + expect(queue[0]).toHaveProperty('ease'); + expect(queue[0]).toHaveProperty('reps'); + expect(queue[0]).toHaveProperty('lapses'); + expect(queue[0]).toHaveProperty('last_reviewed_at'); + expect(queue[0].reps).toBe(0); + expect(queue[1].reps).toBe(1); + }); + + it('should throw instead of resolving to [] when the request fails', async () => { + (apiClient as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500, data: null }); + + await expect(fetchStudyQueue(testDeckId)).rejects.toThrow(); + }); + }); + + describe('reviewFlashcard', () => { + it('should post a rating and return the rescheduled card', async () => { + const cardId = '660e8400-e29b-41d4-a716-446655440010'; + const updated = await reviewFlashcard(testDeckId, cardId, 'good'); + + expect(updated).not.toBeNull(); + expect(updated?.flashcard_id).toBe(cardId); + expect(updated?.interval_days).toBe(1); + expect(updated?.reps).toBe(1); + expect(updated?.due_at).toBeDefined(); + expect(updated?.last_reviewed_at).toBeDefined(); + }); + + it('should record a lapse for again ratings', async () => { + const updated = await reviewFlashcard( + testDeckId, + '660e8400-e29b-41d4-a716-446655440010', + 'again' + ); + + expect(updated?.lapses).toBe(1); + expect(updated?.reps).toBe(0); + }); + + it('should throw instead of resolving to null when the request fails', async () => { + (apiClient as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500, data: null }); + + await expect( + reviewFlashcard(testDeckId, '660e8400-e29b-41d4-a716-446655440010', 'good') + ).rejects.toThrow(); + }); + }); }); \ No newline at end of file diff --git a/front/api/flashcards.ts b/front/api/flashcards.ts index 8d80930f..bd588247 100644 --- a/front/api/flashcards.ts +++ b/front/api/flashcards.ts @@ -1,5 +1,7 @@ import { request, requestRaw, PaginatedResponse } from "./request"; +export type FlashcardRating = "again" | "hard" | "good" | "easy"; + export interface Flashcard { id: number; flashcard_id: string; @@ -7,6 +9,13 @@ export interface Flashcard { front: string; back: string; order: number; + // Spaced repetition scheduling fields + due_at?: string | null; + interval_days?: number; + ease?: number; + reps?: number; + lapses?: number; + last_reviewed_at?: string | null; created_at: string; updated_at: string; } @@ -20,6 +29,8 @@ export interface Deck { description: string; flashcards: Flashcard[]; card_count: number; + due_count?: number; + last_studied_at?: string | null; created_at: string; updated_at: string; } @@ -30,10 +41,14 @@ export interface DeckListItem { name: string; description: string; card_count: number; + due_count?: number; + last_studied_at?: string | null; created_at: string; updated_at: string; } +export type StudyQueueMode = "due" | "all"; + export const fetchDecks = async (profileId: string): Promise> => request>( `/decks.json?profileId=${profileId}`, @@ -130,3 +145,42 @@ export const deleteFlashcard = async ( ); return response?.ok ?? false; }; + +// Cards to study for this deck, ordered by due_at ascending. +// +// Throws on failure (instead of resolving to []) so callers can tell an +// empty queue apart from a network/server error. +export const fetchStudyQueue = async ( + deckId: string, + mode: StudyQueueMode = "due", + limit = 50 +): Promise => { + const response = await requestRaw( + `/decks/${deckId}/study_queue.json?mode=${mode}&limit=${limit}`, + { method: "GET" } + ); + if (!response?.ok || !response.data) { + throw new Error(`Study queue request failed for deck ${deckId}`); + } + return response.data; +}; + +// Rate a card during study; returns the rescheduled flashcard. +// Throws on failure so a failed review can never be counted as done. +export const reviewFlashcard = async ( + deckId: string, + flashcardId: string, + rating: FlashcardRating +): Promise => { + const response = await requestRaw( + `/decks/${deckId}/flashcards/${flashcardId}/review.json`, + { + method: "POST", + body: JSON.stringify({ rating }), + } + ); + if (!response?.ok || !response.data) { + throw new Error(`Review request failed for card ${flashcardId}`); + } + return response.data; +}; diff --git a/front/api/schema.yaml b/front/api/schema.yaml index 3d45f2ca..8a805965 100644 --- a/front/api/schema.yaml +++ b/front/api/schema.yaml @@ -4,6 +4,72 @@ info: version: 1.0.0 description: API for the Bots application paths: + /api/activity/chats/: + get: + operationId: activity_chats_list + description: |- + Parent inbox: list recent chats across profiles + read-only transcript. + + Read-only by design: parents review here, they never write into the kid + thread (roadmap 04 non-goal). + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + tags: + - activity + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedActivityChatListList' + description: '' + /api/activity/chats/{id}/: + get: + operationId: activity_chats_retrieve + description: |- + Parent inbox: list recent chats across profiles + read-only transcript. + + Read-only by design: parents review here, they never write into the kid + thread (roadmap 04 non-goal). + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - activity + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ActivityChatList' + description: '' + /api/activity/summary/: + get: + operationId: activity_summary_retrieve + description: Per-profile activity counts for the summary chips (windowed by + ?days=). + tags: + - activity + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + description: No response body /api/ai_models/: get: operationId: ai_models_list @@ -48,6 +114,25 @@ paths: schema: $ref: '#/components/schemas/AiModel' description: '' + /api/auth/reauthenticate: + post: + operationId: auth_reauthenticate_create + description: |- + POST /api/auth/reauthenticate β€” verify the parent PIN. + + 200 -> {"parentSessionToken": "", "expiresAt": ""} + 400 -> malformed body / no PIN configured + 401 -> {"detail": "Invalid PIN", "remainingAttempts": n} + 403 -> teen-delegated sessions can never reauthenticate + 423 -> {"detail": "PIN locked. Try again later.", "lockedUntil": ""} + tags: + - auth + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + description: No response body /api/bots/: get: operationId: bots_list @@ -379,14 +464,14 @@ paths: schema: $ref: '#/components/schemas/Deck' description: '' - /api/decks/{deck_id_pk}/flashcards/: + /api/decks/{deck_pk}/flashcards/: get: operationId: decks_flashcards_list parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true - name: page required: false @@ -410,9 +495,9 @@ paths: operationId: decks_flashcards_create parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true tags: - decks @@ -438,14 +523,14 @@ paths: schema: $ref: '#/components/schemas/Flashcard' description: '' - /api/decks/{deck_id_pk}/flashcards/{flashcardId}/: + /api/decks/{deck_pk}/flashcards/{flashcardId}/: get: operationId: decks_flashcards_retrieve parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true - in: path name: flashcardId @@ -469,9 +554,9 @@ paths: operationId: decks_flashcards_update parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true - in: path name: flashcardId @@ -507,9 +592,9 @@ paths: operationId: decks_flashcards_partial_update parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true - in: path name: flashcardId @@ -544,9 +629,9 @@ paths: operationId: decks_flashcards_destroy parameters: - in: path - name: deck_id_pk + name: deck_pk schema: - type: string + type: integer required: true - in: path name: flashcardId @@ -562,6 +647,46 @@ paths: responses: '204': description: No response body + /api/decks/{deck_pk}/flashcards/{flashcardId}/review/: + post: + operationId: decks_flashcards_review_create + description: Rate a card (again|hard|good|easy) and reschedule it via SM-2. + parameters: + - in: path + name: deck_pk + schema: + type: integer + required: true + - in: path + name: flashcardId + schema: + type: string + format: uuid + required: true + tags: + - decks + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Flashcard' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Flashcard' + multipart/form-data: + schema: + $ref: '#/components/schemas/Flashcard' + required: true + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Flashcard' + description: '' /api/decks/{id}/: get: operationId: decks_retrieve @@ -666,6 +791,34 @@ paths: responses: '204': description: No response body + /api/decks/{id}/study_queue/: + get: + operationId: decks_study_queue_retrieve + description: |- + Cards to study, ordered by due_at ascending (nulls last). + + Query params: + mode: 'due' (default) only cards due now, or 'all' + limit: max cards returned (default 50, capped at 200) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this deck. + required: true + tags: + - decks + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Deck' + description: '' /api/devices/: get: operationId: devices_list @@ -841,6 +994,19 @@ paths: responses: '200': description: No response body + /api/onboarding/bootstrap: + post: + operationId: onboarding_bootstrap_create + description: 'Atomic wizard save: profile name, first bot, PIN and completion + flag.' + tags: + - onboarding + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + description: No response body /api/profiles/: get: operationId: profiles_list @@ -993,6 +1159,26 @@ paths: responses: '204': description: No response body + /api/profiles/self/: + get: + operationId: profiles_self_retrieve + description: |- + Read-self endpoint for teen-delegated sessions. + + Returns only the profile this session is locked to, redacted via + OwnProfileSerializer. Parents have no use for it (403). + tags: + - profiles + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Profile' + description: '' /api/revenuecat/webhook: post: operationId: revenuecat_webhook_create @@ -1091,8 +1277,113 @@ paths: responses: '204': description: No response body + /api/user/onboarding/complete: + post: + operationId: user_onboarding_complete_create + description: Mark onboarding complete. Idempotent; teen delegated sessions are + 403. + tags: + - user + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '200': + description: No response body + /api/user/pin: + delete: + operationId: user_pin_destroy + description: |- + DELETE /api/user/pin β€” remove the parent PIN (opt out of PIN protection). + + Body: {"currentPin": "1234"}. Always requires a valid parent reauth + session plus the current PIN when one is set, so a kid holding the + device cannot be the one to turn protection off. No-op success when no + PIN is set. After removal, parent mutations rely on the parent session + alone (see ParentReauthRequired). + tags: + - user + security: + - jwtAuth: [] + - cookieAuth: [] + responses: + '204': + description: No response body components: schemas: + ActivityBot: + type: object + properties: + bot_id: + type: string + format: uuid + readOnly: true + name: + type: string + maxLength: 255 + color: + type: string + nullable: true + maxLength: 7 + icon: + type: string + nullable: true + maxLength: 255 + required: + - bot_id + - name + ActivityChatList: + type: object + description: Row of the parent activity inbox. Fields are annotations on the + Chat queryset. + properties: + chat_id: + type: string + format: uuid + title: + type: string + profile: + allOf: + - $ref: '#/components/schemas/ActivityProfile' + nullable: true + bot: + allOf: + - $ref: '#/components/schemas/ActivityBot' + nullable: true + message_count: + type: integer + last_message_preview: + type: string + nullable: true + last_message_at: + type: string + format: date-time + nullable: true + safety_event_count: + type: integer + readOnly: true + required: + - bot + - chat_id + - last_message_at + - last_message_preview + - message_count + - profile + - safety_event_count + - title + ActivityProfile: + type: object + properties: + profile_id: + type: string + format: uuid + readOnly: true + name: + type: string + maxLength: 255 + required: + - name + - profile_id AiModel: type: object properties: @@ -1332,6 +1623,16 @@ components: card_count: type: integer readOnly: true + default: 0 + due_count: + type: integer + readOnly: true + default: 0 + last_studied_at: + type: string + format: date-time + readOnly: true + nullable: true created_at: type: string format: date-time @@ -1343,8 +1644,10 @@ components: required: - card_count - created_at + - due_count - flashcards - id + - last_studied_at - name - profile - updated_at @@ -1365,6 +1668,16 @@ components: card_count: type: integer readOnly: true + default: 0 + due_count: + type: integer + readOnly: true + default: 0 + last_studied_at: + type: string + format: date-time + readOnly: true + nullable: true created_at: type: string format: date-time @@ -1376,7 +1689,9 @@ components: required: - card_count - created_at + - due_count - id + - last_studied_at - name - updated_at Device: @@ -1396,6 +1711,8 @@ components: type: boolean notify_on_new_message: type: boolean + notify_digest_only: + type: boolean deleted_at: type: string format: date-time @@ -1441,7 +1758,30 @@ components: maximum: 9223372036854775807 minimum: 0 format: int64 - default: 0 + due_at: + type: string + format: date-time + readOnly: true + nullable: true + interval_days: + type: number + format: double + readOnly: true + ease: + type: number + format: double + readOnly: true + reps: + type: integer + readOnly: true + lapses: + type: integer + readOnly: true + last_reviewed_at: + type: string + format: date-time + readOnly: true + nullable: true created_at: type: string format: date-time @@ -1453,8 +1793,14 @@ components: required: - back - created_at + - due_at + - ease - front - id + - interval_days + - lapses + - last_reviewed_at + - reps - updated_at Message: type: object @@ -1508,6 +1854,29 @@ components: - message_id - modified_at - text + PaginatedActivityChatListList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/ActivityChatList' PaginatedAiModelList: type: object required: @@ -1780,6 +2149,16 @@ components: card_count: type: integer readOnly: true + default: 0 + due_count: + type: integer + readOnly: true + default: 0 + last_studied_at: + type: string + format: date-time + readOnly: true + nullable: true created_at: type: string format: date-time @@ -1805,6 +2184,8 @@ components: type: boolean notify_on_new_message: type: boolean + notify_digest_only: + type: boolean deleted_at: type: string format: date-time @@ -1843,7 +2224,30 @@ components: maximum: 9223372036854775807 minimum: 0 format: int64 - default: 0 + due_at: + type: string + format: date-time + readOnly: true + nullable: true + interval_days: + type: number + format: double + readOnly: true + ease: + type: number + format: double + readOnly: true + reps: + type: integer + readOnly: true + lapses: + type: integer + readOnly: true + last_reviewed_at: + type: string + format: date-time + readOnly: true + nullable: true created_at: type: string format: date-time @@ -1865,6 +2269,14 @@ components: name: type: string maxLength: 255 + oauth_email: + type: string + format: email + nullable: true + maxLength: 254 + photo_url: + type: string + readOnly: true deleted_at: type: string format: date-time @@ -1890,6 +2302,14 @@ components: name: type: string maxLength: 255 + oauth_email: + type: string + format: email + nullable: true + maxLength: 254 + photo_url: + type: string + readOnly: true deleted_at: type: string format: date-time @@ -1907,6 +2327,7 @@ components: - id - modified_at - name + - photo_url - profile_id ProfileId: type: object diff --git a/front/app/__tests__/deck-test.tsx b/front/app/__tests__/deck-test.tsx index 7feebf01..2e5b1307 100644 --- a/front/app/__tests__/deck-test.tsx +++ b/front/app/__tests__/deck-test.tsx @@ -89,7 +89,7 @@ describe('DeckDetail', () => { expect(mockRouter.push).toHaveBeenCalledWith({ pathname: '/flashcards/study', - params: { deckId: 'deck-1', title: 'Spanish' }, + params: { deckId: 'deck-1', title: 'Spanish', mode: 'due' }, }); }); diff --git a/front/app/__tests__/study-test.tsx b/front/app/__tests__/study-test.tsx index e96e7cfb..0332e11d 100644 --- a/front/app/__tests__/study-test.tsx +++ b/front/app/__tests__/study-test.tsx @@ -3,7 +3,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react-nativ import { useRouter, useLocalSearchParams } from 'expo-router'; import Study from '../flashcards/study'; -import { fetchFlashcards } from '@/api/flashcards'; +import { fetchStudyQueue, reviewFlashcard } from '@/api/flashcards'; +import { UnauthorizedError } from '@/api/apiClient'; +import { handleUnauthorized } from '@/hooks/useSelectedProfile'; jest.mock('expo-router', () => ({ useRouter: jest.fn(), @@ -11,12 +13,19 @@ jest.mock('expo-router', () => ({ })); jest.mock('@/api/flashcards', () => ({ - fetchFlashcards: jest.fn(), + fetchStudyQueue: jest.fn(), + reviewFlashcard: jest.fn(), +})); + +jest.mock('@/hooks/useSelectedProfile', () => ({ + handleUnauthorized: jest.fn(() => Promise.resolve(false)), })); jest.mock('expo-haptics', () => ({ impactAsync: jest.fn(), + notificationAsync: jest.fn(), ImpactFeedbackStyle: { Light: 'light', Medium: 'medium' }, + NotificationFeedbackType: { Warning: 'warning' }, })); const mockRouter = { push: jest.fn(), replace: jest.fn(), back: jest.fn() }; @@ -28,6 +37,12 @@ const card = (id: string, front: string, back: string) => ({ front, back, order: 0, + due_at: '2026-08-25T10:00:00Z', + interval_days: 0, + ease: 2.5, + reps: 0, + lapses: 0, + last_reviewed_at: null, created_at: '2026-08-25T10:00:00Z', updated_at: '2026-08-25T10:00:00Z', }); @@ -37,49 +52,117 @@ describe('Study', () => { jest.clearAllMocks(); (useRouter as jest.Mock).mockReturnValue(mockRouter); (useLocalSearchParams as jest.Mock).mockReturnValue({ deckId: 'deck-1' }); - (fetchFlashcards as jest.Mock).mockResolvedValue({ - results: [card('c1', 'Q1', 'A1'), card('c2', 'Q2', 'A2')], - count: 2, - }); + (fetchStudyQueue as jest.Mock).mockResolvedValue([ + card('c1', 'Q1', 'A1'), + card('c2', 'Q2', 'A2'), + ]); + (reviewFlashcard as jest.Mock).mockImplementation( + async (_deckId: string, flashcardId: string, _rating: string) => ({ + ...card(flashcardId, 'Q', 'A'), + due_at: '2026-08-26T10:00:00Z', + }) + ); }); it('renders the first card with progress', async () => { render(); await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); - expect(screen.getByText('1 / 2')).toBeTruthy(); + expect(screen.getByText('0 / 2')).toBeTruthy(); }); - it('advances to the next card', async () => { + it('advances to the next card after flip + rating', async () => { render(); await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); - fireEvent.press(screen.getByTestId('study-next')); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-good')); await waitFor(() => expect(screen.getByText('Q2')).toBeTruthy()); - expect(screen.getByText('2 / 2')).toBeTruthy(); + expect(screen.getByText('1 / 2')).toBeTruthy(); }); - it('completes the deck on the last card and restarts', async () => { + it('completes the deck on the last card', async () => { render(); await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); - fireEvent.press(screen.getByTestId('study-next')); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-good')); await waitFor(() => expect(screen.getByText('Q2')).toBeTruthy()); - fireEvent.press(screen.getByTestId('study-next')); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-good')); + + await waitFor(() => + expect(screen.getByTestId('study-session-complete')).toBeTruthy() + ); + expect(screen.getByText('You reviewed 2 cards.')).toBeTruthy(); + expect(screen.getByTestId('study-correct-rate')).toHaveTextContent( + '100% correct (2 of 2).' + ); + }); - await waitFor(() => expect(screen.getByText('Done! πŸŽ‰')).toBeTruthy()); - expect(screen.getByText('You studied all 2 cards.')).toBeTruthy(); + it('reports the correct rate excluding Again ratings', async () => { + render(); - fireEvent.press(screen.getByTestId('study-restart')); await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); - expect(screen.getByText('1 / 2')).toBeTruthy(); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-again')); + await waitFor(() => expect(screen.getByText('Q2')).toBeTruthy()); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-good')); + + await waitFor(() => + expect(screen.getByTestId('study-session-complete')).toBeTruthy() + ); + expect(screen.getByTestId('study-correct-rate')).toHaveTextContent( + '50% correct (1 of 2).' + ); }); it('shows the empty state when there are no cards', async () => { - (fetchFlashcards as jest.Mock).mockResolvedValue({ results: [], count: 0 }); + (fetchStudyQueue as jest.Mock).mockResolvedValue([]); + render(); + + await waitFor(() => expect(screen.getByText('Nothing due πŸŽ‰')).toBeTruthy()); + }); + + it('shows an error with retry when the queue fails to load', async () => { + (fetchStudyQueue as jest.Mock).mockRejectedValueOnce(new Error('offline')); + render(); + + await waitFor(() => expect(screen.getByTestId('study-load-error')).toBeTruthy()); + // A failed load must not render as an empty queue. + expect(screen.queryByText('Nothing due πŸŽ‰')).toBeNull(); + + fireEvent.press(screen.getByTestId('study-load-retry')); + await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); + }); + + it('stays on the same card when saving the review fails', async () => { + (reviewFlashcard as jest.Mock).mockRejectedValueOnce(new Error('offline')); + render(); + + await waitFor(() => expect(screen.getByText('Q1')).toBeTruthy()); + fireEvent.press(screen.getByTestId('study-card')); + fireEvent.press(screen.getByTestId('study-rating-good')); + + await waitFor(() => expect(reviewFlashcard).toHaveBeenCalled()); + // Still on the first card: progress unchanged, still flipped + // on the first card (answer visible), session not complete. + expect(screen.getByText('0 / 2')).toBeTruthy(); + expect(screen.getByText('A1')).toBeTruthy(); + expect(screen.queryByTestId('study-session-complete')).toBeNull(); + }); + + it('delegates expired sessions to the logout flow instead of load error', async () => { + const authError = new UnauthorizedError(); + (handleUnauthorized as jest.Mock).mockResolvedValueOnce(true); + (fetchStudyQueue as jest.Mock).mockRejectedValueOnce(authError); render(); - await waitFor(() => expect(screen.getByText('No cards to study')).toBeTruthy()); + await waitFor(() => + expect(handleUnauthorized).toHaveBeenCalledWith(authError, mockRouter) + ); + expect(screen.queryByTestId('study-load-error')).toBeNull(); }); }); diff --git a/front/app/flashcards.tsx b/front/app/flashcards.tsx index a9d7ce9a..efb11fff 100644 --- a/front/app/flashcards.tsx +++ b/front/app/flashcards.tsx @@ -21,6 +21,7 @@ import * as Sentry from "@sentry/react-native"; import { fetchDecks, createDeck, DeckListItem } from "@/api/flashcards"; import { getSelectedProfileId, subscribeToSelectedProfile } from "@/hooks/useSelectedProfile"; import { ThemedButton } from "@/components/ThemedButton"; +import { formatDistanceToNowStrict } from "date-fns"; export default function Flashcards() { const router = useRouter(); @@ -146,6 +147,7 @@ export default function Flashcards() { } renderItem={({ item }) => ( ) : null} + {item.last_studied_at ? ( + + Last studied{" "} + {formatDistanceToNowStrict(new Date(item.last_studied_at))}{" "} + ago + + ) : null} + {(item.due_count ?? 0) > 0 ? ( + + + {item.due_count} due + + + ) : null} @@ -255,6 +277,14 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: "600", }, + dueBadgeText: { + fontSize: 12, + fontWeight: "600", + }, + lastStudied: { + fontSize: 12, + marginTop: 4, + }, description: { fontSize: 14, marginTop: 4, diff --git a/front/app/flashcards/deck.tsx b/front/app/flashcards/deck.tsx index 0f0760f3..1367b5ab 100644 --- a/front/app/flashcards/deck.tsx +++ b/front/app/flashcards/deck.tsx @@ -192,7 +192,8 @@ export default function DeckDetail() { const handleStudyPress = () => { router.push({ pathname: "/flashcards/study", - params: { deckId, title: deck?.name }, + // Default study queue is due-only (see study.tsx). + params: { deckId, title: deck?.name, mode: "due" }, }); }; @@ -278,10 +279,13 @@ export default function DeckDetail() { ) : null} - Study + + {(deck?.due_count ?? 0) > 0 ? `Study (${deck?.due_count})` : "Study"} + setShowAddCard(true)} /> diff --git a/front/app/flashcards/study.tsx b/front/app/flashcards/study.tsx index fa138a89..e4d484d5 100644 --- a/front/app/flashcards/study.tsx +++ b/front/app/flashcards/study.tsx @@ -5,28 +5,79 @@ import { Dimensions, Alert, Pressable, + Animated, + ActivityIndicator, } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { ThemedText } from "@/components/ThemedText"; import { ThemedView } from "@/components/ThemedView"; -import { IconSymbol } from "@/components/ui/IconSymbol"; import { useState, useEffect } from "react"; import * as Haptics from "expo-haptics"; import * as Progress from "react-native-progress"; +import * as Sentry from "@sentry/react-native"; +import { formatDistanceToNowStrict } from "date-fns"; -import { fetchFlashcards, Flashcard } from "@/api/flashcards"; +import { + fetchStudyQueue, + reviewFlashcard, + Flashcard, + FlashcardRating, +} from "@/api/flashcards"; +import { handleUnauthorized } from "@/hooks/useSelectedProfile"; import { useThemeColor } from "@/hooks/useThemeColor"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const CARD_WIDTH = SCREEN_WIDTH - 40; +// Interval preview shown under each rating button, derived from the current +// card's scheduling state (mirrors back/bots/services/srs.py) so the hint +// stays correct as the card progresses past the early learning steps. +const LAPSE_HINT = "<1d"; + +function previewHint(card: Flashcard | undefined, rating: FlashcardRating): string { + const interval = card?.interval_days ?? 0; + const ease = card?.ease ?? 2.5; + const reps = card?.reps ?? 0; + let days: number; + switch (rating) { + case "again": + return LAPSE_HINT; + case "hard": + days = Math.max(1, interval * 1.2); + break; + case "good": + days = reps === 0 ? 1 : reps === 1 ? 6 : interval * ease; + break; + case "easy": + days = (reps === 0 ? 1 : reps === 1 ? 6 : interval * ease) * 1.3; + break; + } + return days < 1 ? LAPSE_HINT : `${Math.round(days)}d`; +} + +const RATINGS: { rating: FlashcardRating; label: string }[] = [ + { rating: "again", label: "Again" }, + { rating: "hard", label: "Hard" }, + { rating: "good", label: "Good" }, + { rating: "easy", label: "Easy" }, +]; + export default function Study() { const { deckId } = useLocalSearchParams<{ deckId: string }>(); const router = useRouter(); + const [loading, setLoading] = useState(true); const [cards, setCards] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [isFlipped, setIsFlipped] = useState(false); const [completed, setCompleted] = useState(false); + const [againCount, setAgainCount] = useState(0); + const [reviewedDues, setReviewedDues] = useState([]); + const [ratingInProgress, setRatingInProgress] = useState(false); + const [loadError, setLoadError] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + const [now] = useState(() => Date.now()); + + const [flipAnim] = useState(() => new Animated.Value(0)); const cardBackground = useThemeColor({}, "cardBackground"); const studyCardBack = useThemeColor({}, "studyCardBack"); @@ -34,87 +85,252 @@ export default function Study() { const iconColor = useThemeColor({}, "icon"); const tintColor = useThemeColor({}, "tint"); const borderColor = useThemeColor({}, "border"); - const navButtonBg = useThemeColor({}, "navButton"); - const navButtonIcon = useThemeColor({}, "navButtonIcon"); + const ratingButtonBg = useThemeColor({}, "studyRating"); + const againButtonBg = useThemeColor({}, "studyAgain"); useEffect(() => { - const loadCards = async () => { + // Loader lives inside the effect and touches state only after the + // first await, so the effect itself performs no synchronous setState. + const loadQueue = async () => { if (!deckId) { Alert.alert("Error", "Invalid deck"); router.back(); return; } - const flashcards = await fetchFlashcards(deckId); - setCards(flashcards.results || []); + try { + // Default study queue: only cards that are due right now. + // fetchStudyQueue throws on failure, so a network/server error lands + // in the error state below instead of rendering as "Nothing due". + const dueCards = await fetchStudyQueue(deckId, "due"); + setCards(dueCards); + } catch (error) { + // Expired sessions take the app's logout flow, not the error card. + if (await handleUnauthorized(error, router)) { + return; + } + Sentry.captureException(error); + setLoadError(true); + } finally { + setLoading(false); + } }; - loadCards(); - }, [deckId, router]); + loadQueue(); + }, [deckId, router, reloadKey]); + + // Retry resets the flags here (event handler, not the effect) and + // re-runs the loader above via reloadKey. + const retryLoad = () => { + setLoading(true); + setLoadError(false); + setReloadKey((k) => k + 1); + }; + + const frontRotate = flipAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "180deg"], + }); + const backRotate = flipAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["180deg", "360deg"], + }); const flipCard = () => { if (process.env.EXPO_OS === "ios") { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } + Animated.spring(flipAnim, { + toValue: isFlipped ? 0 : 1, + friction: 8, + useNativeDriver: true, + }).start(); setIsFlipped(!isFlipped); }; - const goToNext = () => { - if (currentIndex < cards.length - 1) { - if (process.env.EXPO_OS === "ios") { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const resetFlip = () => { + // Stop the flip spring first: setValue alone leaves an in-flight + // animation running, which could show the next card already flipped. + flipAnim.stopAnimation(); + flipAnim.setValue(0); + setIsFlipped(false); + }; + + const handleStudyAllAnyway = async () => { + if (!deckId) return; + if (process.env.EXPO_OS === "ios") { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + setLoading(true); + try { + const allCards = await fetchStudyQueue(deckId, "all"); + setCards(allCards); + } catch (error) { + if (await handleUnauthorized(error, router)) { + return; } - setIsFlipped(false); - setCurrentIndex(currentIndex + 1); + Sentry.captureException(error); + // Surface the failure instead of falling back to "Nothing due": + // retry reloads the due queue, the primary study flow. + setLoadError(true); + Alert.alert("Error", "Failed to load cards"); + } finally { + setLoading(false); } }; - const goToPrev = () => { - if (currentIndex > 0) { + const handleRating = async (rating: FlashcardRating) => { + if (ratingInProgress || !deckId) return; + setRatingInProgress(true); + try { if (process.env.EXPO_OS === "ios") { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (rating === "again") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + } else { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } } - setIsFlipped(false); - setCurrentIndex(currentIndex - 1); - } - }; - const finish = () => { - if (process.env.EXPO_OS === "ios") { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + const currentCard = cards[currentIndex]; + let updated: Flashcard; + try { + updated = await reviewFlashcard( + deckId, + currentCard.flashcard_id, + rating + ); + } catch (error) { + // Expired sessions take the app's logout flow; anything else stays + // on the card with a save error. + if (await handleUnauthorized(error, router)) { + return; + } + Sentry.captureException(error); + Alert.alert("Error", "Failed to save your review"); + return; + } + // reviewFlashcard throws on failure, so reaching here means the + // review was persisted; only then do we advance the session. + const nextDueAt: string | null = updated.due_at ?? null; + + if (rating === "again") { + setAgainCount((c) => c + 1); + } + if (nextDueAt) { + setReviewedDues((prev) => [...prev, nextDueAt as string]); + } + + if (currentIndex < cards.length - 1) { + resetFlip(); + setCurrentIndex(currentIndex + 1); + } else { + setCompleted(true); + } + } finally { + setRatingInProgress(false); } - setCompleted(true); }; - const restart = () => { - if (process.env.EXPO_OS === "ios") { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - setIsFlipped(false); - setCurrentIndex(0); - setCompleted(false); + const earliestNextDue = (): string | null => { + if (reviewedDues.length === 0) return null; + const times = reviewedDues + .map((iso) => new Date(iso).getTime()) + .filter((t) => !Number.isNaN(t)); + if (times.length === 0) return null; + const earliest = new Date(Math.min(...times)); + const diffMs = earliest.getTime() - now; + if (diffMs <= 0) return "now"; + const minutes = Math.round(diffMs / 60000); + if (minutes < 60) return `${minutes} min`; + return formatDistanceToNowStrict(earliest); }; + if (loading) { + return ( + + + + ); + } + + if (!loading && loadError) { + return ( + + + + Couldn't load your cards + + + Check your connection and try again. + + + + Retry + + + + + ); + } + if (cards.length === 0) { return ( - No cards to study + + Nothing due πŸŽ‰ + + All caught up on this deck. + + + + Study all anyway + + + ); } if (completed) { + const nextDueIn = earliestNextDue(); + // "Correct" = any rating other than Again (issue #59 success criteria). + const correctCount = cards.length - againCount; + const correctPct = + cards.length > 0 ? Math.round((correctCount / cards.length) * 100) : 0; return ( - Done! πŸŽ‰ + + Session complete! πŸŽ‰ + - You studied all {cards.length} cards. + You reviewed {cards.length} card{cards.length === 1 ? "" : "s"}. + + + {correctPct}% correct ({correctCount} of {cards.length}). + {againCount > 0 ? ( + + {againCount} rated β€œAgain” β€” they'll be back soon. + + ) : null} + {nextDueIn ? ( + + Next due in {nextDueIn}. + + ) : null} router.back()} > - Restart + Done @@ -122,16 +338,16 @@ export default function Study() { } const currentCard = cards[currentIndex]; - const isLastCard = currentIndex === cards.length - 1; + const reviewedCount = currentIndex; return ( - - {currentIndex + 1} / {cards.length} + + {reviewedCount} / {cards.length} 0 ? reviewedCount / cards.length : 0} width={null} height={6} color={tintColor} @@ -140,14 +356,27 @@ export default function Study() { borderRadius={3} style={styles.progressBar} /> + + {cards.length - reviewedCount} left in session + - {!isFlipped ? ( + + - )} + - - - - - - - + + {isFlipped ? ( + RATINGS.map(({ rating, label }) => ( + [ + styles.ratingButton, + { backgroundColor: rating === "again" ? againButtonBg : ratingButtonBg }, + pressed && styles.ratingButtonPressed, + ratingInProgress && styles.ratingButtonDisabled, + ]} + onPress={() => handleRating(rating)} + disabled={ratingInProgress} + > + {label} + {previewHint(currentCard, rating)} + + )) + ) : ( + + Think of the answer, then flip the card + + )} ); @@ -209,6 +448,11 @@ const styles = StyleSheet.create({ flex: 1, padding: 20, }, + activityIndicator: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, header: { alignItems: "center", marginBottom: 20, @@ -221,11 +465,21 @@ const styles = StyleSheet.create({ alignSelf: "stretch", marginTop: 8, }, + remaining: { + fontSize: 13, + marginTop: 6, + }, cardContainer: { width: CARD_WIDTH, height: 300, alignSelf: "center", }, + cardFace: { + position: "absolute", + width: "100%", + height: "100%", + backfaceVisibility: "hidden", + }, card: { position: "absolute", width: "100%", @@ -250,21 +504,40 @@ const styles = StyleSheet.create({ bottom: 20, fontSize: 14, }, - navigation: { + ratingRow: { + minHeight: 96, flexDirection: "row", justifyContent: "center", - gap: 32, + alignItems: "center", + gap: 12, marginTop: 32, }, - navButton: { - width: 56, - height: 56, - borderRadius: 28, + ratingButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, alignItems: "center", - justifyContent: "center", }, - navButtonDisabled: { - opacity: 0.35, + ratingButtonPressed: { + opacity: 0.7, + }, + ratingButtonDisabled: { + opacity: 0.4, + }, + ratingButtonText: { + color: "#fff", + fontSize: 15, + fontWeight: "600", + }, + ratingHint: { + color: "#fff", + fontSize: 12, + marginTop: 2, + opacity: 0.85, + }, + flipPrompt: { + fontSize: 14, + textAlign: "center", }, completion: { flex: 1, @@ -279,20 +552,35 @@ const styles = StyleSheet.create({ fontSize: 16, marginTop: 8, }, - restartButton: { + completionStat: { + fontSize: 14, + marginTop: 8, + }, + doneButton: { marginTop: 24, paddingVertical: 12, - paddingHorizontal: 32, + paddingHorizontal: 40, borderRadius: 24, }, - restartButtonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 40, }, emptyText: { - fontSize: 18, + fontSize: 22, + fontWeight: "600", + }, + emptySubtext: { + fontSize: 15, + marginTop: 8, textAlign: "center", - marginTop: 100, + }, + studyAllButton: { + marginTop: 24, + paddingVertical: 12, + paddingHorizontal: 28, + borderRadius: 24, }, }); diff --git a/front/constants/Colors.ts b/front/constants/Colors.ts index c91b042a..2ddf888a 100644 --- a/front/constants/Colors.ts +++ b/front/constants/Colors.ts @@ -16,6 +16,10 @@ export const Colors = { cardBackgroundSelected: tintColorLight, border: '#ccc', studyCardBack: '#f0f8ff', + // Rating buttons keep white text: these darker shades meet WCAG AA + // (tint #00a4c9 and again-red #d9534f do not). + studyRating: '#00708c', + studyAgain: '#b52a25', disabled: '#ccc', navButton: '#f0f0f0', navButtonIcon: '#03465b', @@ -29,6 +33,8 @@ export const Colors = { cardBackgroundSelected: tintColorDark, border: '#444', studyCardBack: '#1a2a3a', + studyRating: tintColorDark, + studyAgain: '#b52a25', disabled: '#555', navButton: '#333', navButtonIcon: '#00a4c9', diff --git a/front/e2e/07-spaced-repetition.e2e.js b/front/e2e/07-spaced-repetition.e2e.js new file mode 100644 index 00000000..7cb2659f --- /dev/null +++ b/front/e2e/07-spaced-repetition.e2e.js @@ -0,0 +1,190 @@ +/** + * Roadmap 07 β€” Spaced Repetition Study E2E (real API) + * + * Flow: open deck -> start study session -> flip card -> rate + * Again/Hard/Good/Easy -> session complete summary -> verify the next-due + * state is reflected in the UI and via the study_queue API. + * + * SEEDING + * ------- + * Requires the backend seed command to have been run against the server: + * + * cd back && python manage.py seed_e2e_spaced_repetition --skip-checks + * + * It creates (idempotently): + * - user e2e-test-user / testpassword123 (+ profile + bot) + * - deck "Cell Bio" with 8 cards: 6 due (3 overdue + 3 new), 2 future + * Re-running the seed resets the deck to exactly that state. + * + * ENVIRONMENT + * ----------- + * API_BASE_URL base API url (default http://localhost:8000/api) + * Detox iOS simulator config as for chatImageUpload.e2e.js + * (bundle com.tpaulshippy.botsforkids). Auth tokens, profile and bot are + * injected into AsyncStorage via simctl like chatImageUpload.e2e.js. + */ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const API_BASE = process.env.API_BASE_URL || 'http://localhost:8000/api'; +const BUNDLE_ID = 'com.tpaulshippy.botsforkids'; +const DECK_NAME = 'Cell Bio'; + +async function getTestTokens() { + const response = await fetch(`${API_BASE}/token/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'e2e-test-user', password: 'testpassword123' }), + }); + if (!response.ok) { + throw new Error( + `Backend not available at ${API_BASE} (status ${response.status}). ` + + `Run: cd back && python manage.py seed_e2e_spaced_repetition --skip-checks` + ); + } + const data = await response.json(); + return { access: data.access, refresh: data.refresh }; +} + +async function getSeedState(accessToken) { + const authHeaders = { Authorization: `Bearer ${accessToken}` }; + const [profileRes, botRes] = await Promise.all([ + fetch(`${API_BASE}/profiles.json`, { headers: authHeaders }), + fetch(`${API_BASE}/bots.json`, { headers: authHeaders }), + ]); + const profiles = await profileRes.json(); + const bots = await botRes.json(); + // Other e2e seeds reuse e2e-test-user, so profiles[0] is not guaranteed + // to be this seed's profile β€” and the deck list is profile-filtered. + const profile = (profiles.results || []).find((p) => p.name === 'E2E Test Profile'); + if (!profile) throw new Error('Seeded profile "E2E Test Profile" not found β€” run the seed command'); + // Scope the deck lookup to that profile: another profile (e.g. from the + // streaming-chat seed) may own its own "Cell Bio" deck, which would not + // appear once the app selects E2E Test Profile. + const deckRes = await fetch( + `${API_BASE}/decks.json?profileId=${profile.profile_id}`, + { headers: authHeaders } + ); + const decks = await deckRes.json(); + const deck = (decks.results || []).find((d) => d.name === DECK_NAME); + if (!deck) throw new Error(`Seeded deck "${DECK_NAME}" not found β€” run the seed command`); + const bot = (bots.results || []).find((b) => b.name === 'E2E Test Bot'); + if (!bot) throw new Error('Seeded bot "E2E Test Bot" not found β€” run the seed command'); + return { + profile: JSON.stringify(profile), + bot: JSON.stringify(bot), + deckId: deck.deck_id, + deckDueCount: deck.due_count, + }; +} + +function injectAsyncStorage(udid, tokens, profile, bot) { + const containerPath = execSync( + `xcrun simctl get_app_container ${udid} ${BUNDLE_ID} data`, + { encoding: 'utf8' } + ).trim(); + + const asDir = path.join( + containerPath, + 'Library', + 'Application Support', + BUNDLE_ID, + 'RCTAsyncLocalStorage_V1' + ); + + if (!fs.existsSync(asDir)) { + fs.mkdirSync(asDir, { recursive: true }); + } + + const manifest = { + tokens: JSON.stringify({ [API_BASE]: tokens }), + selectedProfile: profile, + selectedBot: bot, + e2eTestMode: 'true', + }; + + fs.writeFileSync(path.join(asDir, 'manifest.json'), JSON.stringify(manifest), 'utf8'); +} + +async function studyCurrentCard(rating) { + // Flip the card, then rate it. + await waitFor(element(by.id('study-card'))).toBeVisible().withTimeout(10000); + await element(by.id('study-card')).tap(); + await waitFor(element(by.id(`study-rating-${rating}`))).toBeVisible().withTimeout(5000); + await element(by.id(`study-rating-${rating}`)).tap(); +} + +describe('Spaced Repetition Study E2E Flow (Real API)', () => { + let deckId; + + beforeAll(async () => { + const tokens = await getTestTokens(); + const state = await getSeedState(tokens.access); + deckId = state.deckId; + + await device.installApp(); + await device.launchApp({ newInstance: true }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await device.terminateApp(); + + const udid = device.id; + injectAsyncStorage(udid, tokens, state.profile, state.bot); + + await device.launchApp({ newInstance: true }); + await waitFor(element(by.id('drawer-menu-button'))).toBeVisible().withTimeout(15000); + }, 120000); + + it('should run a study session with all four ratings', async () => { + // Open Flashcards from the drawer. + await element(by.id('drawer-menu-button')).tap(); + await waitFor(element(by.id('drawer-item-flashcards'))).toBeVisible().withTimeout(5000); + await element(by.id('drawer-item-flashcards')).tap(); + + // Open the seeded deck. + await waitFor(element(by.id(`deck-row-${deckId}`))).toBeVisible().withTimeout(10000); + await element(by.id(`deck-row-${deckId}`)).tap(); + + // Start studying (button reads "Study (N)"). + await waitFor(element(by.id('study-button'))).toBeVisible().withTimeout(5000); + await element(by.id('study-button')).tap(); + + // Rate six due cards: every rating once, then two more Goods. + const ratings = ['again', 'hard', 'good', 'easy', 'good', 'good']; + for (const rating of ratings) { + await studyCurrentCard(rating); + } + + // Session complete summary: reviewed count + next due preview. + await waitFor(element(by.id('study-session-complete'))).toBeVisible().withTimeout(10000); + await waitFor(element(by.id('study-complete-done'))).toBeVisible().withTimeout(5000); + await element(by.id('study-complete-done')).tap(); + }, 180000); + + it('should reflect next-due state afterwards', async () => { + // All rated cards are scheduled >= 4h out, so the due queue empties. + const tokens = await getTestTokens(); + const response = await fetch(`${API_BASE}/decks/${deckId}/study_queue.json?mode=due`, { + headers: { Authorization: `Bearer ${tokens.access}` }, + }); + expect(response.ok).toBe(true); + const queue = await response.json(); + expect(Array.isArray(queue)).toBe(true); + expect(queue).toEqual([]); + + // We are back on the deck detail after Done; studying again shows the + // "nothing due" state instead of cards. + await waitFor(element(by.id('study-button'))).toBeVisible().withTimeout(10000); + await element(by.id('study-button')).tap(); // reads "Study" without a count + await waitFor(element(by.id('study-all-anyway'))).toBeVisible().withTimeout(10000); + + // Leave the study screen, then go up to the deck list. + await element(by.id('header-back-button')).tap(); + await waitFor(element(by.id('study-button'))).toBeVisible().withTimeout(10000); + await element(by.id('header-back-button')).tap(); + await waitFor(element(by.id(`deck-row-${deckId}`))).toBeVisible().withTimeout(10000); + + // Deck list no longer shows a red due badge for this deck. + await waitFor(element(by.id(`deck-due-badge-${deckId}`))).not.toExist().withTimeout(5000); + }, 120000); +}); diff --git a/front/package.json b/front/package.json index 957d8934..f398afb5 100644 --- a/front/package.json +++ b/front/package.json @@ -7,7 +7,7 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "test": "jest", + "test": "jest --watchman=false", "typecheck": "tsc --noEmit", "lint": "eslint ." },