Files
studyos/client/src/components/ExamHistory.jsx
renato97 4ff4302a8c 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)
2026-06-08 18:18:47 -03:00

145 lines
6.0 KiB
JavaScript

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>
);
}