lct-hack/frontend/src/pages/profile/Profile.tsx
Ivan Gerasimov 38082e2af3 lct-18: профиль курсанта — радар, история попыток, дельта
Работа над ошибками становится измеримым циклом: посмотрел разбор →
попробовал снова → увидел дельту по оценке, времени опроса и добытым
фактам.

В отчёт занятия добавлена сводка числами — время опроса, факты,
подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором
текста метрик («94 с»), а это путь к тихим ошибкам.

Радар — среднее по попыткам: одна неудачная попытка не определяет
курсанта.

Проверено сквозным тестом на живой базе: два занятия одного курсанта
дают две попытки и посчитанную дельту. Проверка доступности базы в тесте
была ложной — синхронного драйвера в проекте нет, и тест молча
пропускался.
2026-09-17 21:42:12 +03:00

120 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Профиль курсанта: радар компетенций, история попыток, дельта между ними.
//
// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md).
import { useState } from "react";
import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar";
import { clock } from "@/features/timers/clock";
import { useProfile, useTrainees } from "@/shared/api/http";
const MODE_LABELS: Record<string, string> = {
training: "тренировочный",
exam: "контрольный",
self: "самостоятельный",
};
function sign(value: number | null, format: (value: number) => string): string {
if (value === null) return "—";
if (value === 0) return "без изменений";
return `${value > 0 ? "+" : "−"}${format(Math.abs(value))}`;
}
export function Profile() {
const url = new URLSearchParams(location.search);
const [traineeId, setTraineeId] = useState<string | null>(url.get("trainee"));
const trainees = useTrainees();
const profile = useProfile(traineeId);
return (
<main className="page">
<h1>Профиль курсанта</h1>
<p>
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
<option value="">— выберите курсанта —</option>
{trainees.data?.map((trainee) => (
<option key={trainee.id} value={trainee.id}>
{trainee.name}{trainee.group ? ` · ${trainee.group}` : ""}
</option>
))}
</select>
</p>
{profile.data && (
<div className="columns">
<section>
<h2>
{profile.data.trainee.name}
{profile.data.trainee.group && ` · группа ${profile.data.trainee.group}`}
</h2>
<Radar
values={Object.entries(profile.data.competencies).map(([competency, value]) => ({
competency,
value,
}))}
/>
<p className="ref">
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
</p>
<h3>Дельта попыток</h3>
<table className="grid">
<tbody>
{profile.data.deltas.map((delta, index) => (
<tr key={index}>
<th>{delta.scenario_id} · {delta.from_attempt} → {delta.to_attempt}</th>
<td>
оценка {sign(delta.score, (value) => value.toFixed(0))}
{" · "}опрос {sign(delta.interview_ms, (value) => clock(value))}
{" · "}фактов {sign(delta.facts_got, (value) => String(value))}
</td>
</tr>
))}
{profile.data.deltas.length === 0 && (
<tr><td colSpan={2}>повторных попыток по сценарию ещё не было</td></tr>
)}
</tbody>
</table>
</section>
<section>
<h2>История занятий</h2>
<table className="grid">
<tbody>
{profile.data.attempts.map((attempt) => (
<tr key={attempt.session_id}>
<th>
{new Date(attempt.created_at).toLocaleString("ru-RU")}
<div className="ref">{MODE_LABELS[attempt.mode] ?? attempt.mode} · попытка {attempt.attempt}</div>
</th>
<td>
{attempt.scenario_id} · оценка {attempt.score?.toFixed(0) ?? "—"}
<div className="ref">
опрос {attempt.interview_ms === null ? "не завершён" : clock(attempt.interview_ms)}
{attempt.facts_required !== null && ` · факты ${attempt.facts_got}/${attempt.facts_required}`}
{attempt.hints ? ` · подсказок ${attempt.hints}` : ""}
{Object.keys(attempt.codes).length > 0 &&
` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`}
</div>
<a href={`/wall?session=${attempt.session_id}`}>разбор</a>
</td>
</tr>
))}
{profile.data.attempts.length === 0 && <tr><td colSpan={2}>занятий не было</td></tr>}
</tbody>
</table>
</section>
</div>
)}
{profile.isError && <p className="violated">Профиль не загрузился.</p>}
{!traineeId && <p className="ref">Курсанты появляются после первого занятия.</p>}
<p className="ref">
Компетенции: {Object.values(COMPETENCY_LABELS).join(" · ")}
</p>
</main>
);
}