feat: cards remember, render as prose, and are reachable from a question
Three things the card system did not have. **Spaced repetition.** There was none. "Known" and "to review" were React state that vanished on reload, so a deck of two hundred was two hundred cards every time and the only spacing was whichever cards a learner remembered to skip. Verdicts are now kept, and the deck comes back in the order the learner's own history calls for: due first, most decayed first, then never seen, then the rest — because somebody who has met the whole deck recently should still get a deck rather than a screen saying come back on Thursday. It borrows the question player's arithmetic rather than choosing its own. `recall_probability`, `DUE_RECALL`, the thirty-day half-life: two schedulers with two ideas of "due", in one product that shows a learner one readiness number, is how the number stops meaning anything. Two outcomes and no four-point scale — a scale asks a learner to rate their own recall in units they have never calibrated, and the extra resolution is noise. **Cards are prose.** Both faces go through the same renderer as everything else, so a card can carry `[[264|respiratory failure]]`, a `==key point==`, a teaching tip or a figure. That is most of what "link cards to things" turns out to mean. **A deck is reachable from the question.** Beside the topic-reading chip under the correct answer, one chip per linked deck. Read from the question's end only, deliberately: a card that listed the questions it belongs to would hand a learner revising the deck the shape of the exam, and the answer with it. Migration m3d4e5f6a7b8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
ddd8c4c4e3
commit
04abd78380
9 changed files with 367 additions and 9 deletions
46
backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py
Normal file
46
backend/alembic/versions/m3d4e5f6a7b8_flashcard_reviews.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Cards remember when they were last answered
|
||||||
|
|
||||||
|
"Known" and "to review" were React state: they vanished on reload, so a deck of
|
||||||
|
two hundred was two hundred cards every time and the only spacing was whichever
|
||||||
|
cards a learner remembered to skip. This is the log the scheduler reads.
|
||||||
|
|
||||||
|
A log rather than a row per card, because what the scheduler needs is the
|
||||||
|
latest verdict and its age — and keeping the history means a card missed three
|
||||||
|
times running can later be treated differently from one missed once, without a
|
||||||
|
migration to add the column that would have recorded it.
|
||||||
|
|
||||||
|
Revision ID: m3d4e5f6a7b8
|
||||||
|
Revises: l2c3d4e5f6a7
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "m3d4e5f6a7b8"
|
||||||
|
down_revision = "l2c3d4e5f6a7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if "flashcard_reviews" in sa.inspect(op.get_bind()).get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"flashcard_reviews",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("flashcard_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("outcome", sa.String(length=10), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_flashcard_reviews_user_id", "flashcard_reviews", ["user_id"])
|
||||||
|
op.create_index("ix_flashcard_reviews_flashcard_id", "flashcard_reviews", ["flashcard_id"])
|
||||||
|
# The scheduler asks "this learner's latest verdict per card", which is this
|
||||||
|
# index read backwards.
|
||||||
|
op.create_index("ix_flashcard_reviews_recent", "flashcard_reviews",
|
||||||
|
["user_id", "flashcard_id", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("flashcard_reviews")
|
||||||
|
|
@ -9,7 +9,9 @@ from app.models.favorite import Favorite
|
||||||
from app.models.user_note import UserNote
|
from app.models.user_note import UserNote
|
||||||
from app.models.lab_reference import LabReference, LabReferenceCardLink
|
from app.models.lab_reference import LabReference, LabReferenceCardLink
|
||||||
from app.models.article import Article, ArticleTopicClaim, QuestionArticleLink
|
from app.models.article import Article, ArticleTopicClaim, QuestionArticleLink
|
||||||
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
|
from app.models.flashcard import (FlashcardDeck, Flashcard, FlashcardDeckRating,
|
||||||
|
FlashcardQuestionLink, FlashcardArticleLink,
|
||||||
|
FlashcardReview)
|
||||||
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||||
from app.models.collection import UserCollection, UserCollectionQuestion
|
from app.models.collection import UserCollection, UserCollectionQuestion
|
||||||
|
|
||||||
|
|
@ -33,6 +35,7 @@ __all__ = [
|
||||||
"Flashcard",
|
"Flashcard",
|
||||||
"FlashcardDeckRating",
|
"FlashcardDeckRating",
|
||||||
"FlashcardQuestionLink",
|
"FlashcardQuestionLink",
|
||||||
|
"FlashcardReview",
|
||||||
"FlashcardArticleLink",
|
"FlashcardArticleLink",
|
||||||
"QuestionCategory",
|
"QuestionCategory",
|
||||||
"QuestionCategoryLink",
|
"QuestionCategoryLink",
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,34 @@ class Flashcard(Base, Embeddable):
|
||||||
deck = relationship("FlashcardDeck", back_populates="cards")
|
deck = relationship("FlashcardDeck", back_populates="cards")
|
||||||
|
|
||||||
|
|
||||||
|
class FlashcardReview(Base):
|
||||||
|
"""One verdict on one card, kept so the deck can come back at the right time.
|
||||||
|
|
||||||
|
Cards had no memory at all: "known" and "to review" were React state that
|
||||||
|
vanished on reload, so a deck of two hundred was two hundred cards every
|
||||||
|
time and the only spacing was whichever ones you happened to remember to
|
||||||
|
skip.
|
||||||
|
|
||||||
|
A log rather than a row per card. What the scheduler needs is the *latest*
|
||||||
|
verdict and how long ago it was, which a log gives; and keeping the history
|
||||||
|
means a card answered wrongly three times in a row can eventually be
|
||||||
|
treated differently from one missed once, without a migration to add the
|
||||||
|
column that would have recorded it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "flashcard_reviews"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True)
|
||||||
|
#: "known" or "again". Two outcomes, because a self-graded scale of four
|
||||||
|
#: asks a learner to rate their own recall on a scale they have not
|
||||||
|
#: calibrated, and the extra resolution is noise.
|
||||||
|
outcome = Column(String(10), nullable=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
class FlashcardQuestionLink(Base):
|
class FlashcardQuestionLink(Base):
|
||||||
__tablename__ = "flashcard_question_links"
|
__tablename__ = "flashcard_question_links"
|
||||||
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
|
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
"""Flashcard decks and cards — generate, browse, edit, delete, and schedule."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -16,6 +17,7 @@ from app.models.question import Question
|
||||||
from app.models.section import Section
|
from app.models.section import Section
|
||||||
from app.models.question_category import QuestionCategory
|
from app.models.question_category import QuestionCategory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services import card_review
|
||||||
from app.services.quiz_builder import category_descendants
|
from app.services.quiz_builder import category_descendants
|
||||||
from app.services.search_service import hybrid_ids
|
from app.services.search_service import hybrid_ids
|
||||||
from app.services.quiz_builder import bank_question_predicate
|
from app.services.quiz_builder import bank_question_predicate
|
||||||
|
|
@ -244,6 +246,71 @@ def get_flashcard_deck(
|
||||||
return deck
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
class CardVerdict(BaseModel):
|
||||||
|
"""What a learner said about a card. Two answers, deliberately."""
|
||||||
|
|
||||||
|
outcome: Literal["known", "again"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{deck_id}/study")
|
||||||
|
def study_deck(deck_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
"""The deck in the order it should be sat, with what is due said out loud.
|
||||||
|
|
||||||
|
Ordering happens here rather than in the page because it needs this
|
||||||
|
learner's history, and a page that fetched the whole review log to sort
|
||||||
|
twenty cards would be downloading a year of answers to draw one screen.
|
||||||
|
"""
|
||||||
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
||||||
|
if not deck:
|
||||||
|
raise HTTPException(status_code=404, detail="Deck not found")
|
||||||
|
if deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your deck")
|
||||||
|
|
||||||
|
cards = db.query(Flashcard).filter(Flashcard.deck_id == deck_id).order_by(Flashcard.id).all()
|
||||||
|
ordered, counts = card_review.study_order(db, current_user.id, cards)
|
||||||
|
return {
|
||||||
|
"deck": {"id": deck.id, "title": deck.title, "card_count": len(cards)},
|
||||||
|
**counts,
|
||||||
|
"cards": [{"id": card.id, "front": card.front, "back": card.back,
|
||||||
|
"image_path": card.image_path} for card in ordered],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cards/{card_id}/review")
|
||||||
|
def review_card(card_id: int, data: CardVerdict, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
"""Record how a card went, so the deck can come back at the right time."""
|
||||||
|
card = db.get(Flashcard, card_id)
|
||||||
|
if not card:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
deck = db.get(FlashcardDeck, card.deck_id)
|
||||||
|
if deck and deck.user_id != current_user.id and not current_user.is_admin and not deck.is_shared:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your deck")
|
||||||
|
card_review.record(db, current_user.id, card_id, data.outcome)
|
||||||
|
return {"card_id": card_id, "outcome": data.outcome}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/questions/{question_id}/cards")
|
||||||
|
def cards_for_question(question_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)):
|
||||||
|
"""The cards an educator tied to this question.
|
||||||
|
|
||||||
|
The link is read from the question's end only. A card that listed the
|
||||||
|
questions it belongs to would hand a learner revising the deck the shape of
|
||||||
|
the exam — and the answer, since a card's back is an answer.
|
||||||
|
"""
|
||||||
|
rows = db.query(Flashcard, FlashcardDeck).join(
|
||||||
|
FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).join(
|
||||||
|
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
|
||||||
|
FlashcardQuestionLink.question_id == question_id,
|
||||||
|
FlashcardDeck.deleted_at.is_(None)).all()
|
||||||
|
return [{"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
|
||||||
|
"front": card.front}
|
||||||
|
for card, deck in rows
|
||||||
|
if deck.user_id == current_user.id or deck.is_shared or current_user.is_admin]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{deck_id}", status_code=204)
|
@router.delete("/{deck_id}", status_code=204)
|
||||||
def delete_flashcard_deck(
|
def delete_flashcard_deck(
|
||||||
deck_id: int,
|
deck_id: int,
|
||||||
|
|
|
||||||
93
backend/app/services/card_review.py
Normal file
93
backend/app/services/card_review.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""When a card should come round again.
|
||||||
|
|
||||||
|
The same arithmetic the question player uses, pointed at cards, and for the
|
||||||
|
same reason: two schedulers with two ideas of what "due" means, in one product
|
||||||
|
that shows a learner one readiness number, is how the number stops meaning
|
||||||
|
anything.
|
||||||
|
|
||||||
|
So this imports the constants rather than choosing its own. A card answered
|
||||||
|
correctly decays past `DUE_RECALL` at about three and a half weeks; a card
|
||||||
|
answered wrongly is below it immediately. There is no separate ease factor and
|
||||||
|
no four-point self-grading scale: a scale asks a learner to rate their own
|
||||||
|
recall on units they have never calibrated, and the extra resolution is noise
|
||||||
|
dressed as precision.
|
||||||
|
|
||||||
|
What a study session is, then: everything due, oldest evidence first, then
|
||||||
|
cards never seen, then — only if the deck is short of both — the rest, so that
|
||||||
|
opening a deck always gives you a deck.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.flashcard import Flashcard, FlashcardReview
|
||||||
|
from app.services.quiz_builder import DUE_RECALL, recall_probability
|
||||||
|
|
||||||
|
KNOWN = "known"
|
||||||
|
AGAIN = "again"
|
||||||
|
OUTCOMES = (KNOWN, AGAIN)
|
||||||
|
|
||||||
|
|
||||||
|
def latest(db: Session, user_id: int, card_ids) -> dict[int, tuple[bool, float]]:
|
||||||
|
"""Each card's most recent verdict and how many days ago, for this learner.
|
||||||
|
|
||||||
|
One query for the whole deck, not one per card: a deck of two hundred is
|
||||||
|
two hundred round trips otherwise, which is how a study page ends up
|
||||||
|
waiting a second before it can draw anything.
|
||||||
|
"""
|
||||||
|
if not card_ids:
|
||||||
|
return {}
|
||||||
|
now = datetime.utcnow()
|
||||||
|
newest = db.query(
|
||||||
|
FlashcardReview.flashcard_id.label("card"),
|
||||||
|
func.max(FlashcardReview.id).label("row"),
|
||||||
|
).filter(
|
||||||
|
FlashcardReview.user_id == user_id,
|
||||||
|
FlashcardReview.flashcard_id.in_(card_ids),
|
||||||
|
).group_by(FlashcardReview.flashcard_id).subquery()
|
||||||
|
|
||||||
|
rows = db.query(FlashcardReview).join(newest, FlashcardReview.id == newest.c.row).all()
|
||||||
|
out = {}
|
||||||
|
for row in rows:
|
||||||
|
when = row.created_at or now
|
||||||
|
age = max(0.0, (now - when).total_seconds() / 86400.0)
|
||||||
|
out[row.flashcard_id] = (row.outcome == KNOWN, age)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def study_order(db: Session, user_id: int, cards: list[Flashcard]) -> tuple[list[Flashcard], dict]:
|
||||||
|
"""The deck in the order it should be sat, and what to say about it.
|
||||||
|
|
||||||
|
Due first — the most decayed first inside that — then never seen, then
|
||||||
|
everything else. A learner who has met the whole deck recently still gets a
|
||||||
|
deck rather than an empty screen saying come back on Thursday, because a
|
||||||
|
card they choose to look at again is not a mistake to prevent.
|
||||||
|
"""
|
||||||
|
history = latest(db, user_id, [card.id for card in cards])
|
||||||
|
due, unseen, rest = [], [], []
|
||||||
|
for card in cards:
|
||||||
|
seen = history.get(card.id)
|
||||||
|
if seen is None:
|
||||||
|
unseen.append(card)
|
||||||
|
continue
|
||||||
|
recall = recall_probability(seen[0], seen[1])
|
||||||
|
(due if recall < DUE_RECALL else rest).append((recall, card))
|
||||||
|
|
||||||
|
due.sort(key=lambda pair: pair[0])
|
||||||
|
rest.sort(key=lambda pair: -pair[0])
|
||||||
|
ordered = [card for _, card in due] + unseen + [card for _, card in rest]
|
||||||
|
return ordered, {
|
||||||
|
"due": len(due),
|
||||||
|
"unseen": len(unseen),
|
||||||
|
"settled": len(rest),
|
||||||
|
"reviewed": len(history),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record(db: Session, user_id: int, card_id: int, outcome: str) -> FlashcardReview:
|
||||||
|
row = FlashcardReview(user_id=user_id, flashcard_id=card_id, outcome=outcome)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
@ -11,20 +11,32 @@ import api from '../api/client'
|
||||||
*/
|
*/
|
||||||
export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
|
export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
|
||||||
const [links, setLinks] = useState(null)
|
const [links, setLinks] = useState(null)
|
||||||
|
//: Cards tied to this question, read from the question's end only. A card
|
||||||
|
//: that listed the questions it belongs to would hand a learner revising the
|
||||||
|
//: deck the shape of the exam — and the answer, since a card's back is one.
|
||||||
|
const [cards, setCards] = useState([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!questionId) { setLinks([]); return }
|
if (!questionId) { setLinks([]); setCards([]); return }
|
||||||
api.get(`/questions/${questionId}/articles`)
|
api.get(`/questions/${questionId}/articles`)
|
||||||
.then(res => setLinks(res.data)).catch(() => setLinks([]))
|
.then(res => setLinks(res.data)).catch(() => setLinks([]))
|
||||||
|
api.get(`/flashcards/questions/${questionId}/cards`)
|
||||||
|
.then(res => setCards(res.data || [])).catch(() => setCards([]))
|
||||||
}, [questionId])
|
}, [questionId])
|
||||||
if (!links || links.length === 0) return null
|
if ((!links || links.length === 0) && cards.length === 0) return null
|
||||||
|
|
||||||
// Under the right answer in the player, the same links are a row of chips —
|
// Under the right answer in the player, the same links are a row of chips —
|
||||||
// one control, plainly a door to an article — rather than a headed list,
|
// one control, plainly a door to an article — rather than a headed list,
|
||||||
// which at that point in the page reads as another section of explanation.
|
// which at that point in the page reads as another section of explanation.
|
||||||
if (variant === 'chips') {
|
if (variant === 'chips') {
|
||||||
|
// Decks are listed once each, however many of their cards are tied here:
|
||||||
|
// three chips saying "Board Review" is three doors to the same room.
|
||||||
|
const decks = []
|
||||||
|
for (const card of cards) {
|
||||||
|
if (!decks.some(deck => deck.deck_id === card.deck_id)) decks.push(card)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="question-reading-chips" data-testid="question-reading">
|
<div className="question-reading-chips" data-testid="question-reading">
|
||||||
{links.map(link => (
|
{(links || []).map(link => (
|
||||||
<Link key={`${link.article_id}-${link.section_id || 'all'}`}
|
<Link key={`${link.article_id}-${link.section_id || 'all'}`}
|
||||||
className="question-reading-chip"
|
className="question-reading-chip"
|
||||||
to={link.section_id ? `/articles/${link.article_id}?section=${link.section_id}` : `/articles/${link.article_id}`}>
|
to={link.section_id ? `/articles/${link.article_id}?section=${link.section_id}` : `/articles/${link.article_id}`}>
|
||||||
|
|
@ -32,6 +44,13 @@ export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
|
||||||
{link.section_title || link.title}
|
{link.section_title || link.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{decks.map(card => (
|
||||||
|
<Link key={`deck-${card.deck_id}`} className="question-reading-chip is-cards"
|
||||||
|
to={`/flashcards/${card.deck_id}/study`}>
|
||||||
|
<span aria-hidden="true">▦</span>
|
||||||
|
{card.deck_title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
import RichText from '../components/RichText'
|
||||||
|
|
||||||
export default function FlashcardStudyPage() {
|
export default function FlashcardStudyPage() {
|
||||||
const { deckId } = useParams()
|
const { deckId } = useParams()
|
||||||
|
|
@ -14,10 +15,18 @@ export default function FlashcardStudyPage() {
|
||||||
const [mode, setMode] = useState('all') // 'all' | 'review'
|
const [mode, setMode] = useState('all') // 'all' | 'review'
|
||||||
const [shuffled, setShuffled] = useState(false)
|
const [shuffled, setShuffled] = useState(false)
|
||||||
const [cardOrder, setCardOrder] = useState([])
|
const [cardOrder, setCardOrder] = useState([])
|
||||||
|
//: What the deck looked like when it was opened: due, never seen, settled.
|
||||||
|
//: Not recomputed as the session goes — a counter that falls as you work is
|
||||||
|
//: encouraging, one that rearranges itself under you is not.
|
||||||
|
const [counts, setCounts] = useState({ due: 0, unseen: 0, settled: 0 })
|
||||||
|
|
||||||
|
// The order comes from the server, because it depends on this learner's own
|
||||||
|
// history: what is due first, then what has never been seen, then the rest.
|
||||||
|
// Sorting here would mean downloading a year of answers to draw one screen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get(`/flashcards/${deckId}`).then(res => {
|
api.get(`/flashcards/${deckId}/study`).then(res => {
|
||||||
setDeck(res.data)
|
setDeck({ ...res.data.deck, cards: res.data.cards })
|
||||||
|
setCounts({ due: res.data.due, unseen: res.data.unseen, settled: res.data.settled })
|
||||||
setCardOrder(res.data.cards.map((_, i) => i))
|
setCardOrder(res.data.cards.map((_, i) => i))
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => navigate('/flashcards'))
|
}).catch(() => navigate('/flashcards'))
|
||||||
|
|
@ -39,10 +48,19 @@ export default function FlashcardStudyPage() {
|
||||||
if (currentIdx > 0) setCurrentIdx(i => i - 1)
|
if (currentIdx > 0) setCurrentIdx(i => i - 1)
|
||||||
}, [currentIdx])
|
}, [currentIdx])
|
||||||
|
|
||||||
|
// Both verdicts are sent as well as held: the set in this component is what
|
||||||
|
// draws the progress bar, and the row on the server is what decides when the
|
||||||
|
// card comes round again. It was only ever the first, so a deck of two
|
||||||
|
// hundred was two hundred cards every time.
|
||||||
|
const record = (cardId, outcome) => {
|
||||||
|
api.post(`/flashcards/cards/${cardId}/review`, { outcome }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const markKnown = () => {
|
const markKnown = () => {
|
||||||
if (currentCard) {
|
if (currentCard) {
|
||||||
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
||||||
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
||||||
|
record(currentCard.id, 'known')
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +69,7 @@ export default function FlashcardStudyPage() {
|
||||||
if (currentCard) {
|
if (currentCard) {
|
||||||
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
||||||
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
||||||
|
record(currentCard.id, 'again')
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +122,8 @@ export default function FlashcardStudyPage() {
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
|
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||||
{cards.length} cards · {known.size} known · {review.size} to review
|
{cards.length} cards · {counts.due} due · {counts.unseen} new
|
||||||
|
{known.size > 0 && ` · ${known.size} answered`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
|
@ -156,7 +176,11 @@ export default function FlashcardStudyPage() {
|
||||||
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
|
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
|
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
|
||||||
{flipped ? currentCard.back : currentCard.front}
|
{/* Rendered, not printed. A card's two faces are prose like
|
||||||
|
everything else here, so `[[264|respiratory failure]]`,
|
||||||
|
`==key points==` and a figure all work on a card — which is
|
||||||
|
most of what "link cards to things" turns out to mean. */}
|
||||||
|
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
|
||||||
</p>
|
</p>
|
||||||
{!flipped && (
|
{!flipped && (
|
||||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
|
||||||
|
|
|
||||||
71
frontend/src/pages/FlashcardStudyPage.test.jsx
Normal file
71
frontend/src/pages/FlashcardStudyPage.test.jsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import FlashcardStudyPage from './FlashcardStudyPage'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
||||||
|
vi.mock('../utils/uploads', () => ({
|
||||||
|
markdownImageUrl: (src) => `/uploads/${src}`,
|
||||||
|
uploadUrl: (src, attemptId, width) => `/uploads/${src}${width ? `?w=${width}` : ''}`,
|
||||||
|
THUMB_WIDTHS: [256, 640],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const DECK = {
|
||||||
|
deck: { id: 3, title: 'Board Review', card_count: 2 },
|
||||||
|
due: 1, unseen: 1, settled: 0,
|
||||||
|
cards: [
|
||||||
|
{ id: 11, front: 'Hereditary angioedema', back: 'Low **C1 inhibitor**; see [[264|respiratory failure]].' },
|
||||||
|
{ id: 12, front: 'Second card', back: 'Second back' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const mount = () => render(
|
||||||
|
<MemoryRouter initialEntries={['/flashcards/3/study']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
||||||
|
<Route path="/flashcards" element={<h1>Decks</h1>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
api.get.mockResolvedValue({ data: DECK })
|
||||||
|
api.post.mockResolvedValue({ data: {} })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks the server for the order and says what is due', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByText('Board Review')
|
||||||
|
// Ordering depends on this learner's own history, so the server does it —
|
||||||
|
// sorting here would mean downloading a year of answers to draw one screen.
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/flashcards/3/study')
|
||||||
|
expect(screen.getByText(/1 due/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/1 new/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remembers a verdict on the server, not just in the page', async () => {
|
||||||
|
mount()
|
||||||
|
// The verdicts appear once the card is turned over: there is nothing to say
|
||||||
|
// about a card whose answer you have not seen.
|
||||||
|
await userEvent.click(await screen.findByText('Hereditary angioedema'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Got it/ }))
|
||||||
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||||
|
'/flashcards/cards/11/review', { outcome: 'known' }))
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Second card'))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Review again/ }))
|
||||||
|
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
|
||||||
|
'/flashcards/cards/12/review', { outcome: 'again' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a card face as prose, so a card can carry a link', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByText('Hereditary angioedema')
|
||||||
|
await userEvent.click(screen.getByText('Hereditary angioedema'))
|
||||||
|
const back = await screen.findByText(/Low/)
|
||||||
|
expect(within(back.closest('.rich-text')).getByText('C1 inhibitor').tagName).toBe('STRONG')
|
||||||
|
expect(screen.getByText('respiratory failure')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
@ -956,3 +956,10 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.question-reading-chips { margin-left: 0; }
|
.question-reading-chips { margin-left: 0; }
|
||||||
}
|
}
|
||||||
|
/* A deck chip sits beside the reading chips and is told apart by its icon
|
||||||
|
rather than by a heading over a second group: they are the same offer — the
|
||||||
|
thing to go and read next — and two labelled sections would make a decision
|
||||||
|
out of it. */
|
||||||
|
.question-reading-chip.is-cards span { color: #8a6417; }
|
||||||
|
.question-reading-chip.is-cards:hover { border-color: #b8860b; color: #8a6417; }
|
||||||
|
.question-reading-chip.is-cards:hover span { color: #8a6417; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue