Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
Проблема (как сформулировал оператор)
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту %
покрытия. Но сам выбор слотов под бронь по-прежнему неудобен: сейчас оператор
отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые /
все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не
знает, можно ли вообще физически отснять выбранный набор (манёвренность КА).
Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью)
- Ограничение брони через смену крена реально существует (агилити КА).
- КОРРЕКЦИЯ: модель манёвренности УЖЕ есть в slots-service, по-КА из каталога
(
mmi, maxSurveyDuration, maxChainLength, ROLL_EPSILON=0.01 — см. ниже).
Ручная бронь (bookReq) её не проверяет, но coverage-solver и buildSurveyChains
её соблюдают. Поэтому не хардкодим физику на фронте — проверка выполнимости
будет бэкенд validate-эндпоинтом, переиспользующим эти по-КА параметры.
- Iteration 1 = только UX выбора полосами (frontend, без правила). Проверка
выполнимости — iteration 2 (бэкенд validate-эндпоинт + проводка на фронте).
Что уже есть в бэкенде (источник правды для it.2)
- Параметры по-КА из каталога (SatelliteCatalogClient.kt:116,
AbstractSatellite.kt:25):
mmi=10с (мин. интервал маневра, тот же крен), maxSurveyDuration=300с,
maxChainLength=30 слотов, ROLL_EPSILON=0.01.
buildSurveyChains / canMergeInTime / sameRoll (SlotService.kt:2151)
— склейка цепочек по-КА (для отрисовки), OccupiedIntervalPrefilter — mmi/maxSurveyDuration.
- Несоответствия исходного черновика (закрыты решением «правило за бэком»): фронтовые
150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» —
в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа
сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся.
Что уже есть (карта на существующий код)
- Выбор:
selectedKeys: Set<slotKey> в TguSlotsTab.tsx.
Поштучный чек, Shift+клик-диапазон (slotSelection.ts selectionAfterCheck),
«выбрать видимые», «все сутки», «снять выбор».
- Слот несёт
roll, revolution, revolutionSign, cycle, satelliteId, slotNumber, tn, tk, state.
- Чистые модули с юнит-тестами: slotFilters.ts, slotSelection.ts, coverage.ts (+ .test.ts) — образец стиля.
- Бронь:
book() → подтверждение → performBook (чанк 200), снятие аналогично.
- Бэкенд бронирует без валидации манёвренности (SlotService.kt:2500). В it.1 не трогаем.
Решение — Iteration 1 (только UX выбора полосами, frontend) — ФИНАЛ после ревью
Модуль 1: slotStrips.ts (чистая логика + юнит-тесты)
groupStrips(slots, keyOf): Map<slotKey, StripId> — максимальные same-roll цепочки
по-КА. keyOf — аксессор ключа слота (как keyAt в selectionAfterCheck), чтобы
не дублировать формулу ${satelliteId}:${slotNumber}:${cycle}.
- Детерминизм: внутри функции тотальный порядок — сортировка по
(satelliteId, parseLocal(tn), parseLocal(tk), slotNumber, cycle, slotKey). Результат
не зависит от порядка входного массива.
- ε крена =
ROLL_EPSILON = 0.01 (именованная константа, как в бэкенде; НЕ 0.1).
Сравнение — с якорным креном полосы (abs(roll − anchorRoll) ≤ 0.01), НЕ с
предыдущим слотом (иначе дрейф крена 0.00→0.009→0.018 ошибочно склеит). Совпадает с
бэкендовым sameRoll(roll, chainRoll).
- StripId стабилен и читаем:
strip:${slotKey первого члена} (не счётчик — счётчик
зависит от порядка обхода КА).
- Краевые: один слот → своя полоса; два КА вперемешку по времени НЕ склеиваются;
tn не парсится (NaN) → синглтон без падения; ровно на границе ε (==0.01) → та же полоса.
Модуль 2: выбор «полосами» (UX в TguSlotsTab) — решения ревью
- Жест: чип в строке
полоса ×N (N = число видимых членов полосы). НЕ Alt+клик
(необнаружим, конфликт с чек/Shift), НЕ клик по карте как основной. Чип появляется
только при N≥2 и если есть хоть один выбираемый (не-BUSY) член.
- Identity ≠ selection target:
stripIdByKey = useMemo(groupStrips(displaySlots, slotKey), [displaySlots]) — id по
СТАБИЛЬНОМУ набору (не filteredSlots/sortedSlots — иначе фильтр-дырка ложно склеит/разорвёт).
- Видимые выбираемые члены полосы берём из
filteredSlots ∩ !slotIsBusy (по образцу
selectableVisible).
- Поведение чипа =
toggleAllVisible, но в рамках одной полосы: если ВСЕ видимые
выбираемые члены уже выбраны → снять их; иначе добавить. После — anchorIndexRef.current = null.
Без нового state (selectedStrips не заводим — рассинхрон с selectedKeys).
- Группировка визуально по StripId, sort-aware: в сортировке «время» — левый рельс
(border-left, цвет по
StripId, приглушённый) на смежных строках полосы. В «крен»/«вклад»
слоты полосы НЕ подряд → рельс НЕ рисуем; подсветка членов полосы по StripId при наведении.
Фон строки оставляем за состоянием (active/selected); статус-ячейки не перекрашиваем.
- Карта it.1: оставляем существующую подсветку выбранных слотов (
markedObjectIds уже
ведётся от selectedKeys). Отдельную раскраску полос на карте и «выбрать полосу из
SlotPickMenu» — НЕ делаем в it.1 (расширяет scope на модель/рисовалку карты).
- CSS/таблица: обновить
colCount; выравнивание ячеек — классами, не сдвигом
nth-child; чип держать в пределах ROW_HEIGHT = 34.
Матрица состояний строки (зафиксировать перед реализацией)
| Состояние |
Источник |
Визуал |
| active (фокус по карте) |
activeSlotId |
подсветка строки (как есть) |
| selected |
selectedKeys.has(key) |
чек отмечен |
| strip-selected |
все видимые выбираемые члены полосы выбраны |
чип в активном виде |
| partial |
часть членов полосы выбрана |
чип в нейтральном виде |
| disabled chip |
N<2 или 0 выбираемых членов |
чипа нет |
| booked/occupied |
как есть (бейдж) |
не меняется; в полосу входит визуально, в выбор — нет |
Реализовано (it.1, 2026-06-17)
slotStrips.ts (groupStrips, ROLL_EPSILON=0.01, якорный крен, тотальный порядок) +
slotStrips.test.ts (11 кейсов: мультиполоса, два КА, граница ε, дрейф крена, порядок,
tie-break, NaN tn, пустой вход).
toggleStripSelection в slotSelection.ts (чистый, без мутации) + тесты.
TguSlotsTab.tsx: stripIdByKey (memo над displaySlots), stripInfo (видимые члены /
выбираемые не-BUSY по полосе), чип полоса ×N в ячейке «КА» (активен при всех выбранных),
рельс по StripId (sortMode time), подсветка членов по StripId при наведении.
- Смежный баг закрыт:
anchorIndexRef сбрасывается при смене видимого набора (filteredSlots).
- Тесты 123 зелёных,
tsc -b чисто, vite build ок. Бэкенд/DTO не тронуты.
Решение — Iteration 2 (проверка выполнимости, отдельно)
- Бэкенд: read-only
POST /api/slots/booking/validate (набор слотов → violations),
переиспользует по-КА mmi/maxSurveyDuration/maxChainLength/ROLL_EPSILON из каталога.
Сверить точную семантику (300 vs «150 на полосу», нужна ли отдельная 30с-граница) с
баллистиками. Опционально позже — валидация и в самом bookReq.
- Фронт: гонять validate при выборе/перед бронью, показывать violations, решить
блокировать кнопку или предупреждать.
NOT in scope (it.1)
- Любая физика выполнимости на фронте (перенесено в it.2, за бэкенд).
- Переработка автоподбора покрытия (GREEDY/CONTINUOUS).
- Изменение модели хранения броней.
Тест-план (it.1) — финал
slotStrips.test.ts (groupStrips): мультиполоса в одном КА; два КА вперемешку по времени
(не склеиваются); один слот → своя полоса; граница ε (diff==0.01 склеивает, 0.0101 рвёт);
дрейф крена 0.00/0.009/0.018 (с якорным сравнением → две полосы); устойчивость к порядку
входа (равенство РАЗБИЕНИЯ при перемешивании); tie-break по равному tn; NaN tn → синглтон
без падения; пустой вход → пустая Map.
- Чистый хелпер выбора полосы (выделить из компонента, как slotSelection): скрытые члены
не выбираются; BUSY исключён; «все видимые выбраны → снять, иначе добавить»; нет мутации
входного
selectedKeys.
- Ручная проверка в vite dev: чип
полоса ×N, рельс в сортировке «время», подсветка по
StripId в «крен»/«вклад», согласованность с Shift+клик/чек/«все сутки», счётчик «Выбрано».
Смежная находка (вне scope, флаг оператору)
anchorIndexRef (якорь Shift-выбора) сбрасывается при смене сортировки, но НЕ при смене
фильтров (статус/КА/ветвь/сутки), хотя они тоже меняют порядок строк → Shift-диапазон после
фильтрации может выбрать не то. Существующий латентный баг, рядом с этой задачей. Дёшево
починить (сбрасывать якорь и при смене фильтров) — включить в it.1 или отдельным фиксом.
Review artifacts (/autoplan, 2026-06-16)
CEO consensus
| # |
Вопрос |
Claude |
Codex |
Консенсус |
| 1 |
Та ли проблема |
да (UX выбора) |
да |
CONFIRMED |
| 2 |
Фронт-физика — регрет? |
да, критично |
да, критично |
CONFIRMED → правило за бэк |
| 3 |
Бэкенд уже имеет модель? |
да (нашёл chain-логику) |
— |
подтверждено в коде |
| 4 |
Калибровка scope |
разбить |
разбить |
CONFIRMED → it.1 только UX |
Design consensus
| Тема |
Claude |
Codex |
Консенсус |
| Жест |
чип в строке |
чип в строке |
CONFIRMED |
| Grouping vs sort |
sort-aware по StripId |
sort-aware по StripId |
CONFIRMED |
| Скрытые члены |
только видимые |
только видимые |
CONFIRMED |
| Карта |
вторично/отложить |
вторично/отложить |
CONFIRMED |
| Состояния |
матрица |
матрица |
CONFIRMED |
Eng consensus
| Тема |
Claude |
Codex |
Консенсус |
| StripId над displaySlots |
да |
да |
CONFIRMED |
| Identity ≠ selection target |
да |
да |
CONFIRMED |
| Memo на [displaySlots] |
да |
да |
CONFIRMED |
| ε: якорь vs предыдущий |
оставил открытым |
якорь (анти-дрейф) |
DISAGREE → решено: якорь |
| Бэкенд-изменения в it.1 |
нет |
нет |
CONFIRMED |
| Сброс якоря на фильтрах |
— |
да (смежный баг) |
флаг оператору |
Decision Audit Trail
| # |
Фаза |
Решение |
Класс |
Принцип |
Обоснование |
| 1 |
premise |
Правило выполнимости — за бэкенд (не фронт-хардкод) |
User Challenge |
— |
оба ревьюера + бэкенд уже имеет по-КА модель из каталога |
| 2 |
premise |
it.1 = только UX выбора; правило → it.2 |
User Challenge |
P2/P3 |
развязать безопасный win от спорной физики |
| 3 |
design |
Жест = чип полоса ×N |
Taste |
P5 (явное) |
обнаружим, не конфликтует с чек/Shift |
| 4 |
design |
Grouping sort-aware (рельс в «время», подсветка иначе) |
Mechanical |
P1 |
зебра по строкам врёт вне time-сортировки |
| 5 |
design |
Чип выбирает только видимые не-BUSY члены |
Mechanical |
P1 |
не выбирать невидимое (доверие счётчику) |
| 6 |
eng |
StripId над displaySlots, memo на [displaySlots] |
Mechanical |
P3 |
стабильность id + перф на тысячах строк |
| 7 |
eng |
ε-сравнение с якорным креном полосы |
Mechanical |
P1 |
анти-дрейф, совпадает с бэкендовым sameRoll |
| 8 |
eng |
Без бэкенд/DTO-изменений в it.1 |
Mechanical |
P4 |
всё нужное уже в SlotDTO |
| 9 |
eng |
Карта — без раскраски полос в it.1 |
Mechanical |
P3/P5 |
не расширять scope на модель карты |