lct-10: карточка КИО — форма, слияние правок, read-only

Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.

Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.

Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.

Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:16:51 +03:00
commit 48c936ea6e
8 changed files with 454 additions and 3 deletions

View file

@ -10,7 +10,11 @@ import { Ambience } from "@/shared/audio/ambience";
import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import type { CallIncoming, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
const PATCH_DEBOUNCE_MS = 300;
export interface Line {
speaker: "caller" | "operator";
@ -28,12 +32,15 @@ export function useCall(sessionId: string | null) {
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [callerSpeaking, setCallerSpeaking] = useState(false);
const [micOn, setMicOn] = useState(false);
const [card, setCard] = useState<CardState>(emptyCard);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null);
const audio = useRef<{ context: AudioContext; playback: Playback; ambience: Ambience } | null>(null);
const gate = useRef(new EnergyGate());
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const append = useCallback((line: Line) => {
setLines((prev) => {
@ -82,6 +89,9 @@ export function useCall(sessionId: string | null) {
case "bg.stop":
audio.current?.ambience.stop();
break;
case "kio.patch":
setCard((prev) => applyPatch(prev, event.fields, event.source));
break;
case "timer.tick":
setTimers(event.timers);
break;
@ -137,5 +147,23 @@ export function useCall(sessionId: string | null) {
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
return { status, phase, incoming, lines, timers, callerSpeaking, micOn, error, answer, hangup, hint };
const patchKio = useCallback((path: string, value: unknown) => {
setCard((prev) => edit(prev, path, value));
outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => {
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service: DDSCode) => {
channel.current?.send({ type: "dds.dispatch", service });
}, []);
return {
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
answer, hangup, hint, patchKio, dispatch,
};
}