feat(work-time): интеграция редизайна модалки «Time worked» — таймлайны с временем суток (клиент, grid) #566

Closed
opened 2026-07-12 19:04:58 +03:00 by agent_vscode · 0 comments
Collaborator

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

Есть готовый прототип редизайна окна «Time worked on this article» — NewDesign/TimeWorkedModal.tsx (Mantine v7, светлая/тёмная тема). Главное отличие от текущей версии: на суточных дорожках видно время суток — часовая ось 00–06–12–18–24 сверху с делениями, ночные часы затемнены, у каждого блока hover-тултип «начало–конец · длительность», а у сегодняшнего дня — граница «сейчас».

Нужно натянуть этот визуал на существующую фичу учёта времени с нулём изменений бэкенда: все данные уже отдаёт текущий эндпоинт POST /pages/history/time. Прототип писался без знания нашей модели данных, поэтому его входные пропсы (days: DaySummary[], заранее готовые totalLabel/agentLabel) надо заменить адаптером над реальным IPageWorkTime.

Для кого: авторы/редакторы, которые смотрят, сколько времени и в какие часы шла работа над статьёй (человек и, отдельно, агент).

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

Фича #395 «time worked», подсистема work-time:

  • Триггер: work-time-stat.tsx — кликабельный заголовок «≈ Xh» в шапке страницы (page-header-menu.tsx), открывает <Modal title="Time worked on this article"> с телом WorkTimePunchCard. Виджет скрыт, пока workMs<=0 && agentOnlyMs<=0; на agent-only странице заголовок показывает агентскую оценку.
  • Текущая виз: work-time-punch-card.tsx — уже рисует посуточные дорожки, позиционируя окна left=(w.start-day.day)/DAY_MS, деления 6/12/18, цвета work/agent, суточные итоги, и схлопывает прогоны ≥8 пустых дней в строку-разделитель «× N days without edits» (collapseEmptyRuns).
  • Данные: work-time.types.tsIPageWorkTime = {workMs, agentOnlyMs, perDay: IPerDay[], config, tz}; IPerDay = {day (epoch-ms локальной полуночи в tz), dayISO, activeMs, agentMs, windows: IDayWindow[]}; IDayWindow = {start, end (epoch-ms), class: 'work'|'agent_only'}. Окна клипаются к суткам на сервере (bucket-by-day.ts).
  • Запрос: use-page-work-time.ts — react-query, tz = viewerTimezone() (браузерная IANA-зона); та же tz уходит на сервер для бакетирования по дням, поэтому day (epoch-ms локальной полуночи) и браузерное форматирование даты согласованы.
  • Форматирование: format-work-time.tsformatHeadline («≈ 1h 24m», "" при workMs<=0), formatDayTotal («1h 24m» / «—»), formatGapMinutes. Все берут t().

Вывод: данные для прототипа полностью доступны на клиенте — это чисто визуальный оверхол WorkTimePunchCard и обёртки-модалки. Совпадает с заявлением прототипа «изменений API не требуется».

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

  • Прототип принимает DaySummary[] с готовыми строками; реально приходит IPageWorkTime — маппим и форматируем существующими хелперами.
  • Block.start/end — часовые доли 0..24; реально windows в epoch-ms → (w.start-day.day)/3.6e6.
  • kind: 'work'|'agent'class: 'work'|'agent_only'.
  • tz='Europe/Moscow', inactivityGapMin=15 захардкожены — берём data.tz и formatGapMinutes(data.config.tGap).
  • Прототип мешает языки (английская шапка + русская строка пустого состояния) — всё через t().
  • У прототипа нет концепции «× N дней без правок» — её надо сохранить (иначе статья, правленная месяцами, даст сотни пустых строк).

Дизайн

Принцип: контейнер, запрос, эндпоинт, алгоритм — прежние; меняем только визуал тела модалки (и её хром). Ось времени — grid (решение человека). Бэкенд не трогаем.

1. Ось grid (только она). Порт визуала из TimeWorkedModal.tsx: часовая шкала 00/06/12/18/24 (липкая), вертикальные деления 25/50/75%, затемнение ночных часов (0–6 и 21–24, как в trackBg прототипа), легенда Work/Agent, per-block тултип, граница «сейчас». Ветку axis='phases' не портируем (вне scope; возможный будущий тумблер).

2. Адаптер IPageWorkTime → входы компонента (заменяет пропсы прототипа):

  • Блоки: day.windows.map(w => ({ start:(w.start-day.day)/3.6e6, end:(w.end-day.day)/3.6e6, kind: w.class==='work' ? 'work' : 'agent' })); позиционирование/ширина — как в текущем punch-card (left, width, clamp [0,24], мин. видимая ширина 0.6%).
  • Суточный итог: formatDayTotal(day.activeMs, t) («—» при 0).
  • Метка дня: существующий dayHeading(day.day) (browser-tz, согласован с бакетингом).
  • Заголовок модалки (fail-safe для agent-only, находка критика №1): total = workMs>0 ? formatHeadline(workMs,t) : t("agent: {{value}}", {value: formatHeadline(agentOnlyMs,t)}). Вторичную строку agent: … показываем только когда workMs>0 && agentOnlyMs>0 (иначе агентская оценка уже в главном слоте). Так большой 22px-слот НИКОГДА не пустой (formatHeadline(0)==="").
  • tz / gap: data.tz, formatGapMinutes(data.config.tGap) в подписи-футере.

3. Сохранение схлопывания пустых прогонов (находка критика №2). Контракт входа делаем rows: (DaySummary | { gap: number })[] (или добавляем DaySummary.gap?: number-вариант). Портируем существующий collapseEmptyRuns (EMPTY_RUN_COLLAPSE = 8): прогон ≥8 пустых дней → строка-разделитель t("× {{count}} days without edits", {count}) в исходном порядке (не в конец); короткий (<8) прогон пустых дней рендерится полупрозрачными дорожками с итогом «—». Прототипный плоский days.map заменяем на рендер rows с веткой gap-строки.

4. Граница «сейчас». Помечаем isToday тот perDay, у которого [day, day+DAY_MS) содержит Date.now(), с nowFraction=(Date.now()-day.day)/DAY_MS. Если сегодняшнего дня в perDay нет (сегодня правок не было — bucketByDay не добавляет пустой хвост), маркер не показываем. Ложных срабатываний нет (последний perDay — всегда активный день).

5. Тултип блока (находка критика №3 — DST). Позиционирование остаётся по часовой доле (w.start-day.day)/DAY_MS (как сейчас). Но время в тултипе («начало–конец») форматируем из реального epoch (w.start/w.end) в data.tz через Intl.DateTimeFormat(locale,{hour,minute,timeZone:data.tz}), а НЕ из 24-часовой доли — иначе в дни перевода часов (сутки 23/25ч) тултип покажет время, съехавшее до часа. Длительность блока — из epoch (w.end-w.start), т.е. клипнутая к суткам порция (для посуточной дорожки это корректно).

6. i18n. Все видимые строки — через react-i18next t() (шапка, легенда, пустое состояние, футер, «× N days», тултип-разделители).

Policy-константы: EMPTY_RUN_COLLAPSE=8 (существующая), мин. ширина блока 0.6%, затемнение ночных часов 0–6 и 21–24, ширина модалки ~46rem — из прототипа.

Затрагиваемые файлы:

  • Правка (тело): переписать work-time-punch-card.tsx на grid-дизайн из прототипа (либо новый work-time-timeline.tsx рядом и переключить импорт в work-time-stat). Сохранить collapseEmptyRuns, форматтеры, позиционную математику.
  • Правка (хром): work-time-stat.tsx — обёртка Modal (шире size, паддинги/шапка по прототипу); логика триггера/useDisclosure/скрытия виджета — без изменений.
  • Переиспользуем: usePageWorkTime, formatHeadline/formatDayTotal/formatGapMinutes, dayHeading, IPageWorkTime/IPerDay/IDayWindow.
  • Удалить после порта: NewDesign/TimeWorkedModal.tsx (исходник-прототип; прочие файлы в NewDesign/ — для отдельных задач, не трогаем).

Границы scope

Не входит:

  • Любые изменения бэкенда/эндпоинта, алгоритма work-time, конфига, заголовочного виджета «≈ Xh» и его логики скрытия.
  • Ось axis='phases' (цветные полосы фаз) — не портируем; возможный будущий тумблер grid/phases.
  • Исправление DST-скоса в позиционировании блоков (оставляем как в текущем punch-card — pre-existing).

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

Проверяемо на стенде (страница с историей правок в разные часы):

  1. Клик по заголовку «≈ Xh» открывает редизайн-модалку; каждая суточная дорожка показывает work/agent-блоки по времени суток, сверху — липкая ось 00/06/12/18/24, ночные часы затемнены.
  2. Окно позиционируется/масштабируется идентично текущему punch-card ((w.start-day.day)/DAY_MS).
  3. Прогон ≥8 пустых дней по-прежнему схлопнут в строку × N days without edits на своём месте в списке; короткий прогон пустых дней — полупрозрачные дорожки с итогом «—».
  4. Agent-only страница (workMs=0, agentOnlyMs>0) открывается, и главный слот заголовка показывает агентскую оценку (НЕ пустой), без дублирующей строки agent:.
  5. Наведение на блок показывает «начало–конец · длительность»; время берётся из реального epoch в data.tz (в DST-переходный день не съезжает).
  6. У сегодняшнего дня (если он есть в данных) видна вертикальная граница «сейчас»; если правок сегодня не было — границы нет и ошибок нет.
  7. Все видимые строки переводимы (нет захардкоженных en/ru литералов, включая пустое состояние и футер).
  8. Тёмная тема: дорожки/оси/блоки читаются (токены Mantine).

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

  • Данные грузит существующий usePageWorkTime (react-query); состояния загрузки/ошибки/пусто — как сейчас (No time tracked yet при workMs<=0 && agentOnlyMs<=0). Новых сетевых путей нет.
  • Чисто презентационный компонент: нет мутаций, нет необратимых действий, audit-trail не затрагивается.
  • Деградация: некорректная/частичная выборка окон не ломает рендер (clamp [0,24], мин. ширина) — как в текущем punch-card.

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

  • Поведение существующих пользователей: меняется только внешний вид модалки. Триггер, скрытие виджета, эндпоинт, tz, алгоритм, суточные итоги — без изменений. Заголовок agent-only страницы становится честнее (не пустой большой слот).
  • Бэкенд: изменений нет. Миграций нет.
  • Rollback: фича клиентская; откат — ревертом фронтового PR.

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

  • Ось времени → grid (часовая сетка). Кто решил: человек (@vvzvlad). Причина: ближе к текущему punch-card и точнее по часам; phases — возможный будущий тумблер, вне scope.
  • Заголовок agent-only → агентская оценка в главном слоте, без дублирующей строки. Кто решил: дизайн-очевидность (formatHeadline(0)==="" иначе даёт пустой 22px-слот — находка критика).
  • Схлопывание пустых прогонов → сохраняем существующий collapseEmptyRuns через rows: (DaySummary|{gap})[]. Кто решил: анти-регрессия (у прототипа концепции нет).
  • Время в тултипе → из epoch в tz через Intl, не из часовой доли. Кто решил: находка критика (DST-скос новой поверхности).

Связи

  • Опирается на: #395 (движок work-time: сессионизация, per-day окна, punch-card, форматтеры, tz-бакетинг).
  • Порождает возможную будущую ишью (вне scope): тумблер оси grid/phases (порт ветки axis='phases' из прототипа).
## Проблема / Цель Есть готовый прототип редизайна окна «Time worked on this article» — `NewDesign/TimeWorkedModal.tsx` (Mantine v7, светлая/тёмная тема). Главное отличие от текущей версии: **на суточных дорожках видно время суток** — часовая ось `00–06–12–18–24` сверху с делениями, ночные часы затемнены, у каждого блока hover-тултип «начало–конец · длительность», а у сегодняшнего дня — граница «сейчас». Нужно **натянуть этот визуал на существующую фичу учёта времени** с **нулём изменений бэкенда**: все данные уже отдаёт текущий эндпоинт `POST /pages/history/time`. Прототип писался без знания нашей модели данных, поэтому его входные пропсы (`days: DaySummary[]`, заранее готовые `totalLabel`/`agentLabel`) надо заменить адаптером над реальным `IPageWorkTime`. Для кого: авторы/редакторы, которые смотрят, сколько времени и в какие часы шла работа над статьёй (человек и, отдельно, агент). ## Контекст и исследование Фича #395 «time worked», подсистема `work-time`: - Триггер: [work-time-stat.tsx](apps/client/src/features/page-history/work-time/work-time-stat.tsx) — кликабельный заголовок «≈ Xh» в шапке страницы ([page-header-menu.tsx](apps/client/src/features/page/components/header/page-header-menu.tsx#L269)), открывает `<Modal title="Time worked on this article">` с телом `WorkTimePunchCard`. Виджет скрыт, пока `workMs<=0 && agentOnlyMs<=0`; на agent-only странице заголовок показывает агентскую оценку. - Текущая виз: [work-time-punch-card.tsx](apps/client/src/features/page-history/work-time/work-time-punch-card.tsx) — уже рисует посуточные дорожки, позиционируя окна `left=(w.start-day.day)/DAY_MS`, деления 6/12/18, цвета work/agent, суточные итоги, и **схлопывает прогоны ≥8 пустых дней** в строку-разделитель «× N days without edits» ([collapseEmptyRuns](apps/client/src/features/page-history/work-time/work-time-punch-card.tsx#L21)). - Данные: [work-time.types.ts](apps/client/src/features/page-history/work-time/work-time.types.ts) — `IPageWorkTime = {workMs, agentOnlyMs, perDay: IPerDay[], config, tz}`; `IPerDay = {day (epoch-ms локальной полуночи в tz), dayISO, activeMs, agentMs, windows: IDayWindow[]}`; `IDayWindow = {start, end (epoch-ms), class: 'work'|'agent_only'}`. Окна **клипаются к суткам на сервере** ([bucket-by-day.ts](apps/server/src/core/page/work-time/bucket-by-day.ts)). - Запрос: [use-page-work-time.ts](apps/client/src/features/page-history/work-time/use-page-work-time.ts) — react-query, `tz = viewerTimezone()` (браузерная IANA-зона); **та же tz уходит на сервер** для бакетирования по дням, поэтому `day` (epoch-ms локальной полуночи) и браузерное форматирование даты согласованы. - Форматирование: [format-work-time.ts](apps/client/src/features/page-history/work-time/format-work-time.ts) — `formatHeadline` («≈ 1h 24m», `""` при `workMs<=0`), `formatDayTotal` («1h 24m» / «—»), `formatGapMinutes`. Все берут `t()`. **Вывод:** данные для прототипа полностью доступны на клиенте — это чисто визуальный оверхол `WorkTimePunchCard` и обёртки-модалки. Совпадает с заявлением прототипа «изменений API не требуется». **Расхождения прототипа ↔ реальность (закрываются адаптером):** - Прототип принимает `DaySummary[]` с готовыми строками; реально приходит `IPageWorkTime` — маппим и форматируем существующими хелперами. - `Block.start/end` — часовые доли 0..24; реально `windows` в epoch-ms → `(w.start-day.day)/3.6e6`. - `kind: 'work'|'agent'` ↔ `class: 'work'|'agent_only'`. - `tz='Europe/Moscow'`, `inactivityGapMin=15` захардкожены — берём `data.tz` и `formatGapMinutes(data.config.tGap)`. - Прототип мешает языки (английская шапка + русская строка пустого состояния) — всё через `t()`. - У прототипа **нет** концепции «× N дней без правок» — её надо сохранить (иначе статья, правленная месяцами, даст сотни пустых строк). ## Дизайн **Принцип: контейнер, запрос, эндпоинт, алгоритм — прежние; меняем только визуал тела модалки (и её хром). Ось времени — `grid` (решение человека). Бэкенд не трогаем.** **1. Ось `grid` (только она).** Порт визуала из [TimeWorkedModal.tsx](NewDesign/TimeWorkedModal.tsx): часовая шкала `00/06/12/18/24` (липкая), вертикальные деления 25/50/75%, затемнение ночных часов (0–6 и 21–24, как в `trackBg` прототипа), легенда Work/Agent, per-block тултип, граница «сейчас». Ветку `axis='phases'` **не портируем** (вне scope; возможный будущий тумблер). **2. Адаптер `IPageWorkTime → входы компонента` (заменяет пропсы прототипа):** - **Блоки:** `day.windows.map(w => ({ start:(w.start-day.day)/3.6e6, end:(w.end-day.day)/3.6e6, kind: w.class==='work' ? 'work' : 'agent' }))`; позиционирование/ширина — как в текущем punch-card (`left`, `width`, clamp `[0,24]`, мин. видимая ширина 0.6%). - **Суточный итог:** `formatDayTotal(day.activeMs, t)` («—» при 0). - **Метка дня:** существующий `dayHeading(day.day)` (browser-tz, согласован с бакетингом). - **Заголовок модалки (fail-safe для agent-only, находка критика №1):** `total = workMs>0 ? formatHeadline(workMs,t) : t("agent: {{value}}", {value: formatHeadline(agentOnlyMs,t)})`. Вторичную строку `agent: …` показываем **только когда `workMs>0 && agentOnlyMs>0`** (иначе агентская оценка уже в главном слоте). Так большой 22px-слот НИКОГДА не пустой (`formatHeadline(0)===""`). - **tz / gap:** `data.tz`, `formatGapMinutes(data.config.tGap)` в подписи-футере. **3. Сохранение схлопывания пустых прогонов (находка критика №2).** Контракт входа делаем `rows: (DaySummary | { gap: number })[]` (или добавляем `DaySummary.gap?: number`-вариант). Портируем существующий `collapseEmptyRuns` (`EMPTY_RUN_COLLAPSE = 8`): прогон ≥8 пустых дней → строка-разделитель `t("× {{count}} days without edits", {count})` **в исходном порядке** (не в конец); короткий (<8) прогон пустых дней рендерится полупрозрачными дорожками с итогом «—». Прототипный плоский `days.map` заменяем на рендер `rows` с веткой gap-строки. **4. Граница «сейчас».** Помечаем `isToday` тот `perDay`, у которого `[day, day+DAY_MS)` содержит `Date.now()`, с `nowFraction=(Date.now()-day.day)/DAY_MS`. Если сегодняшнего дня в `perDay` нет (сегодня правок не было — `bucketByDay` не добавляет пустой хвост), маркер не показываем. Ложных срабатываний нет (последний `perDay` — всегда активный день). **5. Тултип блока (находка критика №3 — DST).** Позиционирование остаётся по часовой доле `(w.start-day.day)/DAY_MS` (как сейчас). Но **время в тултипе** («начало–конец») форматируем из **реального epoch** (`w.start`/`w.end`) в `data.tz` через `Intl.DateTimeFormat(locale,{hour,minute,timeZone:data.tz})`, а НЕ из 24-часовой доли — иначе в дни перевода часов (сутки 23/25ч) тултип покажет время, съехавшее до часа. Длительность блока — из epoch (`w.end-w.start`), т.е. клипнутая к суткам порция (для посуточной дорожки это корректно). **6. i18n.** Все видимые строки — через `react-i18next` `t()` (шапка, легенда, пустое состояние, футер, «× N days», тултип-разделители). **Policy-константы:** `EMPTY_RUN_COLLAPSE=8` (существующая), мин. ширина блока `0.6%`, затемнение ночных часов `0–6` и `21–24`, ширина модалки ~46rem — из прототипа. **Затрагиваемые файлы:** - Правка (тело): переписать [work-time-punch-card.tsx](apps/client/src/features/page-history/work-time/work-time-punch-card.tsx) на grid-дизайн из прототипа (либо новый `work-time-timeline.tsx` рядом и переключить импорт в `work-time-stat`). Сохранить `collapseEmptyRuns`, форматтеры, позиционную математику. - Правка (хром): [work-time-stat.tsx](apps/client/src/features/page-history/work-time/work-time-stat.tsx) — обёртка `Modal` (шире `size`, паддинги/шапка по прототипу); **логика триггера/`useDisclosure`/скрытия виджета — без изменений**. - Переиспользуем: `usePageWorkTime`, `formatHeadline`/`formatDayTotal`/`formatGapMinutes`, `dayHeading`, `IPageWorkTime`/`IPerDay`/`IDayWindow`. - Удалить после порта: `NewDesign/TimeWorkedModal.tsx` (исходник-прототип; прочие файлы в `NewDesign/` — для отдельных задач, не трогаем). ## Границы scope **Не входит:** - Любые изменения бэкенда/эндпоинта, алгоритма work-time, конфига, заголовочного виджета «≈ Xh» и его логики скрытия. - Ось `axis='phases'` (цветные полосы фаз) — не портируем; возможный будущий тумблер grid/phases. - Исправление DST-скоса в **позиционировании** блоков (оставляем как в текущем punch-card — pre-existing). ## Критерии приёмки Проверяемо на стенде (страница с историей правок в разные часы): 1. Клик по заголовку «≈ Xh» открывает редизайн-модалку; каждая суточная дорожка показывает work/agent-блоки по времени суток, сверху — липкая ось `00/06/12/18/24`, ночные часы затемнены. 2. Окно позиционируется/масштабируется идентично текущему punch-card (`(w.start-day.day)/DAY_MS`). 3. Прогон ≥8 пустых дней по-прежнему схлопнут в строку `× N days without edits` **на своём месте** в списке; короткий прогон пустых дней — полупрозрачные дорожки с итогом «—». 4. **Agent-only страница** (`workMs=0, agentOnlyMs>0`) открывается, и главный слот заголовка показывает агентскую оценку (НЕ пустой), без дублирующей строки `agent:`. 5. Наведение на блок показывает «начало–конец · длительность»; время берётся из реального epoch в `data.tz` (в DST-переходный день не съезжает). 6. У сегодняшнего дня (если он есть в данных) видна вертикальная граница «сейчас»; если правок сегодня не было — границы нет и ошибок нет. 7. Все видимые строки переводимы (нет захардкоженных en/ru литералов, включая пустое состояние и футер). 8. Тёмная тема: дорожки/оси/блоки читаются (токены Mantine). ## Отказы и наблюдаемость - Данные грузит существующий `usePageWorkTime` (react-query); состояния загрузки/ошибки/пусто — как сейчас (`No time tracked yet` при `workMs<=0 && agentOnlyMs<=0`). Новых сетевых путей нет. - Чисто презентационный компонент: нет мутаций, нет необратимых действий, audit-trail не затрагивается. - Деградация: некорректная/частичная выборка окон не ломает рендер (clamp `[0,24]`, мин. ширина) — как в текущем punch-card. ## Совместимость и миграции - **Поведение существующих пользователей:** меняется только внешний вид модалки. Триггер, скрытие виджета, эндпоинт, tz, алгоритм, суточные итоги — без изменений. Заголовок agent-only страницы становится честнее (не пустой большой слот). - **Бэкенд:** изменений нет. Миграций нет. - **Rollback:** фича клиентская; откат — ревертом фронтового PR. ## Решённые развилки - Ось времени → **grid** (часовая сетка). Кто решил: человек (@vvzvlad). Причина: ближе к текущему punch-card и точнее по часам; `phases` — возможный будущий тумблер, вне scope. - Заголовок agent-only → **агентская оценка в главном слоте**, без дублирующей строки. Кто решил: дизайн-очевидность (`formatHeadline(0)===""` иначе даёт пустой 22px-слот — находка критика). - Схлопывание пустых прогонов → **сохраняем** существующий `collapseEmptyRuns` через `rows: (DaySummary|{gap})[]`. Кто решил: анти-регрессия (у прототипа концепции нет). - Время в тултипе → **из epoch в tz через Intl**, не из часовой доли. Кто решил: находка критика (DST-скос новой поверхности). ## Связи - Опирается на: #395 (движок work-time: сессионизация, per-day окна, punch-card, форматтеры, tz-бакетинг). - Порождает возможную будущую ишью (вне scope): тумблер оси grid/phases (порт ветки `axis='phases'` из прототипа).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#566