feat(exam): add exam history and improve scenario timer

Signed-off-by: Abhinav Sinha <[email protected]>
This commit is contained in:
Abhinav Sinha
2026-06-16 16:03:08 +05:30
parent 5720b59a5f
commit 6766d772d1
11 changed files with 1403 additions and 108 deletions
+123 -7
View File
@@ -67,6 +67,19 @@ function getDb() {
snapshot TEXT
)
`);
// Separate exam-session progress table — tracks completions per session
_db.exec(`
CREATE TABLE IF NOT EXISTS exam_progress (
session_id TEXT NOT NULL,
scenario_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress',
attempts INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
PRIMARY KEY (session_id, scenario_id),
FOREIGN KEY (session_id) REFERENCES sessions(id)
)
`);
return _db;
}
@@ -258,27 +271,85 @@ app.get('/api/sessions/active', (req, res) => {
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
if (!session) return res.json(null);
const bundle = loadBundles().find(b => b.id === session.bundle_id);
const progress = loadProgress();
const scenarioIds = bundle?.scenario_ids || [];
const completed = scenarioIds.filter(id => progress[id]?.status === 'completed').length;
// Count completions from exam_progress (exam-specific), not global progress
const completed = db.prepare(
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
).get(session.id)?.cnt || 0;
res.json({ ...session, scenarioCount: scenarioIds.length, completedCount: completed });
});
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
app.get('/api/sessions/:id/exam-progress', (req, res) => {
const db = getDb();
const rows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
// Return as a map: scenario_id -> { status, attempts, completed_at }
const result = {};
for (const r of rows) {
result[r.scenario_id] = { status: r.status, attempts: r.attempts, completed_at: r.completed_at };
}
res.json(result);
});
// GET /api/sessions/history — list all completed/abandoned exam sessions (newest first)
app.get('/api/sessions/history', (req, res) => {
const db = getDb();
const sessions = db.prepare(
`SELECT * FROM sessions WHERE status != 'active' ORDER BY started_at DESC`
).all();
const bundles = loadBundles();
const result = sessions.map(s => {
const bundle = bundles.find(b => b.id === s.bundle_id);
let snapshot = [];
try { snapshot = s.snapshot ? JSON.parse(s.snapshot) : []; } catch (_) {}
const completed = snapshot.filter(x => x.status === 'completed');
const totalWeight = snapshot.reduce((a, x) => a + (x.weight || 0), 0);
const earnedWeight = completed.reduce((a, x) => a + (x.weight || 0), 0);
const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0;
return {
id: s.id,
bundle_id: s.bundle_id,
bundle_name: bundle?.name || s.bundle_id,
bundle_icon: bundle?.icon || '🎓',
status: s.status,
started_at: s.started_at,
submitted_at: s.submitted_at,
exam_minutes: s.exam_minutes,
duration_secs: s.duration_secs,
scenarioCount: snapshot.length,
completedCount: completed.length,
totalWeight,
earnedWeight,
pct,
passed: pct >= 66,
snapshot,
};
});
res.json(result);
});
// POST /api/sessions/:id/submit — submit the exam session
app.post('/api/sessions/:id/submit', (req, res) => {
const db = getDb();
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const bundle = loadBundles().find(b => b.id === session.bundle_id);
const progress = loadProgress();
const scenarios = loadScenarios();
const bundleScenarios = scenarios.filter(s => bundle?.scenario_ids?.includes(s.id));
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
const examProgressMap = {};
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
const snapshot = bundleScenarios.map(s => ({
id: s.id, title: s.title, weight: s.weight,
category: s.category, type: s.type, difficulty: s.difficulty,
status: progress[s.id]?.status || 'not_started',
completed_at: progress[s.id]?.completed_at || null,
attempts: progress[s.id]?.attempts || 0,
status: examProgressMap[s.id]?.status || 'not_started',
completed_at: examProgressMap[s.id]?.completed_at || null,
attempts: examProgressMap[s.id]?.attempts || 0,
}));
const startedAt = new Date(session.started_at + 'Z');
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
@@ -474,7 +545,7 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
if (!passed) allPassed = false;
}
// Update progress
// Update global practice progress
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
@@ -486,6 +557,28 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
};
saveProgress(progress);
// Also update exam_progress if there's an active session
const db = getDb();
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
if (activeSession) {
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
.get(activeSession.id, scenario.id);
const epAttempts = (ep?.attempts || 0) + 1;
db.prepare(`
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
status = excluded.status,
attempts = excluded.attempts,
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
`).run(
activeSession.id, scenario.id,
allPassed ? 'completed' : 'in_progress',
epAttempts,
allPassed ? new Date().toISOString() : null
);
}
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
});
@@ -499,6 +592,7 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
const correct = selected === scenario.correct_option;
// Update global practice progress
const progress = loadProgress();
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
@@ -511,6 +605,28 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
};
saveProgress(progress);
// Also update exam_progress if there's an active session
const db = getDb();
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
if (activeSession) {
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
.get(activeSession.id, scenario.id);
const epAttempts = (ep?.attempts || 0) + 1;
db.prepare(`
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
status = excluded.status,
attempts = excluded.attempts,
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
`).run(
activeSession.id, scenario.id,
correct ? 'completed' : 'in_progress',
epAttempts,
correct ? new Date().toISOString() : null
);
}
res.json({
correct,
correct_option: scenario.correct_option,
+24 -4
View File
@@ -7,6 +7,7 @@ import BundleNav from './components/BundleNav'
import ExamTimer from './components/ExamTimer'
import ExamReport from './components/ExamReport'
import ExamStartModal from './components/ExamStartModal'
import ExamHistory from './components/ExamHistory'
import styles from './App.module.css'
const MIN_SIDEBAR_W = 180
@@ -35,6 +36,8 @@ export default function App() {
const [examSession, setExamSession] = useState(null) // active session object
const [examReport, setExamReport] = useState(null) // submitted report
const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal
const [examProgress, setExamProgress] = useState({}) // exam-specific per-scenario progress
const [showHistory, setShowHistory] = useState(false) // exam history modal
// Sidebar resize / collapse
const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W)
@@ -82,10 +85,13 @@ export default function App() {
useEffect(() => {
fetch('/api/sessions/active')
.then(r => r.json())
.then(s => {
.then(async s => {
if (s) {
setExamSession(s)
setActiveBundleId(s.bundle_id)
// Load exam-specific progress
const ep = await fetch(`/api/sessions/${s.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
setExamProgress(ep || {})
}
})
.catch(() => { })
@@ -142,10 +148,14 @@ export default function App() {
const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json())
setScenario(d2)
}
// Refresh exam session completion count
// Refresh exam session completion count + exam-specific progress
if (examSession) {
const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null)
if (updated) setExamSession(updated)
if (updated) {
setExamSession(updated)
const ep = await fetch(`/api/sessions/${updated.id}/exam-progress`).then(r => r.json()).catch(() => ({}))
setExamProgress(ep || {})
}
}
}, [activeBundleId, activeId, examSession])
@@ -171,6 +181,7 @@ export default function App() {
const bundle = bundles.find(b => b.id === examSession.bundle_id)
setExamReport({ ...result, bundle })
setExamSession(null)
setExamProgress({})
refreshProgress()
}, [examSession, bundles, refreshProgress])
@@ -178,6 +189,7 @@ export default function App() {
if (!examSession) return
await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { })
setExamSession(null)
setExamProgress({})
refreshProgress()
}, [examSession, refreshProgress])
@@ -247,7 +259,7 @@ export default function App() {
return (
<div className={styles.app}>
<Header clusterReady={clusterReady} />
<Header clusterReady={clusterReady} onShowHistory={() => setShowHistory(true)} />
{/* Bundle navigation bar */}
<BundleNav
@@ -287,6 +299,8 @@ export default function App() {
width={currentSidebarW}
activeBundleId={activeBundleId}
onProgressUpdate={refreshProgress}
isExamMode={!!examSession}
examProgress={examProgress}
/>
{/* Sidebar resize handle */}
@@ -300,6 +314,7 @@ export default function App() {
onProgressUpdate={refreshProgress}
onScenarioStart={handleScenarioStart}
isExamMode={!!examSession}
examProgress={examProgress}
/>
</div>
@@ -342,6 +357,11 @@ export default function App() {
onCancel={() => setExamModalBundle(null)}
/>
)}
{/* Exam history modal */}
{showHistory && (
<ExamHistory onClose={() => setShowHistory(false)} />
)}
<footer className={styles.footer}>
<div>&copy; {new Date().getFullYear()} The KubeKosh Project &bull; All rights reserved</div>
<div>
+10 -1
View File
@@ -83,8 +83,12 @@ export default function BundleNav({
<span className={styles.tagline}>{b.tagline}</span>
</div>
<div className={styles.countWrap}>
{!examSession && (
<>
<span className={styles.countNum}>{b.stats.completed}/{b.stats.total}</span>
<span className={styles.countPct}>{pct}%</span>
</>
)}
</div>
{/* Start Exam button — shown on hover when not in exam mode */}
@@ -118,15 +122,20 @@ export default function BundleNav({
onProgressUpdate?.()
}}
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
</div>
{/* Inline progress track */}
{!examSession && (
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{ width: `${pct}%` }} />
</div>
)}
</button>
)
})}
+260
View File
@@ -0,0 +1,260 @@
import { useState, useEffect } from 'react'
import styles from './ExamHistory.module.css'
const DIFF_COLOR = { Easy: 'var(--green)', Medium: 'var(--amber)', Hard: 'var(--red)' }
function formatDuration(secs) {
if (!secs && secs !== 0) return '—'
const h = Math.floor(secs / 3600)
const m = Math.floor((secs % 3600) / 60)
const s = secs % 60
if (h > 0) return `${h}h ${m}m ${s}s`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
function formatDate(iso) {
if (!iso) return '—'
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z')
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})
}
function ScoreRing({ pct, passed, size = 72 }) {
const r = (size / 2) - 7
const circ = 2 * Math.PI * r
return (
<div className={styles.ringWrap} style={{ width: size, height: size }}>
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="var(--surface3)" strokeWidth="6" />
<circle
cx={size/2} cy={size/2} r={r}
fill="none"
stroke={passed ? 'var(--green)' : pct > 0 ? 'var(--red)' : 'var(--surface3)'}
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={circ}
strokeDashoffset={circ * (1 - pct / 100)}
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
/>
</svg>
<span className={styles.ringPct}>{pct}%</span>
</div>
)
}
function AttemptListItem({ attempt, active, onClick }) {
const isAbandoned = attempt.status === 'abandoned'
const hasSnapshot = attempt.snapshot?.length > 0
return (
<button
className={`${styles.listItem} ${active ? styles.listItemActive : ''} ${isAbandoned ? styles.listItemAbandoned : ''}`}
onClick={onClick}
>
<div className={styles.listItemLeft}>
<span className={styles.listIcon}>{attempt.bundle_icon}</span>
<div className={styles.listMeta}>
<span className={styles.listBundle}>{attempt.bundle_name}</span>
<span className={styles.listDate}>{formatDate(attempt.started_at)}</span>
</div>
</div>
<div className={styles.listItemRight}>
{isAbandoned ? (
<span className={styles.badgeAbandoned}>Abandoned</span>
) : hasSnapshot ? (
<>
<span className={attempt.passed ? styles.badgePass : styles.badgeFail}>
{attempt.passed ? 'Passed' : 'Failed'}
</span>
<span className={styles.listPct}>{attempt.pct}%</span>
</>
) : (
<span className={styles.badgeAbandoned}>No data</span>
)}
</div>
</button>
)
}
function AttemptDetail({ attempt }) {
if (!attempt) {
return (
<div className={styles.detailEmpty}>
<div className={styles.detailEmptyIcon}>📋</div>
<div className={styles.detailEmptyText}>Select an attempt to see its report</div>
</div>
)
}
const isAbandoned = attempt.status === 'abandoned'
const hasSnapshot = attempt.snapshot?.length > 0
// Group by category
const byCategory = (attempt.snapshot || []).reduce((acc, s) => {
;(acc[s.category] = acc[s.category] || []).push(s)
return acc
}, {})
return (
<div className={styles.detail}>
{/* Detail header */}
<div className={styles.detailHeader}>
<span className={styles.detailIcon}>{attempt.bundle_icon}</span>
<div>
<div className={styles.detailLabel}>Exam Report</div>
<div className={styles.detailBundle}>{attempt.bundle_name}</div>
</div>
<div className={styles.detailDates}>
<span className={styles.detailDateRow}>
<span className={styles.detailDateLabel}>Started</span>
<span className={styles.detailDateVal}>{formatDate(attempt.started_at)}</span>
</span>
<span className={styles.detailDateRow}>
<span className={styles.detailDateLabel}>Ended</span>
<span className={styles.detailDateVal}>{formatDate(attempt.submitted_at)}</span>
</span>
</div>
</div>
{/* Score hero */}
{isAbandoned ? (
<div className={styles.abandonedHero}>
<span className={styles.abandonedIcon}>🚫</span>
<div>
<div className={styles.abandonedTitle}>Exam Abandoned</div>
<div className={styles.abandonedSub}>
{hasSnapshot
? `${attempt.completedCount} of ${attempt.scenarioCount} scenarios were completed before abandoning`
: 'No progress was recorded'}
{attempt.duration_secs
? ` · ${formatDuration(attempt.duration_secs)} elapsed`
: ''}
</div>
</div>
</div>
) : (
<div className={`${styles.hero} ${attempt.passed ? styles.heroPassed : styles.heroFailed}`}>
<ScoreRing pct={attempt.pct} passed={attempt.passed} />
<div className={styles.heroMeta}>
<div className={`${styles.verdict} ${attempt.passed ? styles.verdictPass : styles.verdictFail}`}>
{attempt.passed ? '✅ Passed' : '❌ Not Yet Passing'}
</div>
<div className={styles.heroStats}>
<span>{attempt.completedCount}/{attempt.scenarioCount} scenarios</span>
<span>·</span>
<span>{attempt.earnedWeight}/{attempt.totalWeight} pts</span>
<span>·</span>
<span> {formatDuration(attempt.duration_secs)}</span>
</div>
<div className={styles.passMark}>Pass mark: 66%</div>
</div>
</div>
)}
{/* Scenario breakdown */}
{hasSnapshot && (
<div className={styles.breakdown}>
{Object.entries(byCategory).map(([cat, items]) => (
<div key={cat} className={styles.catGroup}>
<div className={styles.catTitle}>{cat}</div>
{items.map(s => (
<div key={s.id} className={`${styles.row} ${s.status === 'completed' ? styles.rowDone : ''}`}>
<span className={styles.rowIcon}>{s.status === 'completed' ? '✅' : '⬜'}</span>
<span className={styles.rowTitle}>{s.title}</span>
<span className={styles.rowDiff} style={{ color: DIFF_COLOR[s.difficulty] }}>
{s.difficulty}
</span>
{s.attempts > 0 && (
<span className={styles.rowAttempts}>{s.attempts} attempt{s.attempts > 1 ? 's' : ''}</span>
)}
<span className={styles.rowPts}>
{s.status === 'completed' ? s.weight : 0}/{s.weight} pts
</span>
</div>
))}
</div>
))}
</div>
)}
{!hasSnapshot && (
<div className={styles.noSnapshot}>No scenario data recorded for this session.</div>
)}
</div>
)
}
export default function ExamHistory({ onClose }) {
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState(null)
useEffect(() => {
fetch('/api/sessions/history')
.then(r => r.json())
.then(data => {
setHistory(data)
if (data.length > 0) setSelected(data[0])
})
.catch(() => setHistory([]))
.finally(() => setLoading(false))
}, [])
return (
<div className={styles.overlay} onClick={e => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
{/* Modal header */}
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>
<span className={styles.modalTitleIcon}>📋</span>
Exam History
</div>
<div className={styles.modalMeta}>
{!loading && (
<span className={styles.attemptCount}>
{history.length} attempt{history.length !== 1 ? 's' : ''}
</span>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close exam history"></button>
</div>
{/* Body: two-panel layout */}
<div className={styles.body}>
{/* Left: attempt list */}
<div className={styles.list}>
{loading && (
<div className={styles.loadingWrap}>
{[...Array(4)].map((_, i) => (
<div key={i} className={styles.skeleton} style={{ animationDelay: `${i * 0.1}s` }} />
))}
</div>
)}
{!loading && history.length === 0 && (
<div className={styles.emptyList}>
<div className={styles.emptyIcon}>🎓</div>
<div className={styles.emptyText}>No exam attempts yet</div>
<div className={styles.emptySub}>Complete or abandon an exam to see it here</div>
</div>
)}
{!loading && history.map(a => (
<AttemptListItem
key={a.id}
attempt={a}
active={selected?.id === a.id}
onClick={() => setSelected(a)}
/>
))}
</div>
{/* Right: detail */}
<div className={styles.detailWrap}>
<AttemptDetail attempt={selected} />
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,447 @@
/* ── Overlay & modal shell ─────────────────────────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.18s ease;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
.modal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(1000px, 96vw);
height: min(680px, 92vh);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 32px 80px rgba(0,0,0,0.45);
}
/* ── Modal header ──────────────────────────────────────────────────────────── */
.modalHeader {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modalTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.2px;
}
.modalTitleIcon { font-size: 18px; line-height: 1; }
.modalMeta { margin-left: 4px; }
.attemptCount {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
background: var(--surface2);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 10px;
}
.closeBtn {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
font-size: 18px;
padding: 4px 8px;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.closeBtn:hover { color: var(--text); background: var(--surface3); }
/* ── Two-panel body ────────────────────────────────────────────────────────── */
.body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Left: attempt list ────────────────────────────────────────────────────── */
.list {
width: 280px;
min-width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 6px;
}
.listItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: none;
cursor: pointer;
font-family: var(--sans);
text-align: left;
transition: background 0.12s, border-color 0.12s;
width: 100%;
}
.listItem:hover { background: var(--surface2); border-color: var(--border); }
.listItemActive {
background: rgba(57,217,138,0.07);
border-color: rgba(57,217,138,0.25);
}
.listItemAbandoned { opacity: 0.75; }
.listItemLeft {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.listIcon { font-size: 22px; flex-shrink: 0; line-height: 1; }
.listMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.listBundle {
font-size: 12px;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.listDate {
font-size: 10px;
font-family: var(--mono);
color: var(--text-3);
white-space: nowrap;
}
.listItemRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.listPct {
font-family: var(--mono);
font-size: 13px;
font-weight: 700;
color: var(--text);
}
/* ── Badges ────────────────────────────────────────────────────────────────── */
.badgePass, .badgeFail, .badgeAbandoned {
font-size: 10px;
font-weight: 700;
font-family: var(--mono);
padding: 2px 7px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid transparent;
}
.badgePass {
background: var(--green-dim);
color: var(--green);
border-color: rgba(57,217,138,0.3);
}
.badgeFail {
background: var(--red-dim);
color: var(--red);
border-color: rgba(255,80,80,0.3);
}
.badgeAbandoned {
background: var(--surface3);
color: var(--text-3);
border-color: var(--border);
}
/* ── Loading skeleton ──────────────────────────────────────────────────────── */
.loadingWrap {
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
}
.skeleton {
height: 58px;
border-radius: 8px;
background: linear-gradient(90deg, var(--surface2) 25%, var(--surface3) 50%, var(--surface2) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
/* ── Empty state ───────────────────────────────────────────────────────────── */
.emptyList {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 8px;
text-align: center;
flex: 1;
}
.emptyIcon { font-size: 36px; }
.emptyText { font-size: 13px; font-weight: 700; color: var(--text-2); }
.emptySub { font-size: 11px; color: var(--text-3); line-height: 1.5; }
/* ── Right: detail panel ───────────────────────────────────────────────────── */
.detailWrap {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.detailEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 10px;
color: var(--text-3);
}
.detailEmptyIcon { font-size: 40px; }
.detailEmptyText { font-size: 13px; }
.detail {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* Detail header */
.detailHeader {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.detailIcon { font-size: 30px; line-height: 1; }
.detailLabel {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 2px;
}
.detailBundle { font-size: 16px; font-weight: 700; color: var(--text); }
.detailDates {
margin-left: auto;
display: flex;
flex-direction: column;
gap: 4px;
text-align: right;
flex-shrink: 0;
}
.detailDateRow {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.detailDateLabel {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-3);
min-width: 44px;
}
.detailDateVal {
font-size: 11px;
font-family: var(--mono);
color: var(--text-2);
}
/* Score hero */
.hero {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.heroPassed { background: var(--green-dim); }
.heroFailed { background: var(--red-dim); }
.ringWrap {
position: relative;
flex-shrink: 0;
}
.ringPct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.heroMeta { flex: 1; }
.verdict {
font-size: 18px;
font-weight: 800;
margin-bottom: 6px;
}
.verdictPass { color: var(--green); }
.verdictFail { color: var(--red); }
.heroStats {
display: flex;
gap: 8px;
font-size: 12px;
color: var(--text-2);
margin-bottom: 4px;
flex-wrap: wrap;
}
.passMark { font-size: 11px; color: var(--text-3); }
/* Abandoned hero */
.abandonedHero {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface2);
flex-shrink: 0;
}
.abandonedIcon { font-size: 32px; }
.abandonedTitle { font-size: 16px; font-weight: 700; color: var(--text-2); margin-bottom: 4px; }
.abandonedSub { font-size: 12px; color: var(--text-3); }
/* Breakdown */
.breakdown {
flex: 1;
overflow-y: auto;
padding: 16px 22px;
display: flex;
flex-direction: column;
gap: 18px;
}
.catGroup { display: flex; flex-direction: column; gap: 4px; }
.catTitle {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text-3);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
color: var(--text-2);
transition: background 0.1s;
}
.row:hover { background: var(--surface2); }
.rowDone { color: var(--text); }
.rowIcon { font-size: 13px; flex-shrink: 0; }
.rowTitle { flex: 1; font-size: 12px; }
.rowDiff { font-size: 10px; font-weight: 600; flex-shrink: 0; }
.rowAttempts {
font-family: var(--mono);
font-size: 10px;
color: var(--text-3);
background: var(--surface3);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
flex-shrink: 0;
}
.rowPts {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
flex-shrink: 0;
min-width: 60px;
text-align: right;
}
.noSnapshot {
padding: 24px;
font-size: 13px;
color: var(--text-3);
text-align: center;
}
+113 -21
View File
@@ -1,10 +1,71 @@
import { useState, useEffect } from 'react'
import styles from './Header.module.css'
export default function Header({ clusterReady }) {
// ── Inline Reload Cache Modal ─────────────────────────────────────────────────
function ReloadModal({ state, data, error, onClose, onReload }) {
return (
<div className={styles.modalOverlay} onClick={e => e.target === e.currentTarget && state !== 'loading' && onClose()}>
<div className={styles.reloadModal}>
{state === 'loading' && (
<>
<div className={styles.reloadModalIcon}>
<span className={styles.reloadSpinner} />
</div>
<div className={styles.reloadModalTitle}>Reloading Cache</div>
<div className={styles.reloadModalSub}>Fetching latest scenarios and bundles from disk.</div>
</>
)}
{state === 'success' && (
<>
<div className={`${styles.reloadModalIcon} ${styles.reloadIconSuccess}`}></div>
<div className={styles.reloadModalTitle}>Cache Reloaded</div>
<div className={styles.reloadModalSub}>{data?.message}</div>
<div className={styles.reloadStats}>
<div className={styles.reloadStat}>
<span className={styles.reloadStatNum}>{data?.scenarios_count ?? '—'}</span>
<span className={styles.reloadStatLabel}>Scenarios</span>
</div>
<div className={styles.reloadStatDivider} />
<div className={styles.reloadStat}>
<span className={styles.reloadStatNum}>{data?.bundles_count ?? '—'}</span>
<span className={styles.reloadStatLabel}>Bundles</span>
</div>
</div>
<div className={styles.reloadModalActions}>
<button className={styles.reloadPageBtn} onClick={onReload}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 6, verticalAlign: 'middle' }}>
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
Reload Page
</button>
<button className={styles.reloadCloseBtn} onClick={onClose}>Close</button>
</div>
</>
)}
{state === 'error' && (
<>
<div className={`${styles.reloadModalIcon} ${styles.reloadIconError}`}></div>
<div className={styles.reloadModalTitle}>Reload Failed</div>
<div className={styles.reloadModalError}>{error}</div>
<div className={styles.reloadModalActions}>
<button className={styles.reloadCloseBtn} onClick={onClose}>Close</button>
</div>
</>
)}
</div>
</div>
)
}
// ── Header ────────────────────────────────────────────────────────────────────
export default function Header({ clusterReady, onShowHistory }) {
const [theme, setTheme] = useState(
() => localStorage.getItem('kubekosh-theme') || 'dark'
)
const [reloadModal, setReloadModal] = useState(null) // null | { state, data, error }
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
@@ -14,31 +75,26 @@ export default function Header({ clusterReady }) {
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
const handleReloadCache = async () => {
const sendReloadRequest = async () => {
return fetch('/api/cache/reload', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
};
setReloadModal({ state: 'loading', data: null, error: null })
try {
let response = await sendReloadRequest();
const response = await fetch('/api/cache/reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (response.ok) {
const data = await response.json();
alert(`Success: ${data.message}\nScenarios: ${data.scenarios_count}\nBundles: ${data.bundles_count}`);
window.location.reload();
const data = await response.json()
setReloadModal({ state: 'success', data, error: null })
} else {
const data = await response.json().catch(() => ({}));
alert(`Error reloading cache: ${data.error || 'Unknown error'}`);
const data = await response.json().catch(() => ({}))
setReloadModal({ state: 'error', data: null, error: data.error || 'Unknown error' })
}
} catch (err) {
alert(`Network error reloading cache: ${err.message}`);
setReloadModal({ state: 'error', data: null, error: `Network error: ${err.message}` })
}
}
};
return (
<>
<header className={styles.header}>
<div className={styles.brand}>
<div className={styles.logo}>
@@ -51,28 +107,33 @@ export default function Header({ clusterReady }) {
<div className={styles.right}>
{/* GitHub link */}
<div className={styles.githubBtnContainer}>
<a
href="https://github.com/zeborg/kubekosh"
target="_blank"
rel="noopener noreferrer"
className={styles.githubBtn}
title="Visit GitHub repository"
aria-label="GitHub Repository"
>
<svg viewBox="0 0 512 512" width="16" height="16" fill="currentColor">
<path d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256" fillRule="evenodd" clipRule="evenodd"/>
</svg>
</a>
</div>
{/* Theme toggle */}
<div
className={styles.themeBtnContainer}
data-tooltip={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
<button
className={styles.themeBtn}
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to Light mode' : 'Switch to Dark mode'}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
{/* Cluster status */}
<div className={`${styles.clusterBadge} ${clusterReady ? styles.ready : styles.notReady}`}>
@@ -80,11 +141,30 @@ export default function Header({ clusterReady }) {
<span>{clusterReady ? 'Cluster Ready' : 'Connecting…'}</span>
</div>
{/* Exam history */}
<div className={styles.historyBtnContainer}>
<button
className={styles.historyBtn}
onClick={onShowHistory}
aria-label="View exam history"
id="exam-history-btn"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</button>
</div>
{/* Reload cache */}
<div className={styles.reloadBtnContainer}>
<button
className={styles.reloadBtn}
className={`${styles.reloadBtn} ${reloadModal?.state === 'loading' ? styles.reloadBtnSpinning : ''}`}
onClick={handleReloadCache}
disabled={reloadModal?.state === 'loading'}
aria-label="Reload scenario cache"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
@@ -95,5 +175,17 @@ export default function Header({ clusterReady }) {
</div>
</div>
</header>
{/* Reload cache modal */}
{reloadModal && (
<ReloadModal
state={reloadModal.state}
data={reloadModal.data}
error={reloadModal.error}
onClose={() => setReloadModal(null)}
onReload={() => window.location.reload()}
/>
)}
</>
)
}
+297
View File
@@ -65,6 +65,36 @@
gap: 10px;
}
.githubBtnContainer {
position: relative;
display: flex;
align-items: center;
}
.githubBtnContainer::after {
content: "View on GitHub";
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--surface3);
color: var(--text);
font-family: var(--mono);
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border2);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.githubBtnContainer:hover::after {
opacity: 1;
}
.githubBtn {
background: none;
border: 1px solid var(--border);
@@ -87,6 +117,36 @@
color: var(--text);
}
.themeBtnContainer {
position: relative;
display: flex;
align-items: center;
}
.themeBtnContainer::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--surface3);
color: var(--text);
font-family: var(--mono);
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border2);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.themeBtnContainer:hover::after {
opacity: 1;
}
.themeBtn {
background: none;
border: 1px solid var(--border);
@@ -159,6 +219,58 @@
transform: rotate(12deg);
}
/* Exam history button */
.historyBtnContainer {
position: relative;
display: flex;
align-items: center;
}
.historyBtnContainer::after {
content: "Exam History";
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--surface3);
color: var(--text);
font-family: var(--mono);
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border2);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.historyBtnContainer:hover::after {
opacity: 1;
}
.historyBtn {
background: none;
border: 1px solid var(--border);
border-radius: 8px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
color: var(--text-2);
line-height: 1;
}
.historyBtn:hover {
background: var(--surface2);
border-color: var(--border2);
color: var(--text);
}
.clusterBadge {
display: flex;
align-items: center;
@@ -195,3 +307,188 @@
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Reload button spinning state */
.reloadBtnSpinning svg {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg) } }
/* ── Reload Cache Modal ──────────────────────────────────────────────────────── */
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: modalFadeIn 0.18s ease;
}
@keyframes modalFadeIn { from { opacity: 0 } to { opacity: 1 } }
.reloadModal {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius-lg);
width: min(400px, 92vw);
padding: 32px 28px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
box-shadow: 0 24px 60px rgba(0,0,0,0.45);
animation: modalSlideIn 0.2s ease;
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(-12px) scale(0.97) }
to { opacity: 1; transform: translateY(0) scale(1) }
}
.reloadModalIcon {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--surface2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
margin-bottom: 4px;
flex-shrink: 0;
}
.reloadIconSuccess {
background: var(--green-dim);
border-color: rgba(57,217,138,0.3);
color: var(--green);
}
.reloadIconError {
background: var(--red-dim);
border-color: rgba(255,80,80,0.3);
color: var(--red);
}
.reloadSpinner {
width: 22px;
height: 22px;
border: 3px solid var(--border2);
border-top-color: var(--text-2);
border-radius: 50%;
animation: spin 0.75s linear infinite;
display: block;
}
.reloadModalTitle {
font-size: 16px;
font-weight: 800;
color: var(--text);
letter-spacing: -0.2px;
}
.reloadModalSub {
font-size: 12px;
color: var(--text-3);
text-align: center;
line-height: 1.5;
}
.reloadModalError {
font-size: 12px;
font-family: var(--mono);
color: var(--red);
background: var(--red-dim);
border: 1px solid rgba(255,80,80,0.25);
border-radius: 6px;
padding: 8px 12px;
width: 100%;
text-align: center;
word-break: break-all;
}
.reloadStats {
display: flex;
align-items: center;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
margin-top: 4px;
width: 100%;
}
.reloadStat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 3px;
}
.reloadStatDivider {
width: 1px;
height: 36px;
background: var(--border);
flex-shrink: 0;
}
.reloadStatNum {
font-family: var(--mono);
font-size: 22px;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.reloadStatLabel {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-3);
}
.reloadModalActions {
display: flex;
gap: 8px;
margin-top: 6px;
width: 100%;
}
.reloadPageBtn {
flex: 1;
padding: 9px 0;
background: var(--green);
color: #000;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
font-family: var(--sans);
cursor: pointer;
transition: opacity 0.15s;
}
.reloadPageBtn:hover { opacity: 0.85; }
.reloadCloseBtn {
flex: 1;
padding: 9px 0;
background: var(--surface3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: var(--sans);
cursor: pointer;
transition: background 0.15s;
}
.reloadCloseBtn:hover { background: var(--border); }
+20 -7
View File
@@ -24,7 +24,7 @@ async function resetProgress(scope, opts) {
})
}
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode }) {
export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioStart, isExamMode, examProgress }) {
const [tab, setTab] = useState('problem')
const [setupState, setSetupState] = useState('idle') // idle | running | done | error
const [validating, setValidating] = useState(false)
@@ -74,7 +74,9 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
}, [isExamMode])
useEffect(() => {
if (!isExamMode || !scenario || scenario.progress?.status === 'completed') return
// Only track time in exam mode, and only if the scenario isn't already completed in the exam
const examCompleted = isExamMode && examProgress?.[scenario?.id]?.status === 'completed'
if (!isExamMode || !scenario || examCompleted) return
// Begin tracking immediately: if progress database has no started_at record yet, initialize it
if (!scenario.progress?.started_at) {
@@ -108,7 +110,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
keepalive: true
}).catch(() => {})
}
}, [scenario?.id, scenario?.progress?.status, isExamMode, onProgressUpdate])
}, [scenario?.id, scenario?.progress?.status, isExamMode, examProgress, onProgressUpdate])
async function runSetup() {
setSetupState('running')
@@ -190,6 +192,8 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
}
const isCompleted = scenario.progress?.status === 'completed'
// In exam mode, use exam-specific completion status for the banner
const isExamCompleted = isExamMode && examProgress?.[scenario.id]?.status === 'completed'
return (
<div className={styles.panel}>
@@ -201,7 +205,7 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
{scenario.difficulty}
</span>
<span className={styles.typeTag}>{scenario.type === 'mcq' ? 'Multiple Choice' : 'Hands-on Task'}</span>
<span className={styles.weight}>{scenario.weight} pts</span>
{!isExamMode && <span className={styles.weight}>{scenario.weight} pts</span>}
</div>
<div className={styles.titleRow}>
<div className={styles.scenarioTitle}>{scenario.title}</div>
@@ -248,20 +252,29 @@ export default function ScenarioPanel({ scenario, onProgressUpdate, onScenarioSt
}
}}
>
Reset
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 5, verticalAlign: 'middle' }}>
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
Reset
</button>
)}
</div>
{isCompleted && (
{(isCompleted && !isExamMode) && (
<div className={styles.completedBanner}>
<span></span> Scenario completed
</div>
)}
{isExamCompleted && (
<div className={styles.completedBanner}>
<span></span> Completed in this exam
</div>
)}
</div>
{/* Tabs */}
<div className={styles.tabs}>
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' && !isExamMode ? ['validate'] : [])].map(t => (
{['problem', ...(isExamMode ? [] : ['hints']), ...(scenario.type === 'task' ? ['validate'] : [])].map(t => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.activeTab : ''}`}
+34 -10
View File
@@ -16,6 +16,7 @@ export default function Sidebar({
scenarios, activeId, onSelect, loading,
collapsed, onToggleCollapse, width,
activeBundleId, onProgressUpdate,
isExamMode, examProgress,
}) {
const [filterDiff, setFilterDiff] = useState('All')
const [filterType, setFilterType] = useState('All')
@@ -64,7 +65,10 @@ export default function Sidebar({
}, [activeId, scenarios])
const toggle = cat => setOpen(o => ({ ...o, [cat]: !o[cat] }))
const totalDone = scenarios.filter(s => s.progress?.status === 'completed').length
const totalDone = isExamMode
? scenarios.filter(s => examProgress?.[s.id]?.status === 'completed').length
: scenarios.filter(s => s.progress?.status === 'completed').length
const handleCategoryReset = async (e, cat) => {
e.stopPropagation()
@@ -88,7 +92,11 @@ export default function Sidebar({
{/* Top bar */}
<div className={styles.sidebarTop}>
{!collapsed && <span className={styles.sidebarTitle}>Scenarios</span>}
{!collapsed && <span className={styles.sidebarCount}>{totalDone}/{scenarios.length}</span>}
{!collapsed && (
<span className={styles.sidebarCount}>
{totalDone}/{scenarios.length}
</span>
)}
<button
className={styles.collapseBtn}
onClick={onToggleCollapse}
@@ -147,14 +155,21 @@ export default function Sidebar({
<span className={styles.catName}>{cat}</span>
</div>
<div className={styles.accordionRight}>
<span className={styles.catCount}>{catDone}/{items.length}</span>
<span className={styles.catCount}>
{isExamMode
? `${items.filter(s => examProgress?.[s.id]?.status === 'completed').length}/${items.length}`
: `${catDone}/${items.length}`}
</span>
{hasCatProgress && (
<button
className={styles.catResetBtn}
title={`Reset all progress in "${cat}"`}
onClick={e => handleCategoryReset(e, cat)}
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
</div>
@@ -163,20 +178,23 @@ export default function Sidebar({
{isOpen && (
<div className={styles.itemsBox}>
{items.map(s => {
const done = s.progress?.status === 'completed'
const examDone = isExamMode && examProgress?.[s.id]?.status === 'completed'
const done = !isExamMode && s.progress?.status === 'completed'
const active = s.id === activeId
const hasAttempts = s.progress?.attempts > 0
const hasAttempts = isExamMode
? (examProgress?.[s.id]?.attempts || 0) > 0
: s.progress?.attempts > 0
return (
<button
key={s.id}
className={`${styles.item} ${active ? styles.active : ''} ${done ? styles.done : ''}`}
className={`${styles.item} ${active ? styles.active : ''} ${(done || examDone) ? styles.done : ''}`}
onClick={() => onSelect(s.id)}
>
<div className={styles.itemTop}>
<span className={styles.itemNum}>{scenarioIndex[s.id]}</span>
<span className={styles.typeIcon}>{TYPE_ICON[s.type] || '•'}</span>
<span className={styles.itemTitle}>{s.title}</span>
{done && <span className={styles.checkmark}></span>}
{(done || examDone) && <span className={styles.checkmark}></span>}
{/* Per-scenario reset — shown when item has attempts */}
{hasAttempts && (
<button
@@ -184,7 +202,10 @@ export default function Sidebar({
title="Reset this scenario's progress"
onClick={e => handleScenarioReset(e, s.id, s.title)}
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
</div>
@@ -193,7 +214,10 @@ export default function Sidebar({
{s.difficulty}
</span>
<span className={`${styles.type} ${styles[s.type]}`}>{s.type.toUpperCase()}</span>
<span className={styles.weight}>{s.weight}pt</span>
{!isExamMode && <span className={styles.weight}>{s.weight}pt</span>}
{isExamMode && examDone && (
<span className={styles.examCompletedTag}> Completed</span>
)}
</div>
</button>
)
@@ -337,3 +337,16 @@
}
@keyframes slideIn { from{opacity:0;transform:translateX(-4px)} to{opacity:1;transform:translateX(0)} }
.examCompletedTag {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 3px;
background: var(--green-dim);
color: var(--green);
border: 1px solid color-mix(in srgb, var(--green) 30%, transparent);
letter-spacing: 0.3px;
margin-left: auto;
}
+5 -1
View File
@@ -152,7 +152,11 @@ export default function TerminalComponent({ collapsed, onToggleCollapse }) {
</div>
<div className={styles.barRight}>
<button className={styles.barBtn} onClick={connect} title="Reconnect terminal">
Reconnect
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 5, verticalAlign: 'middle' }}>
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
Reconnect
</button>
<button className={styles.barBtn} onClick={onToggleCollapse} title={collapsed ? 'Expand terminal' : 'Collapse terminal'}>
{collapsed ? '▲' : '▼'}