Files
dc-observatio/docs/tasks/TASK_slot-strip-selection.md
T
Дмитрий Соловьев 7c10f9a10d docs: бэклог #49–#53 + дизайн-план выбора слотов полосами
- бэклог-pcp-tgu-ops-ui: #50 (M1/M2/M3 слой поддержки решений), #51 (вызовы через
  gateway), #52/#53 (находки /review), #49 (баг обновления BUSY после снятия брони),
  раздел «Выполнено» (чистки по /review).
- docs/tasks/TASK_slot-strip-selection: дизайн-план и артефакты /autoplan по выбору
  полосами; отметка о реализации it.1.
2026-06-17 09:06:35 +03:00

178 lines
17 KiB
Markdown
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.
<!-- /autoplan restore point: /home/dasolovyev/.gstack/projects/missionis-observatio-terrae/feat-tgu-slots-filters-autoplan-restore-20260616-165024.md -->
# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
## Проблема (как сформулировал оператор)
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту %
покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор
отмечает слоты галочками поштучно (или 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<slotKey>` в [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<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 на модель карты |