pediatric-ai-scribe-v3/client/src/pages/Learning.tsx
Daniel 8d7bffb07a feat(client): port Learning Hub to React
Minimum-viable port of the user-facing Learning Hub at /app/learning.
Sidebar nav flipped to available in the same commit.

client/src/pages/Learning.tsx
  Three-screen flow: search + category pills drive a feed grid; clicking
  a card opens the viewer; viewer shows body + progress + quiz (if any).

  Feed: one query key per filter — ['learning-feed'], ['learning-category',
  slug], or ['learning-search', q] — so React Query caches each view
  independently and flicking between categories is instant after the first
  load. Search hits /api/learning/search; category filter hits
  /api/learning/category/:slug; default hits /api/learning/feed?limit=30.

  Viewer: body rendered as pre-wrap text intentionally. The vanilla tree
  uses DOMPurify (CDN-loaded) to render HTML bodies; adding that dep to
  the client bundle is a follow-up. Authored content is still clinical
  info, so plain-text preservation is acceptable for this commit — no
  content is lost, just unstyled. Presentations (content_type === 'presentation')
  link to the legacy viewer at /#learning/:slug — Marp slide rendering is
  its own port.

  Quiz: supports single-choice, multi-select, and true/false. Answers
  tracked via { optionId?, optionIds: Set<number> } per question so the
  same state shape drives both radio and checkbox rendering. Submit POSTs
  to /api/learning/submit-quiz; results screen shows per-question verdict
  with correct answer + why-incorrect + general explanation — same fields
  the vanilla showQuizResults renders. Retake wipes the answer map;
  Back-to-Feed returns to the list.

  Progress list reads content.progress[] directly from the content response
  — last 5 attempts, color-coded green/amber at 70%.

shared/types.ts + client/src/shared/types.ts — additive:
  LearningCategory/LearningCategoriesOk, LearningFeedRow/LearningFeedListOk,
  LearningOption/LearningQuestion/LearningProgressEntry/LearningContentFull/
  LearningContentOk, QuizAnswer/QuizResultEntry/QuizSubmitOk. Keys match
  the wire shape server routes return (snake_case for DB columns).

e2e/tests/learning-react.spec.js — three smoke tests:
  shell renders, feed shows items OR empty-state (no crash on empty DB),
  typing into search fires /api/learning/search.

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 435.44 kB / 123.81 kB gzipped (+10 kB over Settings complete).
2026-04-23 23:49:56 +02:00

436 lines
16 KiB
TypeScript

// ============================================================
// 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 (
<button
type="button"
onClick={onOpen}
className="w-full text-left rounded-lg border border-border bg-card hover:bg-muted/60 p-4 transition-colors"
data-testid={'lh-feed-item-' + row.slug}
>
<div className="flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wide mb-1">
<span className="font-semibold">{typeBadge(row.content_type)}</span>
{row.category_name && <span>· {row.category_name}</span>}
{row.question_count ? <span>· {row.question_count} Q</span> : null}
</div>
<div className="text-sm font-semibold">{row.title}</div>
{row.subject && <div className="text-xs text-muted-foreground mt-0.5 truncate">{row.subject}</div>}
</button>
);
}
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<LearningFeedListOk>({
queryKey: key,
queryFn: () => {
if (query) return api.get<LearningFeedListOk>('/api/learning/search?q=' + encodeURIComponent(query));
if (filter)
return api.get<LearningFeedListOk & { category?: LearningCategory }>(
'/api/learning/category/' + encodeURIComponent(filter),
);
return api.get<LearningFeedListOk>('/api/learning/feed?limit=30');
},
});
if (isLoading) return <div className="text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
const rows = data?.content || [];
if (rows.length === 0)
return <div className="text-sm text-muted-foreground italic py-4">No content found.</div>;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3" data-testid="lh-feed">
{rows.map((r) => <FeedCard key={r.id} row={r} onOpen={() => onOpen(r.slug)} />)}
</div>
);
}
// ── Viewer + Quiz ───────────────────────────────────────────
type AnswerMap = Record<number, { optionId?: number; optionIds: Set<number> }>;
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<AnswerMap>(() => emptyAnswers(content.questions));
const [result, setResult] = useState<QuizSubmitOk | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = useMutation({
mutationFn: (body: { contentId: number; answers: QuizAnswer[] }) =>
api.post<QuizSubmitOk>('/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 (
<section className={card} data-testid="lh-quiz-results">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold">Results</h3>
<span
className={'px-2 py-0.5 rounded text-xs font-semibold text-white ' + color}
data-testid="lh-quiz-score"
>
{result.score}/{result.total} ({result.percentage}%)
</span>
</div>
<div className="space-y-3">
{result.results.map((r, idx) => (
<div key={r.questionId} className="rounded-md border border-border p-3 bg-muted/30">
<div className="text-sm font-medium">
<span className={r.isCorrect ? 'text-green-600' : 'text-destructive'}>
{r.isCorrect ? '✓' : '✗'}
</span>{' '}
Q{idx + 1}: {r.questionText}
</div>
{!r.isCorrect && r.correctOptionText && (
<div className="text-xs text-green-700 mt-1">
<strong>Correct:</strong> {r.correctOptionText}
</div>
)}
{!r.isCorrect && r.selectedExplanation && (
<div className="text-xs text-destructive mt-1">
<strong>Why incorrect:</strong> {r.selectedExplanation}
</div>
)}
{r.generalExplanation && (
<div className="text-xs text-muted-foreground mt-1">{r.generalExplanation}</div>
)}
</div>
))}
</div>
<div className="flex gap-2">
<button
type="button"
className={btnGhost}
onClick={() => {
setResult(null);
setAnswers(emptyAnswers(content.questions));
}}
>
Retake
</button>
<button type="button" className={btnPrimary} onClick={onReset}>Back to Feed</button>
</div>
</section>
);
}
return (
<section className={card} data-testid="lh-quiz">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Quiz</h3>
<span className="text-xs text-muted-foreground">
{content.questions.length} question{content.questions.length === 1 ? '' : 's'}
</span>
</div>
<form onSubmit={onSubmit} className="space-y-4">
{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 (
<div key={q.id} className="rounded-md border border-border p-3 space-y-2 bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-semibold">Q{idx + 1}</span>
<span>{typeLabel}</span>
</div>
<div className="text-sm font-medium">{q.question_text}</div>
{isMulti && (
<div className="text-xs text-muted-foreground italic">Select all that apply</div>
)}
<div className="space-y-1">
{q.options.map((opt) => {
const a = answers[q.id];
const checked = isMulti
? a?.optionIds.has(opt.id) === true
: a?.optionId === opt.id;
return (
<label
key={opt.id}
className="flex items-start gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-2 py-1"
>
<input
type={isMulti ? 'checkbox' : 'radio'}
name={'q-' + q.id}
checked={checked}
onChange={() =>
isMulti ? toggleMulti(q, opt.id) : selectSingle(q, opt.id)
}
className="mt-0.5"
/>
<span>{opt.option_text}</span>
</label>
);
})}
</div>
</div>
);
})}
{error && <div className="text-sm text-destructive">{error}</div>}
<button
type="submit"
className={btnPrimary}
disabled={submit.isPending}
data-testid="btn-lh-submit-quiz"
>
{submit.isPending ? 'Submitting…' : 'Submit Answers'}
</button>
</form>
</section>
);
}
function ContentViewer({ slug, onBack }: { slug: string; onBack: () => void }) {
const { data, isLoading, error } = useQuery<LearningContentOk>({
queryKey: ['learning-content', slug],
queryFn: () => api.get<LearningContentOk>('/api/learning/content/' + encodeURIComponent(slug)),
});
if (isLoading) return <div className="text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const c = data.content;
return (
<div className="space-y-4">
<button type="button" className={btnGhost} onClick={onBack} data-testid="btn-lh-back">
Back to Feed
</button>
<section className={card} data-testid="lh-viewer">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold" data-testid="lh-viewer-title">{c.title}</h2>
<span className="text-xs text-muted-foreground">
{typeBadge(c.content_type)}
{c.category_name ? ' · ' + c.category_name : ''}
{c.author_name ? ' · ' + c.author_name : ''}
</span>
</div>
{c.content_type === 'presentation' ? (
<div className="text-center py-8 space-y-3 bg-muted/30 rounded-md">
<div className="text-4xl">📊</div>
<div className="text-sm font-medium">{c.title}</div>
<div className="text-xs text-muted-foreground">
Slide rendering lives in the legacy viewer.
</div>
<a
href={'/#learning/' + encodeURIComponent(c.slug)}
className={btnPrimary + ' inline-block'}
rel="noreferrer"
>
Open in legacy viewer
</a>
</div>
) : (
<div
className="whitespace-pre-wrap text-sm leading-relaxed"
data-testid="lh-viewer-body"
>
{c.body || ''}
</div>
)}
</section>
{c.progress && c.progress.length > 0 && (
<section className={card}>
<h3 className="text-base font-semibold">Your past attempts</h3>
<div className="space-y-1 text-sm">
{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 (
<div key={i} className="flex justify-between border-b border-border py-1">
<span>{new Date(p.completed_at).toLocaleDateString()}</span>
<span className={'font-semibold ' + color}>
{p.score}/{p.total} ({pct}%)
</span>
</div>
);
})}
</div>
</section>
)}
{c.questions && c.questions.length > 0 && <Quiz content={c} onReset={onBack} />}
</div>
);
}
// ── Page shell ───────────────────────────────────────────────
export default function Learning() {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<string>('');
const [activeSlug, setActiveSlug] = useState<string | null>(null);
const { data: cats } = useQuery<LearningCategoriesOk>({
queryKey: ['learning-categories'],
queryFn: () => api.get<LearningCategoriesOk>('/api/learning/categories'),
});
if (activeSlug) {
return (
<div className="max-w-4xl mx-auto p-6">
<ContentViewer slug={activeSlug} onBack={() => setActiveSlug(null)} />
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Learning Hub</h1>
<p className="text-sm text-muted-foreground">
Pediatric education, clinical pearls, and self-assessment quizzes.
</p>
</header>
<div className={card}>
<input
type="search"
className={input}
placeholder="Search topics, subjects…"
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="lh-search"
/>
</div>
<div className="flex flex-wrap gap-2" data-testid="lh-categories">
<button
type="button"
onClick={() => setFilter('')}
className={
pill +
(filter === '' ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80')
}
>
All
</button>
{cats?.categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
<Feed filter={filter} query={query.trim()} onOpen={(slug) => setActiveSlug(slug)} />
</div>
);
}