lct-18: профиль курсанта — радар, история попыток, дельта

Работа над ошибками становится измеримым циклом: посмотрел разбор →
попробовал снова → увидел дельту по оценке, времени опроса и добытым
фактам.

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

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

Проверено сквозным тестом на живой базе: два занятия одного курсанта
дают две попытки и посчитанную дельту. Проверка доступности базы в тесте
была ложной — синхронного драйвера в проекте нет, и тест молча
пропускался.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:42:12 +03:00
commit 38082e2af3
8 changed files with 412 additions and 0 deletions

View file

@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
import { Call } from "@/pages/trainee/Call";
import { Instructor } from "@/pages/instructor/Instructor";
import { Profile } from "@/pages/profile/Profile";
import { Wall } from "@/pages/wall/Wall";
import { Stub } from "@/shared/ui/Stub";
@ -12,5 +13,6 @@ export const router = createBrowserRouter([
{ path: "/trainee", element: <Call /> },
{ path: "/instructor", element: <Instructor /> },
{ path: "/wall", element: <Wall /> },
{ path: "/profile", element: <Profile /> },
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
]);

View file

@ -185,6 +185,7 @@ export function Instructor() {
<section style={{ flex: "0 0 260px" }}>
<h2>История группы</h2>
<p><a href="/profile">профили курсантов</a></p>
<table className="grid">
<tbody>
{history.data?.slice(0, 15).map((item) => (

View file

@ -0,0 +1,120 @@
// Профиль курсанта: радар компетенций, история попыток, дельта между ними.
//
// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент
// планирования подготовки. Работа над ошибками превращается в измеримый цикл:
// посмотрел разбор → попробовал снова → увидел дельту (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>
);
}

View file

@ -70,6 +70,51 @@ export const useSessions = (params: { trainee?: string; group?: string; mode?: S
});
};
export interface TraineeInfo {
id: string;
name: string;
group: string | null;
}
export interface Attempt {
session_id: string;
scenario_id: string;
mode: SessionMode;
attempt: number;
created_at: string;
score: number | null;
interview_ms: number | null;
facts_got: number | null;
facts_required: number | null;
hints: number | null;
codes: Record<string, number>;
}
export interface Profile {
trainee: TraineeInfo;
attempts: Attempt[];
competencies: Record<string, number>;
deltas: {
scenario_id: string;
from_attempt: number;
to_attempt: number;
score: number | null;
interview_ms: number | null;
facts_got: number | null;
}[];
hints_total: number;
}
export const useTrainees = () =>
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
export const useProfile = (traineeId: string | null) =>
useQuery({
queryKey: ["profile", traineeId],
queryFn: () => request<Profile>(`/api/trainees/${traineeId}/profile`),
enabled: Boolean(traineeId),
});
export const useCreateSession = () =>
useMutation({
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>