From d34233d87b3d296884ca41f6ae11bc39b37802a7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 02:59:32 +0200 Subject: [PATCH] feat: attending tip, per-question notes, save to folder, session clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question toolbar now carries what a learner actually reaches for. An attending tip — one sentence of the kind said at the bedside, stored separately from the explanation because it is read before the answer is known and must not give it away. A note of their own on that question, replacing a single global note that was one page for everything and so was never about the question in front of you. Saving to a folder, which the collections API has supported all along with nothing in the player able to call it. And the share link, which previously only appeared on the start screen. Panels open one at a time under the toolbar; two at once would push the options off screen. Reset question resets one question, not the attempt: a misclick should cost the answer you just gave, not the nineteen before it. The clock shows session time, time on this question and the running average, in study mode as well as exam mode — four minutes on one question is the number that says whether you are learning or stuck, countdown or no countdown. It pauses, because time spent making tea is not time spent thinking. 208 backend, 249 frontend green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../d8e9f0a1b2c3_question_tips_and_notes.py | 47 +++++ backend/app/models/question.py | 3 + backend/app/models/user_note.py | 20 +- backend/app/routers/questions.py | 48 ++++- frontend/src/pages/QuizPage.jsx | 190 ++++++++++++++++++ frontend/src/pages/QuizPlayer.css | 59 ++++++ 6 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/d8e9f0a1b2c3_question_tips_and_notes.py diff --git a/backend/alembic/versions/d8e9f0a1b2c3_question_tips_and_notes.py b/backend/alembic/versions/d8e9f0a1b2c3_question_tips_and_notes.py new file mode 100644 index 0000000..fa1c386 --- /dev/null +++ b/backend/alembic/versions/d8e9f0a1b2c3_question_tips_and_notes.py @@ -0,0 +1,47 @@ +"""An attending's one-line pearl, and a learner's own note on a question. + +Revision ID: d8e9f0a1b2c3 +Revises: c7d8e9f0a1b2 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "d8e9f0a1b2c3" +down_revision = "c7d8e9f0a1b2" +branch_labels = None +depends_on = None + + +def _has(table: str, column: str) -> bool: + return column in {c["name"] for c in inspect(op.get_bind()).get_columns(table)} + + +def upgrade(): + # The sentence an attending would say at the bedside — one idea, not a + # summary. Separate from the explanation because it is read before the + # answer is known, and must therefore never give it away. + if not _has("questions", "attending_tip"): + op.add_column("questions", sa.Column("attending_tip", sa.Text, nullable=True)) + + if "question_notes" not in inspect(op.get_bind()).get_table_names(): + op.create_table( + "question_notes", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("question_id", sa.Integer, + sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("content", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + # A note belongs to one learner on one question; a second note on the + # same question is an edit of the first, not another note. + sa.UniqueConstraint("user_id", "question_id", name="uq_question_note"), + ) + + +def downgrade(): + op.drop_table("question_notes") + if _has("questions", "attending_tip"): + op.drop_column("questions", "attending_tip") diff --git a/backend/app/models/question.py b/backend/app/models/question.py index d07cd90..319a786 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -27,6 +27,9 @@ class Question(Base): explanation_image_path = Column(String, nullable=True) option_explanations = Column(JSON, nullable=True) # {option_text: explanation} key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links + # One sentence an attending would say at the bedside. Read before the answer + # is known, so it must point at the thinking without giving the answer away. + attending_tip = Column(Text, nullable=True) difficulty = Column(String(10), nullable=True) # easy | medium | hard user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it) diff --git a/backend/app/models/user_note.py b/backend/app/models/user_note.py index 473b869..f906bf1 100644 --- a/backend/app/models/user_note.py +++ b/backend/app/models/user_note.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text +from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text, UniqueConstraint from sqlalchemy.orm import relationship from app.database import Base @@ -16,3 +16,21 @@ class UserNote(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) user = relationship("User", back_populates="note") + + +class QuestionNote(Base): + """A learner's own note on one question. + + Separate from the single global note, which was one page for everything and + so was never about the question in front of you. + """ + + __tablename__ = "question_notes" + __table_args__ = (UniqueConstraint("user_id", "question_id", name="uq_question_note"),) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True) + content = Column(Text, nullable=False, default="") + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index aa54ee3..e97ba71 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -83,6 +83,7 @@ class QuestionEdit(BaseModel): additional_category_ids: list[int] | None = None # Full set of extra (non-primary) categories. option_explanations: dict | None = None key_points: list | None = None + attending_tip: str | None = None difficulty: Literal['easy', 'medium', 'hard'] | None = None image_path: str | None = None explanation_image_path: str | None = None @@ -384,6 +385,7 @@ def get_question_bank( "explanation_image_path": qu.explanation_image_path, "option_explanations": qu.option_explanations, "key_points": qu.key_points, + "attending_tip": qu.attending_tip, "difficulty": qu.difficulty, "user_id": qu.user_id, "is_shared": qu.is_shared if qu.is_shared is not None else 1, @@ -415,6 +417,7 @@ class ManualQuestionCreate(BaseModel): question_category_id: int | None = None option_explanations: dict | None = None key_points: list | None = None + attending_tip: str | None = None difficulty: Literal['easy', 'medium', 'hard'] | None = None image_path: str | None = None explanation_image_path: str | None = None @@ -453,6 +456,7 @@ def create_question_manually( explanation_image_path=images["explanation_image_path"], option_explanations=option_explanations, key_points=key_points, + attending_tip=(data.attending_tip or None), difficulty=data.difficulty, user_id=current_user.id, is_shared=1, @@ -708,7 +712,7 @@ def bulk_question_action( MAX_VERSIONS = 5 VERSIONED_FIELDS = ("question_text", "question_type", "options", "correct_answer", - "explanation", "option_explanations", "key_points", "difficulty", + "explanation", "option_explanations", "key_points", "attending_tip", "difficulty", "question_category_id", "image_path", "explanation_image_path") @@ -732,6 +736,47 @@ def _snapshot_question(db, question, user_id) -> None: ).delete(synchronize_session=False) +class QuestionNoteIn(BaseModel): + content: str = Field(default="", max_length=8000) + + +@router.get("/detail/{question_id}/note") +def read_question_note(question_id: int, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """This learner's own note on this question.""" + from app.models.user_note import QuestionNote + + row = db.query(QuestionNote).filter_by( + user_id=current_user.id, question_id=question_id).first() + return {"question_id": question_id, "content": row.content if row else "", + "updated_at": row.updated_at if row else None} + + +@router.put("/detail/{question_id}/note") +def write_question_note(question_id: int, data: QuestionNoteIn, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """Save or clear it. Empty means delete, so an emptied note leaves no trace.""" + from app.models.user_note import QuestionNote + + if not db.query(Question.id).filter(Question.id == question_id).first(): + raise HTTPException(404, "Question not found") + row = db.query(QuestionNote).filter_by( + user_id=current_user.id, question_id=question_id).first() + content = data.content.strip() + if not content: + if row: + db.delete(row) + db.commit() + return {"question_id": question_id, "content": ""} + if row: + row.content = content + else: + db.add(QuestionNote(user_id=current_user.id, question_id=question_id, content=content)) + db.commit() + return {"question_id": question_id, "content": content} + + @router.get("/detail/{question_id}/versions") def list_question_versions( question_id: int, @@ -808,6 +853,7 @@ def get_question_detail( "explanation": question.explanation, "option_explanations": question.option_explanations, "key_points": question.key_points, + "attending_tip": question.attending_tip, "difficulty": question.difficulty, "question_category_id": question.question_category_id, "question_category_name": category.name if category else None, diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 7c5df95..5ac715c 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -199,6 +199,46 @@ function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange ) } +const clock = (seconds) => { + const s = Math.max(0, Math.round(seconds)) + return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` +} + +/** + * Session time, time on this question, and the running average. + * + * Shown in study mode as well as exam mode: knowing you have spent four minutes + * on one question is exactly as useful when nothing is counting down, and it is + * the number that tells you whether you are learning or stuck. It can be paused, + * because time spent making tea is not time spent thinking. + */ +function SessionClock({ sessionSeconds, questionSeconds, answered, paused, onTogglePause }) { + const average = answered > 0 ? sessionSeconds / answered : null + return ( +
+ + + {Math.floor(sessionSeconds / 3600)}h {String(Math.floor((sessionSeconds % 3600) / 60)).padStart(2, '0')}m + session + + + {clock(questionSeconds)} + question + + {average != null && ( + + {clock(average)} + average + + )} + {paused && paused} +
+ ) +} + function TimerDisplay({ seconds, total }) { const pct = total > 0 ? (seconds / total) * 100 : 100 const mins = Math.floor(seconds / 60), secs = seconds % 60 @@ -354,11 +394,23 @@ export default function QuizPage() { const [expandedImagePath, setExpandedImagePath] = useState('') const [imageZoom, setImageZoom] = useState(1) const [startedAt, setStartedAt] = useState(null) + // Wall-clock seconds spent in this session and on the question in front of + // you. Kept here rather than derived from startedAt so pausing can stop them. + const [sessionSeconds, setSessionSeconds] = useState(0) + const [questionSeconds, setQuestionSeconds] = useState(0) + const [clockPaused, setClockPaused] = useState(false) const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) const [manualHighlights, setManualHighlights] = useState({}) const [draftAnswer, setDraftAnswer] = useState('') const [tool, setTool] = useState(null) + // Which of the per-question panels is open. One at a time: they sit in the + // same place under the stem, and two at once would push the options off screen. + const [panel, setPanel] = useState(null) + const [note, setNote] = useState('') + const [noteSaved, setNoteSaved] = useState(true) + const [folders, setFolders] = useState([]) + const [shareUrl, setShareUrl] = useState('') const [showReview, setShowReview] = useState(false) const [responseStats, setResponseStats] = useState(null) const [statsError, setStatsError] = useState('') @@ -710,7 +762,64 @@ const timerStarted = timeLeft !== null } }, [attemptId, quizMode, saveProgressNow]) + useEffect(() => { + if (clockPaused || !attemptId) return + const tick = setInterval(() => { + setSessionSeconds(v => v + 1) + setQuestionSeconds(v => v + 1) + }, 1000) + return () => clearInterval(tick) + }, [clockPaused, attemptId]) + + // Time on *this* question restarts when you move to another one. + useEffect(() => { setQuestionSeconds(0) }, [current?.id]) + + useEffect(() => { + if (!current?.id) return + let live = true + setPanel(null) + api.get(`/questions/detail/${current.id}/note`) + .then(res => { if (live) { setNote(res.data?.content || ''); setNoteSaved(true) } }) + .catch(() => { if (live) { setNote(''); setNoteSaved(true) } }) + return () => { live = false } + }, [current?.id]) + + useEffect(() => { + api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([])) + }, []) + + useEffect(() => { + setShareUrl(quiz?.share_token ? `${window.location.origin}/share/${quiz.share_token}` : '') + }, [quiz?.share_token]) + const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) + + // Reset one question rather than the whole attempt: a misclick should cost + // the answer you just gave, not the nineteen before it. + const resetQuestion = (questionId) => { + setAnswers(prev => { + const next = { ...prev } + delete next[questionId] + return next + }) + setDraftAnswer('') + } + + const saveNote = async (questionId, content) => { + setNoteSaved(false) + try { + await api.put(`/questions/detail/${questionId}/note`, { content }) + setNoteSaved(true) + } catch { setNoteSaved(false) } + } + + const saveToFolder = async (collectionId, questionId) => { + try { + await api.put(`/collections/${collectionId}/questions/${questionId}`) + setFolders(list => list.map(f => (f.id === collectionId + ? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f))) + } catch { /* the row stays unticked, which is the honest signal */ } + } const chooseAnswer = value => { if (!current || (isStudy && answers[current.id])) return if (isStudy) setDraftAnswer(value) @@ -1115,6 +1224,23 @@ const timerStarted = timeLeft !== null />
+ {current.attending_tip && ( + + )} + +
+ + {/* The panels sit under the toolbar, where the eye already is, and + only one opens at a time: two would push the options off screen. */} + {panel === 'tip' && current.attending_tip && ( +
+ + +
+ )} + + {panel === 'note' && ( +
+