feat: «Время работы над статьёй» — число в UI + суточный punch-card #395

Open
opened 2026-07-06 21:47:43 +03:00 by agent_vscode · 1 comment
Collaborator

Что делаем

Число «время работы над статьёй» в UI страницы + drill-down по клику в суточный таймлайн (строка-день = 24-часовая дорожка с окнами активности и суммой за день). Это НЕ MCP-инструмент, а метрика для человека.

Оценка времени = сессионизация истории page_history по паузам (не span между крайними правками).

Полная нормативная спека — в конце тикета (раскрыть «§1–§10»). История ревью в неё НЕ входит.

Зависимость — сначала #374

Завязано на #374 (page versioning): page_history.kind (manual/agent/idle/boundary), константы IDLE_MAX_WAIT_*, broadcast version.saved, kind уже в PageHistoryRepo.baseFields. Делать после мёржа #374 или прямо на ветке feat/370-page-versioning. Ядро формально считает и на легаси-полях (lastUpdatedSource+createdAt), но классификация и калибровка порога — из #374.

Зафиксированные дефолты (§10, подтверждено backcast)

T_gap=15м · agentTGap=7м · P_in=5м · P_out=5м · P_single=2м · excludeGit=true · tz = локаль зрителя · headline-число = work · место = панель истории. longRangeDayThreshold (сворачивание день→неделя для очень длинных диапазонов) — optional, для v1 достаточно вертикального скролла.

Алгоритм (кратко; детали §5–§6.3)

  • Один проход по ВСЕМ сэмплам (не по парам, не по классам) → сессии по паузам. Порог зависит от пары сэмплов: оба agentagentTGap, иначе T_gap. Обязательно закрыть последнюю сессию после цикла.
  • Коллапс агентских всплесков: подряд идущие source=agent c одним aiChatId → сегмент; сэмпл с другим aiChatId/не-agent/boundary рвёт его.
  • Класс сессии: все сэмплы agentagent_only, иначе work (человек + надзор за агентом внутри сессии).
  • Интервалы: многосэмпловая → [first−P_in, last+P_out]; одиночный скаляр → pre-roll [t−P_single, t].
  • Метрики = union-wall-clock по классу: workMs (headline), agentOnlyMs (вторично). Один проход ⇒ классы дизъюнктны, workMs+agentOnlyMs ≤ прошедшего (при T_gap ≥ P).
  • Per-day bucketByDay(sessions, tz): U_work/U_agent = union по классу, разрез по полуночи tz (dayjs+tz, startOf('day'), НЕ «+24ч»), activeMs=work / agentMs=agent, windows обоих классов с меткой класса.

Задачи

Сервер

  • Чистая computeWorkTime(rows, config){ workMs, agentOnlyMs, sessions[{start,end,class}] } (отдельный модуль, детерминированная, юнит-тесты)
  • Чистая bucketByDay(sessions, tz)perDay[{ day, activeMs, agentMs, windows }] (общий модуль сервер+клиент)
  • PageHistoryRepo.findTimelineByPageId(pageId) — лёгкая проекция (createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind), без content, ASC
  • PageHistoryService.computeWorkTime(pageId, config) + кэш; инвалидация/пересчёт хвоста на version.saved
  • POST /pages/history/time в page.controller.ts (рядом с /history, /history/info)

Клиент

  • Хук usePageWorkTime(pageId)workMs/agentOnlyMs/sessions[] (features/page-history/queries/)
  • Кликабельное число work в панели истории (+agent_only вторично), подпись «≈» и порог
  • Модалка punch-card (§6.2): 24-часовые дорожки по дням, окна work/agent разным цветом, сумма за день справа, пустой день — «—», подпись tz+T_gap. Кастомные CSS/SVG-отрезки, без новых тяжёлых зависимостей.

DoD / приёмка

  • Юнит-тесты на hostile-сценарии из спеки: закрытие последней сессии; n=1→одна P_single, n=0→0; единый проход + классификация (надзорное агентское время идёт человеку); коллапс всплеска рвётся человеком и другим aiChatId; полуночный и DST-разрез; P_single pre-roll
  • Инвариант Σ perDay.activeMs == workMs (держится по построению)
  • workMs + agentOnlyMs ≤ прошедшего; конфиг валидирует T_gap ≥ P_in+P_out
  • Число подаётся как оценка (символ «≈» + показ T_gap)
  • pnpm --filter server exec tsc --noEmit = 0; юниты зелёные

Вне scope

Долговечность черновика (#374 не трогаем); MCP-инструмент save_page_version (это PR-3 из #370); per-author разбивка (опц. позже).

Эмпирический sanity-check

На статье dwzDdgPep2 (ESP, 307 снимков, 29.06–06.07) при T_gap=15м выходит ≈24 ч work — совпало с оценкой владельца; наивный span был бы 165 ч. Годится как проверочный кейс.


📋 Полная спека (§1–§10) — раскрыть

Фича «Время работы над статьёй» — дизайн-документ

Статус: черновик проектирования (код не пишется).
Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц).

1. Цель и не-цели

Цель. Показывать в UI страницы одно число — оценку времени, реально затраченного
на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к
паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов».
По клику на число — раскрытие в суточный таймлайн: строка-день = 24-часовая дорожка с
окнами активности и суммой за день (см. §6.2).

Явно не-цели.

  • Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI.
  • Не хронометраж с точностью до минуты — это заведомо оценка.
  • Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика.

2. Проблема

История страницы в Docmost — таблица page_history: снимок на каждое сохранение.
У снимка есть createdAt, автор (lastUpdatedById), тип источника
(lastUpdatedSource: user/agent/git) и группировка агентских правок
(lastUpdatedAiChatId).

Наивная оценка max(createdAt) − min(createdAt) завышает в разы: между крайними
правками лежат сон, обед, дни простоя. На реальных данных статьи-примера
(первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов,
тогда как реальной работы — пара часов в 5–6 коротких заходов.

Правильная постановка: длительность — это не span между крайними правками, а сумма
интервалов внутри «сессий»; историю режем по паузам бездействия.
Это классическая
сессионизация по таймауту неактивности (WakaTime / RescueTime / веб-аналитика).

3. Почему PR #374 — фундамент фичи

До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить»,
в page_history за этот час почти нет строк (тяжёлые автосейвы ydoc идут в pages/ydoc,
а не в историю). Непрерывную работу нечем измерить.

PR #374 вводит типизированную историю через колонку page_history.kind:

kind что означает ценность для фичи
manual человек нажал Save сильный маркер активной работы человека
agent снимок правки агентом машинное время агента (группируется по aiChatId)
idle автоснимок idle-флеша (потолок ~maxWait) регулярный «пульс» непрерывной работы (~≤10м, §3)
boundary автоснимок на переходе актора (user↔agent↔git) бесплатная разметка «кто работал в этом сегменте»
null легаси-автосейв (старые страницы) обычный сэмпл активности

Два env-параметра #374 напрямую задают качество измерения:

  • IDLE_MAX_WAIT_USER=10м / AGENT=5м (потолок ожидания) — определяющий параметр.
    Проверено по computeHistoryJob (persistence.extension.ts):
    delay = max(0, min(interval, burstStart + maxWait − now)), а enqueuePageHistory
    сбрасывает burstStart каждые maxWait. Так как maxWait (10м/5м) < interval (60м/15м),
    потолок всегда доминирует: во время непрерывной работы idle-снимок форсится каждые
    ~maxWait, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent).
  • IDLE_INTERVAL_USER=60м / AGENT=15м — номинальный трейлинг-интервал, который потолок
    ожидания на практике всегда упреждает. Поэтому метка любого idle-снимка отстоит от
    реальной правки не более чем на ~maxWait (≤10м user / ≤5м agent), а не на 60 мин.
    (Это ключевой факт для точности: idle-метки — достоверный сигнал активности, не «хвост».)

Вывод: ядро алгоритма работает и на голых createdAt+lastUpdatedSource (есть с миграции
20260616T130000-agent-provenance), поэтому фича считает и на легаси-страницах — просто
грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет,
но полноценная точность появляется вместе с ним.

4. Входной сигнал

Дешёвый проекционный запрос по page_history (без тяжёлой колонки content): на строку —
createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind.
Все поля (включая kind) уже в PageHistoryRepo.baseFields после #374 — схему трогать не
нужно, findTimelineByPageId это лишь лёгкая проекция без content.

Важный факт об авторстве (проверено по persistence.extension.tsupdatePage):
page_history.lastUpdatedById — это ВСЕГДА ответственный человек, даже для агентских снимков
(«human stays the responsible author»). Признак «человек vs агент» живёт в
lastUpdatedSource (user/agent/git) и lastUpdatedAiChatId, а НЕ в lastUpdatedById.
Отсюда: разделять человеко-/агенто-время нужно по lastUpdatedSource, а атрибутировать
конкретному человеку — по lastUpdatedById/contributorIds.

5. Алгоритм: сессионизация по паузам

Параметры (env, в стиле #374; полный список — §10):

  • T_gap — таймаут неактивности: пауза между соседними сэмплами ≤ T_gap = непрерывная
    работа, больше = перерыв (в зачёт не идёт).
  • P_in / P_out — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась
    после последнего сохранения).
  • P_single — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах T_gap нет). Мал
    (дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный
    P_in+P_out = выдумывать время. НЕ путать с многосэмпловой добивкой.

Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1):

samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC
    (все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается)

# коллапс агентских всплесков (§5.1)
collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}.
    Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent,
    boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран,
    склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ
    (или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с
    .t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t.

gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap   # порог зависит от ПАРЫ (§10)

# сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам)
sessions = []; cur = null
for s in samples:                              # s — скаляр или коллапс-сегмент
    if cur == null:            cur = { first: s, last: s, samples: [s] }
    elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s):
                               cur.last = s; cur.samples.push(s)         # непрерывная работа
    else:                      sessions.push(cur); cur = { first: s, last: s, samples: [s] }
if cur != null: sessions.push(cur)             # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется)

# класс и интервал каждой сессии
for sess in sessions:
    sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work'   # §5.1 (по source сэмплов)
    sess.iv    = (sess.first == sess.last && sess.first.t_start == sess.first.t_end)
        ? [ sess.t − P_single, sess.t ]                        # одиночный скаляр: pre-roll (без «будущей» работы)
        : [ sess.first.t_start − P_in, sess.last.t_end + P_out ]   # многосэмпловая / лон-сегмент

workMs      = duration( union( sess.iv : sess.class=='work' ) )         # union внутри класса
agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) )

Ключевые свойства:

  • Один проход, потом классификация → метрики дизъюнктны. Сессии не перекрываются (разделены
    гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают
    в одну сессию класса work (надзор засчитан человеку, не дважды). work- и agent_only-интервалы
    не пересекаются по wall-clock и workMs + agentOnlyMs ≤ реально прошедшегопри рекомендованном
    T_gap ≥ P_in+P_out (§10)
    : иначе P-добивка соседних сессий РАЗНЫХ классов может пересечься, и
    пересечение попадёт в обе метрики. Инвариант §6.3 (Σ perDay == work) от этого НЕ зависит.
  • Закрытие последней сессии обязательно (иначе теряется самая свежая): n=1 → одна одиночная
    сессия P_single, n=0 → 0.
  • union, а не Σ. Перекрытия (соседние ближе P; одновременное соредактирование) не
    задваиваются. Отсюда Σ perDay == work держится сам собой (§6.3) — без «клампа» и без условия
    T_gap ≥ P.
  • Калибровка T_gap по «пульсу» #374 (важно). После #374 непрерывная работа гарантированно
    оставляет history-строку не реже ~maxWait (idle-пульс §3, гейт isDeepStrictEqual). Значит гэп
    между соседними строками БОЛЬШЕ ~maxWait содержит участок без изменений контента = (частичное)
    бездействие — даже между двумя manual. Поэтому T_gap калибруется ОТ maxWait (реком. ~15м
    user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы.
    Гэп ≤ T_gap ≈ maxWait ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы
    до #374 пульса не имеют → там T_gap вынужденно шире и оценка грубее (§7, §10).
  • Всё равно оценка, а не строгая граница: даже с пульсом «читал/думал 12 мин не печатая» не
    отличить от «отошёл»; подпись «≈» и показ T_gap обязательны (§6).
  • Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках
    (8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.

5.1. Классификация сэмплов и всплесков

  • Класс сэмпла (человек / агент) — по lastUpdatedSource: user→человек, agent→агент,
    git→исключаем (§10 excludeGit), legacy-null→человек. idle наследует source страницы
    на момент флеша (= source последней правки). boundary несёт СТАРЫЙ (pre-transition) source
    — трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не
    недосмотр — точность посекундная тут не нужна).
  • Класс СЕССИИ (нужен для метрик §6.1): сессия, где ВСЕ сэмплы source=agentagent_only
    (автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом →
    work (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5).
  • idle — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка
    отстаёт от реальной правки ≤ maxWait (≤10м) — в пределах округления, отмотки не требует.
    Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым.

6. Что показывать в UI

6.1. Свёрнутое состояние — одно число

Две метрики, каждая = union-wall-clock СВОИХ сессий (§5):

  • work — headline, кликабельное число (≈ 4 ч 30 мин, рядом с панелью истории или в
    мета-инфо у заголовка): сессии класса work (≥1 человеческий сэмпл = человек + надзор за
    агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по
    дням.
  • agent_only — вторично: сессии автономных прогонов агента (ни одного человеческого сэмпла).
    На таймлайне — отдельным цветом; в work НЕ входит.
  • Подпись «≈» и показ T_gap обязательны (оценка, не хронометраж — §5). Округление headline —
    шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»).

6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни)

Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»:

  • Строка = один календарный день; ширина строки = 24 часа (фиксированная шкала 00:00→24:00).
  • На дорожке дня закрашены окна активности — реальные интервалы работы в их часовом
    положении, так что видно «вечерний марафон» vs «утренняя сессия».
  • Справа от строки — сумма за день «ч мм» (напр. 3 ч 17 м); пустой день — пустая дорожка
    и «—».
  • Внизу — общий итог (= headline work §6.1) плюс подпись таймзоны и T_gap.

Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь
то же рисуется отрезками на 24-часовой дорожке.

Детали:

  • Окна = сессии класса work (§5), обрезанные по границам суток; сессии agent_only — отдельным
    цветом (§6.1). По умолчанию work — один цвет «активность».
  • Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в
    следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
  • Честность добивки. Окно включает P/P_single — это оценка «до/после», а не измеренный
    интервал. Одиночная сессия (P_single) рисуется минимальной видимой шириной и приглушённо
    (иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».

6.3. Агрегация по дням (алгоритм)

Вход — ВСЕ сессии из §5 (work и agent_only), каждая развёрнута в интервал (P_in/P_out или
P_single).

bucketByDay(sessions, tz):
  U_work  = union( sess.iv : sess.class=='work' )       # снимаем перекрытия ОДИН раз (§5)
  U_agent = union( sess.iv : sess.class=='agent_only' )  # СИММЕТРИЧНО — иначе окна агента наложатся
  для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')):
    workWin[D]  = { u ∩ [начало D, начало D+1) : непусто, u в U_work }
    agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent }
    activeMs[D] = Σ длительностей workWin[D]            # U_work без перекрытий → просто сумма
    agentMs[D]  = Σ длительностей agentWin[D]
  → perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }]
  • Инвариант согласованности Σ activeMs[D] == work держится ПО ПОСТРОЕНИЮ: день — это
    разбиение union'а U_work границами суток, ничего не теряется и не дублируется (в т.ч. на
    23/25-часовых DST-сутках — §9#14). agent_only-окна рисуются, но в activeMs НЕ входят. Клампа
    и скрытого условия T_gap ≥ P не требуется (в отличие от наивного Σ длительностей).
  • Таймзона TZ определяет, где проходит «полночь» И в каких часовых координатах рисуются
    окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере
    «По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на
    общий итог → настройка (§10).
  • Длинный диапазон (месяцы правок): строка ВСЕГДА = один день (суточная шкала однородна).
    При большом числе дней — вертикальный скролл + схлопывание длинных серий ПУСТЫХ дней в один
    разделитель («× N дней без правок»). БЕЗ переагрегации в недели/месяцы. Порог — longRangeDayThreshold.
  • Округление дисплея: сумму за день округляем до минут для подписи, но общий итог берём из
    точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин).

7. Проверка на реальной статье

Ручной прогон на 20 снимках (1-я страница истории, T_gap=30 мин, P_in+P_out=10 мин,
P_single=2 мин; все сессии здесь класса work — в каждой есть человеческий сэмпл):

Сессия Интервал Длит.
S1 07-04 03:40 → 03:49 (многосэмпловая) ≈19 мин
S2 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13) ≈41 мин
S3 07-04 18:11 (одиночная) ≈2 мин
S4 07-04 19:38 → 19:54 (многосэмпловая) ≈26 мин
S5 07-06 15:34 (одиночная) ≈2 мин
S6 07-06 16:18 (одиночная, закрыта пост-циклом §5) ≈2 мин
Итого ≈1 ч 32 мин

Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.

Наблюдения:

  • Разрезы легли по перерывам: ночь 03:49 → 15:43 (~12 ч) и сутки
    07-04 19:54 → 07-06 15:34 — оба выброшены.
  • Чувствительность к порогу: S5/S6 отстоят на 44 мин. При T_gap=30 это две сессии,
    при T_gap=60 — одна (~44 мин). Число надо показывать вместе с использованным порогом.
  • Это оценка, не строгая граница (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения
    ВНУТРИ T_gap мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме
    (напр. автосейв раз в ~T_gap при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не
    «±порог». Сохранения ДАЛЬШЕ T_gap друг от друга, наоборот, теряют между-время → недобор. Поэтому
    T_gap калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > maxWait и
    рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны.
    Пример иллюстративен, посчитан на до-#374 срезе при T_gap=30.

8. Архитектура (куда встраивать)

Ядро — чистая функция computeWorkTime(rows, config) (детерминированная, без БД) в
отдельном модуле → легко покрыть юнит-тестами. Выход —
{ workMs, agentOnlyMs, sessions[] }, где session = { start, end, class: 'work'|'agent_only' }:
абсолютные границы (с добивкой P/P_single) плюс класс (§5.1) — этого достаточно и для метрик,
и для цвета окна. workMs/agentOnlyMs считаются как union-wall-clock сессий своего класса (§5).

Вторая чистая функция bucketByDay(sessions, tz) (ВСЕ сессии обоих классов) →
perDay[] = [{ day, activeMs, agentMs, windows }]: activeMs = длительность work-окон за день
(сходится к work, §6.3), agentMs = то же для agent_only (для подписи машинного времени за
сутки), windows — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для
отрисовки work/agent_only разным цветом, §6.2). Полуночный разрез — календарный, через
dayjs + tz-плагин (startOf('day') в tz), НЕ «+24 ч» (§9#14); dayjs уже в проекте. Отдельная
тестируемая функция (общий модуль сервера и клиента); tz — презентационный параметр, удобно звать
на клиенте от локали зрителя.

Сервер:

  • page-history.repo.ts — метод findTimelineByPageId(pageId): лёгкая проекция
    (createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind) по всем строкам
    ASC, без content.
  • page-history.service.tscomputeWorkTime(pageId, config): тянет таймлайн, зовёт ядро,
    кэширует.
  • page.controller.ts — рядом с POST /history и POST /history/info добавить
    POST /history/time (или вложить в page-info). Отдаёт число + разбивку по сессиям.

Клиент:

  • page-history-query.ts — хук usePageWorkTime(pageId) (возвращает workMs, agentOnlyMs,
    sessions[]).
  • Рендер кликабельного числа в панели истории.
  • Модалка/поповер с суточным таймлайном (§6.2): bucketByDay(sessions, viewerTz) → строки-дни,
    в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками
    на 24-часовой шкале (позиция окна = startOfDayOffset/24ч, ширина = длительность/24ч). Готовые
    чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых
    зависимостей. Пустые дни — пустая дорожка + «—».

Производительность: проекция без content дёшева; результат можно инкрементально кэшировать
(при version.saved, который #374 броадкастит, пересчитывать хвост).

9. Крайние случаи

  1. Один снимок → одна одиночная сессия P_single, не ноль. Последняя сессия ВСЕГДА закрывается
    пост-циклом (§5) — иначе теряется самая свежая.
  2. История целиком агентская → work = 0, agent_only = union прогонов.
  3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков);
    опц. кап burstCapMs.
  4. Метка idle лагает ≤ maxWait (10м user / 5м agent) — в пределах округления; это
    полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
  5. Несколько соавторов → атрибуция человеку по lastUpdatedById/contributorIds; разделение
    человек/агент — по lastUpdatedSource (НЕ по lastUpdatedById, он всегда человек).
  6. Легаси kind=null → работает на source+createdAt, грубее.
  7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по id)
    → дедуп по округлённому t.
  8. Одновременное соредактирование → union (§5) убирает двойной счёт wall-clock при перекрытии
    окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим
    (perAuthor), а не поведение по умолчанию.
  9. Сессия через полночь (напр. 23:14 → 02:00) → режется на границе суток tz, части идут
    в разные дни; сумма по дням = общему числу (§6.3).
  10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий
    итог не меняется; tz фиксируется в подписи графика.
  11. Длинный диапазон правок (месяцы) → строка = день (всегда): вертикальный скролл + схлопывание
    серий ПУСТЫХ дней в разделитель «× N дней без правок», без недель/месяцев (§6.3).
  12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы),
    не пропускаем.
  13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не
    исчезало (§6.2).
  14. Переход на летнее/зимнее время внутри tz → сутки в 23/25 ч; полуночный разрез считать
    по календарю tz (dayjs+tz, §8), а не «+24 ч». Инвариант Σ activeMs == work держится
    (разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (startOf('day')
    неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2)
    на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не
    влияет.

10. Параметры по умолчанию и открытые решения

Полный config (env, дефолты):

  • T_gap≈15м — калибровка по IDLE_MAX_WAIT_USER=10м + запас (§5: гэп больше maxWait не
    подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы).
    Эмпирически подтверждено (backcast на полной истории статьи про ESP, 307 снимков): владелец
    оценил реальное время как ≈24 ч, что совпадает с T_gap≈15м (24:05); пороги 30/45м дают
    31:29 / 36:31 — перебор. То есть узкий, привязанный к пульсу порог даёт честное число.
  • agentTGap≈7м — порог для ПАРЫ подряд идущих агентских сэмплов (IDLE_MAX_WAIT_AGENT=5м + запас).
  • P_in=5м, P_out=5м, P_single=2м (одиночная — pre-roll, §5).
  • burstCapMs (опц. кап на сегмент всплеска, §9#3), dedupRoundMs (дедуп совпадающих меток, §9#7),
    excludeGit=true, tz=локаль-зрителя, longRangeDayThreshold (схлопывать серии пустых дней, §6.3),
    perAuthor=false (§9#8).
  • Легаси-страницы до #374 (нет пульса) → T_gap вынужденно шире, оценка там грубее.
  • T_gap ≥ P_in+P_out НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется
    — иначе work/agent_only могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.

Развилки (настройки, не блокируют проектирование):

  • work vs agent_only — показывать оба, крупно work (headline), agent_only вторично;
  • точное место в UI — панель истории (основное) или мета-строка страницы;
  • дефолт T_gap — вынести в env (как IDLE_* в #374), калибровать на реальных статьях
    после мёржа #374;
  • таймзона дня для графика (§6.2/§6.3) — рекомендую локаль зрителя (интуитивно «мои
    вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на
    раскладку по дням, не на общий итог;
  • порог схлопывания пустых дней (longRangeDayThreshold) для длинного диапазона правок:
    строка всегда = день, длинные серии пустых дней сворачиваются в «× N дней без правок».
## Что делаем Число «время работы над статьёй» в UI страницы + drill-down по клику в **суточный таймлайн** (строка-день = 24-часовая дорожка с окнами активности и суммой за день). Это НЕ MCP-инструмент, а метрика для человека. Оценка времени = сессионизация истории `page_history` по паузам (не span между крайними правками). **Полная нормативная спека — в конце тикета** (раскрыть «§1–§10»). История ревью в неё НЕ входит. ## Зависимость — сначала #374 Завязано на #374 (page versioning): `page_history.kind` (`manual`/`agent`/`idle`/`boundary`), константы `IDLE_MAX_WAIT_*`, broadcast `version.saved`, `kind` уже в `PageHistoryRepo.baseFields`. Делать **после мёржа #374** или прямо на ветке `feat/370-page-versioning`. Ядро формально считает и на легаси-полях (`lastUpdatedSource`+`createdAt`), но классификация и калибровка порога — из #374. ## Зафиксированные дефолты (§10, подтверждено backcast) `T_gap=15м` · `agentTGap=7м` · `P_in=5м` · `P_out=5м` · `P_single=2м` · `excludeGit=true` · `tz` = локаль зрителя · headline-число = `work` · место = панель истории. `longRangeDayThreshold` (сворачивание день→неделя для очень длинных диапазонов) — **optional**, для v1 достаточно вертикального скролла. ## Алгоритм (кратко; детали §5–§6.3) - **Один проход** по ВСЕМ сэмплам (не по парам, не по классам) → сессии по паузам. Порог зависит от пары сэмплов: оба `agent` → `agentTGap`, иначе `T_gap`. Обязательно **закрыть последнюю сессию** после цикла. - **Коллапс агентских всплесков**: подряд идущие `source=agent` c одним `aiChatId` → сегмент; сэмпл с другим `aiChatId`/не-agent/boundary рвёт его. - **Класс сессии**: все сэмплы `agent` → `agent_only`, иначе `work` (человек + надзор за агентом внутри сессии). - **Интервалы**: многосэмпловая → `[first−P_in, last+P_out]`; одиночный скаляр → pre-roll `[t−P_single, t]`. - **Метрики = union-wall-clock по классу**: `workMs` (headline), `agentOnlyMs` (вторично). Один проход ⇒ классы дизъюнктны, `workMs+agentOnlyMs ≤ прошедшего` (при `T_gap ≥ P`). - **Per-day** `bucketByDay(sessions, tz)`: `U_work`/`U_agent` = union по классу, разрез по полуночи `tz` (`dayjs`+tz, `startOf('day')`, НЕ «+24ч»), `activeMs`=work / `agentMs`=agent, `windows` обоих классов с меткой класса. ## Задачи **Сервер** - [ ] Чистая `computeWorkTime(rows, config)` → `{ workMs, agentOnlyMs, sessions[{start,end,class}] }` (отдельный модуль, детерминированная, юнит-тесты) - [ ] Чистая `bucketByDay(sessions, tz)` → `perDay[{ day, activeMs, agentMs, windows }]` (общий модуль сервер+клиент) - [ ] `PageHistoryRepo.findTimelineByPageId(pageId)` — лёгкая проекция (`createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind`), без `content`, ASC - [ ] `PageHistoryService.computeWorkTime(pageId, config)` + кэш; инвалидация/пересчёт хвоста на `version.saved` - [ ] `POST /pages/history/time` в `page.controller.ts` (рядом с `/history`, `/history/info`) **Клиент** - [ ] Хук `usePageWorkTime(pageId)` → `workMs`/`agentOnlyMs`/`sessions[]` (`features/page-history/queries/`) - [ ] Кликабельное число `work` в панели истории (+`agent_only` вторично), подпись «≈» и порог - [ ] Модалка punch-card (§6.2): 24-часовые дорожки по дням, окна `work`/`agent` разным цветом, сумма за день справа, пустой день — «—», подпись tz+`T_gap`. Кастомные CSS/SVG-отрезки, без новых тяжёлых зависимостей. ## DoD / приёмка - [ ] Юнит-тесты на hostile-сценарии из спеки: закрытие последней сессии; `n=1`→одна `P_single`, `n=0`→0; единый проход + классификация (надзорное агентское время идёт человеку); коллапс всплеска рвётся человеком и другим `aiChatId`; полуночный и DST-разрез; `P_single` pre-roll - [ ] Инвариант `Σ perDay.activeMs == workMs` (держится по построению) - [ ] `workMs + agentOnlyMs ≤ прошедшего`; конфиг валидирует `T_gap ≥ P_in+P_out` - [ ] Число подаётся как оценка (символ «≈» + показ `T_gap`) - [ ] `pnpm --filter server exec tsc --noEmit` = 0; юниты зелёные ## Вне scope Долговечность черновика (#374 не трогаем); MCP-инструмент `save_page_version` (это PR-3 из #370); per-author разбивка (опц. позже). ## Эмпирический sanity-check На статье `dwzDdgPep2` (ESP, 307 снимков, 29.06–06.07) при `T_gap=15м` выходит ≈24 ч `work` — совпало с оценкой владельца; наивный span был бы 165 ч. Годится как проверочный кейс. --- <details> <summary><b>📋 Полная спека (§1–§10) — раскрыть</b></summary> # Фича «Время работы над статьёй» — дизайн-документ Статус: черновик проектирования (код не пишется). Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц). ## 1. Цель и не-цели **Цель.** Показывать в UI страницы одно число — оценку времени, реально затраченного на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов». По клику на число — раскрытие в **суточный таймлайн**: строка-день = 24-часовая дорожка с окнами активности и суммой за день (см. §6.2). **Явно не-цели.** - Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI. - Не хронометраж с точностью до минуты — это заведомо оценка. - Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика. ## 2. Проблема История страницы в Docmost — таблица `page_history`: снимок на каждое сохранение. У снимка есть `createdAt`, автор (`lastUpdatedById`), тип источника (`lastUpdatedSource`: `user`/`agent`/`git`) и группировка агентских правок (`lastUpdatedAiChatId`). Наивная оценка `max(createdAt) − min(createdAt)` завышает в разы: между крайними правками лежат сон, обед, дни простоя. На реальных данных статьи-примера (первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов, тогда как реальной работы — пара часов в 5–6 коротких заходов. Правильная постановка: **длительность — это не span между крайними правками, а сумма интервалов внутри «сессий»; историю режем по паузам бездействия.** Это классическая *сессионизация по таймауту неактивности* (WakaTime / RescueTime / веб-аналитика). ## 3. Почему PR #374 — фундамент фичи До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить», в `page_history` за этот час почти нет строк (тяжёлые автосейвы ydoc идут в `pages`/`ydoc`, а не в историю). Непрерывную работу нечем измерить. PR #374 вводит типизированную историю через колонку `page_history.kind`: | `kind` | что означает | ценность для фичи | |------------|------------------------------------------------|----------------------------------------------------| | `manual` | человек нажал Save | сильный маркер активной работы человека | | `agent` | снимок правки агентом | машинное время агента (группируется по `aiChatId`) | | `idle` | автоснимок idle-флеша (потолок ~`maxWait`) | регулярный «пульс» непрерывной работы (~≤10м, §3) | | `boundary` | автоснимок на переходе актора (user↔agent↔git) | бесплатная разметка «кто работал в этом сегменте» | | `null` | легаси-автосейв (старые страницы) | обычный сэмпл активности | Два env-параметра #374 напрямую задают качество измерения: - **`IDLE_MAX_WAIT_USER=10м` / `AGENT=5м` (потолок ожидания)** — определяющий параметр. Проверено по `computeHistoryJob` (`persistence.extension.ts`): `delay = max(0, min(interval, burstStart + maxWait − now))`, а `enqueuePageHistory` сбрасывает `burstStart` каждые `maxWait`. Так как `maxWait` (10м/5м) < `interval` (60м/15м), **потолок всегда доминирует**: во время непрерывной работы `idle`-снимок форсится каждые ~`maxWait`, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent). - **`IDLE_INTERVAL_USER=60м` / `AGENT=15м`** — номинальный трейлинг-интервал, который потолок ожидания на практике всегда упреждает. Поэтому метка любого `idle`-снимка отстоит от реальной правки не более чем на ~`maxWait` (≤10м user / ≤5м agent), а **не** на 60 мин. (Это ключевой факт для точности: `idle`-метки — достоверный сигнал активности, не «хвост».) Вывод: ядро алгоритма работает и на голых `createdAt`+`lastUpdatedSource` (есть с миграции `20260616T130000-agent-provenance`), поэтому фича считает и на легаси-страницах — просто грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет, но полноценная точность появляется вместе с ним. ## 4. Входной сигнал Дешёвый проекционный запрос по `page_history` (без тяжёлой колонки `content`): на строку — `createdAt`, `lastUpdatedById`, `lastUpdatedSource`, `lastUpdatedAiChatId`, `kind`. Все поля (включая `kind`) уже в `PageHistoryRepo.baseFields` после #374 — схему трогать не нужно, `findTimelineByPageId` это лишь лёгкая проекция без `content`. **Важный факт об авторстве (проверено по `persistence.extension.ts` → `updatePage`):** `page_history.lastUpdatedById` — это ВСЕГДА ответственный человек, даже для агентских снимков («human stays the responsible author»). Признак «человек vs агент» живёт в `lastUpdatedSource` (`user`/`agent`/`git`) и `lastUpdatedAiChatId`, а НЕ в `lastUpdatedById`. Отсюда: разделять человеко-/агенто-время нужно по `lastUpdatedSource`, а атрибутировать конкретному человеку — по `lastUpdatedById`/`contributorIds`. ## 5. Алгоритм: сессионизация по паузам Параметры (env, в стиле #374; полный список — §10): - **`T_gap`** — таймаут неактивности: пауза между соседними сэмплами `≤ T_gap` = непрерывная работа, больше = перерыв (в зачёт не идёт). - **`P_in` / `P_out`** — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась после последнего сохранения). - **`P_single`** — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах `T_gap` нет). Мал (дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный `P_in+P_out` = выдумывать время. НЕ путать с многосэмпловой добивкой. Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1): ```text samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC (все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается) # коллапс агентских всплесков (§5.1) collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}. Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent, boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран, склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ (или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с .t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t. gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap # порог зависит от ПАРЫ (§10) # сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам) sessions = []; cur = null for s in samples: # s — скаляр или коллапс-сегмент if cur == null: cur = { first: s, last: s, samples: [s] } elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s): cur.last = s; cur.samples.push(s) # непрерывная работа else: sessions.push(cur); cur = { first: s, last: s, samples: [s] } if cur != null: sessions.push(cur) # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется) # класс и интервал каждой сессии for sess in sessions: sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work' # §5.1 (по source сэмплов) sess.iv = (sess.first == sess.last && sess.first.t_start == sess.first.t_end) ? [ sess.t − P_single, sess.t ] # одиночный скаляр: pre-roll (без «будущей» работы) : [ sess.first.t_start − P_in, sess.last.t_end + P_out ] # многосэмпловая / лон-сегмент workMs = duration( union( sess.iv : sess.class=='work' ) ) # union внутри класса agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) ) ``` Ключевые свойства: - **Один проход, потом классификация → метрики дизъюнктны.** Сессии не перекрываются (разделены гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают в одну сессию класса `work` (надзор засчитан человеку, не дважды). `work`- и `agent_only`-интервалы не пересекаются по wall-clock и `workMs + agentOnlyMs ≤ реально прошедшего` — **при рекомендованном `T_gap ≥ P_in+P_out` (§10)**: иначе `P`-добивка соседних сессий РАЗНЫХ классов может пересечься, и пересечение попадёт в обе метрики. Инвариант §6.3 (`Σ perDay == work`) от этого НЕ зависит. - **Закрытие последней сессии обязательно** (иначе теряется самая свежая): `n=1` → одна одиночная сессия `P_single`, `n=0` → 0. - **`union`, а не `Σ`.** Перекрытия (соседние ближе `P`; одновременное соредактирование) не задваиваются. Отсюда `Σ perDay == work` держится сам собой (§6.3) — без «клампа» и без условия `T_gap ≥ P`. - **Калибровка `T_gap` по «пульсу» #374 (важно).** После #374 непрерывная работа гарантированно оставляет history-строку не реже ~`maxWait` (idle-пульс §3, гейт `isDeepStrictEqual`). Значит гэп между соседними строками БОЛЬШЕ ~`maxWait` содержит участок без изменений контента = (частичное) бездействие — даже между двумя `manual`. Поэтому `T_gap` калибруется ОТ `maxWait` (реком. ~15м user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы. Гэп `≤ T_gap ≈ maxWait` ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы до #374 пульса не имеют → там `T_gap` вынужденно шире и оценка грубее (§7, §10). - **Всё равно оценка, а не строгая граница**: даже с пульсом «читал/думал 12 мин не печатая» не отличить от «отошёл»; подпись «≈» и показ `T_gap` обязательны (§6). - Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках (8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков. ### 5.1. Классификация сэмплов и всплесков - **Класс сэмпла (человек / агент)** — по `lastUpdatedSource`: `user`→человек, `agent`→агент, `git`→исключаем (§10 `excludeGit`), legacy-`null`→человек. `idle` наследует `source` страницы на момент флеша (= source последней правки). `boundary` несёт СТАРЫЙ (pre-transition) `source` — трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не недосмотр — точность посекундная тут не нужна). - **Класс СЕССИИ** (нужен для метрик §6.1): сессия, где ВСЕ сэмплы `source=agent` → **`agent_only`** (автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом → **`work`** (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5). - **`idle`** — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка отстаёт от реальной правки ≤ `maxWait` (≤10м) — в пределах округления, отмотки не требует. Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым. ## 6. Что показывать в UI ### 6.1. Свёрнутое состояние — одно число Две метрики, каждая = union-wall-clock СВОИХ сессий (§5): - **`work` — headline, кликабельное число** (`≈ 4 ч 30 мин`, рядом с панелью истории или в мета-инфо у заголовка): сессии класса `work` (≥1 человеческий сэмпл = человек + надзор за агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по дням. - **`agent_only` — вторично**: сессии автономных прогонов агента (ни одного человеческого сэмпла). На таймлайне — отдельным цветом; в `work` НЕ входит. - Подпись «≈» и показ `T_gap` обязательны (оценка, не хронометраж — §5). Округление headline — шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»). ### 6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни) Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»: - **Строка = один календарный день**; ширина строки = 24 часа (фиксированная шкала 00:00→24:00). - На дорожке дня закрашены **окна активности** — реальные интервалы работы в их часовом положении, так что видно «вечерний марафон» vs «утренняя сессия». - **Справа от строки — сумма за день** «ч мм» (напр. `3 ч 17 м`); пустой день — пустая дорожка и «—». - Внизу — общий итог (= headline `work` §6.1) плюс подпись таймзоны и `T_gap`. Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь то же рисуется отрезками на 24-часовой дорожке. Детали: - Окна = сессии класса `work` (§5), обрезанные по границам суток; сессии `agent_only` — отдельным цветом (§6.1). По умолчанию `work` — один цвет «активность». - Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден. - **Честность добивки.** Окно включает `P`/`P_single` — это оценка «до/после», а не измеренный интервал. Одиночная сессия (`P_single`) рисуется минимальной видимой шириной и приглушённо (иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈». ### 6.3. Агрегация по дням (алгоритм) Вход — ВСЕ сессии из §5 (`work` и `agent_only`), каждая развёрнута в интервал (`P_in/P_out` или `P_single`). ```text bucketByDay(sessions, tz): U_work = union( sess.iv : sess.class=='work' ) # снимаем перекрытия ОДИН раз (§5) U_agent = union( sess.iv : sess.class=='agent_only' ) # СИММЕТРИЧНО — иначе окна агента наложатся для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')): workWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_work } agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent } activeMs[D] = Σ длительностей workWin[D] # U_work без перекрытий → просто сумма agentMs[D] = Σ длительностей agentWin[D] → perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }] ``` - **Инвариант согласованности `Σ activeMs[D] == work`** держится ПО ПОСТРОЕНИЮ: день — это разбиение union'а `U_work` границами суток, ничего не теряется и не дублируется (в т.ч. на 23/25-часовых DST-сутках — §9#14). `agent_only`-окна рисуются, но в `activeMs` НЕ входят. Клампа и скрытого условия `T_gap ≥ P` не требуется (в отличие от наивного `Σ` длительностей). - **Таймзона `TZ`** определяет, где проходит «полночь» И в каких часовых координатах рисуются окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере «По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на общий итог → настройка (§10). - **Длинный диапазон** (месяцы правок): строка ВСЕГДА = один день (суточная шкала однородна). При большом числе дней — вертикальный скролл + **схлопывание длинных серий ПУСТЫХ дней** в один разделитель («× N дней без правок»). БЕЗ переагрегации в недели/месяцы. Порог — `longRangeDayThreshold`. - **Округление дисплея:** сумму за день округляем до минут для подписи, но общий итог берём из точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин). ## 7. Проверка на реальной статье Ручной прогон на 20 снимках (1-я страница истории, `T_gap=30 мин`, `P_in+P_out=10 мин`, `P_single=2 мин`; все сессии здесь класса `work` — в каждой есть человеческий сэмпл): | Сессия | Интервал | Длит. | |--------|-------------------------------------------------------|----------| | S1 | 07-04 03:40 → 03:49 (многосэмпловая) | ≈19 мин | | S2 | 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13)| ≈41 мин | | S3 | 07-04 18:11 (одиночная) | ≈2 мин | | S4 | 07-04 19:38 → 19:54 (многосэмпловая) | ≈26 мин | | S5 | 07-06 15:34 (одиночная) | ≈2 мин | | S6 | 07-06 16:18 (одиночная, закрыта пост-циклом §5) | ≈2 мин | | **Итого** | | **≈1 ч 32 мин** | Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи. Наблюдения: - Разрезы легли по перерывам: ночь `03:49 → 15:43` (~12 ч) и сутки `07-04 19:54 → 07-06 15:34` — оба выброшены. - Чувствительность к порогу: `S5/S6` отстоят на 44 мин. При `T_gap=30` это две сессии, при `T_gap=60` — одна (~44 мин). Число надо показывать **вместе с использованным порогом**. - Это **оценка, не строгая граница** (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения ВНУТРИ `T_gap` мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме (напр. автосейв раз в ~`T_gap` при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не «±порог». Сохранения ДАЛЬШЕ `T_gap` друг от друга, наоборот, теряют между-время → недобор. Поэтому `T_gap` калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > `maxWait` и рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны. Пример иллюстративен, посчитан на до-#374 срезе при `T_gap=30`. ## 8. Архитектура (куда встраивать) **Ядро — чистая функция** `computeWorkTime(rows, config)` (детерминированная, без БД) в отдельном модуле → легко покрыть юнит-тестами. Выход — `{ workMs, agentOnlyMs, sessions[] }`, где `session = { start, end, class: 'work'|'agent_only' }`: абсолютные границы (с добивкой `P`/`P_single`) плюс класс (§5.1) — этого достаточно и для метрик, и для цвета окна. `workMs`/`agentOnlyMs` считаются как union-wall-clock сессий своего класса (§5). **Вторая чистая функция** `bucketByDay(sessions, tz)` (ВСЕ сессии обоих классов) → `perDay[] = [{ day, activeMs, agentMs, windows }]`: `activeMs` = длительность `work`-окон за день (сходится к `work`, §6.3), `agentMs` = то же для `agent_only` (для подписи машинного времени за сутки), `windows` — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для отрисовки `work`/`agent_only` разным цветом, §6.2). Полуночный разрез — календарный, через `dayjs` + tz-плагин (`startOf('day')` в `tz`), НЕ «+24 ч» (§9#14); `dayjs` уже в проекте. Отдельная тестируемая функция (общий модуль сервера и клиента); `tz` — презентационный параметр, удобно звать на клиенте от локали зрителя. **Сервер:** - `page-history.repo.ts` — метод `findTimelineByPageId(pageId)`: лёгкая проекция (`createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind`) по всем строкам ASC, без `content`. - `page-history.service.ts` — `computeWorkTime(pageId, config)`: тянет таймлайн, зовёт ядро, кэширует. - `page.controller.ts` — рядом с `POST /history` и `POST /history/info` добавить `POST /history/time` (или вложить в page-info). Отдаёт число + разбивку по сессиям. **Клиент:** - `page-history-query.ts` — хук `usePageWorkTime(pageId)` (возвращает `workMs`, `agentOnlyMs`, `sessions[]`). - Рендер кликабельного числа в панели истории. - Модалка/поповер с суточным таймлайном (§6.2): `bucketByDay(sessions, viewerTz)` → строки-дни, в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками на 24-часовой шкале (позиция окна = `startOfDayOffset/24ч`, ширина = длительность/24ч). Готовые чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых зависимостей. Пустые дни — пустая дорожка + «—». **Производительность:** проекция без `content` дёшева; результат можно инкрементально кэшировать (при `version.saved`, который #374 броадкастит, пересчитывать хвост). ## 9. Крайние случаи 1. Один снимок → одна одиночная сессия `P_single`, не ноль. Последняя сессия ВСЕГДА закрывается пост-циклом (§5) — иначе теряется самая свежая. 2. История целиком агентская → `work = 0`, `agent_only = union прогонов`. 3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков); опц. кап `burstCapMs`. 4. Метка `idle` лагает ≤ `maxWait` (10м user / 5м agent) — в пределах округления; это полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1). 5. Несколько соавторов → атрибуция человеку по `lastUpdatedById`/`contributorIds`; разделение человек/агент — по `lastUpdatedSource` (НЕ по `lastUpdatedById`, он всегда человек). 6. Легаси `kind=null` → работает на `source`+`createdAt`, грубее. 7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по `id`) → дедуп по округлённому `t`. 8. Одновременное соредактирование → `union` (§5) убирает двойной счёт wall-clock при перекрытии окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим (`perAuthor`), а не поведение по умолчанию. 9. Сессия через полночь (напр. `23:14 → 02:00`) → режется на границе суток `tz`, части идут в разные дни; сумма по дням = общему числу (§6.3). 10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий итог не меняется; `tz` фиксируется в подписи графика. 11. Длинный диапазон правок (месяцы) → строка = день (всегда): вертикальный скролл + схлопывание серий ПУСТЫХ дней в разделитель «× N дней без правок», без недель/месяцев (§6.3). 12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы), не пропускаем. 13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не исчезало (§6.2). 14. Переход на летнее/зимнее время внутри `tz` → сутки в 23/25 ч; полуночный разрез считать по календарю `tz` (`dayjs`+tz, §8), а не «+24 ч». Инвариант `Σ activeMs == work` держится (разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (`startOf('day')` неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2) на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не влияет. ## 10. Параметры по умолчанию и открытые решения **Полный `config`** (env, дефолты): - `T_gap≈15м` — калибровка по `IDLE_MAX_WAIT_USER=10м` + запас (§5: гэп больше `maxWait` не подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы). **Эмпирически подтверждено** (backcast на полной истории статьи про ESP, 307 снимков): владелец оценил реальное время как ≈24 ч, что совпадает с `T_gap≈15м` (24:05); пороги 30/45м дают 31:29 / 36:31 — перебор. То есть узкий, привязанный к пульсу порог даёт честное число. - `agentTGap≈7м` — порог для ПАРЫ подряд идущих агентских сэмплов (`IDLE_MAX_WAIT_AGENT=5м` + запас). - `P_in=5м`, `P_out=5м`, `P_single=2м` (одиночная — pre-roll, §5). - `burstCapMs` (опц. кап на сегмент всплеска, §9#3), `dedupRoundMs` (дедуп совпадающих меток, §9#7), `excludeGit=true`, `tz=локаль-зрителя`, `longRangeDayThreshold` (схлопывать серии пустых дней, §6.3), `perAuthor=false` (§9#8). - **Легаси-страницы до #374** (нет пульса) → `T_gap` вынужденно шире, оценка там грубее. - **`T_gap ≥ P_in+P_out`** НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется — иначе `work`/`agent_only` могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится. Развилки (настройки, не блокируют проектирование): - `work` vs `agent_only` — показывать оба, крупно `work` (headline), `agent_only` вторично; - точное место в UI — панель истории (основное) или мета-строка страницы; - дефолт `T_gap` — вынести в env (как `IDLE_*` в #374), калибровать на реальных статьях после мёржа #374; - **таймзона дня для графика (§6.2/§6.3)** — рекомендую локаль зрителя (интуитивно «мои вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на раскладку по дням, не на общий итог; - **порог схлопывания пустых дней** (`longRangeDayThreshold`) для длинного диапазона правок: строка всегда = день, длинные серии пустых дней сворачиваются в «× N дней без правок». </details>
Author
Collaborator

Взгляд архитектора поверх спеки (решённое в §1–§10 не перепроверяю — оно решено и подтверждено backcast'ом). Четыре дополнения:

1. Статус зависимости

#374 в develop ещё не смержен (PR открыт, review/approved). Гейт из шапки актуален: либо ждать, либо делать на ветке feat/370-page-versioning.

2. Скрытая зависимость, которой нет в спеке: retention истории

Метрика молча деградирует при ЛЮБОМ будущем pruning page_history — а он придёт: idle-пульс #374 (снимок каждые ≤10 мин активной работы) на активной странице даёт ~150 строк за 24 ч работы, и каждая строка — полный снапшот content. Через полгода тяжёлые страницы накопят тысячи полноконтентных снимков, и кто-нибудь неизбежно предложит чистку старых idle-снапшотов — после чего workMs задним числом усохнет без единого следа в UI.

Зафиксировать контракт сейчас, пока помним: при введении любой retention-политики idle/boundary-снимки схлопываются в агрегат (таблица сессий или голые createdAt-метки без content), а не удаляются. Дешёвый вариант — при pruning писать вычисленные sessions[] в постоянный кэш и дальше считать хвостом. Достаточно абзаца в спеке + строки в ишью будущей retention-политики, кода сейчас не надо.

3. Упрощение: «пересчёт хвоста» — оверинжиниринг v1

computeWorkTime на проекции даже в 5000 строк — единицы миллисекунд (один проход + union). Инкрементальный пересчёт хвоста добавляет stateful-класс багов (граница хвоста vs открытая сессия) ради экономии, которой нет. V1: кэш + полная инвалидация на version.saved, полный пересчёт. Хвостовую инкрементальность — только если появятся страницы с ~10⁵ снимков (см. п. 2 — при живом retention-контракте не появятся).

4. Перф-дисциплина (стыковка со свежесмерженным пакетом)

  • Индекс уже есть: 20260705T120000-perf-indexes (#364) выровнял page_historyfindTimelineByPageId ляжет на него, seq scan'а не будет.
  • Новый POST /pages/history/time автоматически попадает в http_request_duration_seconds — цель p95 < 100 мс на кэше зафиксировать в DoD.
  • Модалку punch-card — React.lazy (дисциплина #342: редко открываемый UI не должен жить в eager-чанке). Кастомный SVG без чарт-либ — правильно, это и есть требование.
  • dayjs в клиенте уже есть, но tz-плагин подключать точечно в модуле punch-card, не в entry.

Остальное — годно к реализации as-is после мержа #374.

Взгляд архитектора поверх спеки (решённое в §1–§10 не перепроверяю — оно решено и подтверждено backcast'ом). Четыре дополнения: ## 1. Статус зависимости #374 в develop **ещё не смержен** (PR открыт, review/approved). Гейт из шапки актуален: либо ждать, либо делать на ветке `feat/370-page-versioning`. ## 2. Скрытая зависимость, которой нет в спеке: retention истории Метрика молча деградирует при ЛЮБОМ будущем pruning `page_history` — а он **придёт**: idle-пульс #374 (снимок каждые ≤10 мин активной работы) на активной странице даёт ~150 строк за 24 ч работы, и каждая строка — полный снапшот `content`. Через полгода тяжёлые страницы накопят тысячи полноконтентных снимков, и кто-нибудь неизбежно предложит чистку старых idle-снапшотов — после чего `workMs` задним числом усохнет без единого следа в UI. Зафиксировать контракт сейчас, пока помним: **при введении любой retention-политики idle/boundary-снимки схлопываются в агрегат (таблица сессий или голые `createdAt`-метки без content), а не удаляются**. Дешёвый вариант — при pruning писать вычисленные `sessions[]` в постоянный кэш и дальше считать хвостом. Достаточно абзаца в спеке + строки в ишью будущей retention-политики, кода сейчас не надо. ## 3. Упрощение: «пересчёт хвоста» — оверинжиниринг v1 `computeWorkTime` на проекции даже в 5000 строк — единицы миллисекунд (один проход + union). Инкрементальный пересчёт хвоста добавляет stateful-класс багов (граница хвоста vs открытая сессия) ради экономии, которой нет. **V1: кэш + полная инвалидация на `version.saved`, полный пересчёт.** Хвостовую инкрементальность — только если появятся страницы с ~10⁵ снимков (см. п. 2 — при живом retention-контракте не появятся). ## 4. Перф-дисциплина (стыковка со свежесмерженным пакетом) - Индекс уже есть: `20260705T120000-perf-indexes` (#364) выровнял `page_history` — `findTimelineByPageId` ляжет на него, seq scan'а не будет. - Новый `POST /pages/history/time` автоматически попадает в `http_request_duration_seconds` — цель p95 < 100 мс на кэше зафиксировать в DoD. - Модалку punch-card — `React.lazy` (дисциплина #342: редко открываемый UI не должен жить в eager-чанке). Кастомный SVG без чарт-либ — правильно, это и есть требование. - `dayjs` в клиенте уже есть, но tz-плагин подключать точечно в модуле punch-card, не в entry. Остальное — годно к реализации as-is после мержа #374.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#395