// ============================================================ // 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 ( ); } 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}
)}
))}
); } 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 ( ); })}
); })} {error &&
{error}
}
); } 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 (

{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" />
{cats?.categories.map((cat) => ( ))}
setActiveSlug(slug)} />
); }