Files
jellybit/openspec/changes/archive/2026-07-04-review-unified-source-block/design.md
T
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

14 KiB
Raw Blame History

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-редиректом (reviewActionredirectReview, 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).