Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
16 KiB
ТЗ #44 — Явные фильтры и группировка устройств
- Issue: https://github.com/Matysh/houseplan-card/issues/44
- Приоритет: P2, tech-debt; полный трек (discovery/UI/settings/i18n/tests — решение аналитики 2026-08-15)
- Ревизия: 4 (2026-08-30) — по SPEC-REVIEW-44-r2 (M1 + Low); policy v1 принята владельцем, конкретизация под инбокс v1.69
- Связано: device inbox #29, field registry/манифест #33 (паспорта
allow-extraобоих ключей уже выданы), персист-паттерн #377
Сценарий
Хозяин плана открывает «Устройства» и на вкладке «Доступны» видит новый раскрываемый блок «Фильтры обнаружения». Там два честных элемента: тумблер «Объединять светильники комнаты в группу» (включён по умолчанию — как продукт и вёл себя всегда) и список «Исключённые интеграции» с рекомендованным набором, поиском по реально присутствующим в HA интеграциям и кнопкой «Вернуть рекомендуемые». Изменение показывает счётчики «появится / скроется / сгруппируется» до сохранения; Сохранить пишет настройки один раз.
Что человек увидит до и после
До: группировка света и исключение интеграций происходят молча; причина
excluded_integration в инбоксе уже существует (#29), но безлична
(«Интеграция исключена фильтрами»). После: обе настройки видны и
управляемы; текст причины называет интеграцию по имени («Исключена
интеграция {integration}»); ничего не меняется само — только по Сохранить.
Проблема
Оба ключа — «третий вариант», запрещённый issue: влияют на результат, не
видны, без migration policy. Факты кода: group_lights читается как
!== false (default TRUE, devices.ts:1033/1098); exclude_integrations
при наличии ЦЕЛИКОМ подменяет продуктовый EXCLUDED_DOMAINS (13 доменов,
rules.ts:12; потребители houseplan-card.ts:3924, space-render.ts:245,
discovery devices.ts:1491).
Решение (policy v1, принята)
Оба ключа — поддерживаемые advanced-настройки. Storage-имена и семантика НЕ
меняются (обратная совместимость): group_lights отсутствие = true;
exclude_integrations отсутствие = продуктовый список, наличие = полная
замена. UI прячет эту механику за честными представлениями.
Скоуп
1. Блок «Фильтры обнаружения» в инбоксе
- Раскрываемая секция внизу вкладки «Доступны» диалога «Устройства» (editor-runtime; холодный View не задет — весь UI в runtime, класс #357).
- Тумблер группировки: отражает
settings.group_lights(unset→вкл). Выключение НЕ удаляет существующие маркеры (в т.ч. group-маркеры) — меняет только будущих кандидатов discovery. - Исключённые интеграции: чипы текущего действующего набора (ключ или продуктовый список); поиск-добавление по интеграциям, реально присутствующим в registry HA (platform-ы entity registry); удаление чипа; кнопка «Вернуть рекомендуемые» (= удалить ключ из settings). Если действующий набор равен продуктовому — ключ в settings отсутствует (паттерн «дефолт = отсутствие ключа», #377).
- Превью до сохранения: счётчики «появится N / скроется M / группировка изменит K» — пересчёт кандидатов discovery на черновых значениях (чистая функция buildDevices уже принимает settings/excluded через ctx — используется она же, без дублирования логики). Список затронутых биндингов раскрывается (имена, без лишних id).
- Транзакция: Изменения живут в черновике диалога; Сохранить пишет settings ОДИН раз штатным сериализованным путём (expected_rev #340); Отмена — no-op. Undo — штатный именованный снапшот конфига (паттерн существующих правок настроек).
2. Причина с именем интеграции (H1 r1: факт исправлен)
Причина excluded_integration УЖЕ существует и рендерится на вкладке
«Доступны» (#29, houseplan-editor-runtime.ts:7637,
device_inbox.reason_excluded_integration) — но текст обобщённый. Скоуп
этой задачи: тексту добавляется плейсхолдер {integration} с фактическим
platform-именем кандидата; вкладка НЕ меняется (перенос категории — отдельный
продуктовый вопрос, здесь не открывается). Значение reason и его механика не
трогаются.
3. Registry/доки
config-field-registry.mjs: статусы обоих ключейdecision-required→current,ui: 'device inbox → Discovery filters',enforcedBy— новый смок; migration-строки — «none, supported setting».- USER-GUIDE(.ru): подраздел «Фильтры обнаружения» в теме инбокса.
Не-скоуп
- Изменение семантики replacement→additive у
exclude_integrations(совместимость; additive-режим — отдельное решение, если понадобится). - Пер-пространственные фильтры; фильтры по доменам сущностей.
- Изменение самого продуктового списка
EXCLUDED_DOMAINS. - Материализация/
show_all(закрыто ранее, #33 registry).
Контракт поведения
- Конфиг без ключей → поведение байт-в-байт сегодняшнее (default-путь не
меняется ни в одном потребителе).
1a. (H2 r1)
roomClimateMap(devices.ts:1491) — единственное место вsrc/**, хардкодящееEXCLUDED_DOMAINSмимо настройки, — переводится на тот же резолвер действующего набора исключений, что и discovery: климат комнаты следует за настройкой пользователя, а не за старым жёстким списком (иначе UI создаёт новый «третий вариант»). Явный climate-opt-in (галочка «этот датчик меряет воздух комнаты») по-прежнему сильнее исключения — существующая веткаoptClimateне меняется. - Explicit-маркер (живой или tombstone) НИКОГДА не исчезает из-за смены фильтров — фильтры влияют только на автоматических кандидатов и seed.
- Оба значения читаются из одного источника (
_settings) всеми потребителями — новых копий состояния нет. Вернуть рекомендуемыеудаляет ключ (не пишет копию списка).- Никакой записи до явного Сохранить; двойная вкладка — штатный conflict.
UX / i18n
Новые ключи (en/ru/de/fr, все четыре — паритет-гейт): заголовок секции,
label+hint тумблера, label списка, placeholder поиска, кнопка reset,
счётчики превью (3), причина device_inbox.reason_excluded_integration
(с плейсхолдером {integration}). Порядка 9 ключей.
Модель данных и миграция
Нет изменений хранения: ключи те же, семантика та же. Бэкенд-схему не
трогаем: ключи остаются allow-extra (паспорта #33). Причина: перенос в
строгую схему — отдельное решение с рисками отклонения старых конфигов;
issue требует видимости и policy, не пере-схемизации.
Критерии приёмки
- AC1 (смок): тумблер группировки выключить+Сохранить → в конфиге
group_lights: false, повторное открытие показывает выкл, discovery- кандидаты содержат отдельные лампы вместо группы; обратное включение → ключ УДАЛЯЕТСЯ из settings (default = отсутствие). - AC2 (смок): добавление интеграции в исключения → превью-счётчик «скроется» ненулевой; Сохранить → ключ в settings = полный действующий список; «Вернуть рекомендуемые» + Сохранить → ключа нет.
- AC3 (смок): у явного маркера с binding исключённой интеграции ничего не меняется (живой остаётся на плане, tombstone не трогается).
- AC4 (смок): кандидат исключённой интеграции виден на «Доступны» с причиной, называющей интеграцию по имени («Исключена интеграция demo_x»); вкладка и значение reason прежние (регресс-ветка).
- AC4b (юнит, H2):
roomClimateMapс настроеннымexclude_integrationsфильтрует по НЕМУ (интеграция вне списка даёт климат; продуктовая, вручную включённая в список — не даёт); без ключа — байт-в-байт сегодняшний результат; явный climate-opt-in побеждает исключение (регресс-ветка). - AC5 (юнит): резолвер действующего набора: unset→продуктовый список, []→пусто (валидная «ничего не исключать»), список→список; тумблер: unset→true.
- AC6 (юнит): превью-диффер чист: те же ctx-входы, что у boевого buildDevices (контракт-проверка, что превью не дублирует логику фильтра).
- AC7: отсутствие ключей → discovery-выдача байт-в-байт с сегодняшней (регресс-юнит на фикстуре).
- AC8: полный гейт; i18n 4/4; бюджет: UI в editor-runtime (lazy), initial ≈ без изменений.
План автотестов
- Юниты: резолверы (AC5), превью-контракт (AC6), регресс дефолта (AC7), климат-фильтр (AC4b) — test/devices.test.mjs + test/device-inbox.test.mjs.
- Смок
demo/smoke_discovery_filters.mjs(новый): AC1–AC4 на демо-стенде с моковым config/set (паттерн #377-смока). - Мутанты: м1 — превью-счётчики от копии логики (подмена вызова общего билдера константой) → красный AC6/смок; м2 — «Вернуть рекомендуемые» пишет копию списка вместо удаления ключа → красный AC2.
Риски
- Пересчёт превью на большом registry — переиспользуется кэшируемый buildDevices; расчёт по явному действию (открытие секции/изменение), не на каждый тик.
- Текст причины обогащается плейсхолдером на уже существующей вкладке «Доступны» — новых классов записей не появляется; смок проверяет отсутствие дублей.
- Гонка вкладок — штатный conflict #340.
- Перевод roomClimateMap на резолвер (H2) меняет климат ТОЛЬКО при заданном пользователем ключе — default-путь передаёт тот же продуктовый список (AC4b регресс-ветка + существующие климат-юниты #317 не слабеют).
Откат
git revert: UI и причина исчезают, ключи продолжают работать как до
задачи (скрыто); записанные пользователями значения остаются валидными —
семантика не менялась. Потери данных нет.
DoR-примечания: миграция/compatibility — нет (семантика и имена ключей неизменны); touch — стандартные элементы диалога (чипы/тумблер), новых жестов нет; производительность — превью по явному действию.
Release-артефакты
- CHANGELOG + CHANGELOG.ru: user-visible запись (настройки фильтров стали видимыми и объяснимыми), ссылка #44.
- USER-GUIDE(.ru): подраздел; ARCHITECTURE.md: абзац про policy «нет скрытых discovery-ключей» со ссылкой на registry.
Принятые предположения
- Размещение — вкладка «Доступны» (фильтры определяют её содержимое); если ревью ТЗ предпочтёт отдельную вкладку/шестерёнку — правится без смены механики.
[](пустой список) — валидное значение «ничего не исключать», отличное от отсутствия ключа; так уже работает код (list ? new Set(list) : EXCLUDED_DOMAINS).- Причина остаётся на «Доступны», где уже живёт (#29); ни переноса категории, ни новой вкладки.