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' && ( +
+