diff --git a/back/bots/management/__init__.py b/back/bots/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/back/bots/management/commands/__init__.py b/back/bots/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 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 0000000..f7d5a39 --- /dev/null +++ b/back/bots/management/commands/seed_e2e_spaced_repetition.py @@ -0,0 +1,103 @@ +"""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.get_or_create(user=user, name='E2E Test Profile') + + ai_model = AiModel.objects.order_by('pk').first() + bot, _ = Bot.objects.get_or_create( + user=user, + name='E2E Test Bot', + defaults={'ai_model': ai_model}, + ) + + deck, _ = Deck.objects.get_or_create( + profile=profile, + name=DECK_NAME, + defaults={'description': 'Seeded deck for spaced repetition e2e tests'}, + ) + + now = timezone.now() + created_cards = 0 + for order, (front, back, state) in enumerate(CARDS): + card, card_created = Flashcard.objects.get_or_create( + deck=deck, + front=front, + defaults={'back': back, 'order': order}, + ) + if card_created: + created_cards += 1 + + # 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 + if state == 'due': + card.due_at = now - timedelta(days=1) + elif state == 'future': + card.due_at = now + timedelta(days=7) + else: # new + card.due_at = now + card.save() + + 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"(new={created_cards}) due_now={due_count}" + )) diff --git a/back/bots/migrations/0039_flashcard_due_at_flashcard_ease_and_more.py b/back/bots/migrations/0039_flashcard_due_at_flashcard_ease_and_more.py new file mode 100644 index 0000000..fd4a1bc --- /dev/null +++ b/back/bots/migrations/0039_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', '0038_alter_aimodel_options_alter_device_options'), + ] + + 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 67df483..a583036 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 .usage_limit_hit import UsageLimitHit @@ -16,6 +17,7 @@ 'Deck', 'Device', 'Flashcard', + 'FlashcardReview', 'Message', 'Profile', 'RevenueCatWebhookEvent', diff --git a/back/bots/models/flashcard.py b/back/bots/models/flashcard.py index 36d1396..1e9fd28 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 0000000..e6d07ec --- /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 5da3426..46b43f0 100644 --- a/back/bots/serializers/flashcard_serializer.py +++ b/back/bots/serializers/flashcard_serializer.py @@ -14,12 +14,18 @@ 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', + ] class DeckSerializer(serializers.ModelSerializer): flashcards = FlashcardSerializer(many=True, read_only=True) card_count = serializers.IntegerField(read_only=True, source='flashcard_count') + due_count = serializers.IntegerField(read_only=True) + last_studied_at = serializers.DateTimeField(read_only=True) profile = serializers.SlugRelatedField( queryset=Profile.objects.all(), slug_field='profile_id', @@ -33,12 +39,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') + due_count = serializers.IntegerField(read_only=True) + last_studied_at = serializers.DateTimeField(read_only=True) 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 0000000..f914630 --- /dev/null +++ b/back/bots/services/srs.py @@ -0,0 +1,96 @@ +"""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 + 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) + 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 0000000..226d28c --- /dev/null +++ b/back/bots/tests/test_spaced_repetition_api.py @@ -0,0 +1,301 @@ +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 + + +@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_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_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 + + +@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 diff --git a/back/bots/tests/test_srs.py b/back/bots/tests/test_srs.py new file mode 100644 index 0000000..778ff6c --- /dev/null +++ b/back/bots/tests/test_srs.py @@ -0,0 +1,147 @@ +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'] == 0 + 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_does_not_increment_reps(self): + card = FakeCard(interval_days=10, reps=3) + fields = apply_sm2(card, 'hard', NOW) + assert fields['reps'] == 3 + + +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 a91ae96..bcca59a 100644 --- a/back/bots/viewsets/flashcard_viewset.py +++ b/back/bots/viewsets/flashcard_viewset.py @@ -1,25 +1,38 @@ import uuid -from django.db.models import Count, Max -from rest_framework import viewsets - -from bots.models import Deck, Flashcard, Profile +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.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 _owned_decks(self): + """Decks owned by the requesting user (empty when unauthenticated).""" + if not self.request.user.is_authenticated: + return Deck.objects.none() + return Deck.objects.filter(profile__user=self.request.user) + def get_queryset(self): deck_id = self.kwargs['deck_pk'] - deck = get_object_by_uuid_or_id(Deck.objects.all(), '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) @@ -29,7 +42,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(Deck.objects.all(), '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 ) @@ -40,13 +53,38 @@ def get_object(self): def perform_create(self, serializer): deck_id = self.kwargs['deck_pk'] - deck = get_object_by_uuid_or_id(Deck.objects.all(), '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): + """Rate a card (again|hard|good|easy) and reschedule it via SM-2.""" + flashcard = self.get_object() + + 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, + ) + + 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] @@ -66,7 +104,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': @@ -117,3 +160,38 @@ 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): + """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) + limit = max(1, min(limit, 200)) + queryset = queryset[:limit] + + serializer = FlashcardSerializer(queryset, many=True) + return Response(serializer.data) diff --git a/evidence/pr49-spaced.mp4 b/evidence/pr49-spaced.mp4 new file mode 100644 index 0000000..2498c46 Binary files /dev/null and b/evidence/pr49-spaced.mp4 differ diff --git a/evidence/pr49-spaced.png b/evidence/pr49-spaced.png new file mode 100644 index 0000000..0133246 Binary files /dev/null and b/evidence/pr49-spaced.png differ diff --git a/evidence/pr49-spaced.webm b/evidence/pr49-spaced.webm new file mode 100644 index 0000000..a1fb42c Binary files /dev/null and b/evidence/pr49-spaced.webm differ diff --git a/front/__mocks__/handlers.ts b/front/__mocks__/handlers.ts index 5d92d86..d1239b2 100644 --- a/front/__mocks__/handlers.ts +++ b/front/__mocks__/handlers.ts @@ -14,7 +14,7 @@ export const handlers = [ http.post('/api/decks.json', async ({ request }) => { await delay(200); - const body = await request.json(); + const body = (await request.json()) as Record; return HttpResponse.json({ id: 1, deck_id: '550e8400-e29b-41d4-a716-446655440000', @@ -31,7 +31,7 @@ export const handlers = [ http.get('/api/decks/:id.json', async ({ params }) => { await delay(200); - const id = params.id; + const id = params.id as string; return HttpResponse.json({ id: typeof id === 'string' && !id.match(/^\d+$/) ? 1 : parseInt(id, 10), deck_id: id, @@ -48,7 +48,7 @@ export const handlers = [ http.patch('/api/decks/:id.json', async ({ request }) => { await delay(200); - const body = await request.json(); + const body = (await request.json()) as Record; return HttpResponse.json({ id: 1, deck_id: '550e8400-e29b-41d4-a716-446655440000', @@ -81,7 +81,7 @@ export const handlers = [ http.post('/api/decks/:deck_pk/flashcards.json', async ({ request }) => { await delay(200); - const body = await request.json(); + const body = (await request.json()) as Record; return HttpResponse.json({ id: 1, flashcard_id: '550e8400-e29b-41d4-a716-446655440001', @@ -96,7 +96,7 @@ export const handlers = [ http.get('/api/decks/:deck_pk/flashcards/:id.json', async ({ params }) => { await delay(200); - const id = params.id; + const id = params.id as string; return HttpResponse.json({ id: typeof id === 'string' && !id.match(/^\d+$/) ? 1 : parseInt(id, 10), flashcard_id: id, @@ -111,7 +111,7 @@ export const handlers = [ http.patch('/api/decks/:deck_pk/flashcards/:id.json', async ({ request }) => { await delay(200); - const body = await request.json(); + const body = (await request.json()) as Record; return HttpResponse.json({ id: 1, flashcard_id: '550e8400-e29b-41d4-a716-446655440001', diff --git a/front/__tests__/api/aiModels.test.ts b/front/__tests__/api/aiModels.test.ts index bcd6e0c..c425379 100644 --- a/front/__tests__/api/aiModels.test.ts +++ b/front/__tests__/api/aiModels.test.ts @@ -55,8 +55,8 @@ describe('AI Models API', () => { const response = await fetchAiModels(); expect(response).toBeDefined(); - expect(response.results.length).toBeGreaterThan(0); - const model = response.results[0]; + expect(response?.results?.length).toBeGreaterThan(0); + const model = response?.results?.[0]; expect(model).toHaveProperty('model_id'); expect(model).toHaveProperty('name'); expect(model).toHaveProperty('input_token_cost'); diff --git a/front/__tests__/api/apiClient.test.ts b/front/__tests__/api/apiClient.test.ts index 3172a11..bab97e6 100644 --- a/front/__tests__/api/apiClient.test.ts +++ b/front/__tests__/api/apiClient.test.ts @@ -15,7 +15,7 @@ describe("apiClient", () => { ok: true, text: jest.fn().mockResolvedValue('{"success": true}'), }); - global.fetch = mockFetch; + globalThis.fetch = mockFetch; // Mock XMLHttpRequest const mockXhr = { @@ -28,7 +28,7 @@ describe("apiClient", () => { responseText: '{"success": true}', }; const XMLHttpRequestMock = jest.fn().mockImplementation(() => mockXhr); - (global as any).XMLHttpRequest = XMLHttpRequestMock; + (globalThis as any).XMLHttpRequest = XMLHttpRequestMock; const formData = new FormData(); (formData as any)._parts = [ @@ -71,7 +71,7 @@ describe("apiClient", () => { ok: true, text: jest.fn().mockResolvedValue('{"success": true}'), }); - global.fetch = mockFetch; + globalThis.fetch = mockFetch; await apiClient("/test", { method: "POST", body: JSON.stringify({ message: "hello" }) }); diff --git a/front/__tests__/api/flashcards.test.ts b/front/__tests__/api/flashcards.test.ts index 237dfe0..ff0b2fb 100644 --- a/front/__tests__/api/flashcards.test.ts +++ b/front/__tests__/api/flashcards.test.ts @@ -4,6 +4,8 @@ import { createDeck, fetchDeck, deleteDeck, + fetchStudyQueue, + reviewFlashcard, } from '../../api/flashcards'; // Mock the apiClient to return paginated responses matching OpenAPI schema @@ -132,7 +134,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' || body.rating === 'hard' ? 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 +306,52 @@ 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); + }); + }); + + 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); + }); + }); }); \ No newline at end of file diff --git a/front/__tests__/api/profiles.test.ts b/front/__tests__/api/profiles.test.ts index 92192f5..4fc818c 100644 --- a/front/__tests__/api/profiles.test.ts +++ b/front/__tests__/api/profiles.test.ts @@ -41,8 +41,8 @@ describe('Profiles API', () => { const response = await fetchProfiles(); expect(response).toBeDefined(); - expect(response.results.length).toBeGreaterThan(0); - const profile = response.results[0]; + expect(response?.results?.length).toBeGreaterThan(0); + const profile = response?.results?.[0]; expect(profile).toHaveProperty('profile_id'); expect(profile).toHaveProperty('name'); }); diff --git a/front/api/bots.ts b/front/api/bots.ts index 1480bdb..52a18bf 100644 --- a/front/api/bots.ts +++ b/front/api/bots.ts @@ -7,7 +7,7 @@ export interface Bot { ai_model: string; system_prompt: string; simple_editor: boolean; - template_name: string; + template_name: string | null; response_length: number; restrict_language: boolean; restrict_adult_topics: boolean; diff --git a/front/api/flashcards.ts b/front/api/flashcards.ts index 8d80930..83ff976 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,30 @@ export const deleteFlashcard = async ( ); return response?.ok ?? false; }; + +// Cards to study for this deck, ordered by due_at ascending. +export const fetchStudyQueue = async ( + deckId: string, + mode: StudyQueueMode = "due", + limit = 50 +): Promise => + request( + `/decks/${deckId}/study_queue/.json?mode=${mode}&limit=${limit}`, + { method: "GET" }, + [] + ); + +// Rate a card during study; returns the rescheduled flashcard. +export const reviewFlashcard = async ( + deckId: string, + flashcardId: string, + rating: FlashcardRating +): Promise => + request( + `/decks/${deckId}/flashcards/${flashcardId}/review/.json`, + { + method: "POST", + body: JSON.stringify({ rating }), + }, + null + ); diff --git a/front/app/flashcards.tsx b/front/app/flashcards.tsx index 7c4e8ff..bc20b9d 100644 --- a/front/app/flashcards.tsx +++ b/front/app/flashcards.tsx @@ -20,6 +20,7 @@ import * as Sentry from "@sentry/react-native"; import { fetchDecks, createDeck, DeckListItem } from "@/api/flashcards"; import { getSelectedProfileId } from "@/hooks/useSelectedProfile"; +import { formatDistanceToNowStrict } from "date-fns"; export default function Flashcards() { const router = useRouter(); @@ -140,6 +141,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} @@ -238,6 +260,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 0f0760f..1367b5a 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 fa138a8..b0158bb 100644 --- a/front/app/flashcards/study.tsx +++ b/front/app/flashcards/study.tsx @@ -5,28 +5,52 @@ 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 { useThemeColor } from "@/hooks/useThemeColor"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const CARD_WIDTH = SCREEN_WIDTH - 40; +// Rating buttons shown after the flip. Interval hints mirror the SM-2 +// defaults for an early card (<1d for Again's same-day relearn step). +const RATINGS: { rating: FlashcardRating; label: string; hint: string }[] = [ + { rating: "again", label: "Again", hint: "<1d" }, + { rating: "hard", label: "Hard", hint: "1d" }, + { rating: "good", label: "Good", hint: "3d" }, + { rating: "easy", label: "Easy", hint: "7d" }, +]; + 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 [now] = useState(() => Date.now()); + + const [flipAnim] = useState(() => new Animated.Value(0)); const cardBackground = useThemeColor({}, "cardBackground"); const studyCardBack = useThemeColor({}, "studyCardBack"); @@ -34,87 +58,183 @@ export default function Study() { const iconColor = useThemeColor({}, "icon"); const tintColor = useThemeColor({}, "tint"); const borderColor = useThemeColor({}, "border"); - const navButtonBg = useThemeColor({}, "navButton"); - const navButtonIcon = useThemeColor({}, "navButtonIcon"); useEffect(() => { - const loadCards = async () => { + 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. + const dueCards = await fetchStudyQueue(deckId, "due"); + setCards(dueCards); + } catch (error) { + Sentry.captureException(error); + setCards([]); + } finally { + setLoading(false); + } }; - loadCards(); + loadQueue(); }, [deckId, router]); + 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); - } - setIsFlipped(false); - setCurrentIndex(currentIndex + 1); + const resetFlip = () => { + 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) { + Sentry.captureException(error); + } finally { + setLoading(false); } }; - const goToPrev = () => { - if (currentIndex > 0) { - if (process.env.EXPO_OS === "ios") { + const handleRating = async (rating: FlashcardRating) => { + if (ratingInProgress || !deckId) return; + setRatingInProgress(true); + + if (process.env.EXPO_OS === "ios") { + 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 nextDueAt: string | null = null; + try { + const updated = await reviewFlashcard( + deckId, + currentCard.flashcard_id, + rating + ); + nextDueAt = updated?.due_at ?? null; + } catch (error) { + Sentry.captureException(error); + Alert.alert("Error", "Failed to save your review"); } - setCompleted(true); - }; - const restart = () => { - if (process.env.EXPO_OS === "ios") { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + if (rating === "again") { + setAgainCount(againCount + 1); } - setIsFlipped(false); - setCurrentIndex(0); - setCompleted(false); + if (nextDueAt) { + setReviewedDues((prev) => [...prev, nextDueAt as string]); + } + + if (currentIndex < cards.length - 1) { + resetFlip(); + setCurrentIndex(currentIndex + 1); + } else { + setCompleted(true); + } + setRatingInProgress(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 (cards.length === 0) { return ( - No cards to study + + Nothing due πŸŽ‰ + + All caught up on this deck. + + + + Study all anyway + + + ); } if (completed) { + const nextDueIn = earliestNextDue(); return ( - Done! πŸŽ‰ + + Session complete! πŸŽ‰ + - You studied all {cards.length} cards. + You reviewed {cards.length} card{cards.length === 1 ? "" : "s"}. + {againCount > 0 ? ( + + {againCount} rated β€œAgain” β€” they'll be back soon. + + ) : null} + {nextDueIn ? ( + + Next due in {nextDueIn}. + + ) : null} router.back()} > - Restart + Done @@ -122,16 +242,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 +260,23 @@ export default function Study() { borderRadius={3} style={styles.progressBar} /> + + {cards.length - reviewedCount} left in session + - {!isFlipped ? ( + - ) : ( + + - )} + - - - - - - - + + {isFlipped ? ( + RATINGS.map(({ rating, label, hint }) => ( + [ + styles.ratingButton, + { backgroundColor: rating === "again" ? "#d9534f" : tintColor }, + pressed && styles.ratingButtonPressed, + ratingInProgress && styles.ratingButtonDisabled, + ]} + onPress={() => handleRating(rating)} + disabled={ratingInProgress} + > + {label} + {hint} + + )) + ) : ( + + Think of the answer, then flip the card + + )} ); @@ -209,6 +344,11 @@ const styles = StyleSheet.create({ flex: 1, padding: 20, }, + activityIndicator: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, header: { alignItems: "center", marginBottom: 20, @@ -221,11 +361,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 +400,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 +448,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/components/HeaderButtons.tsx b/front/components/HeaderButtons.tsx index 15b81d0..c8484e9 100644 --- a/front/components/HeaderButtons.tsx +++ b/front/components/HeaderButtons.tsx @@ -10,7 +10,7 @@ export type DrawerMenuButtonProps = { export function DrawerMenuButton({ onOpen }: DrawerMenuButtonProps) { const iconColor = useThemeColor({}, "tint"); return ( - + + = ({ return ( handleMenuPress(item.path)} style={[ styles.menuItem, diff --git a/front/e2e/07-spaced-repetition.e2e.js b/front/e2e/07-spaced-repetition.e2e.js new file mode 100644 index 0000000..1fad8ee --- /dev/null +++ b/front/e2e/07-spaced-repetition.e2e.js @@ -0,0 +1,181 @@ +/** + * 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 [profileRes, botRes, deckRes] = await Promise.all([ + fetch(`${API_BASE}/profiles.json`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }), + fetch(`${API_BASE}/bots.json`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }), + fetch(`${API_BASE}/decks.json`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }), + ]); + const profiles = await profileRes.json(); + const bots = await botRes.json(); + 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`); + return { + profile: JSON.stringify(profiles.results[0]), + bot: JSON.stringify(bots.results[0]), + 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}` }, + }); + const queue = await response.json(); + expect(Array.isArray(queue) ? 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('back-button')).atIndex(0).tap(); + await waitFor(element(by.id('study-button'))).toBeVisible().withTimeout(10000); + await element(by.id('back-button')).atIndex(0).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/eslint.config.js b/front/eslint.config.js index 4c9b0a9..94bf064 100644 --- a/front/eslint.config.js +++ b/front/eslint.config.js @@ -36,6 +36,7 @@ module.exports = defineConfig([ waitFor: 'readonly', element: 'readonly', by: 'readonly', + expect: 'readonly', } } } diff --git a/front/package.json b/front/package.json index 458d214..b64ee5a 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 ." },