feat: «Время работы над статьёй» — число в UI + суточный punch-card #395
Open
opened 2026-07-06 21:47:43 +03:00 by agent_vscode
·
1 comment
No Branch/Tag Specified
main
feat/529-search-lexical
feat/506-apikeys-ui
feat/395-work-time
feat/502-mcp-md-modes
feat/492-incremental-render
fix/549-hardbreak-md-canon
feat/542-resolve-undo
fix/505-drawio-strip-comments
fix/503-hardbreak-canon
docs/471-breadcrumb-permission
fix/534-mcp-spaceid
feat/481-version-coherence
fix/535-list-seam-coalesce
develop
fix/520-recovery-escalation
fix/525-insert-unloaded
fix/522-internal-links
fix/507-drawio-cyrillic
fix/541-reconnect-timeout
feat/501-mcp-apikey-auth
feat/493-converter
feat/370-page-versioning
fix/515-code-emphasis
fix/523-tree-dnd
fix/517-toasts
feat/495-tails
feat/494-mcp-registry-guards
feat/496-suggestions-audit
integ/497-wave1
feat/488-client-fsm
ci/476-publish-gates
feat/489-tool-resilience
feat/487-terminal-lifecycle
fix/486-iter1-prod-fires
refactor/347-client-md-paste
perf/479-getpage-cache
refactor/449-write-invariants
refactor/450-split-client
feat/443-get-page-context
feat/443-get-tree
feat/443-search
docs/415-lossy-descriptions
feat/413-markdown-default
refactor/412-camelcase-tool-names
feat/425-drawio-graph
fix/464-diffdocs-cpu-guard
refactor/411-update-page-markdown
feat/424-drawio-rules
fix/409-invalid-node-validation
refactor/448-generate-inventory
refactor/445-execute-mapping
refactor/446-derive-client-types
fix/447-registry-stamp-ci
fix/444-agent-loop-guards
fix/396-sendnow-detached-run
fix/452-immutable-cache-control
docs/footnote-authoring-comment-cleanup
feat/437-error-diagnostics
fix/442-cursor-pagination
feat/419-footnote-normalize
perf/399-comment-resolve-async
perf/435-collab-token-cache
perf/344-background-rerenders
perf/348-backend-lowhanging
feat/423-drawio-crud
perf/401-collab-hotpath
feat/430-live-reconnect
perf/400-collab-session
refactor/414-dedup-node-ops
feat/417-new-comments-signal
feat/408-createcomment-hints
feat/407-persist-tool-errors
feat/420-footnote-render
dummy-review/mcp-hang-fix
dummy-review/mcp-hang-base
feat/381-resumable-sse-pr1
feat/git-sync-2
docs/agents-workspace-build-order
feature/offline-sync
perf/342-code-splitting
perf/346-compression-cache
feat/196-multi-cursor
perf/343-typing-latency
docs/how-to-test
fix/ai-sdk-partial-output-oom
test/351-generative-converter
feat/371-roles-catalog
refactor/345-server-converter
refactor/294-spec-registry-cont
fix/363-migration-order
fix/e2e-callout-and-gate-build
fix/docker-re2-toolchain
feat/git-sync
fix/media-roundtrip-stability
fix/340-comment-panel-perf
fix/332-deferred-tools
fix/329-ephemeral-suggestions
fix/330-search-in-page
fix/328-resolved-anchor-spam
fix/331-intraline-diff
fix/324-coverage-gate
fix/325-mobile-390
feat/293-A-git-sync-package
feat/300-avatar-oklch
fix/321-banner-mobile
feat/300-avatar-colors
feat/315-comment-suggestions
feat/scroll-restore-stable-wait
feat/300-agent-avatar-stack
feat/300-avatar-polish
refactor/294-tool-spec-registry
feat/scroll-restore-ux
fix/responsive-tablet-sidebar
feature/ai-chat-page-change-observability
image-inline-center
fix/283-short-remap-title
fix/283-slash-layout
image-inline-row
feat/276-ai-chat-dock
fix/269-table-menu-refocus
docs/dev-stand-guide
feat/266-scroll-position
fix/260-collab-docname-slugid
test/244-phase2-tail
fix/262-reindex-progress-realtime
fix/258-changelog-compare-links
fix/244-dataloss-bugs
feat/246-spoiler
feat/221-image-captions
test/244-part-b
feat/251-intentional-clear
fix/embeddings-reindex-progress
refactor/193-tool-spec-registry
fix/255-ws-redis-adapter-leak
fix/252-e2e-open-handles
feat/229-catalog-yaml
feat/243-blob-sandbox
feat/228-inline-footnotes
fix/qa-ui-bugs-216-218
feature/agent-roles-catalog
fix/share-alias-rename
fix/ai-chat-empty-render
feat/191-chat-doc-binding
feat/201-temporary-notes
feat/198-interrupt-agent
feat/ai-chat-full-history
feat/199-ai-generate-title
feat/205-share-aliases
batch/issues-189-187-170
feat/170-mcp-test-button
feat/189-context-badge
feat/198-interrupt-agent-send-now
fix/issues-190-159
fix/ai-chat-new-chat-during-stream
fix/ai-chat-stream-perf
batch/issues-2026-06-25
feat/ai-chat-persistent-history
fix/ai-chat-copy-chat-wysiwyg
fix/ai-stream-reset-resilience
fix/ai-stream-undici-timeout
fix/footnote-review-1227-followup
fix/ai-chat-token-counter-realtime
docs/manual-qa-test-plan
v0.94.1
v0.94.0
v0.93.0
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#395
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что делаем
Число «время работы над статьёй» в UI страницы + drill-down по клику в суточный таймлайн (строка-день = 24-часовая дорожка с окнами активности и суммой за день). Это НЕ MCP-инструмент, а метрика для человека.
Оценка времени = сессионизация истории
page_historyпо паузам (не span между крайними правками).Полная нормативная спека — в конце тикета (раскрыть «§1–§10»). История ревью в неё НЕ входит.
Зависимость — сначала #374
Завязано на #374 (page versioning):
page_history.kind(manual/agent/idle/boundary), константыIDLE_MAX_WAIT_*, broadcastversion.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=agentc однимaiChatId→ сегмент; сэмпл с другимaiChatId/не-agent/boundary рвёт его.agent→agent_only, иначеwork(человек + надзор за агентом внутри сессии).[first−P_in, last+P_out]; одиночный скаляр → pre-roll[t−P_single, t].workMs(headline),agentOnlyMs(вторично). Один проход ⇒ классы дизъюнктны,workMs+agentOnlyMs ≤ прошедшего(приT_gap ≥ P).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, ASCPageHistoryService.computeWorkTime(pageId, config)+ кэш; инвалидация/пересчёт хвоста наversion.savedPOST /pages/history/timeвpage.controller.ts(рядом с/history,/history/info)Клиент
usePageWorkTime(pageId)→workMs/agentOnlyMs/sessions[](features/page-history/queries/)workв панели истории (+agent_onlyвторично), подпись «≈» и порогwork/agentразным цветом, сумма за день справа, пустой день — «—», подпись tz+T_gap. Кастомные CSS/SVG-отрезки, без новых тяжёлых зависимостей.DoD / приёмка
n=1→однаP_single,n=0→0; единый проход + классификация (надзорное агентское время идёт человеку); коллапс всплеска рвётся человеком и другимaiChatId; полуночный и DST-разрез;P_singlepre-rollΣ perDay.activeMs == workMs(держится по построению)workMs + agentOnlyMs ≤ прошедшего; конфиг валидируетT_gap ≥ P_in+P_outT_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).
Явно не-цели.
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:kindmanualagentaiChatId)idlemaxWait)boundarynullДва 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):
Ключевые свойства:
гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают
в одну сессию класса
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).отличить от «отошёл»; подпись «≈» и показ
T_gapобязательны (§6).(8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.
5.1. Классификация сэмплов и всплесков
lastUpdatedSource:user→человек,agent→агент,git→исключаем (§10excludeGit), legacy-null→человек.idleнаследуетsourceстраницына момент флеша (= source последней правки).
boundaryнесёт СТАРЫЙ (pre-transition)source— трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не
недосмотр — точность посекундная тут не нужна).
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»:
положении, так что видно «вечерний марафон» vs «утренняя сессия».
3 ч 17 м); пустой день — пустая дорожкаи «—».
work§6.1) плюс подпись таймзоны иT_gap.Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь
то же рисуется отрезками на 24-часовой дорожке.
Детали:
work(§5), обрезанные по границам суток; сессииagent_only— отдельнымцветом (§6.1). По умолчанию
work— один цвет «активность».следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
P/P_single— это оценка «до/после», а не измеренныйинтервал. Одиночная сессия (
P_single) рисуется минимальной видимой шириной и приглушённо(иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».
6.3. Агрегация по дням (алгоритм)
Вход — ВСЕ сессии из §5 (
workиagent_only), каждая развёрнута в интервал (P_in/P_outилиP_single).Σ 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— в каждой есть человеческий сэмпл):Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.
Наблюдения:
03:49 → 15:43(~12 ч) и сутки07-04 19:54 → 07-06 15:34— оба выброшены.S5/S6отстоят на 44 мин. ПриT_gap=30это две сессии,при
T_gap=60— одна (~44 мин). Число надо показывать вместе с использованным порогом.ВНУТРИ
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[]).bucketByDay(sessions, viewerTz)→ строки-дни,в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками
на 24-часовой шкале (позиция окна =
startOfDayOffset/24ч, ширина = длительность/24ч). Готовыечарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых
зависимостей. Пустые дни — пустая дорожка + «—».
Производительность: проекция без
contentдёшева; результат можно инкрементально кэшировать(при
version.saved, который #374 броадкастит, пересчитывать хвост).9. Крайние случаи
P_single, не ноль. Последняя сессия ВСЕГДА закрываетсяпост-циклом (§5) — иначе теряется самая свежая.
work = 0,agent_only = union прогонов.опц. кап
burstCapMs.idleлагает ≤maxWait(10м user / 5м agent) — в пределах округления; этополноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
lastUpdatedById/contributorIds; разделениечеловек/агент — по
lastUpdatedSource(НЕ поlastUpdatedById, он всегда человек).kind=null→ работает наsource+createdAt, грубее.id)→ дедуп по округлённому
t.union(§5) убирает двойной счёт wall-clock при перекрытииокон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим
(
perAuthor), а не поведение по умолчанию.23:14 → 02:00) → режется на границе сутокtz, части идутв разные дни; сумма по дням = общему числу (§6.3).
итог не меняется;
tzфиксируется в подписи графика.серий ПУСТЫХ дней в разделитель «× N дней без правок», без недель/месяцев (§6.3).
не пропускаем.
исчезало (§6.2).
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).T_gapвынужденно шире, оценка там грубее.T_gap ≥ P_in+P_outНЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется— иначе
work/agent_onlyмогут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.Развилки (настройки, не блокируют проектирование):
workvsagent_only— показывать оба, крупноwork(headline),agent_onlyвторично;T_gap— вынести в env (какIDLE_*в #374), калибровать на реальных статьяхпосле мёржа #374;
вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на
раскладку по дням, не на общий итог;
longRangeDayThreshold) для длинного диапазона правок:строка всегда = день, длинные серии пустых дней сворачиваются в «× N дней без правок».
Взгляд архитектора поверх спеки (решённое в §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.React.lazy(дисциплина #342: редко открываемый UI не должен жить в eager-чанке). Кастомный SVG без чарт-либ — правильно, это и есть требование.dayjsв клиенте уже есть, но tz-плагин подключать точечно в модуле punch-card, не в entry.Остальное — годно к реализации as-is после мержа #374.