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).
This commit is contained in:
parent
289db2bd6f
commit
8d7bffb07a
10 changed files with 669 additions and 7 deletions
|
|
@ -13,6 +13,7 @@ import WellVisit from '@/pages/WellVisit';
|
|||
import VaxSchedule from '@/pages/VaxSchedule';
|
||||
import Catchup from '@/pages/Catchup';
|
||||
import Settings from '@/pages/Settings';
|
||||
import Learning from '@/pages/Learning';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
|
|
@ -57,6 +58,7 @@ export default function App() {
|
|||
<Route path="/catchup" element={<Catchup />} />
|
||||
<Route path="/extensions" element={<Extensions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/learning" element={<Learning />} />
|
||||
<Route path="/faq" element={<Faq />} />
|
||||
{/* catch-all falls back to home while more tabs port over */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const NAV: NavGroup[] = [
|
|||
{ to: '/bedside', label: 'Bedside', available: false },
|
||||
{ to: '/calculators', label: 'Calculators', available: false },
|
||||
{ to: '/extensions', label: 'Pagers & Extensions', available: true },
|
||||
{ to: '/learning', label: 'Learning Hub', available: false },
|
||||
{ to: '/learning', label: 'Learning Hub', available: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
436
client/src/pages/Learning.tsx
Normal file
436
client/src/pages/Learning.tsx
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// ============================================================
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -314,6 +314,98 @@ export interface MemoriesOk {
|
|||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Learning Hub (user-facing) ──────────────────────────────
|
||||
// Categories
|
||||
export interface LearningCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
}
|
||||
export interface LearningCategoriesOk {
|
||||
categories: LearningCategory[];
|
||||
}
|
||||
// Feed / category / search list rows — same shape across all list endpoints
|
||||
export interface LearningFeedRow {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
subject?: string | null;
|
||||
content_type: 'article' | 'pearl' | 'presentation' | 'quiz' | string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
category_name?: string | null;
|
||||
category_slug?: string | null;
|
||||
author_name?: string | null;
|
||||
question_count?: number;
|
||||
score?: number;
|
||||
match_type?: 'keyword' | 'semantic';
|
||||
}
|
||||
export interface LearningFeedListOk {
|
||||
content: LearningFeedRow[];
|
||||
total?: number;
|
||||
method?: 'keyword' | 'semantic' | 'hybrid';
|
||||
}
|
||||
// Single content with questions + progress
|
||||
export interface LearningOption {
|
||||
id: number;
|
||||
option_text: string;
|
||||
sort_order: number;
|
||||
}
|
||||
export interface LearningQuestion {
|
||||
id: number;
|
||||
question_text: string;
|
||||
question_type: 'single' | 'multi' | 'true_false' | string;
|
||||
explanation?: string | null;
|
||||
options: LearningOption[];
|
||||
}
|
||||
export interface LearningProgressEntry {
|
||||
score: number;
|
||||
total: number;
|
||||
completed_at: string;
|
||||
}
|
||||
export interface LearningContentFull {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
subject?: string | null;
|
||||
body?: string;
|
||||
content_type: string;
|
||||
category_name?: string | null;
|
||||
category_slug?: string | null;
|
||||
author_name?: string | null;
|
||||
questions: LearningQuestion[];
|
||||
progress: LearningProgressEntry[];
|
||||
}
|
||||
export interface LearningContentOk {
|
||||
content: LearningContentFull;
|
||||
}
|
||||
// Quiz submit
|
||||
export interface QuizAnswer {
|
||||
questionId: number;
|
||||
optionId?: number | null;
|
||||
optionIds?: number[];
|
||||
}
|
||||
export interface QuizResultEntry {
|
||||
questionId: number;
|
||||
questionType: string;
|
||||
questionText: string;
|
||||
isCorrect: boolean;
|
||||
selectedOptionId?: number | null;
|
||||
selectedOptionIds?: number[];
|
||||
correctOptionId?: number | null;
|
||||
correctOptionIds?: number[];
|
||||
correctOptionText?: string;
|
||||
selectedExplanation?: string;
|
||||
generalExplanation?: string;
|
||||
}
|
||||
export interface QuizSubmitOk {
|
||||
score: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
results: QuizResultEntry[];
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
40
e2e/tests/learning-react.spec.js
Normal file
40
e2e/tests/learning-react.spec.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB (React port) — smoke tests for /app/learning.
|
||||
//
|
||||
// Covers minimum-viable port: search input + category pills +
|
||||
// feed list render; clicking an item opens the viewer (if any
|
||||
// content is seeded) and renders the Back button.
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
async function openReactLearning(page) {
|
||||
await page.goto(E2E_BASE + '/app/learning');
|
||||
await page.waitForSelector('[data-testid="lh-search"]', { timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('React Learning Hub — shell + feed', () => {
|
||||
|
||||
test('search + categories render', async ({ authedPage: _, page }) => {
|
||||
await openReactLearning(page);
|
||||
await expect(page.locator('[data-testid="lh-search"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="lh-categories"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('feed either lists items or shows an empty-state (no crash)', async ({ authedPage: _, page }) => {
|
||||
await openReactLearning(page);
|
||||
// Either the feed grid exists with items, or the empty-state label shows.
|
||||
const feedItems = await page.locator('[data-testid^="lh-feed-item-"]').count();
|
||||
const empty = await page.getByText('No content found.').count();
|
||||
expect(feedItems + empty).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('typing in search triggers a new request', async ({ authedPage: _, page }) => {
|
||||
await openReactLearning(page);
|
||||
const [resp] = await Promise.all([
|
||||
page.waitForResponse('**/api/learning/search**', { timeout: 10000 }),
|
||||
page.fill('[data-testid="lh-search"]', 'fever'),
|
||||
]);
|
||||
expect(resp.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
2
public/app/assets/index-BRcV9crV.css
Normal file
2
public/app/assets/index-BRcV9crV.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-DG4q33Hv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-DiAauNJX.css">
|
||||
<script type="module" crossorigin src="/app/assets/index-DSAxM8lT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BRcV9crV.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -314,6 +314,98 @@ export interface MemoriesOk {
|
|||
memories: MemoryRow[];
|
||||
}
|
||||
|
||||
// ── Learning Hub (user-facing) ──────────────────────────────
|
||||
// Categories
|
||||
export interface LearningCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
}
|
||||
export interface LearningCategoriesOk {
|
||||
categories: LearningCategory[];
|
||||
}
|
||||
// Feed / category / search list rows — same shape across all list endpoints
|
||||
export interface LearningFeedRow {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
subject?: string | null;
|
||||
content_type: 'article' | 'pearl' | 'presentation' | 'quiz' | string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
category_name?: string | null;
|
||||
category_slug?: string | null;
|
||||
author_name?: string | null;
|
||||
question_count?: number;
|
||||
score?: number;
|
||||
match_type?: 'keyword' | 'semantic';
|
||||
}
|
||||
export interface LearningFeedListOk {
|
||||
content: LearningFeedRow[];
|
||||
total?: number;
|
||||
method?: 'keyword' | 'semantic' | 'hybrid';
|
||||
}
|
||||
// Single content with questions + progress
|
||||
export interface LearningOption {
|
||||
id: number;
|
||||
option_text: string;
|
||||
sort_order: number;
|
||||
}
|
||||
export interface LearningQuestion {
|
||||
id: number;
|
||||
question_text: string;
|
||||
question_type: 'single' | 'multi' | 'true_false' | string;
|
||||
explanation?: string | null;
|
||||
options: LearningOption[];
|
||||
}
|
||||
export interface LearningProgressEntry {
|
||||
score: number;
|
||||
total: number;
|
||||
completed_at: string;
|
||||
}
|
||||
export interface LearningContentFull {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
subject?: string | null;
|
||||
body?: string;
|
||||
content_type: string;
|
||||
category_name?: string | null;
|
||||
category_slug?: string | null;
|
||||
author_name?: string | null;
|
||||
questions: LearningQuestion[];
|
||||
progress: LearningProgressEntry[];
|
||||
}
|
||||
export interface LearningContentOk {
|
||||
content: LearningContentFull;
|
||||
}
|
||||
// Quiz submit
|
||||
export interface QuizAnswer {
|
||||
questionId: number;
|
||||
optionId?: number | null;
|
||||
optionIds?: number[];
|
||||
}
|
||||
export interface QuizResultEntry {
|
||||
questionId: number;
|
||||
questionType: string;
|
||||
questionText: string;
|
||||
isCorrect: boolean;
|
||||
selectedOptionId?: number | null;
|
||||
selectedOptionIds?: number[];
|
||||
correctOptionId?: number | null;
|
||||
correctOptionIds?: number[];
|
||||
correctOptionText?: string;
|
||||
selectedExplanation?: string;
|
||||
generalExplanation?: string;
|
||||
}
|
||||
export interface QuizSubmitOk {
|
||||
score: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
results: QuizResultEntry[];
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue