feat: implement 33 nice-to-have features + fix 37 code review bugs
5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
This commit is contained in:
144
client/src/components/ExamHistory.jsx
Normal file
144
client/src/components/ExamHistory.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Trash2, Calendar, BookOpen, Tag, TrendingUp } from 'lucide-react';
|
||||
|
||||
export default function ExamHistory({ exams, onDelete, onRefresh }) {
|
||||
const [filterTopic, setFilterTopic] = useState('');
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh();
|
||||
}, [onRefresh]);
|
||||
|
||||
const allTopics = useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const e of exams) {
|
||||
if (Array.isArray(e.topics)) {
|
||||
e.topics.forEach((t) => set.add(t));
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [exams]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filterTopic) return exams;
|
||||
return exams.filter((e) => e.topics && e.topics.includes(filterTopic));
|
||||
}, [exams, filterTopic]);
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BookOpen size={22} /> Historial de Exámenes
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<select
|
||||
value={filterTopic}
|
||||
onChange={(e) => setFilterTopic(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<option value="">Todos los temas</option>
|
||||
{allTopics.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
{filterTopic && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setFilterTopic('')}>
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="chat-empty-state" style={{ padding: 48 }}>
|
||||
<span style={{ fontSize: 32 }}>📝</span>
|
||||
<p>{exams.length === 0 ? 'Aún no has realizado exámenes.' : 'Ningún examen coincide con el filtro.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((exam) => {
|
||||
const isExpanded = expandedId === exam.id;
|
||||
return (
|
||||
<div
|
||||
key={exam.id}
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '14px 18px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
onClick={() => setExpandedId(isExpanded ? null : exam.id)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: '50%',
|
||||
background: exam.score >= 80 ? 'rgba(52,211,153,0.12)' : exam.score >= 50 ? 'rgba(251,191,36,0.12)' : 'rgba(248,113,113,0.12)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{exam.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||
<Calendar size={10} /> {formatDate(exam.taken_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)' }}>
|
||||
{exam.score}%
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(exam.id); }}
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>Temas:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{Array.isArray(exam.topics) && exam.topics.map((t) => (
|
||||
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--bg-surface)', padding: '4px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, border: '1px solid var(--border)' }}>
|
||||
<Tag size={10} /> {t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user