// ============================================================ // LEARNING HUB — pediatric education, pearls, and self-assessment // quizzes. Minimum-viable port: // // • Search box (keyword, posts to /api/learning/search) // • Category pills (/api/learning/categories) filter the feed // • Feed list (/api/learning/feed or /category/:slug depending on filter) // • Viewer (body rendered as pre-wrap text for now — rich HTML via // DOMPurify / Markdown lands as a follow-up; presentations link to // the legacy /#learning/:slug view since Marp rendering is its own port) // • Quiz (single / multi / true_false) + results with explanations // • Per-user progress list (last 5 attempts) // // Endpoints all live in src/routes/learningHub.ts at /api/learning/*. // ============================================================ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api'; import type { LearningCategoriesOk, LearningCategory, LearningFeedListOk, LearningFeedRow, LearningContentOk, LearningContentFull, LearningQuestion, QuizAnswer, QuizSubmitOk, } from '@/shared/types'; const card = 'rounded-lg border border-border bg-card p-5 space-y-3'; const pill = 'px-3 py-1 rounded-full text-xs font-medium border transition-colors cursor-pointer'; const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm'; const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50'; const btnGhost = 'rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50'; function typeBadge(t: string) { switch (t) { case 'quiz': return 'Quiz'; case 'pearl': return 'Pearl'; case 'presentation': return 'Slides'; default: return 'Article'; } } // ── Feed ──────────────────────────────────────────────────── function FeedCard({ row, onOpen }: { row: LearningFeedRow; onOpen: () => void }) { return ( {typeBadge(row.content_type)} {row.category_name && · {row.category_name}} {row.question_count ? · {row.question_count} Q : null} {row.title} {row.subject && {row.subject}} ); } function Feed({ filter, query, onOpen, }: { filter: string; // category slug or '' for all query: string; onOpen: (slug: string) => void; }) { const key: unknown[] = query ? ['learning-search', query] : filter ? ['learning-category', filter] : ['learning-feed']; const { data, isLoading, error } = useQuery({ queryKey: key, queryFn: () => { if (query) return api.get('/api/learning/search?q=' + encodeURIComponent(query)); if (filter) return api.get( '/api/learning/category/' + encodeURIComponent(filter), ); return api.get('/api/learning/feed?limit=30'); }, }); if (isLoading) return Loading…; if (error) return {(error as Error).message}; const rows = data?.content || []; if (rows.length === 0) return No content found.; return ( {rows.map((r) => onOpen(r.slug)} />)} ); } // ── Viewer + Quiz ─────────────────────────────────────────── type AnswerMap = Record }>; function emptyAnswers(questions: LearningQuestion[]): AnswerMap { const m: AnswerMap = {}; for (const q of questions) m[q.id] = { optionIds: new Set() }; return m; } function Quiz({ content, onReset, }: { content: LearningContentFull; onReset: () => void; }) { const qc = useQueryClient(); const [answers, setAnswers] = useState(() => emptyAnswers(content.questions)); const [result, setResult] = useState(null); const [error, setError] = useState(null); const submit = useMutation({ mutationFn: (body: { contentId: number; answers: QuizAnswer[] }) => api.post('/api/learning/submit-quiz', body), onSuccess: (data) => { setResult(data); // Refresh progress list the next time the viewer opens. qc.invalidateQueries({ queryKey: ['learning-content', content.slug] }); }, onError: (e: Error) => setError(e.message || 'Submit failed'), }); function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); const payload: QuizAnswer[] = content.questions.map((q) => { const a = answers[q.id]; if (q.question_type === 'multi') { return { questionId: q.id, optionIds: Array.from(a?.optionIds || []) }; } return { questionId: q.id, optionId: a?.optionId ?? null }; }); submit.mutate({ contentId: content.id, answers: payload }); } function selectSingle(q: LearningQuestion, optionId: number) { setAnswers((prev) => ({ ...prev, [q.id]: { optionId, optionIds: new Set() } })); } function toggleMulti(q: LearningQuestion, optionId: number) { setAnswers((prev) => { const s = new Set(prev[q.id]?.optionIds || []); if (s.has(optionId)) s.delete(optionId); else s.add(optionId); return { ...prev, [q.id]: { optionIds: s } }; }); } if (result) { const color = result.percentage >= 80 ? 'bg-green-600' : result.percentage >= 50 ? 'bg-amber-500' : 'bg-destructive'; return ( Results {result.score}/{result.total} ({result.percentage}%) {result.results.map((r, idx) => ( {r.isCorrect ? '✓' : '✗'} {' '} Q{idx + 1}: {r.questionText} {!r.isCorrect && r.correctOptionText && ( Correct: {r.correctOptionText} )} {!r.isCorrect && r.selectedExplanation && ( Why incorrect: {r.selectedExplanation} )} {r.generalExplanation && ( {r.generalExplanation} )} ))} { setResult(null); setAnswers(emptyAnswers(content.questions)); }} > Retake Back to Feed ); } return ( Quiz {content.questions.length} question{content.questions.length === 1 ? '' : 's'} {content.questions.map((q, idx) => { const isMulti = q.question_type === 'multi'; const typeLabel = q.question_type === 'true_false' ? 'True / False' : isMulti ? 'Multiple Select' : 'Single Choice'; return ( Q{idx + 1} {typeLabel} {q.question_text} {isMulti && ( Select all that apply )} {q.options.map((opt) => { const a = answers[q.id]; const checked = isMulti ? a?.optionIds.has(opt.id) === true : a?.optionId === opt.id; return ( isMulti ? toggleMulti(q, opt.id) : selectSingle(q, opt.id) } className="mt-0.5" /> {opt.option_text} ); })} ); })} {error && {error}} {submit.isPending ? 'Submitting…' : 'Submit Answers'} ); } function ContentViewer({ slug, onBack }: { slug: string; onBack: () => void }) { const { data, isLoading, error } = useQuery({ queryKey: ['learning-content', slug], queryFn: () => api.get('/api/learning/content/' + encodeURIComponent(slug)), }); if (isLoading) return Loading…; if (error) return {(error as Error).message}; if (!data) return null; const c = data.content; return ( ← Back to Feed {c.title} {typeBadge(c.content_type)} {c.category_name ? ' · ' + c.category_name : ''} {c.author_name ? ' · ' + c.author_name : ''} {c.content_type === 'presentation' ? ( 📊 {c.title} Slide rendering lives in the legacy viewer. Open in legacy viewer ) : ( {c.body || ''} )} {c.progress && c.progress.length > 0 && ( Your past attempts {c.progress.map((p, i) => { const pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0; const color = pct >= 70 ? 'text-green-600' : 'text-amber-600'; return ( {new Date(p.completed_at).toLocaleDateString()} {p.score}/{p.total} ({pct}%) ); })} )} {c.questions && c.questions.length > 0 && } ); } // ── Page shell ─────────────────────────────────────────────── export default function Learning() { const [query, setQuery] = useState(''); const [filter, setFilter] = useState(''); const [activeSlug, setActiveSlug] = useState(null); const { data: cats } = useQuery({ queryKey: ['learning-categories'], queryFn: () => api.get('/api/learning/categories'), }); if (activeSlug) { return ( setActiveSlug(null)} /> ); } return ( Learning Hub Pediatric education, clinical pearls, and self-assessment quizzes. setQuery(e.target.value)} data-testid="lh-search" /> setFilter('')} className={ pill + (filter === '' ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80') } > All {cats?.categories.map((cat) => ( setFilter(cat.slug)} className={ pill + (filter === cat.slug ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80') } data-testid={'lh-cat-' + cat.slug} > {cat.name} ))} setActiveSlug(slug)} /> ); }
Pediatric education, clinical pearls, and self-assessment quizzes.