spec: record approved income page simplification

This commit is contained in:
2026-09-09 13:50:45 +03:00
parent 0608d4dc23
commit 4c905c1433
5 changed files with 475 additions and 0 deletions
@@ -0,0 +1,268 @@
## Context
Изменение ограничено presentation и page orchestration маршрута
`/accounts/:accountId/income`. Текущий `AccountIncomePage` выбирает состояния
через page VM; VM владеет периодом из URL, формой и запросами, а section views
отображают переданные данные. Верхняя форма использует native date inputs;
история событий уже использует установленный `@mantine/dates` DateInput.
Ответ `income-analytics` уже содержит totals, comparisons, instrument breakdown
и все включённые entries. VM соединяет entries с инструментами по непрозрачному
`instrumentKey`, проверяет валюту и receipt count. Денежные значения и порядок
инструментов приходят с backend. Дополнительная подписка income VM на `/events`
обслуживает только удаляемый прогноз. Trend view использует отдельную derived
модель из buckets того же ответа.
Ограничения: FSD `app → pages → entities → shared`, чистые section views,
существующие Mantine theme и i18n, без новой инфраструктуры или зависимостей.
Даты API и URL остаются календарными `YYYY-MM-DD` в Europe/Moscow;
формат отображения поля не меняет их смысл.
## Goals / Non-Goals
**Goals:** использовать существующий календарный контрол, сохранить явную
границу draft/applied period, отобразить полный состав дохода в отдельных
карточках выплат и удалить принадлежащие странице зависимости исчезающих разделов.
Сохранить проверки целостности membership и текущие денежные вычисления.
**Non-Goals:** изменение API, серверных buckets, денежных агрегатов, определения
Instrument, общих Ledger rows, обзора счёта, самостоятельной страницы будущих
событий, общей темы или навигации. Пагинация карточек, виртуализация,
дополнительные фильтры и новый каталог компонентов не вводятся.
## Decisions
### 1. Календарные поля адаптируются на границе page form
Используется Mantine DateInput с `valueFormat="DD.MM.YYYY"` и существующим
русским DatesProvider. Форма остаётся под управлением react-hook-form и Zod в
page VM; form-context/controlled-field adapter подключает DateInput к ней.
Общий date-range widget не создаётся: income требует обязательного периода,
запрета будущих дат и явных ошибок, тогда как Ledger допускает другие фильтры.
Импорта между page slices нет.
VM различает видимый черновик и применённые ISO-даты. Ручной ввод, в том числе
пустой или недопустимый текст, должен попасть в проверяемый черновик. Адаптер
учитывает raw input, календарный выбор и blur; автоматический откат текста
DateInput к последнему допустимому значению на blur отключается (`fixOnBlur=false`).
Парсинг допускает только существующую календарную дату формата поля, без
нормализации вроде 31 февраля в март. Успешный календарный выбор заменяет
черновик поля; валидированный submit переводит обе даты в ISO и обновляет URL.
Последнее внутреннее допустимое значение DateInput не заменяет ошибочный ввод.
Календарь ограничивает начало допустимым окончанием и сегодняшней московской
датой, окончание — допустимым началом и сегодняшней датой. Невалидное соседнее
поле не передаётся в min/max. Ограничения календаря помогают выбору, но Zod
остаётся проверкой обязательности, календарной корректности, будущих дат и
порядка. Для отсутствующей, некорректной или будущей даты ошибка принадлежит
самому полю; при обратном диапазоне — полю «По».
Адаптация календарной даты использует компоненты года, месяца и дня, как в
Ledger, без UTC-преобразования через `toISOString()` пользовательского Date.
«Сегодня» и default period определяются существующим московским правилом VM,
а не часовым поясом устройства. URL остаётся источником применённого периода;
его текущая нормализация, открытие и browser navigation сохраняются.
Сброс очищает ошибки и восстанавливает оба поля даже тогда, когда текущий URL
уже содержит default period, а пользователь изменил только черновик.
Альтернативы: native date inputs не обеспечивают согласованный контрол;
единый range picker меняет привычное взаимодействие двух полей; копирование
всего Ledger filter state переносит неподходящие income семантики. Выбран
существующий DateInput с локальной адаптацией формы.
### 2. Одна карточка соответствует одной выплате
`IncomePayments` отображает один семантический список: каждый Ledger Entry
передаётся существующему `LedgerEntryRow`. Нет внешней Paper-карточки Instrument,
заголовков валютных групп, instrument subtotal/total и receipt count на UI.
Как в списке позиций портфеля, карточки идут общей колонкой; импортов между
page slices и нового row renderer нет. LedgerEntryRow сохраняет дату, описание,
доступную identity, signed Money, характеристику выплаты и fallback изображения.
VM сначала проверяет opaque instrument keys, их непустоту и уникальность,
принадлежность каждой entry к известному instrumentKey, совпадение валюты и
receipt count. Валюта инструмента должна присутствовать среди totalsByCurrency.
Повреждённый ответ идёт целиком в error/retry, без частично заполненного списка.
Клиент не восстанавливает identity формулой и не пересчитывает денежные суммы.
После проверки VM отдаёт плоский список в исходном порядке `analytics.entries`.
В ключ строки входят opaque instrumentKey, operation ID, occurrence внутри
этой группы и logo source. Одинаковые IDs не схлопываются, в том числе между
инструментами. Валюта видна в каждой Money, а общие summary/comparisons по-прежнему
разделены по валютам. Серверный порядок не заменяется клиентской сортировкой.
### 3. Удаление секций завершает их локальное владение
Income page прекращает вызывать `useBrokerageEvents` и готовить forecast model.
Удаляются принадлежащие только этой странице forecast/trend views, derived
trend model и неиспользуемые стили/строки этих секций. Shared event entity,
её запрос и самостоятельная страница будущих событий сохраняются.
Backend продолжает возвращать buckets по прежнему контракту; frontend больше
не строит из них presentation для income page. Это позволяет ограничить
изменение потребителем без миграции transport types и соседних consumers.
Удаление полей API ради размера ответа — отдельное изменение контракта,
не необходимое для этого решения.
### 4. Границы состояний и доказательства остаются у страницы
Top-level loading/error и loaded composition остаются в существующей
container/VM/view структуре. Skeleton отражает новые регионы. Пустой результат
использует StateView; ошибка запроса или membership — существующий error/retry.
При фоновой загрузке того же периода валидный Client Cached Snapshot сохраняется
с неблокирующим progress, а после ошибки — с локальным предупреждением и retry
согласно frontend UI conventions. При отсутствии данных нового query используется loading, без старого результата.
Отдельной подписи применённого периода под формой нет; draft/applied boundary
сохраняется и видимые суммы меняются только после применения.
Проверка опирается на существующие page tests и MSW и защищает период и состав
денежных данных: draft/apply/reset/URL, недопустимый ручной ввод, московскую границу дня,
несколько валют, отрицательные и повторяющиеся выплаты, повреждённый membership
и retry. Удаляемые chart/forecast expectations заменяются наблюдаемым отсутствием
секций и запроса events от income page. Сохраняемые денежные/membership assertions
не удаляются вместе с Accordion. Адаптивность и keyboard flow оцениваются по
пакету ниже; новая test/browser инфраструктура для этого не требуется.
## UI/UX Applicability and Package
**Applicability and scope:** `new/changed` — income page: календарные фильтры,
плоский список выплат, последовательность секций и loading representation.
Общая оболочка, обзор счёта и самостоятельные «Будущие события» вне scope.
**Package revision:** `simplify-income-page-ui-r2`.
Пакет полностью inline; дополнительных attachments, external-only макетов
и immutable reuse inputs нет. Revision r2 отражает уточнение владельца:
убрать подпись периода, заголовки валют и внешние карточки Instrument,
переименовать раздел в «Выплаты», показывать отдельную карточку каждой выплаты.
**Screens, states and interactions:**
Loaded: существующая ширина content до 960 px, Stack gap `lg`, текущая светлая
Mantine theme. h1 страницы; h2 итогов, сравнений и «Выплаты». Нет h3/h4 групп
валют или инструментов. Суммы доступны текстом, не только цветом.
```text
Пассивный доход
┌ Период ──────────────────────────────────────────┐
│ С По │
│ [ДД.ММ.ГГГГ] [ДД.ММ.ГГГГ] │
│ [Применить] [Сбросить] │
└──────────────────────────────────────────────────┘
Получено купонами и дивидендами
[Итого в валюте · Купоны · Дивиденды]
[Остальные валюты отдельными карточками]
Налоговые операции не включены в этот результат.
Сравнения
[Предыдущий период · значения и разницы]
[Годом ранее · значения] или текст недоступности
Выплаты
[Ledger Entry: описание, дата, identity, signed Money]
[Следующая Ledger Entry, отдельная карточка]
```
Форма — одна Paper с border, radius `md`, padding `md`, видимым «Период».
Подписанные DateInput «С» и «По» имеют placeholder `ДД.ММ.ГГГГ`, штатный
календарь по focus/click, `valueFormat="DD.MM.YYYY"`, strict raw validation и
`fixOnBlur=false`. Действия отдельной строкой: «Применить» filled, «Сбросить»
default. Под формой нет повторяющей даты подписи. Неприменённый черновик не
меняет аналитику; ограничения и московское правило описаны в Decision 1.
«Выплаты» — один `ul`, одна LedgerEntryRow-карточка на `li`, gap `sm`.
Не добавляются внешние instrument cards, currency headings, instrument totals,
subtotals/count, chevron, раскрытие или внутренние scroll-панели. Метаданные и
logo fallback принадлежат существующему LedgerEntryRow. Все строки сразу видимы;
денежные значения не обрезаются, при нехватке ширины переносятся.
| Состояние | Представление |
| --- | --- |
| Initial loading / новый период без cached data | Busy skeleton повторяет heading, форму, summary/comparisons и отдельные строки выплат; shapes без фокуса, shell сохраняется. |
| Loaded | Итоги, сравнения и плоский список всех выплат; валюты указаны в Money, summary/comparisons не смешивают их. |
| Изменён draft | Поля отражают ввод; суммы, сравнения и выплаты сохраняют последний применённый результат. |
| Invalid draft | Ошибка под соответствующим полем, фокус на первом ошибочном поле, исходный текст и URL сохраняются, запроса нет. |
| Apply | Click или Enter проверяет raw draft; только успех меняет URL/query, повторный submit во время обработки блокируется. |
| Reset | Немедленно восстанавливает обе даты текущего московского месяца, очищает ошибки и синхронизирует URL даже при изменённом только draft. |
| Empty | Фильтры, «Купоны и дивиденды не найдены» / «За выбранный период подходящих исполненных выплат нет.», налоговое пояснение, сравнения; «Выплаты» без фиктивных карточек. |
| Error без результата / повреждённый membership | StateView «Не удалось рассчитать пассивный доход», пояснение, «Повторить» текущий applied period; частичные данные скрыты. |
| Фоновое обновление того же периода | Cached snapshot и draft сохраняются; progress не блокирует; при ошибке предупреждение с retry. |
| Нет year comparison | «Сравнение с годом ранее недоступно.», без вымышленных нулей. |
| Отрицательные суммы / неполная metadata | Исходный знак, доступная identity и существующий logo fallback; запись не исключается. |
Во всех состояниях отсутствуют trend, buckets UI, forecast и его состояния.
Keyboard order: «С» → «По» → «Применить» → «Сбросить»; Escape закрывает календарь.
Штатные доступные controls Mantine и focus indicator сохранены. Ошибки связаны
с inputs; суммы и неинтерактивные строки не получают tabIndex. Все продуктовые
подписи принадлежат русскому i18n.
**Responsive behavior:**
| Viewport | Форма | Выплаты |
| --- | --- | --- |
| От `48em` | Два равных поля, отдельная строка кнопок по содержимому. | Одна колонка LedgerEntryRow; identity и сумма используют существующий layout. |
| `36em`–`48em` | Поля рядом, кнопки переносятся при необходимости. | Одна колонка, текст и суммы переносятся без обрезки. |
| Менее `36em` | Поля и кнопки вертикально на всю ширину, touch target не менее 44 px. | Существующий mobile row: сумма под identity. |
На 320 px и при увеличенном масштабе страница не требует горизонтального scroll;
popover остаётся в viewport. Высота определяется данными, прокрутка общая,
пагинация, виртуализация и ограничения количества строк не вводятся.
**Reused source and differences:** immutable visual reuse `N/A`.
DateInput истории, LedgerEntryRow, StateView, LoadingSkeleton и theme —
implementation references. Поля income обязательны и запрещают будущие даты;
список выплат плоский. Код страницы портфеля не копируется между slices.
## Delivery Map
| Area | Status | Architectural outcome or N/A reason |
| --- | --- | --- |
| frontend | `affected` | Calendar form adapter, плоский список карточек выплат, composition/states и относящиеся к ним проверки; удаление только income-owned trend/forecast consumer code. |
| backend | `N/A` | Текущий income-analytics уже возвращает totals/comparisons, отсортированные instruments и полные entries с instrumentKey. Endpoint, buckets, денежные расчёты, events и хранение не меняются. |
| specs | `affected` | Canonical planning и последующая синхронизация согласованных delta requirements в capability brokerage-account-income-analytics по lifecycle. |
| docs | `affected` | После реализации актуализируется as-built описание пассивного дохода: календарные фильтры, отдельные карточки выплат, отсутствие подписи периода и trend/forecast UI и использование прежнего analytics response. |
### Contract Gate Decision
| Gate | Status | Reason |
| --- | --- | --- |
| runtime-openapi | `N/A` | API contract не меняется ни на одной стороне; frontend потребляет прежний типизированный ответ и прекращает отдельный вызов events только со страницы дохода. Snapshot/types не требуют регенерации. |
## Risks / Trade-offs
- Открытый список увеличивает высоту и число одновременно отрисованных строк.
Это ожидаемая цена согласованного полного состава выплат. Используется один
проход группировки в VM и обычный document flow; лимиты, скрытие строк или
виртуализация не подменяют согласованное поведение.
- Raw input DateInput, permissive calendar parsing и timezone conversion могут
незаметно подменить период. Локальный form adapter и строгая проверка видимого
черновика защищают эту границу; одних minDate/maxDate недостаточно.
- Замена Accordion может потерять повторяющиеся выплаты или ошибочные membership
guards при упрощении VM. Сохраняются исходный порядок, occurrence identity и
существующий отказ от повреждённого результата.
- Удаление forecast UI должно удалить и его query subscription; shared events
остаётся нужен самостоятельной странице и не удаляется вместе с consumer.
- В текущем main Purpose осталось упоминание отдельного ближайшего прогноза.
Согласованная delta явно удаляет требование его показа на income page.
Это известная устаревающая формулировка Purpose, не разрешение вернуть UI:
native sync сохраняет existing Purpose и незатронутые requirements. В этом
change Purpose не переписывается; его последующая редактура требует отдельного
разрешённого изменения. Упоминания buckets в сохраняемых серверных инвариантах
совместимы с неизменным API и не требуют их визуализации.
## Migration Plan
Поставка выполняется без серверной или data migration. Сначала обновляется
согласованная frontend composition и её адаптация формы в рамках существующих
границ; после удаления consumers очищаются их локальные неиспользуемые части.
Сохраняемые сценарии периода и membership проходят проверку вместе с новой
компоновкой. Затем docs отражает фактически поставленный поток. Синхронизация
main specs и archive остаются последующими canonical completion mechanics.
Предварительный backend rollout, изменение environment и установка новых
зависимостей не нужны. API остаётся совместимым со старым и новым frontend.
### Rollback Contract
| Field | Decision |
| --- | --- |
| Status | `N/A` |
| Reason | Обратимое read-only изменение frontend UI; auth/security, public API, persistent data и денежные вычисления не меняются. Возврат прежней frontend версии восстанавливает прежнее отображение на том же backend без преобразования или удаления данных. |