diff --git a/docs/specs/044-filter-grouping-policy.md b/docs/specs/044-filter-grouping-policy.md index 7f9a3658..7f8842dc 100644 --- a/docs/specs/044-filter-grouping-policy.md +++ b/docs/specs/044-filter-grouping-policy.md @@ -1,59 +1,183 @@ # ТЗ #44 — Явные фильтры и группировка устройств - Issue: https://github.com/Matysh/houseplan-card/issues/44 -- Приоритет: P2 -- Статус ТЗ: policy proposal ready for owner review -- Связано: device inbox #29, field registry #33 +- Приоритет: P2, tech-debt; полный трек (discovery/UI/settings/i18n/tests — + решение аналитики 2026-08-15) +- Ревизия: 2 (2026-08-30) — policy v1 принята владельцем (вход в работу без + возражений к записанной политике); конкретизация под инбокс v1.69 +- Связано: device inbox #29, field registry/манифест #33 (паспорта + `allow-extra` обоих ключей уже выданы), персист-паттерн #377 -## Цель +## Сценарий -Убрать скрытые настройки, влияющие на discovery. Каждый ключ либо получает -поддерживаемый advanced UI и documented default, либо мигрирует в фиксированное -product rule. +Хозяин плана открывает «Устройства» и на вкладке «Доступные» видит новый +раскрываемый блок «Фильтры обнаружения». Там два честных элемента: тумблер +«Объединять светильники комнаты в группу» (включён по умолчанию — как продукт +и вёл себя всегда) и список «Исключённые интеграции» с рекомендованным +набором, поиском по реально присутствующим в HA интеграциям и кнопкой +«Вернуть рекомендуемые». Изменение показывает счётчики «появится / скроется / +сгруппируется» до сохранения; Сохранить пишет настройки один раз. -## Решение v1 +## Что человек увидит до и после -Оба текущих ключа остаются поддерживаемыми и становятся видимыми в Advanced -разделе inbox: +**До**: группировка света и исключение интеграций происходят молча; понять +«почему этого устройства нет в списке» нельзя. **После**: обе настройки видны +и объяснимы; у кандидата, скрытого фильтром, в инбоксе появляется причина +«Исключена интеграция X»; ничего не меняется само — только по Сохранить. -### `group_lights` +## Проблема -- default `true`; -- label: «Объединять несколько светильников комнаты»; -- preview показывает, какие bindings будут группой/отдельными marker; -- переключение не применяется до Save и не удаляет explicit markers; -- existing light-group marker сохраняет lifecycle; конфликт bindings preview. +Оба ключа — «третий вариант», запрещённый 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`). -### `exclude_integrations` +## Решение (policy v1, принята) -- default — текущий `EXCLUDED_DOMAINS`/product list; -- UI — searchable multi-select интеграций, реально присутствующих в registry, - плюс reset to recommended defaults; -- изменение влияет только на automatic candidates/seed, не скрывает explicit - live marker и не удаляет tombstone; -- reason в inbox — «Исключена интеграция X». +Оба ключа — поддерживаемые advanced-настройки. Storage-имена и семантика НЕ +меняются (обратная совместимость): `group_lights` отсутствие = true; +`exclude_integrations` отсутствие = продуктовый список, наличие = полная +замена. UI прячет эту механику за честными представлениями. -Название storage key `exclude_integrations` сохраняется для compatibility; -семантика и default фиксируются в registry #33. +## Скоуп -## Исследование перед включением Save +### 1. Блок «Фильтры обнаружения» в инбоксе -Локальный config-audit считает значения/отклонения от defaults без вывода ids. -Synthetic fixtures моделируют реальные классы: group lights, parent device, -service/bridge, explicit override, tombstone. Если audit показывает, что ключ -невозможно объяснить без вредного поведения, owner может отдельным решением -перевести его в fixed rule до реализации UI. +- Раскрываемая секция внизу вкладки «Доступные» диалога «Устройства» + (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 — штатный именованный снапшот конфига (паттерн + существующих правок настроек). -## UX/transaction +### 2. Причина «Исключена интеграция X» в инбоксе -Preview diff: новые/скрытые/grouped candidates counts и конкретный список в -локальной session. Apply пишет settings один раз, rebuild devices и создаёт -named undo/config snapshot. Cancel no-op. Два клиента используют config rev. +Кандидат, отфильтрованный исключением, при показе на вкладке «Скрытые» +несёт `DeviceInboxReason` нового значения `excluded_integration` с текстом +«Исключена интеграция {integration}». (Сегодня такие устройства не попадают +в инбокс вовсе — они появляются на «Скрытых» именно с этой причиной; +объём выдачи ограничен существующей механикой вкладки.) -## Приёмка +### 3. Registry/доки -- ни один runtime discovery key не скрыт от пользователя/registry; -- defaults совпадают frontend, backend, docs и fixtures; -- explicit marker никогда не исчезает из-за filter change; -- preview объясняет каждую изменившуюся binding; -- old config даёт прежний result до user action. +- `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). + +## Контракт поведения + +1. Конфиг без ключей → поведение байт-в-байт сегодняшнее (default-путь не + меняется ни в одном потребителе). +2. Explicit-маркер (живой или tombstone) НИКОГДА не исчезает из-за смены + фильтров — фильтры влияют только на автоматических кандидатов и seed. +3. Оба значения читаются из одного источника (`_settings`) всеми + потребителями — новых копий состояния нет. +4. `Вернуть рекомендуемые` удаляет ключ (не пишет копию списка). +5. Никакой записи до явного Сохранить; двойная вкладка — штатный 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** (смок): скрытый фильтром кандидат виден на «Скрытых» с причиной + «Исключена интеграция X». +- **AC5** (юнит): резолвер действующего набора: unset→продуктовый список, + []→пусто (валидная «ничего не исключать»), список→список; тумблер: + unset→true. +- **AC6** (юнит): превью-диффер чист: те же ctx-входы, что у boевого + buildDevices (контракт-проверка, что превью не дублирует логику фильтра). +- **AC7**: отсутствие ключей → discovery-выдача байт-в-байт с сегодняшней + (регресс-юнит на фикстуре). +- **AC8**: полный гейт; i18n 4/4; бюджет: UI в editor-runtime (lazy), + initial ≈ без изменений. + +## План автотестов + +- Юниты: резолверы (AC5), превью-контракт (AC6), регресс дефолта (AC7) — + 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; расчёт по явному действию (открытие секции/изменение), не + на каждый тик. +- Реason-вкладка «Скрытые» получает новый класс записей — объём ограничен + существующим механизмом вкладки; смок проверяет отсутствие дублей. +- Гонка вкладок — штатный conflict #340. + +## Откат + +`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`). +- Причина показывается на «Скрытых», НЕ в отдельной новой вкладке.