feat(page-history): интеграция редизайна окна истории — плотный список + мини-календарь heatmap #568

Open
opened 2026-07-12 19:08:27 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

Есть готовый прототип редизайна окна «Page history» — NewDesign/PageHistoryModal.tsx (Mantine v7, светлая/тёмная, полноразмерная модалка). Идея: слева — панель навигации (мини-календарь-heatmap + плотный список ревизий: одна ревизия = одна строка глиф · время · автор · [SAVED]), справа — отрендеренная версия страницы с подсветкой изменений; все контролы (Highlight changes, навигация по диффу N/M, Restore) — в одну строку шапки. Агентские ревизии визуально отличаются квадратным глифом роли + «via <кто запустил>».

Нужно натянуть визуал на существующую систему истории (IPageHistory, реальные запросы, restore, права, diff) с минимумом изменений бэка. Единственное реальное изменение бэка — новый дешёвый агрегат «число ревизий по дням» под heatmap (решение принято человеком, см. «Решённые развилки»). Всё остальное — переиспользование существующего поведения. Всё в одной ишью.

Для кого: авторы/редакторы страниц, которые разбирают историю правок (в т.ч. агентских — Корректор/Фактчекер из agent-roles-catalog).

Контекст и исследование

Существующая подсистема (переиспользуем, не переписываем механику):

  • Окно: history-modal.tsx — десктоп/мобайл через useMediaQuery; мобайл = history-modal-mobile.tsx. Десктоп-тело: history-modal-body.tsx (слева HistoryList, справа HistoryView + плавающая панель Highlight changes + N/M).
  • Список: history-list.tsxuseInfiniteQuery с пагинацией (догрузка страниц по IntersectionObserver, gcTime:0), фильтр «Only versions» через historyKindMeta(kind).version; выбор ставит activeHistoryIdAtom + activeHistoryPrevIdAtom = resolvePrevSnapshotId(ПОЛНЫЙ_список, id) (не по отфильтрованному/сгруппированному!).
  • Строка: history-item.tsxhistoryKindMeta: manual→Saved(blue,version), agent→Agent version(violet,version), boundary/idle/null→Autosave(gray, не version, opacity .55); провенанс AgentAvatarStack при lastUpdatedSource==='agent' && agent.
  • Модель: page.types.ts IPageHistory: id, title, content?, createdAt(string), lastUpdatedBy, contributors?[], kind?('manual'|'agent'|'idle'|'boundary'|null), lastUpdatedSource?, lastUpdatedAiChatId?, agent?/launcher? (#300). Два ортогональных сигнала: lastUpdatedSource==='agent' = идентичность автора (в т.ч. у агентского автосейва), kind = интенциональность (version vs autosave).
  • Рендер/дифф: history-view.tsxHistoryEditor(content, title, previousContent); дифф считается selected↔предыдущий снапшот. Навигация: highlightChangesAtom, diffCountsAtom, useDiffNavigation(scrollViewportRef) (ref — viewport ПРАВОЙ панели; якоря data-diff-index эмитит HistoryEditor).
  • Restore: use-history-restore.tsxсерверного restore-API для истории нет; handleRestore грузит снапшот в живые редакторы (mainEditor/titleEditor.setContent) через confirm-модалку («Any changes not versioned will be lost») и гейт CASL spaceAbility.can(Manage, Page) → canRestore. (@Post('/pages/restore') — это восстановление из корзины, не сюда.)
  • Сервис/эндпоинты: page.controller.ts @Post('/history'), @Post('/history/info'), @Post('/history/time'). Последний (page-history.service.ts computeWorkTime) — per-day рабочее время (#395, activeMs/agentMs), через findTimelineByPageId (дешёвая проекция без content) + bucketByDay(sessions, tz); tzRangeError→400. Числа ревизий по дням он НЕ отдаёт.

Расхождения прототипа ↔ кода (закрываются адаптером/минимальным бэком):

  • Прототип: onRestore: api.restore(rev.id). Реально restore — загрузка в редактор + confirm + CASL (переиспользуем).
  • Прототип: saved: boolean + один бейдж SAVED. Оставляем именно так: бейдж SAVED только для manual; агентские версии маркируются глифом роли (не отдельным бейджем — он бы дублировал глиф), автосейвы приглушены без бейджа.
  • Прототип: calendar.days[].count = число ревизий за день. В системе такого агрегата нет → добавляем (см. дизайн, часть D).
  • Прототип: dayGroup/atLabel предформатированы вызывающим. Считаем на клиенте в браузерном tz.
  • Прототип десктоп-only (фикс. 280px, 80rem). Мобайл не трогаем.

Дизайн

Принцип: механику (запросы, дифф, restore, права, пагинация) переиспользуем; меняем компоновку десктоп-окна и плотность строк; добавляем один дешёвый агрегат под heatmap. Мобайл, work-time/TimeWorkedModal — вне scope.

A. Каркас окна (десктоп)

  • Новый десктоп-body (напр. history-modal-desktop.tsx) с одно-строчной шапкой (заголовок, лейбл выбранной версии, тумблер Highlight changes + N/M-навигация по диффу, кнопка Restore, закрытие) и телом «левая навигация 280px | правый рендер версии». В history-modal.tsx десктоп-ветку переключаем на новый body; мобильную ветку (history-modal-mobile.tsx) не трогаем.
  • Правую панель — переиспользуем HistoryView/HistoryEditor, управляемые существующими атомами (activeHistoryIdAtom/activeHistoryPrevIdAtom). Дифф-навигацию — существующими highlightChangesAtom/diffCountsAtom/useDiffNavigation; viewportRef правой ScrollArea прокинуть в useDiffNavigation (как сейчас в body).
  • Состояние держим в существующих атомах/хуках, а не в новых локальных props — контролируемые props прототипа маппим на атомы.
  • Инвариант diff/restore: предыдущий снапшот = resolvePrevSnapshotId(ПОЛНЫЙ плоский список, id). Группировка по дням — чисто презентационная; в resolvePrevSnapshotId по-прежнему передаём плоский historyItems, НЕ сгруппированный/отфильтрованный.

B. Плотная строка ревизии (адаптер IPageHistory → строка)

  • atLabel = время; dayISO (YYYY-MM-DD в браузерном tz) — стабильный ключ строки для группировки и jump-to-day; dayGroupLabel = Today/Yesterday/«Mon 12 Jul» (i18n) — только для показа.
  • Глиф — по идентичности: lastUpdatedSource==='agent' && item.agent → квадратный глиф роли (agent.name) + «via launcher?.name»; иначе круглый аватар lastUpdatedBy. (Плотная строка — один аватар; мульти-контрибьюторный стек в плотном списке опускаем осознанно.)
  • Бейдж — по интенциональности, но упрощённо: показываем только SAVED при kind==='manual'. Агентские версии — без бейджа (глиф уже отличает). Не-version (autosave/boundary/idle/null) — приглушены (opacity как сейчас), без бейджа.
  • Заголовок дня-группы несёт data-day={dayISO}-якорь; левая ScrollArea получает viewportRef — для скролла из календаря (часть E).
  • Фильтр «Only versions» — существующий (historyKindMeta(kind).version, manual+agent).

C. Restore

  • Переиспользуем useHistoryRestore: confirmRestore (confirm-модалка) + гейт canRestore (CASL Manage Page) + загрузка снапшота в редакторы. Наивный onRestore прототипа НЕ используем.
  • Кнопка Restore disabled, если !canRestore или выбран новейший снапшот (historyItems[0] — список грузится newest-first, поэтому индекс 0 — глобально новейший даже при пагинации).

D. Серверный агрегат «число ревизий по дням» (единственное изменение бэка)

  • Новый POST /pages/history/day-counts { pageId, tz }[{ dayISO, count }] за всю историю за один запрос (без параметра month — это убирает повторный full-scan на каждую смену месяца и валидацию месяца).
  • Считаем через дешёвую проекцию findTimelineByPageId (без content) + новый чистый tz-aware per-day count-баркетер, переиспользующий уже протестированное tz-ядро: экспортировать zonedDayStart/isoDay из work-time/bucket-by-day.ts и строить счётчик как createdAt → isoDay(zonedDayStart(ms, tz), tz) → tally (единый источник DST/дней с bucketByDay, без копипасты).
  • Считаем только version-строки (kind ∈ {manual, agent}) — тогда крашеный день в heatmap всегда имеет видимую цель в списке и совпадает с фильтром «Only versions» (см. критерий 6).
  • View-gated (validateCanView, как /history и /history/time). Неверный tz (RangeError из Intl) → 400 (как /history/time).

E. Мини-календарь + jump-to-day

  • MiniCalendar из прототипа + хук usePageHistoryDayCounts(pageId, tz); tz — единый браузерный (Intl.DateTimeFormat().resolvedOptions().timeZone), тот же, что используется для клиентской группировки по дням (часть B) — иначе счётчики календаря разъедутся с днями списка.
  • Интенсивность ячейки heatmap = count из агрегата (version-ревизии). Навигация месяцев prev/next/today — чисто клиентская (данные за всю историю уже загружены; режем по месяцу локально).
  • Pick-day (date-jumper): выбрать/проскроллить к первой строке этого дня.
    • Матчим по dayISO (не по отображаемому лейблу — он относительный и без года), скроллим к data-day-якорю в левой ScrollArea.
    • Если день ещё не в загруженных страницах пагинации — догружаем fetchNextPage() с корректной границей: список newest-first, поэтому тянем страницы, пока dayISO самой старой загруженной строки не станет <= targetDayISO или hasNextPage===false. Не «N страниц». Если день так и не достигнут (исчерпали пагинацию) — no-op + короткий тост, без бесконечного цикла.

Policy-константы:

  • Порог heatmap-интенсивности (fewer/…/more) — из прототипа (heat()), оставляем.
  • Ширина левой панели 280px, размер модалки — из прототипа.
  • Множество для heatmap и «Only versions» — version-строки (manual+agent).

Затрагиваемые файлы (примерно):

  • Клиент: новый history-modal-desktop.tsx (порт каркаса + шапки из прототипа); новая плотная RevisionRow + адаптер (dayISO, глиф, бейдж SAVED); MiniCalendar (порт); work-time-стиль сервис/хук usePageHistoryDayCounts; правка history-modal.tsx (десктоп-ветка). Переиспользуем HistoryView/HistoryEditor, useDiffNavigation, useHistoryReset, resolvePrevSnapshotId, useHistoryRestore, historyKindMeta, атомы.
  • Сервер: новый DTO + @Post('/pages/history/day-counts') в page.controller.ts; метод computeDayCounts в page-history.service.ts; новый чистый баркетер + экспорт zonedDayStart/isoDay из work-time/bucket-by-day.ts; юнит-тесты баркетера.
  • Удалить после порта: NewDesign/PageHistoryModal.tsx (исходник-прототип, в корне жить не должен).

Границы scope

Не входит: мобильная модалка (history-modal-mobile.tsx) — без изменений; редизайн work-time/TimeWorkedModal.tsx — отдельно; серверный history-restore-API (restore остаётся клиентским); мульти-контрибьюторный стек в плотной строке (осознанно опущен). Живое обновление heatmap при поступлении новых ревизий во время открытого окна — не требуется (счётчики тянутся при открытии).

Критерии приёмки

Проверяемо на стенде (страница с ручными, агентскими и авто-ревизиями):

  1. Плотный список: одна ревизия = одна строка, сгруппировано по дням (заголовок дня); агентская строка — квадратный глиф роли + «via »; человеческая — круглый аватар lastUpdatedBy.
  2. Бейдж SAVED только у manual; агентские версии — без бейджа (видны глифом); автосейвы приглушены без бейджа.
  3. Выбор версии рендерит её справа; Highlight changes + N/M-навигация работают из шапки (дифф против истинного предыдущего снапшота, а не против соседа по отфильтрованному/сгруппированному списку).
  4. Restore идёт через существующий confirm + CASL-гейт и грузит снапшот в редактор; кнопка disabled у новейшего снапшота и при !canRestore.
  5. POST /pages/history/day-counts {pageId, tz} отдаёт [{dayISO,count}] за всю историю; неверный tz → 400; неавторизованный просмотр → 403; счётчик дня == числу version-строк этого дня в том же tz.
  6. Согласованность heatmap↔список: любой крашеный день календаря имеет видимую строку в списке того же дня (heatmap и «Only versions» считают одно множество — version-строки).
  7. Jump-to-day: клик по дню скроллит к первой строке этого дня; если день ниже загруженной части — подгружает страницы с корректной границей (по dayISO старейшей строки), а не фиксированным числом; недостижимый день → no-op + тост, без зацикливания.
  8. Единый tz: день в списке и ячейка календаря для одной даты согласованы (одинаковый браузерный tz на клиенте и в запросе агрегата).
  9. Мобильная модалка не изменилась.

Отказы и наблюдаемость

  • day-counts упал (400 неверный tz / 5xx / сеть): heatmap показывает пустую сетку (fail-open, без блокировки списка), список и restore работают. Не роняем окно.
  • Неверный tz — явный 400 (RangeError из Intl), как у /history/time; невалидный день/пустой месяц — пустые ячейки.
  • Restore — необратимая для «неверсионированных» правок операция; существующая confirm-модалка это проговаривает, поведение не меняем.
  • Jump-to-day с недостижимым днём: явный no-op + короткий тост (не молчаливое зависание).

Совместимость и миграции

  • Дефолты/поведение существующих пользователей: меняется компоновка десктопного окна истории; механика (дифф, restore, права, пагинация, «Only versions») та же. Мобайл без изменений. Новый эндпоинт — аддитивный.
  • Миграций БД нет (агрегат считается по существующим строкам). Rollback: ревертом PR; эндпоинт можно не вызывать (heatmap деградирует до пустого/скрытого, окно работает).

Решённые развилки

  • Heatmap-данные → новый серверный агрегат «число ревизий по дням» (не work-time). Кто решил: человек (@vvzvlad). Уточнение по итогам разбора: считаем version-строки и отдаём всю историю за один запрос (убирает re-scan по месяцам и валидацию месяца; гарантирует совпадение heatmap с фильтром/списком).
  • Бейджи → только SAVED (manual); агентские версии маркируются глифом, отдельный «Agent version»-бейдж не вводим (дублировал бы глиф). Кто решил: человек.
  • Restore/права/дифф/мобайл → существующее поведение переиспользуем (не развилка): useHistoryRestore (confirm+CASL), useDiffNavigation, мобильную модалку не трогаем.
  • Глиф vs бейдж → ортогонально: глиф по lastUpdatedSource==='agent', бейдж по kind (дизайн-решение по итогам разбора — иначе агентский автосейв терял бы агентскую идентичность).
  • Объём → одна ишью (фронт-редизайн + серверный агрегат + календарь вместе). Кто решил: человек.

Связи

  • Опирается на: #370 (kinds/интенциональность истории), #395 (/history/time, work-time-ядро и tz-баркетинг — переиспользуем bucket-by-day), #300 (provenance-стек агента).
  • Не пересекается с: редизайном TimeWorkedModal.tsx (отдельный прототип, отдельная ишью).
## Проблема / Цель Есть готовый прототип редизайна окна «Page history» — `NewDesign/PageHistoryModal.tsx` (Mantine v7, светлая/тёмная, полноразмерная модалка). Идея: слева — панель навигации (мини-календарь-heatmap + **плотный** список ревизий: одна ревизия = одна строка `глиф · время · автор · [SAVED]`), справа — отрендеренная версия страницы с подсветкой изменений; все контролы (Highlight changes, навигация по диффу N/M, Restore) — в одну строку шапки. Агентские ревизии визуально отличаются квадратным глифом роли + «via <кто запустил>». Нужно **натянуть визуал на существующую систему истории** (`IPageHistory`, реальные запросы, restore, права, diff) с минимумом изменений бэка. Единственное реальное изменение бэка — новый дешёвый агрегат «число ревизий по дням» под heatmap (решение принято человеком, см. «Решённые развилки»). Всё остальное — переиспользование существующего поведения. **Всё в одной ишью.** Для кого: авторы/редакторы страниц, которые разбирают историю правок (в т.ч. агентских — Корректор/Фактчекер из `agent-roles-catalog`). ## Контекст и исследование **Существующая подсистема (переиспользуем, не переписываем механику):** - Окно: [history-modal.tsx](apps/client/src/features/page-history/components/history-modal.tsx) — десктоп/мобайл через `useMediaQuery`; мобайл = [history-modal-mobile.tsx](apps/client/src/features/page-history/components/history-modal-mobile.tsx). Десктоп-тело: [history-modal-body.tsx](apps/client/src/features/page-history/components/history-modal-body.tsx) (слева `HistoryList`, справа `HistoryView` + плавающая панель Highlight changes + N/M). - Список: [history-list.tsx](apps/client/src/features/page-history/components/history-list.tsx) — `useInfiniteQuery` с пагинацией (догрузка страниц по `IntersectionObserver`, `gcTime:0`), фильтр «Only versions» через `historyKindMeta(kind).version`; выбор ставит `activeHistoryIdAtom` + `activeHistoryPrevIdAtom = resolvePrevSnapshotId(ПОЛНЫЙ_список, id)` (не по отфильтрованному/сгруппированному!). - Строка: [history-item.tsx](apps/client/src/features/page-history/components/history-item.tsx) — `historyKindMeta`: `manual→Saved(blue,version)`, `agent→Agent version(violet,version)`, `boundary/idle/null→Autosave(gray, не version, opacity .55)`; провенанс `AgentAvatarStack` при `lastUpdatedSource==='agent' && agent`. - Модель: [page.types.ts](apps/client/src/features/page-history/types/page.types.ts) `IPageHistory`: `id, title, content?, createdAt(string), lastUpdatedBy, contributors?[], kind?('manual'|'agent'|'idle'|'boundary'|null), lastUpdatedSource?, lastUpdatedAiChatId?, agent?/launcher?` (#300). **Два ортогональных сигнала:** `lastUpdatedSource==='agent'` = идентичность автора (в т.ч. у агентского автосейва), `kind` = интенциональность (version vs autosave). - Рендер/дифф: [history-view.tsx](apps/client/src/features/page-history/components/history-view.tsx) → `HistoryEditor(content, title, previousContent)`; дифф считается selected↔предыдущий снапшот. Навигация: `highlightChangesAtom`, `diffCountsAtom`, `useDiffNavigation(scrollViewportRef)` (ref — viewport ПРАВОЙ панели; якоря `data-diff-index` эмитит `HistoryEditor`). - **Restore:** [use-history-restore.tsx](apps/client/src/features/page-history/hooks/use-history-restore.tsx) — **серверного restore-API для истории нет**; `handleRestore` грузит снапшот в живые редакторы (`mainEditor/titleEditor.setContent`) через confirm-модалку («Any changes not versioned will be lost») и гейт CASL `spaceAbility.can(Manage, Page) → canRestore`. (`@Post('/pages/restore')` — это восстановление из корзины, не сюда.) - Сервис/эндпоинты: [page.controller.ts](apps/server/src/core/page/page.controller.ts) `@Post('/history')`, `@Post('/history/info')`, `@Post('/history/time')`. Последний ([page-history.service.ts](apps/server/src/core/page/services/page-history.service.ts) `computeWorkTime`) — per-day **рабочее время** (#395, `activeMs/agentMs`), через `findTimelineByPageId` (дешёвая проекция без `content`) + `bucketByDay(sessions, tz)`; `tz`→`RangeError`→400. **Числа ревизий по дням он НЕ отдаёт.** **Расхождения прототипа ↔ кода (закрываются адаптером/минимальным бэком):** - Прототип: `onRestore: api.restore(rev.id)`. Реально restore — загрузка в редактор + confirm + CASL (переиспользуем). - Прототип: `saved: boolean` + один бейдж `SAVED`. Оставляем именно так: **бейдж SAVED только для `manual`**; агентские версии маркируются глифом роли (не отдельным бейджем — он бы дублировал глиф), автосейвы приглушены без бейджа. - Прототип: `calendar.days[].count` = число ревизий за день. В системе такого агрегата нет → добавляем (см. дизайн, часть D). - Прототип: `dayGroup`/`atLabel` предформатированы вызывающим. Считаем на клиенте в браузерном tz. - Прототип десктоп-only (фикс. 280px, 80rem). Мобайл не трогаем. ## Дизайн **Принцип: механику (запросы, дифф, restore, права, пагинация) переиспользуем; меняем компоновку десктоп-окна и плотность строк; добавляем один дешёвый агрегат под heatmap. Мобайл, work-time/TimeWorkedModal — вне scope.** ### A. Каркас окна (десктоп) - Новый десктоп-body (напр. `history-modal-desktop.tsx`) с одно-строчной шапкой (заголовок, лейбл выбранной версии, тумблер Highlight changes + N/M-навигация по диффу, кнопка Restore, закрытие) и телом «левая навигация 280px | правый рендер версии». В [history-modal.tsx](apps/client/src/features/page-history/components/history-modal.tsx) десктоп-ветку переключаем на новый body; **мобильную ветку (`history-modal-mobile.tsx`) не трогаем.** - Правую панель — переиспользуем `HistoryView`/`HistoryEditor`, управляемые существующими атомами (`activeHistoryIdAtom`/`activeHistoryPrevIdAtom`). Дифф-навигацию — существующими `highlightChangesAtom`/`diffCountsAtom`/`useDiffNavigation`; **`viewportRef` правой `ScrollArea` прокинуть в `useDiffNavigation`** (как сейчас в body). - Состояние держим в существующих атомах/хуках, а не в новых локальных props — контролируемые props прототипа маппим на атомы. - **Инвариант diff/restore:** предыдущий снапшот = `resolvePrevSnapshotId(ПОЛНЫЙ плоский список, id)`. Группировка по дням — чисто презентационная; в `resolvePrevSnapshotId` по-прежнему передаём плоский `historyItems`, НЕ сгруппированный/отфильтрованный. ### B. Плотная строка ревизии (адаптер `IPageHistory → строка`) - `atLabel` = время; **`dayISO` (`YYYY-MM-DD` в браузерном tz)** — стабильный ключ строки для группировки и jump-to-day; `dayGroupLabel` = Today/Yesterday/«Mon 12 Jul» (i18n) — только для показа. - **Глиф — по идентичности:** `lastUpdatedSource==='agent' && item.agent` → квадратный глиф роли (`agent.name`) + «via `launcher?.name`»; иначе круглый аватар `lastUpdatedBy`. (Плотная строка — один аватар; мульти-контрибьюторный стек в плотном списке опускаем осознанно.) - **Бейдж — по интенциональности, но упрощённо:** показываем **только `SAVED`** при `kind==='manual'`. Агентские версии — без бейджа (глиф уже отличает). Не-version (autosave/boundary/idle/null) — приглушены (`opacity` как сейчас), без бейджа. - Заголовок дня-группы несёт `data-day={dayISO}`-якорь; левая `ScrollArea` получает `viewportRef` — для скролла из календаря (часть E). - Фильтр «Only versions» — существующий (`historyKindMeta(kind).version`, manual+agent). ### C. Restore - Переиспользуем `useHistoryRestore`: `confirmRestore` (confirm-модалка) + гейт `canRestore` (CASL Manage Page) + загрузка снапшота в редакторы. Наивный `onRestore` прототипа НЕ используем. - Кнопка Restore **disabled**, если `!canRestore` или выбран новейший снапшот (`historyItems[0]` — список грузится newest-first, поэтому индекс 0 — глобально новейший даже при пагинации). ### D. Серверный агрегат «число ревизий по дням» (единственное изменение бэка) - Новый `POST /pages/history/day-counts` `{ pageId, tz }` → `[{ dayISO, count }]` **за всю историю за один запрос** (без параметра `month` — это убирает повторный full-scan на каждую смену месяца и валидацию месяца). - Считаем через дешёвую проекцию `findTimelineByPageId` (без `content`) + **новый чистый tz-aware per-day count-баркетер**, переиспользующий уже протестированное tz-ядро: **экспортировать `zonedDayStart`/`isoDay` из `work-time/bucket-by-day.ts`** и строить счётчик как `createdAt → isoDay(zonedDayStart(ms, tz), tz) → tally` (единый источник DST/дней с `bucketByDay`, без копипасты). - **Считаем только version-строки** (`kind ∈ {manual, agent}`) — тогда крашеный день в heatmap всегда имеет видимую цель в списке и совпадает с фильтром «Only versions» (см. критерий 6). - View-gated (`validateCanView`, как `/history` и `/history/time`). Неверный `tz` (`RangeError` из `Intl`) → **400** (как `/history/time`). ### E. Мини-календарь + jump-to-day - `MiniCalendar` из прототипа + хук `usePageHistoryDayCounts(pageId, tz)`; **`tz` — единый браузерный** (`Intl.DateTimeFormat().resolvedOptions().timeZone`), тот же, что используется для клиентской группировки по дням (часть B) — иначе счётчики календаря разъедутся с днями списка. - Интенсивность ячейки heatmap = `count` из агрегата (version-ревизии). Навигация месяцев prev/next/today — чисто клиентская (данные за всю историю уже загружены; режем по месяцу локально). - **Pick-day (date-jumper):** выбрать/проскроллить к первой строке этого дня. - Матчим по `dayISO` (не по отображаемому лейблу — он относительный и без года), скроллим к `data-day`-якорю в левой `ScrollArea`. - Если день ещё не в загруженных страницах пагинации — **догружаем `fetchNextPage()` с корректной границей:** список newest-first, поэтому тянем страницы, пока `dayISO` самой старой загруженной строки не станет `<= targetDayISO` **или** `hasNextPage===false`. Не «N страниц». Если день так и не достигнут (исчерпали пагинацию) — no-op + короткий тост, без бесконечного цикла. **Policy-константы:** - Порог heatmap-интенсивности (fewer/…/more) — из прототипа (`heat()`), оставляем. - Ширина левой панели 280px, размер модалки — из прототипа. - Множество для heatmap и «Only versions» — version-строки (`manual+agent`). **Затрагиваемые файлы (примерно):** - Клиент: новый `history-modal-desktop.tsx` (порт каркаса + шапки из прототипа); новая плотная `RevisionRow` + адаптер (`dayISO`, глиф, бейдж SAVED); `MiniCalendar` (порт); `work-time`-стиль сервис/хук `usePageHistoryDayCounts`; правка `history-modal.tsx` (десктоп-ветка). Переиспользуем `HistoryView/HistoryEditor`, `useDiffNavigation`, `useHistoryReset`, `resolvePrevSnapshotId`, `useHistoryRestore`, `historyKindMeta`, атомы. - Сервер: новый DTO + `@Post('/pages/history/day-counts')` в `page.controller.ts`; метод `computeDayCounts` в `page-history.service.ts`; новый чистый баркетер + экспорт `zonedDayStart`/`isoDay` из `work-time/bucket-by-day.ts`; юнит-тесты баркетера. - Удалить после порта: `NewDesign/PageHistoryModal.tsx` (исходник-прототип, в корне жить не должен). ## Границы scope **Не входит:** мобильная модалка (`history-modal-mobile.tsx`) — без изменений; редизайн work-time/`TimeWorkedModal.tsx` — отдельно; серверный history-restore-API (restore остаётся клиентским); мульти-контрибьюторный стек в плотной строке (осознанно опущен). Живое обновление heatmap при поступлении новых ревизий во время открытого окна — не требуется (счётчики тянутся при открытии). ## Критерии приёмки Проверяемо на стенде (страница с ручными, агентскими и авто-ревизиями): 1. Плотный список: одна ревизия = одна строка, сгруппировано по дням (заголовок дня); агентская строка — квадратный глиф роли + «via <launcher>»; человеческая — круглый аватар `lastUpdatedBy`. 2. Бейдж `SAVED` только у `manual`; агентские версии — без бейджа (видны глифом); автосейвы приглушены без бейджа. 3. Выбор версии рендерит её справа; Highlight changes + N/M-навигация работают из шапки (дифф против истинного предыдущего снапшота, а не против соседа по отфильтрованному/сгруппированному списку). 4. Restore идёт через существующий confirm + CASL-гейт и грузит снапшот в редактор; кнопка disabled у новейшего снапшота и при `!canRestore`. 5. `POST /pages/history/day-counts {pageId, tz}` отдаёт `[{dayISO,count}]` за всю историю; неверный `tz` → 400; неавторизованный просмотр → 403; счётчик дня == числу version-строк этого дня в том же tz. 6. **Согласованность heatmap↔список:** любой крашеный день календаря имеет видимую строку в списке того же дня (heatmap и «Only versions» считают одно множество — version-строки). 7. **Jump-to-day:** клик по дню скроллит к первой строке этого дня; если день ниже загруженной части — подгружает страницы с корректной границей (по `dayISO` старейшей строки), а не фиксированным числом; недостижимый день → no-op + тост, без зацикливания. 8. Единый tz: день в списке и ячейка календаря для одной даты согласованы (одинаковый браузерный tz на клиенте и в запросе агрегата). 9. Мобильная модалка не изменилась. ## Отказы и наблюдаемость - `day-counts` упал (400 неверный tz / 5xx / сеть): heatmap показывает пустую сетку (fail-open, без блокировки списка), список и restore работают. Не роняем окно. - Неверный `tz` — явный 400 (`RangeError` из `Intl`), как у `/history/time`; невалидный день/пустой месяц — пустые ячейки. - Restore — необратимая для «неверсионированных» правок операция; существующая confirm-модалка это проговаривает, поведение не меняем. - Jump-to-day с недостижимым днём: явный no-op + короткий тост (не молчаливое зависание). ## Совместимость и миграции - **Дефолты/поведение существующих пользователей:** меняется компоновка **десктопного** окна истории; механика (дифф, restore, права, пагинация, «Only versions») та же. Мобайл без изменений. Новый эндпоинт — аддитивный. - **Миграций БД нет** (агрегат считается по существующим строкам). **Rollback:** ревертом PR; эндпоинт можно не вызывать (heatmap деградирует до пустого/скрытого, окно работает). ## Решённые развилки - Heatmap-данные → **новый серверный агрегат «число ревизий по дням»** (не work-time). Кто решил: человек (@vvzvlad). Уточнение по итогам разбора: считаем **version-строки** и отдаём **всю историю за один запрос** (убирает re-scan по месяцам и валидацию месяца; гарантирует совпадение heatmap с фильтром/списком). - Бейджи → **только `SAVED` (manual)**; агентские версии маркируются глифом, отдельный «Agent version»-бейдж не вводим (дублировал бы глиф). Кто решил: человек. - Restore/права/дифф/мобайл → существующее поведение переиспользуем (не развилка): `useHistoryRestore` (confirm+CASL), `useDiffNavigation`, мобильную модалку не трогаем. - Глиф vs бейдж → ортогонально: глиф по `lastUpdatedSource==='agent'`, бейдж по `kind` (дизайн-решение по итогам разбора — иначе агентский автосейв терял бы агентскую идентичность). - Объём → **одна ишью** (фронт-редизайн + серверный агрегат + календарь вместе). Кто решил: человек. ## Связи - Опирается на: #370 (kinds/интенциональность истории), #395 (`/history/time`, work-time-ядро и tz-баркетинг — переиспользуем `bucket-by-day`), #300 (provenance-стек агента). - Не пересекается с: редизайном `TimeWorkedModal.tsx` (отдельный прототип, отдельная ишью).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#568