Files
jellybit/openspec/changes/archive/2026-08-10-card-live-refresh/specs/web-ui/spec.md
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

15 KiB
Raw Blame History

ADDED Requirements

Requirement: Самообновление живой задачи

Карточка списка и страница /download/{id} SHALL самообновляться, пока задача наблюдаема, и SHALL прекращать самообновление, как только она наблюдаемой быть перестала. Наблюдаемы все нетерминальные задачи, а из терминальных — те, которые фоновая сверка возвращает в поток сама: failed, target_missing, orphaned. Задача, которую с места двигает только человек (done, cancelled, reverted, deleted), наблюдаемой не является. Признак SHALL жить в домене рядом с признаком терминальности; второго перечня состояний веб-UI MUST NOT заводить.

Самообновление SHALL приносить смену состояния целиком — бейдж статуса, заголовок, набор доступных действий и живые цифры, если они есть, — и MUST NOT сбрасывать клиентские фильтр, поиск и прокрутку. Смена, произошедшая без участия этого браузера (переход воркера, действие из Telegram, фоновая сверка), MUST становиться видимой тем же способом, пока задача наблюдаема: интерфейс не знает, кто изменил состояние.

У одной поверхности SHALL быть ровно один источник самообновления. Вложенные живые регионы (прогресс качания в карточке, секция раздачи на странице) MUST NOT опрашивать сервер самостоятельно: своп корня уносит вложенный узел вместе с его поллером, поэтому два опроса на одну поверхность подменяют разметку друг друга и опрашивают одно и то же дважды.

Интервал самообновления SHALL зависеть от того, несёт ли поверхность блок живых цифр качания: у поверхности с таким блоком интервал SHALL быть строго меньше, чем у поверхности без него. Числовые значения интервалов живут в документации проекта, не в спеке.

Тик самообновления, не сумевший прочитать задачу (записи нет, хранилище отказало), SHALL отвечать успехом и фрагментом, который объясняет положение дел и не несёт самообновления: неуспешный ответ не заменяет разметку, поэтому поверхность осталась бы прежней, а опрос продолжался бы бесконечно.

Scenario: Завершение качания видно без перезагрузки

  • GIVEN открыт список загрузок и в нём есть задача в downloading
  • WHEN qBittorrent довёл раздачу до конца и воркер увёл задачу в recognizing и дальше в review
  • THEN карточка без перезагрузки страницы показывает бейдж ревью и кнопку «Ревью →»
  • AND блок живого прогресса с неё исчезает

Scenario: Переход, сделанный не из этого браузера

  • GIVEN открыт список загрузок и в нём есть задача в review
  • WHEN человек подтвердил план из Telegram и задача прошла linking в done
  • THEN карточка без перезагрузки страницы показывает бейдж done и действия терминальной задачи

Scenario: Ненаблюдаемая задача не опрашивается

  • WHEN задача находится в done, cancelled, reverted или deleted
  • THEN её карточка и страница /download/{id} не несут самообновления, и фоновых запросов по ним не уходит

Scenario: Задача, оживлённая сверкой, видна без перезагрузки

  • GIVEN открыт список, и в нём есть задача в failed (магнет не добрал метаданные за отведённое время)
  • WHEN источник ожил и фоновая сверка вернула задачу в downloading
  • THEN карточка без перезагрузки страницы показывает состояние качания

Scenario: Один источник обновления на поверхность

  • WHEN отрисована карточка задачи в downloading или страница задачи, чья раздача сидирует
  • THEN самообновление объявлено ровно в одном месте поверхности, а вложенные живые регионы своего опроса не ведут

Scenario: Быстрее обновляется то, где есть живые цифры

  • WHEN рядом отрисованы карточка задачи в downloading и карточка задачи в review
  • THEN объявленный интервал самообновления первой строго меньше, чем у второй

Scenario: Тик, который не смог прочитать задачу

  • GIVEN открыта карточка наблюдаемой задачи
  • WHEN очередной тик самообновления не нашёл записи или получил отказ хранилища
  • THEN ответ успешен и несёт фрагмент с объяснением
  • AND фрагмент не несёт самообновления, поэтому опрос прекращается

Scenario: Группа и фильтр списка пересчитываются навигацией

  • GIVEN открыт список и в нём есть задача в downloading
  • WHEN задача дошла до терминального состояния на глазах у смотрящего
  • THEN карточка показывает новое состояние и остаётся на своём месте в прежней группе списка
  • AND группа и фильтр пересчитываются при следующей навигации или перезагрузке — список целиком самообновлением не пересобирается

MODIFIED Requirements

Requirement: Отображение промежуточного состояния catched

Веб-UI SHALL отображать состояние catched как штатную промежуточную фазу («поймано, добавляется в qBittorrent»): бейдж статуса загрузки SHALL иметь понятную человекочитаемую подпись для catched (а не сырое catched), а загрузка в catched SHALL относиться к активной группе списка.

Пока отображаемое имя ещё не выведено (в catched download.display_name пуст), заголовок загрузки SHALL деградировать по существующему фолбеку (распознанное название или усечённый источник) — см. «Заголовок загрузки из имени раздачи». Секция раздачи/живого прогресса для catched SHALL корректно отсутствовать (раздачи в qBittorrent ещё нет), не создавая ошибок отображения.

Самообновление карточки и страницы в catched — частный случай требования «Самообновление живой задачи»: catched нетерминален, поэтому интерфейс подхватывает переход в downloading (бейдж, выведенное имя, появившийся живой прогресс) без перезагрузки страницы. Отдельного правила самообновления для этой фазы веб-UI MUST NOT иметь: фаза перестала быть единственной, где интерфейс обновляется сам.

Scenario: Бейдж и группа для catched

  • WHEN загрузка находится в состоянии catched
  • THEN её бейдж статуса имеет человекочитаемую подпись для catched
  • AND загрузка попадает в активную группу списка

Scenario: Заголовок catched без имени

  • GIVEN загрузка в catched с пустым download.display_name
  • WHEN рендерится карточка/страница загрузки
  • THEN заголовок берётся из фолбека (распознанное название или усечённый источник), без ошибок отображения
  • AND секция раздачи/живого прогресса не показывается (раздачи ещё нет)

Scenario: Самообновление при переходе в downloading

  • GIVEN открытая карточка загрузки в catched
  • WHEN worker перевёл загрузку в downloading
  • THEN интерфейс без перезагрузки показывает состояние downloading (бейдж, имя, живой прогресс)
  • AND самообновление продолжается, потому что задача осталась наблюдаемой

Requirement: Обзор жизненного цикла в карточке списка

Карточка загрузки в списке SHALL показывать обзорную мета-строку для решения о судьбе раздачи: метку ID: перед копируемым идентификатором загрузки, дату добавления раздачи (всегда), размер раздачи и рейтинг отдачи. Контекст загрузки MUST NOT показываться в карточке списка — он доступен на странице /download/{id}.

Дата добавления SHALL показываться всегда как абсолютная дата и относительная давность (например «2026-06-30 · 5 дней назад»); источником SHALL быть время добавления раздачи в источник (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки (created_at), согласованным с порядком списка.

Рейтинг отдачи SHALL браться из живого снимка телеметрии; если торрента нет в снимке (источник ушёл из qBittorrent), рейтинг SHALL отображаться прочерком «—».

Размер раздачи SHALL браться из живого снимка (общий размер торрента), а при отсутствии торрента в снимке — из суммарного размера разложенных файлов загрузки; если неизвестно ни то, ни другое — прочерк «—».

Карточка, пришедшая самообновлением, SHALL показывать те же значения, что и карточка в полном рендере списка: фоновое обновление MUST NOT подменять известное значение прочерком.

Scenario: Метка идентификатора

  • WHEN рендерится карточка загрузки в списке
  • THEN перед значением download.id показана метка «ID:», а кнопка копирования копирует именно download.id

Scenario: Дата добавления показана всегда

  • WHEN рендерится любая карточка списка
  • THEN в ней показана дата добавления раздачи абсолютной датой и относительной давностью
  • AND если source_added_at неизвестно, используется created_at

Scenario: Рейтинг из живого снимка

  • WHEN торрент загрузки присутствует в живом снимке
  • THEN в карточке показан его рейтинг отдачи
  • AND если торрента в снимке нет, рейтинг показан прочерком «—»

Scenario: Размер с фолбэком на разложенные файлы

  • WHEN торрент загрузки присутствует в живом снимке
  • THEN размер раздачи в карточке берётся из общего размера торрента
  • AND если торрента в снимке нет, но у загрузки есть разложенные файлы — размер берётся из суммарного размера этих файлов

Scenario: Самообновление не теряет размер

  • GIVEN торрента нет в живом снимке, а файлы задачи разложены
  • WHEN карточка пришла самообновлением, а не полным рендером списка
  • THEN размер показан по тому же фолбэку, а не прочерком «—»

Scenario: Контекст не в карточке

  • WHEN у загрузки есть переданный контекст
  • THEN он не показывается в карточке списка, но доступен на странице /download/{id}