Files
avandClaude Opus 4.8 1f4267a046 Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:12:28 +03:00

178 lines
14 KiB
Markdown
Raw Permalink 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.
## Context
Экран ревью (`web/templates/review.html` + `internal/httpapi/review.go`) сейчас
состоит из трёх секций:
- **Догадка** — тип (переключатель movie↔series, POST `/type`), название, год;
- **Источник совпадения** — единый список вариантов (`.Sources`); у каждого
не-активного варианта — нативный `<details>` с предпросмотром раскладки, и
отдельная кнопка «выбрать» (POST `/candidate` или `/nobase`); ниже — форма
ручного добавления (POST `/source`);
- **Раскладка** — та же таблица предпросмотра, но для активного источника.
Все действия — обычные `<form method=post>` c PRG-редиректом (`reviewAction`
`redirectReview`, `303`). htmx подключён (`review.html:8`), но на странице ревью
не используется; на других страницах он уже применяется для фрагментов
(`hx-get .../progress`, `every 3s`).
Домен уже отдаёт всё нужное: `worker.ReviewData` строит `Sources []SourceOption`
(нейронка + кандидаты), у каждого — эффективные поля и эфемерный предпросмотр;
верхнеуровневые поля `reviewView` (`Title/Year/Files/...`) уже соответствуют
**активному** источнику. Инвариант «превью == применённое» обеспечивается тем,
что выбор источника пишет те же пины, что показаны в превью.
## Goals / Non-Goals
**Goals:**
- Слить три секции в один блок: список вариантов (радио) → инфо о выбранном →
предпросмотр раскладки выбранного.
- Выбор варианта — одним кликом/тапом по строке; инфо и предпросмотр
обновляются немедленно, без полной перезагрузки (htmx частичный своп блока).
- Тип показывать read-only; убрать переключатель типа с веб-экрана.
- Сохранить: ручное добавление источника, инвариант «превью == применённое»,
тонкость транспорта (доменная логика в `worker` не трогается).
**Non-Goals:**
- Менять доменный слой `internal/worker/review.go` (выбор источника, построение
плана/превью остаются как есть).
- Менять команду `SetType` в домене и её доступность в Telegram (убираем только
веб-контрол).
- Клиентский рефреймворк/сборка. Оптимизация «не считать превью для не-активных
источников» — отдельная будущая задача, не входит сюда.
## Decisions
### Решение 1: htmx частичный своп единого блока (не полная перезагрузка)
Выделяем единый блок в партиал `web/templates/partials/review_source_block.html`
с корневым контейнером `id="source-block"`. Партиал рендерится из того же
`reviewView`: список радио из `.Sources`, инфо — из верхнеуровневых полей
активного источника (`.Title/.OriginalTitle/.Year/.IsSeries/.SeasonSummary`),
предпросмотр — из `.Files`.
Радиокнопка варианта несёт htmx-атрибуты: `hx-trigger="change"`,
`hx-target="#source-block"`, `hx-swap="outerHTML"` и `hx-post` на эндпоинт
выбора. Клик по строке (label оборачивает кликабельную зону строки) переключает
радио → `change` → POST → сервер сохраняет выбор и возвращает **свежий партиал
блока** → htmx подменяет блок. Инфо и предпросмотр в новом партиале уже
относятся к новому активному источнику.
Все радио вариантов имеют **общий `name`** для взаимной эксклюзивности;
кандидатские несут `value`=`candidate_id` и постят на `/candidate`, нейронка —
пустое `value` и постит на `/nobase` (тот `candidate_id` игнорирует).
**Внешняя ссылка «запись ↗»** у кандидата (открывается в новой вкладке) НЕ
должна попадать в кликабельную зону label — иначе клик по ссылке заодно
переключит источник. Выносим ссылку из `<label>` (или гасим всплытие клика),
чтобы «перейти к записи» и «выбрать источник» не конфликтовали.
**Почему так, а не клиентское переключение:** сохранение выбора — доменная
операция (пишет override/пины), поэтому нужен раундтрип; после него активный
источник и его превью пересчитываются на сервере единой логикой (инвариант
«превью == применённое» держится сам собой). Чистый клиентский свитч потребовал
бы дублировать превью-логику и рассинхронизировался бы с применением.
**Альтернатива (отклонено):** заранее рендерить инфо+превью всех источников и
показывать активный через CSS/JS без запроса. Отклонено: выбор не сохранялся бы,
«Применить» не знал бы что применять, и вернулась бы рассинхронизация
превью/применения.
### Решение 2: эндпоинты выбора становятся htmx-aware, без новых роутов
Переиспользуем существующие POST-эндпоинты `/candidate`, `/nobase`, `/source`.
Радио кандидата постит на `/candidate` (поле `candidate_id` = value радио), радио
нейронки — на `/nobase`, форма ручного добавления — на `/source` (тоже
`hx-post`, target = `#source-block`).
Хендлеры (`handleChooseCandidate`, `handleNoBase`, `handleAddSource`) после
успешной доменной операции определяют htmx-запрос по заголовку `HX-Request` и:
- при htmx — перечитывают `ReviewData`, рендерят **партиал блока** (`200`);
- без htmx (фолбек) — как сейчас, PRG-редирект на `/review/{id}`.
Это **новый паттерн** для проекта: существующие живые партиалы (`progress`,
`seeding`) работают через отдельные GET-роуты `/fragments/...` с htmx-поллингом,
а не через ветвление одного POST-эндпоинта по `HX-Request` — так что чтение
`r.Header.Get("HX-Request")` вводится здесь впервые. Сам механизм рендера одного
партиала уже есть: `server.render(w, "<name>", data)` вызывает
`ExecuteTemplate` по имени define (как `render(w, "progress", …)`), никаких
правок в `render` не нужно.
Ошибку (напр. невалидный ручной ввод) на htmx-пути рендерим тем же партиалом с
баннером ошибки **внутри блока** и **без смены активного источника**
(перечитанный `ReviewData` отражает прежний матч). Чтобы не задваивать баннер с
уже существующим верхним `?err=` (его показывают PRG-редиректы других действий —
apply/defer/cancel), ошибку блока держим в **отдельном поле** view (напр.
`BlockError`), которое рендерит только партиал; верхний `.Error` остаётся для
полностраничного `?err=`. Общий помощник — по образцу `reviewAction`, но с
ветвлением htmx/redirect (напр. `reviewBlockAction`).
**Почему не новый единый роут `/select`:** минимизируем изменения и
переиспользуем валидацию и доменные вызовы; семантика «кандидат» vs «без базы»
уже разведена по эндпоинтам.
### Решение 3: тип — read-only, веб-контрол `/type` убираем
Из шаблона убираем форму переключения типа; тип показываем текстом
(`фильм`/`сериал`) в инфо-части. Роут `/type` и `handleSetType` в `httpapi`
удаляем (веб — единственный их потребитель; Telegram вызывает `worker.SetType`
напрямую, доменный метод остаётся). Перед удалением — убедиться grep'ом, что на
`/type`/`handleSetType` в `httpapi` больше никто не ссылается.
Корректировать тип пользователь по-прежнему может через «Уточнить» (мягкая
подсказка «это сериал»), что согласовано в модифицированном требовании «Команды
ревью и их эффекты».
### Решение 4: инфо-часть — состав полей
Инфо-часть выбранного источника: тип (read-only), название, ориг. название, год,
для сериала — **сводка сезонов**, плюс зарезервированное место под режиссёра
(пустой прочерк).
Сезон в плане задан **на каждом файле** (`recognize.PlanFile.Season *int`), а не
на плане целиком — одна раздача может быть многосезонным паком. Поэтому в
инфо-части показываем компактную сводку по различным сезонам эпизодных файлов
эффективного плана (`rd.Plan`):
- один сезон → «Сезон 2»;
- несколько подряд → «Сезоны 1–3» (диапазон), с разрывами → список «Сезоны 1,
3, 4»;
- только спецвыпуски (`Season == nil`/0) → «Спецвыпуски».
Сводку собираем в транспорте из `rd.Plan.Files` по файлам с ролью `episode`
(игнор-файлы `applyOverrides` уже пометил ролью `ignore` — они выпадают из
фильтра). `PlanFile.Season``*int`, где и `nil`, и `*0` трактуются как
спецвыпуск. Добавляем в `reviewView` строковое поле `SeasonSummary string`
(пусто для фильма). Номер сезона построчно и так виден в предпросмотре раскладки
(`.../Season 02/...`); сводка — это верхнеуровневая подпись-страховка «что за
сезоны в раздаче», обычный случай — один сезон.
## Risks / Trade-offs
- **[Выбор одним кликом требует htmx/JS — нет чистого no-JS фолбека выбора]** →
htmx всегда загружен, инструмент однопользовательский домашний; прочие
действия (Применить/Уточнить/Позже/Отклонить) остаются обычными формами и
работают без JS; хендлеры сохраняют redirect-фолбек, так что без htmx выбор
деградирует до перезагрузки, а не ломается (radio без submit-кнопки, впрочем,
без JS не отправится — это осознанный компромисс UI-мелочи).
- **[Случайный клик меняет сохранённый матч]** → эффект не разрушительный
(хардлинки только по «Применить»), возврат — один клик по другому варианту.
- **[Панель действий вне свопаемого блока может рассинхрониться]** (`Применить`
зависит от `HasLinks`) → на практике план/превью есть всегда, когда есть
активный источник, поэтому набор действий при переключении источников не
меняется; если понадобится — обновляем панель через `hx-swap-oob` из того же
партиала.
- **[Мобильный тап]** → строка-вариант должна иметь крупную кликабельную зону
(label оборачивает всю строку), проверить на узком экране.
## Migration Plan
Чистая замена рендера страницы ревью; данные/БД не затрагиваются, миграций нет.
Откат — возврат шаблона и хендлеров. Деплой — обычная пересборка бинаря.
## Open Questions
- Нет — открытые вопросы закрыты (сезон показываем сводкой, см. Решение 4).