From bfc5ec93d7e78e3c2f5bb6444607aa4c6c91cf9f Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 02:02:16 +0200 Subject: [PATCH] feat: an educator can actually write a tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps closed. `attending_tip` has been on the question model and in the API since before this session, rendered by the player — and there was no field anywhere in the UI to put anything in it. The question editor now has one, at the foot of the Explanation card. And the inline kind is written by selecting the words: select "stridor" in the stem, press ⚕ Tip on that field's toolbar, and the caret waits where the tip goes. With nothing selected it drops in a marker with "phrase" highlighted to type over. A question with no tip offers no button — that was already true in the player, and is now pinned by a test rather than left to hold by accident. An emptied tip saves as null for the same reason. The handbook says how to do both, and what a tip costs the learner: opened before answering it is recorded with the answer and shown as "correct after a tip"; opened while reading the explanation it is revision and costs nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/MarkdownToolbar.jsx | 22 +++++- .../src/components/MarkdownToolbar.test.jsx | 23 ++++++- frontend/src/pages/HandbookPage.jsx | 29 +++++++- frontend/src/pages/QuestionEditPage.css | 2 + frontend/src/pages/QuestionEditPage.jsx | 22 +++++- frontend/src/pages/QuestionEditPage.test.jsx | 68 +++++++++++++++++++ frontend/src/pages/QuizPage.test.jsx | 23 +++++++ 7 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/QuestionEditPage.test.jsx diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx index 25ddacd..5d7c9dc 100644 --- a/frontend/src/components/MarkdownToolbar.jsx +++ b/frontend/src/components/MarkdownToolbar.jsx @@ -18,6 +18,11 @@ import './MarkdownToolbar.css' const WRAP = 'wrap' const LINE = 'line' const BLOCK = 'block' +const TIP = 'tip' + +//: What a tip reads as before anything has been typed into it. +const TIP_PHRASE = 'phrase' +const TIP_TEXT = 'the teaching point' const ACTIONS = [ { key: 'bold', label: 'B', title: 'Bold', kind: WRAP, before: '**', after: '**', style: { fontWeight: 800 } }, @@ -31,6 +36,8 @@ const ACTIONS = [ text: '\n| Test | Value | Reference |\n| --- | --- | --- |\n| | | |\n', }, { key: 'math', label: '∑', title: 'Inline maths', kind: WRAP, before: '$', after: '$' }, + // Select the words first; the tip is what you type next. + { key: 'tip', label: '⚕ Tip', title: 'Turn the selected words into a tip', kind: TIP }, ] export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) { @@ -42,6 +49,8 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = const text = value || '' let next = text let caret = end + // Where to leave the selection, when landing the caret is not enough. + let select = null if (action.kind === WRAP) { const selected = text.slice(start, end) @@ -63,6 +72,17 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = const replaced = lines.join('\n') next = text.slice(0, lineStart) + replaced + text.slice(stop) caret = lineStart + replaced.length + } else if (action.kind === TIP) { + // With words selected, they become the underlined phrase and the caret + // waits where the tip goes. With nothing selected there is a placeholder + // to type over, selected so the first keystroke replaces it. + const selected = text.slice(start, end) + const phrase = selected || TIP_PHRASE + const tip = selected ? '' : TIP_TEXT + next = `${text.slice(0, start)}{{${phrase}|${tip}}}${text.slice(end)}` + const tipAt = start + 2 + phrase.length + 1 + select = selected ? [tipAt, tipAt] : [start + 2, start + 2 + phrase.length] + caret = tipAt } else { next = text.slice(0, start) + action.text + text.slice(end) caret = start + action.text.length @@ -72,7 +92,7 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = // The caret has to be restored after React has written the new value. requestAnimationFrame(() => { field.focus() - field.setSelectionRange(caret, caret) + field.setSelectionRange(...(select || [caret, caret])) }) } diff --git a/frontend/src/components/MarkdownToolbar.test.jsx b/frontend/src/components/MarkdownToolbar.test.jsx index 6524348..6f4711f 100644 --- a/frontend/src/components/MarkdownToolbar.test.jsx +++ b/frontend/src/components/MarkdownToolbar.test.jsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useRef, useState } from 'react' import MarkdownToolbar from './MarkdownToolbar' @@ -65,6 +65,27 @@ describe('markdown formatting buttons', () => { expect(box().value.startsWith('Labs:')).toBe(true) }) + it('turns the selected words into a tip, waiting where the tip goes', async () => { + render() + select(12, 19) + await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' })) + expect(box()).toHaveValue('A child has {{stridor|}} at rest.') + // The caret sits after the bar, so the next thing typed is the tip itself. + await waitFor(() => expect(box().selectionStart).toBe('A child has {{stridor|'.length)) + }) + + it('with nothing selected, offers a placeholder to type over', async () => { + render() + select(0, 0) + await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' })) + expect(box()).toHaveValue('{{phrase|the teaching point}}') + // "phrase" is selected, so the first keystroke replaces it. + await waitFor(() => { + expect(box().selectionStart).toBe(2) + expect(box().selectionEnd).toBe(8) + }) + }) + it('leaves everything else exactly as typed — no reflow', async () => { // The whole reason this is not a WYSIWYG: a stem carries learners' // highlight offsets, so untouched text must come back byte for byte. diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx index f3c35f4..126ea46 100644 --- a/frontend/src/pages/HandbookPage.jsx +++ b/frontend/src/pages/HandbookPage.jsx @@ -122,9 +122,18 @@ export default function HandbookPage() {

Tips in the prose

+

1. A tip on one phrase

+

+ In the question editor, select the words in the stem + (or an option, or the explanation) and press ⚕ Tip on + that field's toolbar. The words become the phrase and the caret waits + where the tip goes — type the sentence and you are done. With nothing + selected the button drops in a marker with phrase + highlighted to type over. +

A phrase in a stem, an option, an explanation or an article can carry - one sentence of teaching behind it. Written as{' '} + one sentence of teaching behind it. What the button writes is{' '} {'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}{' '} — the phrase first, then a bar, then the tip.

@@ -145,6 +154,24 @@ export default function HandbookPage() { braces. Anything that does not match the shape exactly is left in the text as written — a stray {'{'} in a formula is safe.

+ +

2. A tip for the whole question

+

+ The Attending tip field at the foot of the editor. + One nudge for the question as a whole, offered in the player behind a + ⚕ button above the options. Leave it empty and no button + appears — a question with nothing to say says nothing. +

+ +

What it costs the learner

+

+ Either kind, opened before the answer goes in, is recorded + with that answer. It still counts as correct and the percentage is not + docked; it is shown on Analysis as its own slice —{' '} + correct after a tip — so a learner can see how much of a + score leaned on one. A tip opened while reading the explanation is + revision and costs nothing. +

diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index f9387da..9cbae9f 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -46,6 +46,8 @@ .qe-field textarea { min-height: 200px; resize: vertical; line-height: 1.65; } .qe-field textarea.qe-stem { min-height: 340px; } .qe-field textarea.qe-explanation { min-height: 280px; } +/* Shorter than an explanation: the tip is meant to be one sentence. */ +.qe-field textarea.qe-tip { min-height: 84px; } /* ── Options ──────────────────────────────────────────────────────── */ .qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; } diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx index 0f6f5b1..d2dedd8 100644 --- a/frontend/src/pages/QuestionEditPage.jsx +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -22,7 +22,7 @@ const apiError = (err, fallback) => { const blank = () => ({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', - explanation: '', question_category_id: '', extraCategoryIds: [], option_explanations: {}, + explanation: '', attending_tip: '', question_category_id: '', extraCategoryIds: [], option_explanations: {}, difficulty: '', image_path: '', explanation_image_path: '', }) @@ -46,6 +46,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { const [picking, setPicking] = useState(null) const stemRef = useRef(null) const explanationRef = useRef(null) + const tipRef = useRef(null) // One ref per option, kept across renders so the toolbar can find the // field it belongs to when options are added or reordered. const optionRefs = useRef([]) @@ -74,6 +75,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { options: found.options ? [...found.options] : [], correct_answer: found.correct_answer || '', explanation: found.explanation || '', + attending_tip: found.attending_tip || '', question_category_id: found.question_category_id || '', extraCategoryIds: (found.category_ids || []).filter(c => c !== found.question_category_id), option_explanations: { ...(found.option_explanations || {}) }, @@ -134,6 +136,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { options: form.question_type === 'mcq' ? form.options.filter(o => o.trim()) : null, correct_answer: form.correct_answer.trim(), explanation: form.explanation || null, + attending_tip: form.attending_tip.trim() || null, question_category_id: form.question_category_id === '' ? null : Number(form.question_category_id), additional_category_ids: form.extraCategoryIds.filter(c => c !== Number(form.question_category_id)), option_explanations: form.option_explanations, @@ -302,6 +305,23 @@ export default function QuestionEditPage({ mode = 'edit' }) {