# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони ## Проблема (как сформулировал оператор) Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту % покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые / все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не знает, можно ли вообще физически отснять выбранный набор (манёвренность КА). ## Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью) 1. **Ограничение брони через смену крена реально существует** (агилити КА). 2. **КОРРЕКЦИЯ:** модель манёвренности УЖЕ есть в slots-service, по-КА из каталога (`mmi`, `maxSurveyDuration`, `maxChainLength`, `ROLL_EPSILON=0.01` — см. ниже). Ручная бронь (`bookReq`) её не проверяет, но coverage-solver и `buildSurveyChains` её соблюдают. Поэтому **не хардкодим физику на фронте** — проверка выполнимости будет **бэкенд validate-эндпоинтом**, переиспользующим эти по-КА параметры. 3. **Iteration 1 = только UX выбора полосами** (frontend, без правила). Проверка выполнимости — **iteration 2** (бэкенд validate-эндпоинт + проводка на фронте). ## Что уже есть в бэкенде (источник правды для it.2) - Параметры по-КА из каталога ([SatelliteCatalogClient.kt:116](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt#L116), [AbstractSatellite.kt:25](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt#L25)): `mmi`=10с (мин. интервал маневра, тот же крен), `maxSurveyDuration`=300с, `maxChainLength`=30 слотов, `ROLL_EPSILON`=0.01. - `buildSurveyChains` / `canMergeInTime` / `sameRoll` ([SlotService.kt:2151](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2151)) — склейка цепочек по-КА (для отрисовки), `OccupiedIntervalPrefilter` — mmi/maxSurveyDuration. - **Несоответствия исходного черновика** (закрыты решением «правило за бэком»): фронтовые 150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» — в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся. ## Что уже есть (карта на существующий код) - Выбор: `selectedKeys: Set` в [TguSlotsTab.tsx](../../services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx). Поштучный чек, Shift+клик-диапазон ([slotSelection.ts](../../services/pcp-tgu-ui-service/src/features/tgu-slots/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](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2500)). В it.1 не трогаем. ## Решение — Iteration 1 (только UX выбора полосами, frontend) — ФИНАЛ после ревью ### Модуль 1: `slotStrips.ts` (чистая логика + юнит-тесты) - `groupStrips(slots, keyOf): Map` — максимальные 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 на модель карты |