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).
436 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|