From c674db2b2f5add0caff572c372cc2d54770fba09 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 06:32:12 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(page-history):=20=C2=AB=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=B4=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=91=D0=B9?= =?UTF-8?q?=C2=BB=20=E2=80=94=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D0=B2=20?= =?UTF-8?q?UI=20+=20=D1=81=D1=83=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20pun?= =?UTF-8?q?ch-card=20(#395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оценка времени работы над страницей как сессионизация истории page_history по паузам бездействия (WakaTime-подобно), а не span между крайними правками, плюс drill-down в суточный таймлайн 24ч×дни. Зависит от #374 (kind + idle-пульс). Server: - Чистая computeWorkTime(rows, config) (§5): нормализация+дедуп → коллапс агентских всплесков по aiChatId → ОДИН проход сессионизации (порог зависит от пары: оба agent → agentTGap, иначе tGap; последняя сессия обязательно закрывается) → класс готовой сессии (все agent → agent_only, иначе work) → добивка (много-сэмпл → [first−P_in, last+P_out], одиночный скаляр → [t−P_single, t]) → метрики = union wall-clock по классу. Детерминированная, без БД. - Чистая bucketByDay(sessions, tz) (§6.3): union work/agent_only РАЗДЕЛЬНО, разрез по полуночи tz через нативный Intl (DST-корректно, без «+24ч»). Инвариант Σ activeMs == workMs держится по построению. - PageHistoryRepo.findTimelineByPageId — лёгкая проекция без content, ASC. - PageHistoryService.computeWorkTime + POST /pages/history/time, гейт validateCanView как у /history; атрибуция человек/агент по lastUpdatedSource. Client: - usePageWorkTime(pageId) шлёт tz зрителя (Intl локаль). - Кликабельное число «≈ 4 ч 30 мин» в шапке страницы (порог в тултипе). - Модалка-punch-card: 24-часовые дорожки по дням, окна work/agent разным цветом, одиночные (P_single) приглушены, сумма за день, пустой день «—», сворачивание длинных серий пустых дней, подпись tz + T_gap. i18n ru-RU + en-US. Tests: 22 юнита на computeWorkTime/bucketByDay (§7-фикстура ≈1ч32м vs ≈60h наив, закрытие последней сессии, коллапс/разрыв всплеска, idle-пульс, DST 23/25ч, полуночный разрез, инвариант, union-без-задвоения); 5 юнитов гейта контроллера; int-spec на реальном pg (проекция + совпадение с ядром); 6 клиентских на формат. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 17 +- .../public/locales/ru-RU/translation.json | 17 +- .../work-time/format-work-time.test.ts | 45 ++++ .../work-time/format-work-time.ts | 45 ++++ .../work-time/use-page-work-time.ts | 25 ++ .../work-time/work-time-punch-card.tsx | 171 ++++++++++++++ .../work-time/work-time-service.ts | 23 ++ .../page-history/work-time/work-time-stat.tsx | 63 +++++ .../work-time/work-time.module.css | 82 +++++++ .../page-history/work-time/work-time.types.ts | 44 ++++ .../components/header/page-header-menu.tsx | 3 + apps/server/src/core/page/dto/page.dto.ts | 10 + .../src/core/page/page.controller.spec.ts | 68 ++++++ apps/server/src/core/page/page.controller.ts | 27 +++ .../page/services/page-history.service.ts | 47 ++++ .../core/page/work-time/bucket-by-day.spec.ts | 129 ++++++++++ .../src/core/page/work-time/bucket-by-day.ts | 180 ++++++++++++++ .../page/work-time/compute-work-time.spec.ts | 196 +++++++++++++++ .../core/page/work-time/compute-work-time.ts | 223 ++++++++++++++++++ apps/server/src/core/page/work-time/index.ts | 15 ++ .../core/page/work-time/work-time.config.ts | 87 +++++++ .../core/page/work-time/work-time.types.ts | 73 ++++++ .../database/repos/page/page-history.repo.ts | 38 +++ .../integration/page-work-time.int-spec.ts | 137 +++++++++++ 24 files changed, 1763 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/page-history/work-time/format-work-time.test.ts create mode 100644 apps/client/src/features/page-history/work-time/format-work-time.ts create mode 100644 apps/client/src/features/page-history/work-time/use-page-work-time.ts create mode 100644 apps/client/src/features/page-history/work-time/work-time-punch-card.tsx create mode 100644 apps/client/src/features/page-history/work-time/work-time-service.ts create mode 100644 apps/client/src/features/page-history/work-time/work-time-stat.tsx create mode 100644 apps/client/src/features/page-history/work-time/work-time.module.css create mode 100644 apps/client/src/features/page-history/work-time/work-time.types.ts create mode 100644 apps/server/src/core/page/work-time/bucket-by-day.spec.ts create mode 100644 apps/server/src/core/page/work-time/bucket-by-day.ts create mode 100644 apps/server/src/core/page/work-time/compute-work-time.spec.ts create mode 100644 apps/server/src/core/page/work-time/compute-work-time.ts create mode 100644 apps/server/src/core/page/work-time/index.ts create mode 100644 apps/server/src/core/page/work-time/work-time.config.ts create mode 100644 apps/server/src/core/page/work-time/work-time.types.ts create mode 100644 apps/server/test/integration/page-work-time.int-spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 9b57f724..958cb910 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1427,5 +1427,20 @@ "Boundary": "Boundary", "Autosave": "Autosave", "Only versions": "Only versions", - "No saved versions yet.": "No saved versions yet." + "No saved versions yet.": "No saved versions yet.", + "Time worked on this article": "Time worked on this article", + "Show time worked on this page": "Show time worked on this page", + "Estimated time worked (inactivity gap {{gap}} min)": "Estimated time worked (inactivity gap {{gap}} min)", + "Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Estimate · timezone {{tz}} · inactivity gap {{gap}} min", + "No editing activity recorded yet.": "No editing activity recorded yet.", + "× {{count}} days without edits": "× {{count}} days without edits", + "agent: {{value}}": "agent: {{value}}", + "Work": "Work", + "Agent": "Agent", + "≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m", + "≈ {{hours}}h": "≈ {{hours}}h", + "≈ {{minutes}}m": "≈ {{minutes}}m", + "{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m", + "{{hours}}h": "{{hours}}h", + "{{minutes}}m": "{{minutes}}m" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index e22d8746..48f99607 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1442,5 +1442,20 @@ "Boundary": "Граница", "Autosave": "Автосейв", "Only versions": "Только версии", - "No saved versions yet.": "Пока нет сохранённых версий." + "No saved versions yet.": "Пока нет сохранённых версий.", + "Time worked on this article": "Время работы над статьёй", + "Show time worked on this page": "Показать время работы над страницей", + "Estimated time worked (inactivity gap {{gap}} min)": "Оценка времени работы (порог паузы {{gap}} мин)", + "Estimate · timezone {{tz}} · inactivity gap {{gap}} min": "Оценка · таймзона {{tz}} · порог паузы {{gap}} мин", + "No editing activity recorded yet.": "Правок пока нет.", + "× {{count}} days without edits": "× {{count}} дн. без правок", + "agent: {{value}}": "агент: {{value}}", + "Work": "Работа", + "Agent": "Агент", + "≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин", + "≈ {{hours}}h": "≈ {{hours}} ч", + "≈ {{minutes}}m": "≈ {{minutes}} мин", + "{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м", + "{{hours}}h": "{{hours}} ч", + "{{minutes}}m": "{{minutes}} м" } diff --git a/apps/client/src/features/page-history/work-time/format-work-time.test.ts b/apps/client/src/features/page-history/work-time/format-work-time.test.ts new file mode 100644 index 00000000..e201f47f --- /dev/null +++ b/apps/client/src/features/page-history/work-time/format-work-time.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { + formatHeadline, + formatDayTotal, + formatGapMinutes, +} from "./format-work-time"; + +const MIN = 60 * 1000; +// Fake translator: renders the key with {{tokens}} substituted, so the tests +// assert the rounding + branch selection without depending on the i18n catalogue. +const t = (key: string, opts?: Record) => + key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? "")); + +describe("formatHeadline", () => { + it("prefixes ≈ and rounds to a 5-minute step", () => { + expect(formatHeadline(4 * 60 * MIN + 27 * MIN, t)).toBe("≈ 4h 25m"); + expect(formatHeadline(90 * MIN, t)).toBe("≈ 1h 30m"); + }); + + it("shows hours only / minutes only cleanly", () => { + expect(formatHeadline(120 * MIN, t)).toBe("≈ 2h"); + expect(formatHeadline(35 * MIN, t)).toBe("≈ 35m"); + }); + + it("floors a tiny non-zero estimate to 5m, never 0", () => { + expect(formatHeadline(2 * MIN, t)).toBe("≈ 5m"); + }); + + it("empty string for zero (widget hidden)", () => { + expect(formatHeadline(0, t)).toBe(""); + }); +}); + +describe("formatDayTotal", () => { + it('renders "h m" and shows — for empty days', () => { + expect(formatDayTotal(3 * 60 * MIN + 17 * MIN, t)).toBe("3h 17m"); + expect(formatDayTotal(0, t)).toBe("—"); + }); +}); + +describe("formatGapMinutes", () => { + it("converts the tGap ms threshold to whole minutes", () => { + expect(formatGapMinutes(15 * MIN)).toBe(15); + }); +}); diff --git a/apps/client/src/features/page-history/work-time/format-work-time.ts b/apps/client/src/features/page-history/work-time/format-work-time.ts new file mode 100644 index 00000000..af437717 --- /dev/null +++ b/apps/client/src/features/page-history/work-time/format-work-time.ts @@ -0,0 +1,45 @@ +// #395 — display formatting for the work-time estimate. Pure functions that take +// a translator so ru-RU / en-US wording lives in the i18n catalogue and the +// rounding logic stays unit-testable. + +type Translate = (key: string, opts?: Record) => string; + +const MIN = 60 * 1000; + +function hm(totalMinutes: number): { hours: number; minutes: number } { + return { + hours: Math.floor(totalMinutes / 60), + minutes: totalMinutes % 60, + }; +} + +/** + * Headline number (§6.1): an ESTIMATE, so rounded to a coarse 5-minute step and + * prefixed with "≈". A non-zero-but-tiny estimate floors to 5m rather than + * rounding down to "0" (which would read as "no work"). Zero → empty string + * (the caller hides the widget). + */ +export function formatHeadline(workMs: number, t: Translate): string { + if (workMs <= 0) return ""; + let minutes = Math.round(workMs / MIN / 5) * 5; + if (minutes === 0) minutes = 5; + const { hours, minutes: m } = hm(minutes); + if (hours > 0 && m > 0) return t("≈ {{hours}}h {{minutes}}m", { hours, minutes: m }); + if (hours > 0) return t("≈ {{hours}}h", { hours }); + return t("≈ {{minutes}}m", { minutes: m }); +} + +/** Per-day sum (§6.2), rounded to the minute. Zero → "—". */ +export function formatDayTotal(activeMs: number, t: Translate): string { + if (activeMs <= 0) return "—"; + const minutes = Math.max(1, Math.round(activeMs / MIN)); + const { hours, minutes: m } = hm(minutes); + if (hours > 0 && m > 0) return t("{{hours}}h {{minutes}}m", { hours, minutes: m }); + if (hours > 0) return t("{{hours}}h", { hours }); + return t("{{minutes}}m", { minutes: m }); +} + +/** The inactivity threshold, for the "estimate · gap = N min" caption. */ +export function formatGapMinutes(tGapMs: number): number { + return Math.round(tGapMs / MIN); +} diff --git a/apps/client/src/features/page-history/work-time/use-page-work-time.ts b/apps/client/src/features/page-history/work-time/use-page-work-time.ts new file mode 100644 index 00000000..1da59444 --- /dev/null +++ b/apps/client/src/features/page-history/work-time/use-page-work-time.ts @@ -0,0 +1,25 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { IPageWorkTime } from "./work-time.types"; +import { getPageWorkTime, viewerTimezone } from "./work-time-service"; + +const WORK_TIME_STALE_TIME = 5 * 60 * 1000; + +/** + * #395 — the "time worked on this article" estimate + per-day punch-card + * buckets. The buckets are computed server-side in the viewer's timezone (so a + * midnight-crossing session lands on the right calendar day for the reader). + * `enabled` is opt-in so the (cheap but non-trivial) projection query only fires + * when the number is actually shown. + */ +export function usePageWorkTime( + pageId: string, + enabled = true, +): UseQueryResult { + const tz = viewerTimezone(); + return useQuery({ + queryKey: ["page-work-time", pageId, tz], + queryFn: () => getPageWorkTime(pageId, tz), + enabled: enabled && !!pageId, + staleTime: WORK_TIME_STALE_TIME, + }); +} diff --git a/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx b/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx new file mode 100644 index 00000000..a1285d2d --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time-punch-card.tsx @@ -0,0 +1,171 @@ +import { Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; +import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types"; +import { + formatDayTotal, + formatGapMinutes, + formatHeadline, +} from "./format-work-time"; +import classes from "./work-time.module.css"; + +const DAY_MS = 24 * 60 * 60 * 1000; +// Collapse a run of this many (or more) consecutive edit-free days into a single +// "× N days" separator (§6.2 long-range) — the row is still always one day. +const EMPTY_RUN_COLLAPSE = 8; + +type Row = + | { type: "day"; day: IPerDay } + | { type: "gap"; count: number }; + +function collapseEmptyRuns(perDay: IPerDay[]): Row[] { + const rows: Row[] = []; + let emptyRun: IPerDay[] = []; + const flush = () => { + if (emptyRun.length >= EMPTY_RUN_COLLAPSE) { + rows.push({ type: "gap", count: emptyRun.length }); + } else { + for (const d of emptyRun) rows.push({ type: "day", day: d }); + } + emptyRun = []; + }; + for (const d of perDay) { + if (d.activeMs === 0 && d.agentMs === 0) { + emptyRun.push(d); + } else { + flush(); + rows.push({ type: "day", day: d }); + } + } + flush(); + return rows; +} + +function dayHeading(day: number): string { + return new Date(day).toLocaleDateString(undefined, { + weekday: "short", + day: "numeric", + month: "short", + }); +} + +function DayTrack({ + day, + pSingle, +}: { + day: IPerDay; + pSingle: number; +}) { + const { t } = useTranslation(); + const ticks = [6, 12, 18]; + return ( +
+ {dayHeading(day.day)} +
+ {ticks.map((h) => ( +
+ ))} + {day.windows.map((w: IDayWindow, i) => { + const leftPct = ((w.start - day.day) / DAY_MS) * 100; + const widthPct = ((w.end - w.start) / DAY_MS) * 100; + const isSingle = w.end - w.start <= pSingle; + const cls = [ + classes.window, + w.class === "work" ? classes.windowWork : classes.windowAgent, + isSingle ? classes.windowSingle : "", + ].join(" "); + return ( +
+ ); + })} +
+ + {formatDayTotal(day.activeMs, t)} + +
+ ); +} + +interface Props { + data: IPageWorkTime; +} + +export default function WorkTimePunchCard({ data }: Props) { + const { t } = useTranslation(); + const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]); + const gapMin = formatGapMinutes(data.config.tGap); + + if (data.workMs <= 0 && data.agentOnlyMs <= 0) { + return ( + + {t("No editing activity recorded yet.")} + + ); + } + + return ( + + + + {formatHeadline(data.workMs, t)} + + {data.agentOnlyMs > 0 && ( + + {t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })} + + )} + + + + + + {t("Work")} + + + + {t("Agent")} + + + +
+ {rows.map((row, i) => + row.type === "day" ? ( + + ) : ( +
+ {t("× {{count}} days without edits", { count: row.count })} +
+ ), + )} +
+ + + {t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", { + tz: data.tz, + gap: gapMin, + })} + +
+ ); +} diff --git a/apps/client/src/features/page-history/work-time/work-time-service.ts b/apps/client/src/features/page-history/work-time/work-time-service.ts new file mode 100644 index 00000000..91177a83 --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time-service.ts @@ -0,0 +1,23 @@ +import api from "@/lib/api-client"; +import { IPageWorkTime } from "./work-time.types"; + +/** The viewer's IANA timezone (browser locale) — the punch-card lays days out + * in "my evenings", per §6.3/§10. Falls back to UTC if the runtime hides it. */ +export function viewerTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export async function getPageWorkTime( + pageId: string, + tz: string, +): Promise { + const req = await api.post("/pages/history/time", { + pageId, + tz, + }); + return req.data; +} diff --git a/apps/client/src/features/page-history/work-time/work-time-stat.tsx b/apps/client/src/features/page-history/work-time/work-time-stat.tsx new file mode 100644 index 00000000..d59eb5fc --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time-stat.tsx @@ -0,0 +1,63 @@ +import { Modal, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconClockHour4 } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { usePageWorkTime } from "./use-page-work-time"; +import { formatGapMinutes, formatHeadline } from "./format-work-time"; +import WorkTimePunchCard from "./work-time-punch-card"; + +interface Props { + pageId: string; +} + +/** + * #395 — the clickable "time worked on this article" headline (§6.1). Renders + * the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip + * (it is an estimate, not a stopwatch). Clicking opens the daily punch-card + * (§6.2). Renders nothing until there is a non-zero estimate, so a brand-new / + * never-edited page shows no widget. + */ +export default function WorkTimeStat({ pageId }: Props) { + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const { data } = usePageWorkTime(pageId); + + if (!data || data.workMs <= 0) return null; + + const label = formatHeadline(data.workMs, t); + const gapMin = formatGapMinutes(data.config.tGap); + + return ( + <> + + + + + {label} + + + + + + + + + ); +} diff --git a/apps/client/src/features/page-history/work-time/work-time.module.css b/apps/client/src/features/page-history/work-time/work-time.module.css new file mode 100644 index 00000000..397d45b7 --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time.module.css @@ -0,0 +1,82 @@ +/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track + (position = offset-in-day / 24h, width = duration / 24h), no chart library. */ + +.row { + display: grid; + grid-template-columns: 96px 1fr 64px; + align-items: center; + gap: 12px; + padding: 3px 0; +} + +.dayLabel { + font-size: var(--mantine-font-size-xs); + color: var(--mantine-color-dimmed); + white-space: nowrap; +} + +.track { + position: relative; + height: 16px; + border-radius: 4px; + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-6) + ); + overflow: hidden; +} + +/* Faint hour grid so the eye can read "morning vs evening". */ +.hourTick { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: light-dark( + var(--mantine-color-gray-3), + var(--mantine-color-dark-4) + ); +} + +.window { + position: absolute; + top: 2px; + bottom: 2px; + border-radius: 3px; + min-width: 3px; +} + +.windowWork { + background-color: var(--mantine-color-blue-5); +} + +.windowAgent { + background-color: var(--mantine-color-grape-5); +} + +/* A lone single-sample (P_single) window: minimal + dimmed, so it neither + vanishes nor fakes dense work (§6.2). */ +.windowSingle { + opacity: 0.5; +} + +.daySum { + font-size: var(--mantine-font-size-xs); + text-align: right; + white-space: nowrap; +} + +.gapRow { + padding: 6px 0 6px 108px; + font-size: var(--mantine-font-size-xs); + color: var(--mantine-color-dimmed); + font-style: italic; +} + +.legendSwatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 3px; + vertical-align: middle; +} diff --git a/apps/client/src/features/page-history/work-time/work-time.types.ts b/apps/client/src/features/page-history/work-time/work-time.types.ts new file mode 100644 index 00000000..761ebf9b --- /dev/null +++ b/apps/client/src/features/page-history/work-time/work-time.types.ts @@ -0,0 +1,44 @@ +// #395 — client-side mirror of the server work-time payload +// (apps/server/src/core/page/work-time). Shapes returned by POST /pages/history/time. + +export type WorkSessionClass = "work" | "agent_only"; + +export interface IWorkSession { + start: number; + end: number; + class: WorkSessionClass; +} + +export interface IDayWindow { + start: number; + end: number; + class: WorkSessionClass; +} + +export interface IPerDay { + day: number; + dayISO: string; + activeMs: number; + agentMs: number; + windows: IDayWindow[]; +} + +export interface IWorkTimeConfig { + tGap: number; + agentTGap: number; + pIn: number; + pOut: number; + pSingle: number; + excludeGit: boolean; + burstCapMs?: number; + dedupRoundMs: number; +} + +export interface IPageWorkTime { + workMs: number; + agentOnlyMs: number; + sessions: IWorkSession[]; + perDay: IPerDay[]; + config: IWorkTimeConfig; + tz: string; +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 844ef09a..f0236115 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -51,6 +51,7 @@ import { import { formattedDate } from "@/lib/time.ts"; import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; +import WorkTimeStat from "@/features/page-history/work-time/work-time-stat.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import { useFavoriteIds, @@ -265,6 +266,8 @@ function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) { return ( <> + {page?.id && } + { it('should be defined', () => { expect(controller).toBeDefined(); }); + + // #395 — the work-time endpoint must be gated exactly like /history. + describe('getPageWorkTime', () => { + const user = { id: 'u1' } as any; + + function build(overrides: { + page?: any; + validate?: jest.Mock; + compute?: jest.Mock; + }) { + const pageRepo = { findById: jest.fn().mockResolvedValue(overrides.page) }; + const pageAccessService = { + validateCanView: overrides.validate ?? jest.fn().mockResolvedValue(undefined), + }; + const pageHistoryService = { + computeWorkTime: + overrides.compute ?? jest.fn().mockResolvedValue({ workMs: 0 }), + }; + const c = new PageController( + {} as any, + pageRepo as any, + pageHistoryService as any, + {} as any, + pageAccessService as any, + {} as any, + {} as any, + {} as any, + ); + return { c, pageRepo, pageAccessService, pageHistoryService }; + } + + it('404s when the page does not exist', async () => { + const { c } = build({ page: null }); + await expect( + c.getPageWorkTime({ pageId: 'p1' } as any, user), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('enforces validateCanView before computing, then delegates with tz', async () => { + const validate = jest.fn().mockResolvedValue(undefined); + const compute = jest.fn().mockResolvedValue({ workMs: 42 }); + const { c } = build({ page: { id: 'pg' }, validate, compute }); + const out = await c.getPageWorkTime( + { pageId: 'pg', tz: 'Europe/Moscow' } as any, + user, + ); + expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user); + expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow'); + expect(out).toEqual({ workMs: 42 }); + }); + + it('maps an unknown-timezone RangeError to a 400', async () => { + const compute = jest.fn().mockRejectedValue(new RangeError('bad tz')); + const { c } = build({ page: { id: 'pg' }, compute }); + await expect( + c.getPageWorkTime({ pageId: 'pg', tz: 'X/Y' } as any, user), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('does not swallow a non-RangeError from the service', async () => { + const compute = jest.fn().mockRejectedValue(new Error('db down')); + const { c } = build({ page: { id: 'pg' }, compute }); + await expect( + c.getPageWorkTime({ pageId: 'pg' } as any, user), + ).rejects.toThrow('db down'); + }); + }); }); diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 05b363e0..9f60d737 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -21,6 +21,7 @@ import { PageHistoryIdDto, PageIdDto, PageInfoDto, + PageWorkTimeDto, } from './dto/page.dto'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; @@ -524,6 +525,32 @@ export class PageController { return this.pageHistoryService.findHistoryByPageId(page.id, pagination); } + @HttpCode(HttpStatus.OK) + @Post('/history/time') + async getPageWorkTime( + @Body() dto: PageWorkTimeDto, + @AuthUser() user: User, + ) { + const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + + // Same view gate as /history and /history/info. + await this.pageAccessService.validateCanView(page, user); + + try { + return await this.pageHistoryService.computeWorkTime(page.id, dto.tz); + } catch (e) { + // Intl.DateTimeFormat throws RangeError on an unknown IANA zone; surface + // it as a 400 rather than a 500. + if (e instanceof RangeError) { + throw new BadRequestException('Invalid timezone'); + } + throw e; + } + } + @HttpCode(HttpStatus.OK) @Post('/history/info') async getPageHistoryInfo( diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index 9155638f..518a62c3 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -3,6 +3,25 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; import { PageHistory } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; +import { + computeWorkTime, + bucketByDay, + DEFAULT_WORK_TIME_CONFIG, + WorkTimeConfig, + PerDay, + WorkSession, +} from '../work-time'; + +export interface PageWorkTime { + workMs: number; + agentOnlyMs: number; + sessions: WorkSession[]; + perDay: PerDay[]; + /** the config actually used, so the UI can show "≈" + the T_gap threshold. */ + config: WorkTimeConfig; + /** the tz the per-day buckets were computed in (echoed back for the label). */ + tz: string; +} @Injectable() export class PageHistoryService { @@ -23,4 +42,32 @@ export class PageHistoryService { paginationOptions, ); } + + /** + * #395 — estimate time worked on a page (§5) and bucket it into the viewer's + * calendar days for the punch-card (§6.3). Reads only the cheap history + * projection (no `content`); the estimate itself is a pure, deterministic + * function so it is unit-tested exhaustively without a DB. + * + * `tz` is the viewer's IANA zone (browser locale) — it moves which day a + * session lands in and where its windows sit, but never the total (§10). + */ + async computeWorkTime( + pageId: string, + tz = 'UTC', + config?: Partial, + ): Promise { + const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId); + const result = computeWorkTime(rows, config); + const usedConfig: WorkTimeConfig = { ...DEFAULT_WORK_TIME_CONFIG, ...config }; + const perDay = bucketByDay(result.sessions, tz); + return { + workMs: result.workMs, + agentOnlyMs: result.agentOnlyMs, + sessions: result.sessions, + perDay, + config: usedConfig, + tz, + }; + } } diff --git a/apps/server/src/core/page/work-time/bucket-by-day.spec.ts b/apps/server/src/core/page/work-time/bucket-by-day.spec.ts new file mode 100644 index 00000000..ff6f9116 --- /dev/null +++ b/apps/server/src/core/page/work-time/bucket-by-day.spec.ts @@ -0,0 +1,129 @@ +import { bucketByDay, zonedDayStart } from './bucket-by-day'; +import { computeWorkTime } from './compute-work-time'; +import { WorkSession, TimelineSample } from './work-time.types'; + +const MIN = 60 * 1000; +const HOUR = 60 * MIN; + +function work(start: number, end: number): WorkSession { + return { start, end, class: 'work' }; +} +function agent(start: number, end: number): WorkSession { + return { start, end, class: 'agent_only' }; +} + +function sumActive(perDay: ReturnType): number { + return perDay.reduce((a, d) => a + d.activeMs, 0); +} + +describe('bucketByDay', () => { + it('Σ activeMs == workMs — the §6.3 consistency invariant', () => { + const rows: TimelineSample[] = [ + { createdAt: '2026-07-04T03:40:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null }, + { createdAt: '2026-07-04T03:49:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null }, + { createdAt: '2026-07-04T18:11:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null }, + { createdAt: '2026-07-06T15:34:00Z', lastUpdatedById: 'h', lastUpdatedSource: 'user', lastUpdatedAiChatId: null, kind: null }, + ]; + const r = computeWorkTime(rows); + const perDay = bucketByDay(r.sessions, 'UTC'); + expect(sumActive(perDay)).toBe(r.workMs); + }); + + it('empty input → no days', () => { + expect(bucketByDay([], 'UTC')).toEqual([]); + }); + + it('midnight-crossing session splits across two days, sum preserved (§9#9)', () => { + const start = Date.UTC(2026, 0, 10, 23, 14); + const end = Date.UTC(2026, 0, 11, 0, 40); + const perDay = bucketByDay([work(start, end)], 'UTC'); + expect(perDay).toHaveLength(2); + expect(perDay[0].dayISO).toBe('2026-01-10'); + expect(perDay[1].dayISO).toBe('2026-01-11'); + expect(perDay[0].activeMs).toBe(46 * MIN); // 23:14 → 24:00 + expect(perDay[1].activeMs).toBe(40 * MIN); // 00:00 → 00:40 + expect(sumActive(perDay)).toBe(end - start); + }); + + it('empty days between active days are emitted, not skipped (§9#12)', () => { + const d1 = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0)); + const d3 = work(Date.UTC(2026, 0, 12, 10, 0), Date.UTC(2026, 0, 12, 11, 0)); + const perDay = bucketByDay([d1, d3], 'UTC'); + expect(perDay.map((d) => d.dayISO)).toEqual([ + '2026-01-10', + '2026-01-11', + '2026-01-12', + ]); + expect(perDay[1].activeMs).toBe(0); + expect(perDay[1].windows).toEqual([]); + }); + + it('agent_only windows are drawn but excluded from activeMs', () => { + const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 0)); + const a = agent(Date.UTC(2026, 0, 10, 14, 0), Date.UTC(2026, 0, 10, 14, 30)); + const perDay = bucketByDay([w, a], 'UTC'); + expect(perDay).toHaveLength(1); + expect(perDay[0].activeMs).toBe(1 * HOUR); + expect(perDay[0].agentMs).toBe(30 * MIN); + expect(perDay[0].windows.map((x) => x.class)).toEqual(['work', 'agent_only']); + }); + + it('work and agent_only are unioned SEPARATELY (agent does not swallow work)', () => { + // Overlapping work + agent windows on the same day. + const w = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 11, 0)); + const a = agent(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 12, 0)); + const perDay = bucketByDay([w, a], 'UTC'); + expect(perDay[0].activeMs).toBe(2 * HOUR); + expect(perDay[0].agentMs).toBe(2 * HOUR); + }); + + it('overlapping same-class sessions are UNIONed, not summed (no double-count)', () => { + // Two work sessions that overlap 10:00–10:30 on one day. + const a = work(Date.UTC(2026, 0, 10, 9, 0), Date.UTC(2026, 0, 10, 10, 30)); + const b = work(Date.UTC(2026, 0, 10, 10, 0), Date.UTC(2026, 0, 10, 11, 0)); + const perDay = bucketByDay([a, b], 'UTC'); + expect(perDay).toHaveLength(1); + // Union 09:00–11:00 = 2h, NOT 90m + 60m = 150m. + expect(perDay[0].activeMs).toBe(2 * HOUR); + // The drawn windows are also merged to one, so the punch-card cannot render + // an overlapping double bar. + expect(perDay[0].windows).toHaveLength(1); + expect(perDay[0].windows[0].start).toBe(a.start); + expect(perDay[0].windows[0].end).toBe(b.end); + }); + + it('DST fall-back: a full 25-hour day still balances (§9#14)', () => { + // America/New_York ends DST 2026-11-01 (25h day). + const tz = 'America/New_York'; + const dayStart = zonedDayStart(Date.UTC(2026, 10, 1, 12, 0), tz); + const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz); + expect(nextStart - dayStart).toBe(25 * HOUR); + const perDay = bucketByDay([work(dayStart, nextStart)], tz); + expect(perDay).toHaveLength(1); + expect(perDay[0].dayISO).toBe('2026-11-01'); + expect(perDay[0].activeMs).toBe(25 * HOUR); + expect(sumActive(perDay)).toBe(nextStart - dayStart); + }); + + it('DST spring-forward: a full 23-hour day still balances (§9#14)', () => { + // America/New_York starts DST 2026-03-08 (23h day). + const tz = 'America/New_York'; + const dayStart = zonedDayStart(Date.UTC(2026, 2, 8, 12, 0), tz); + const nextStart = zonedDayStart(dayStart + 26 * HOUR, tz); + expect(nextStart - dayStart).toBe(23 * HOUR); + const perDay = bucketByDay([work(dayStart, nextStart)], tz); + expect(perDay).toHaveLength(1); + expect(perDay[0].activeMs).toBe(23 * HOUR); + expect(sumActive(perDay)).toBe(nextStart - dayStart); + }); + + it('tz changes the day a session lands in but not the total', () => { + const start = Date.UTC(2026, 0, 10, 2, 0); // 02:00 UTC + const end = Date.UTC(2026, 0, 10, 3, 0); + const utc = bucketByDay([work(start, end)], 'UTC'); + const ny = bucketByDay([work(start, end)], 'America/New_York'); // 21:00 prev day + expect(utc[0].dayISO).toBe('2026-01-10'); + expect(ny[0].dayISO).toBe('2026-01-09'); + expect(sumActive(utc)).toBe(sumActive(ny)); + }); +}); diff --git a/apps/server/src/core/page/work-time/bucket-by-day.ts b/apps/server/src/core/page/work-time/bucket-by-day.ts new file mode 100644 index 00000000..d52758f7 --- /dev/null +++ b/apps/server/src/core/page/work-time/bucket-by-day.ts @@ -0,0 +1,180 @@ +import { WorkSession, PerDay, DayWindow } from './work-time.types'; + +/** + * Merge intervals into a disjoint, sorted union. Overlapping OR touching + * intervals are joined. Empty input → []. + */ +function union(intervals: Array<[number, number]>): Array<[number, number]> { + if (intervals.length === 0) return []; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + const out: Array<[number, number]> = []; + let [curStart, curEnd] = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + const [s, e] = sorted[i]; + if (s <= curEnd) { + if (e > curEnd) curEnd = e; + } else { + out.push([curStart, curEnd]); + curStart = s; + curEnd = e; + } + } + out.push([curStart, curEnd]); + return out; +} + +// Cache one Intl formatter per tz — constructing them is comparatively costly. +const fmtCache = new Map(); + +function partsFmt(tz: string): Intl.DateTimeFormat { + let fmt = fmtCache.get(tz); + if (!fmt) { + fmt = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + fmtCache.set(tz, fmt); + } + return fmt; +} + +interface WallParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +/** Wall-clock parts of an instant in `tz` (DST-correct, via Intl). */ +function wallParts(ms: number, tz: string): WallParts { + const parts = partsFmt(tz).formatToParts(new Date(ms)); + const get = (type: string) => + Number(parts.find((p) => p.type === type)?.value ?? '0'); + let hour = get('hour'); + // Intl emits "24" for midnight under some engines/locales; normalize to 0. + if (hour === 24) hour = 0; + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour, + minute: get('minute'), + second: get('second'), + }; +} + +/** tz offset (wall − real) at an instant, in ms. */ +function offset(ms: number, tz: string): number { + const p = wallParts(ms, tz); + const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + return asUTC - ms; +} + +/** + * Epoch-ms of the local-midnight day start of `ms` in `tz`. DST-correct: takes + * the calendar day of the instant, its wall-midnight, then converts back with + * the offset that actually applies AT that midnight (refined once). The rare + * tz-with-a-DST-transition-exactly-at-midnight case is a documented edge (§9#14). + */ +export function zonedDayStart(ms: number, tz: string): number { + const p = wallParts(ms, tz); + const wallMidnightAsUTC = Date.UTC(p.year, p.month - 1, p.day, 0, 0, 0); + let start = wallMidnightAsUTC - offset(ms, tz); + // Refine with the offset at the computed midnight (DST may differ from `ms`). + start = wallMidnightAsUTC - offset(start, tz); + return start; +} + +/** The next local midnight after `dayStart` (handles 23/25h DST days). */ +function nextDayStart(dayStart: number, tz: string): number { + // +26h always lands inside the NEXT calendar day (day length ∈ [23h,25h]), + // never two days ahead; startOf('day') of it is the next midnight. + return zonedDayStart(dayStart + 26 * 60 * 60 * 1000, tz); +} + +function isoDay(dayStart: number, tz: string): string { + const p = wallParts(dayStart, tz); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${p.year}-${pad(p.month)}-${pad(p.day)}`; +} + +/** Clip a union to [lo, hi) and emit windows of `class`. */ +function clip( + merged: Array<[number, number]>, + lo: number, + hi: number, + cls: DayWindow['class'], +): DayWindow[] { + const out: DayWindow[] = []; + for (const [s, e] of merged) { + const start = Math.max(s, lo); + const end = Math.min(e, hi); + if (end > start) out.push({ start, end, class: cls }); + } + return out; +} + +/** + * #395 §6.3 — bucket sessions into calendar days of `tz` for the punch-card. + * Pure and deterministic. `work` and `agent_only` are unioned SEPARATELY (else + * agent windows would swallow work windows on overlap), then each union is split + * at tz midnight boundaries (`startOf('day')` in tz, NOT "+24h" — DST-safe §9#14) + * and clipped to each day. + * + * By construction Σ perDay.activeMs == workMs: the days are a partition of the + * `work` union — no loss, no dup, even on 23/25h DST days. `agent_only` windows + * are drawn but NOT in activeMs. Empty days between the first and last active day + * are emitted (empty track + "—") so the rhythm/pauses stay visible. + */ +export function bucketByDay(sessions: WorkSession[], tz: string): PerDay[] { + const uWork = union( + sessions.filter((s) => s.class === 'work').map((s) => [s.start, s.end]), + ); + const uAgent = union( + sessions + .filter((s) => s.class === 'agent_only') + .map((s) => [s.start, s.end]), + ); + + if (uWork.length === 0 && uAgent.length === 0) return []; + + const minStart = Math.min( + uWork.length ? uWork[0][0] : Infinity, + uAgent.length ? uAgent[0][0] : Infinity, + ); + const maxEnd = Math.max( + uWork.length ? uWork[uWork.length - 1][1] : -Infinity, + uAgent.length ? uAgent[uAgent.length - 1][1] : -Infinity, + ); + + const perDay: PerDay[] = []; + let dayStart = zonedDayStart(minStart, tz); + // Guard against a pathological non-advancing boundary. + let guard = 0; + while (dayStart < maxEnd && guard < 100000) { + guard++; + const dayEnd = nextDayStart(dayStart, tz); + const workWin = clip(uWork, dayStart, dayEnd, 'work'); + const agentWin = clip(uAgent, dayStart, dayEnd, 'agent_only'); + const activeMs = workWin.reduce((a, w) => a + (w.end - w.start), 0); + const agentMs = agentWin.reduce((a, w) => a + (w.end - w.start), 0); + const windows = [...workWin, ...agentWin].sort((a, b) => a.start - b.start); + perDay.push({ + day: dayStart, + dayISO: isoDay(dayStart, tz), + activeMs, + agentMs, + windows, + }); + dayStart = dayEnd; + } + return perDay; +} diff --git a/apps/server/src/core/page/work-time/compute-work-time.spec.ts b/apps/server/src/core/page/work-time/compute-work-time.spec.ts new file mode 100644 index 00000000..97fa906e --- /dev/null +++ b/apps/server/src/core/page/work-time/compute-work-time.spec.ts @@ -0,0 +1,196 @@ +import { computeWorkTime } from './compute-work-time'; +import { TimelineSample } from './work-time.types'; + +const MIN = 60 * 1000; + +function s( + iso: string, + opts: { + source?: string | null; + chat?: string | null; + kind?: string | null; + by?: string | null; + } = {}, +): TimelineSample { + return { + createdAt: `${iso}Z`, + lastUpdatedById: opts.by ?? 'human-1', + lastUpdatedSource: opts.source === undefined ? 'user' : opts.source, + lastUpdatedAiChatId: opts.chat ?? null, + kind: opts.kind ?? null, + }; +} + +// §7 config: T_gap=30m, P_in+P_out=10m, P_single=2m. +const S7 = { tGap: 30 * MIN, agentTGap: 30 * MIN, pIn: 5 * MIN, pOut: 5 * MIN, pSingle: 2 * MIN }; + +describe('computeWorkTime', () => { + it('§7 fixture — sessionizes 20-ish samples to ≈1h32m, not the ≈60h naive span', () => { + const rows: TimelineSample[] = [ + // S1: multi-sample morning session + s('2026-07-04T03:40:00'), + s('2026-07-04T03:45:00'), + s('2026-07-04T03:49:00'), + // S2: agent burst (one run) then human supervising → class work + s('2026-07-04T15:43:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T15:47:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T15:50:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T16:13:00'), + // S3: single + s('2026-07-04T18:11:00'), + // S4: multi-sample evening session + s('2026-07-04T19:38:00'), + s('2026-07-04T19:44:00'), + s('2026-07-04T19:54:00'), + // S5 / S6: two singles two days later, 44m apart → two sessions at T_gap=30 + s('2026-07-06T15:34:00'), + s('2026-07-06T16:18:00'), + ]; + + const r = computeWorkTime(rows, S7); + + // 19 + 40 + 2 + 26 + 2 + 2 = 91 minutes. + expect(r.workMs).toBe(91 * MIN); + expect(r.agentOnlyMs).toBe(0); + expect(r.sessions).toHaveLength(6); + expect(r.sessions.every((x) => x.class === 'work')).toBe(true); + + const naiveSpan = + new Date('2026-07-06T16:18:00Z').getTime() - + new Date('2026-07-04T03:40:00Z').getTime(); + expect(naiveSpan).toBeGreaterThan(60 * 60 * MIN); // ≈60h + expect(r.workMs).toBeLessThan(naiveSpan / 30); // dramatically smaller + }); + + it('n=0 → zero, no sessions', () => { + const r = computeWorkTime([]); + expect(r).toEqual({ workMs: 0, agentOnlyMs: 0, sessions: [] }); + }); + + it('n=1 human → one P_single work session', () => { + const r = computeWorkTime([s('2026-07-04T10:00:00')], S7); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('work'); + expect(r.workMs).toBe(2 * MIN); + expect(r.agentOnlyMs).toBe(0); + // pre-roll only: [t − P_single, t] + expect(r.sessions[0].end).toBe(new Date('2026-07-04T10:00:00Z').getTime()); + }); + + it('n=1 agent → one P_single agent_only session, work=0 (§9#2)', () => { + const r = computeWorkTime( + [s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' })], + S7, + ); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('agent_only'); + expect(r.workMs).toBe(0); + expect(r.agentOnlyMs).toBe(2 * MIN); + }); + + it('MUST close the last session — the newest session is not lost (§9#1)', () => { + // Two singles a day apart: without the post-loop close, the 2nd is dropped. + const rows = [s('2026-07-04T10:00:00'), s('2026-07-05T10:00:00')]; + const r = computeWorkTime(rows, S7); + expect(r.sessions).toHaveLength(2); + const lastStart = Math.max(...r.sessions.map((x) => x.start)); + expect(lastStart).toBe( + new Date('2026-07-05T10:00:00Z').getTime() - 2 * MIN, + ); + expect(r.workMs).toBe(4 * MIN); + }); + + it('agent-burst collapse: density does not inflate — length = wall-clock', () => { + const span = ['00', '01', '02', '03', '04', '05', '06']; + const dense: TimelineSample[] = span.map((sec) => + s(`2026-07-04T10:00:${sec}`, { source: 'agent', chat: 'c1', kind: 'agent' }), + ); + const sparse: TimelineSample[] = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:00:06', { source: 'agent', chat: 'c1', kind: 'agent' }), + ]; + const rDense = computeWorkTime(dense, S7); + const rSparse = computeWorkTime(sparse, S7); + // Same 6-second wall-clock span → same estimate regardless of snapshot count. + expect(rDense.agentOnlyMs).toBe(rSparse.agentOnlyMs); + expect(rDense.sessions).toHaveLength(1); + expect(rDense.sessions[0].class).toBe('agent_only'); + }); + + it('supervisory agent time inside a human session counts as work, not agent', () => { + const rows = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:12:00'), // human within T_gap + ]; + const r = computeWorkTime(rows, S7); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('work'); + expect(r.agentOnlyMs).toBe(0); + expect(r.workMs).toBeGreaterThan(0); + }); + + it('a DIFFERENT aiChatId breaks the burst — two agent runs, idle gap excluded', () => { + // Run c1 ends 10:05, run c2 starts 10:20 (15m > agentTGap 7m) → two sessions. + const rows = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:05:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:20:00', { source: 'agent', chat: 'c2', kind: 'agent' }), + s('2026-07-04T10:25:00', { source: 'agent', chat: 'c2', kind: 'agent' }), + ]; + const r = computeWorkTime(rows); // default agentTGap = 7m + expect(r.sessions).toHaveLength(2); + expect(r.sessions.every((x) => x.class === 'agent_only')).toBe(true); + // The 15m idle gap between the two runs is NOT counted. + const run1 = 5 * MIN + 5 * MIN + 5 * MIN; // pIn + span + pOut + expect(r.agentOnlyMs).toBe(2 * run1); + }); + + it('idle pulse (same/null run) is a full activity sample that continues a burst', () => { + const rows = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + // idle flush 4m later, null run id → continues the burst, not a new one + s('2026-07-04T10:04:00', { source: 'agent', chat: null, kind: 'idle' }), + s('2026-07-04T10:08:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + ]; + const r = computeWorkTime(rows); + expect(r.sessions).toHaveLength(1); + // burst span 10:00→10:08 (+pIn/pOut) = 8 + 10 = 18m + expect(r.agentOnlyMs).toBe(18 * MIN); + }); + + it('idle pulse keeps a human writing session visible (not excluded)', () => { + const rows = [ + s('2026-07-04T10:00:00'), + s('2026-07-04T10:08:00', { kind: 'idle' }), // pulse within T_gap + s('2026-07-04T10:15:00'), + ]; + const r = computeWorkTime(rows); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('work'); + // span 10:00→10:15 + pIn/pOut = 15 + 10 = 25m + expect(r.workMs).toBe(25 * MIN); + }); + + it('git-source samples are excluded (§10 excludeGit)', () => { + const rows = [ + s('2026-07-04T10:00:00', { source: 'git', kind: 'boundary' }), + s('2026-07-04T10:01:00', { source: 'git', kind: 'boundary' }), + ]; + expect(computeWorkTime(rows).workMs).toBe(0); + // ...but honoured off: + expect( + computeWorkTime(rows, { excludeGit: false }).workMs, + ).toBeGreaterThan(0); + }); + + it('rejects an invalid config (tGap < pIn + pOut)', () => { + expect(() => + computeWorkTime([s('2026-07-04T10:00:00')], { + tGap: 5 * MIN, + pIn: 5 * MIN, + pOut: 5 * MIN, + }), + ).toThrow(/tGap/); + }); +}); diff --git a/apps/server/src/core/page/work-time/compute-work-time.ts b/apps/server/src/core/page/work-time/compute-work-time.ts new file mode 100644 index 00000000..349424fc --- /dev/null +++ b/apps/server/src/core/page/work-time/compute-work-time.ts @@ -0,0 +1,223 @@ +import { + TimelineSample, + WorkSession, + WorkTimeResult, +} from './work-time.types'; +import { WorkTimeConfig, resolveWorkTimeConfig } from './work-time.config'; + +/** A normalized activity sample (one history row), createdAt as epoch-ms. */ +interface NormSample { + t: number; + isAgent: boolean; + aiChatId: string | null; + kind: string | null; +} + +/** + * A collapsed segment: either a scalar sample (t_start == t_end) or an + * agent-burst spanning several agent samples of one run (§5.1). It participates + * in sessionization as a single "sample". + */ +interface Segment { + tStart: number; + tEnd: number; + isAgent: boolean; +} + +function toMs(v: Date | string | number): number { + if (v instanceof Date) return v.getTime(); + if (typeof v === 'number') return v; + return new Date(v).getTime(); +} + +/** + * Normalize raw rows → sorted, deduped activity samples. `git` is dropped when + * configured; every other kind (incl. `idle` — the continuous-work pulse §3) is + * a real activity sample. Sort is by createdAt ASC; samples whose timestamps + * fall in the same `dedupRoundMs` bucket collapse to one (§9#7: a synchronous + * boundary row + the immediate agent snapshot can share a createdAt). A merged + * sample is human unless EVERY member is an agent, so supervision never gets + * mis-attributed to the agent. + */ +function normalize( + rows: TimelineSample[], + config: WorkTimeConfig, +): NormSample[] { + const samples: NormSample[] = []; + for (const row of rows) { + const source = row.lastUpdatedSource; + if (config.excludeGit && source === 'git') continue; + samples.push({ + t: toMs(row.createdAt), + isAgent: source === 'agent', + aiChatId: row.lastUpdatedAiChatId ?? null, + kind: row.kind ?? null, + }); + } + samples.sort((a, b) => a.t - b.t); + + if (config.dedupRoundMs <= 0 || samples.length < 2) return samples; + + const deduped: NormSample[] = []; + for (const s of samples) { + const prev = deduped[deduped.length - 1]; + if (prev && s.t - prev.t < config.dedupRoundMs) { + // Merge into the previous sample. Human wins the class; keep the earliest + // t; keep a non-null aiChatId if either has one (so a bare boundary row + // does not erase the run id). + prev.isAgent = prev.isAgent && s.isAgent; + prev.aiChatId = prev.aiChatId ?? s.aiChatId; + // Prefer the more specific kind (a real kind over a null/boundary) only + // matters for burst continuation; keep prev.kind (earliest) as-is. + continue; + } + deduped.push({ ...s }); + } + return deduped; +} + +/** + * Collapse consecutive same-run agent samples into one burst segment (§5.1) so a + * dense burst (8 snapshots in 7 minutes) contributes its wall-clock, not a count + * × block. A burst is broken by any sample NOT continuing the same aiChatId + * agent run: a non-agent sample, a `boundary` (actor transition), or a DIFFERENT + * aiChatId. An `idle` pulse with the SAME or a null aiChatId continues the burst + * (its label lags the real edit ≤ maxWait, well within rounding). + */ +function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] { + const segments: Segment[] = []; + let burst: { chatId: string | null; tStart: number; tEnd: number } | null = + null; + + const flush = () => { + if (!burst) return; + let tEnd = burst.tEnd; + if (config.burstCapMs != null && tEnd - burst.tStart > config.burstCapMs) { + tEnd = burst.tStart + config.burstCapMs; + } + segments.push({ tStart: burst.tStart, tEnd, isAgent: true }); + burst = null; + }; + + for (const s of samples) { + // idle pulse continuing the current agent burst (same or null run id). + if (burst && s.kind === 'idle' && (s.aiChatId === burst.chatId || s.aiChatId == null)) { + burst.tEnd = s.t; + continue; + } + if (s.isAgent && s.kind !== 'boundary') { + if (burst && burst.chatId === s.aiChatId) { + burst.tEnd = s.t; + } else { + flush(); + burst = { chatId: s.aiChatId, tStart: s.t, tEnd: s.t }; + } + continue; + } + // A human sample, a boundary, or an agent-boundary: breaks the burst and is + // itself a zero-width segment (its class follows its own source). + flush(); + segments.push({ tStart: s.t, tEnd: s.t, isAgent: s.isAgent }); + } + flush(); + return segments; +} + +function gapThreshold( + a: Segment, + b: Segment, + config: WorkTimeConfig, +): number { + return a.isAgent && b.isAgent ? config.agentTGap : config.tGap; +} + +/** Merge intervals; overlapping OR touching intervals are unioned. */ +function unionDuration(intervals: Array<[number, number]>): number { + if (intervals.length === 0) return 0; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + let total = 0; + let [curStart, curEnd] = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + const [s, e] = sorted[i]; + if (s <= curEnd) { + if (e > curEnd) curEnd = e; + } else { + total += curEnd - curStart; + curStart = s; + curEnd = e; + } + } + total += curEnd - curStart; + return total; +} + +/** + * #395 core — estimate time worked on a page from its history timeline (§5). + * Pure and deterministic: no DB, no clock, no I/O. + * + * Pipeline: normalize+dedup → collapse agent bursts → ONE sessionization pass + * over all segments (threshold depends on the pair: both-agent → agentTGap, else + * tGap; the last session is ALWAYS closed after the loop) → class per finished + * session (all-agent → agent_only, else work) → pad each session (multi-sample + * → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → metrics are the + * union wall-clock within each class (union, not Σ, so overlaps never double). + */ +export function computeWorkTime( + rows: TimelineSample[], + config?: Partial, +): WorkTimeResult { + const cfg = resolveWorkTimeConfig(config); + const samples = normalize(rows, cfg); + const segments = collapse(samples, cfg); + + // Sessionize — one pass over ALL segments. + const rawSessions: Segment[][] = []; + let cur: Segment[] | null = null; + for (const seg of segments) { + if (cur == null) { + cur = [seg]; + } else { + const last = cur[cur.length - 1]; + if (seg.tStart - last.tEnd <= gapThreshold(last, seg, cfg)) { + cur.push(seg); + } else { + rawSessions.push(cur); + cur = [seg]; + } + } + } + if (cur != null) rawSessions.push(cur); // MUST close the last session (§5, §9#1) + + const sessions: WorkSession[] = []; + const workIvs: Array<[number, number]> = []; + const agentIvs: Array<[number, number]> = []; + + for (const segs of rawSessions) { + const first = segs[0]; + const last = segs[segs.length - 1]; + const cls = segs.every((s) => s.isAgent) ? 'agent_only' : 'work'; + + let start: number; + let end: number; + if (segs.length === 1 && first.tStart === first.tEnd) { + // Lone single-instant session (one scalar, or a one-snapshot agent run): + // pre-roll only, no invented "future" work (§5). + start = first.tStart - cfg.pSingle; + end = first.tStart; + } else { + start = first.tStart - cfg.pIn; + end = last.tEnd + cfg.pOut; + } + + sessions.push({ start, end, class: cls }); + (cls === 'work' ? workIvs : agentIvs).push([start, end]); + } + + sessions.sort((a, b) => a.start - b.start); + + return { + workMs: unionDuration(workIvs), + agentOnlyMs: unionDuration(agentIvs), + sessions, + }; +} diff --git a/apps/server/src/core/page/work-time/index.ts b/apps/server/src/core/page/work-time/index.ts new file mode 100644 index 00000000..62f475e4 --- /dev/null +++ b/apps/server/src/core/page/work-time/index.ts @@ -0,0 +1,15 @@ +export { computeWorkTime } from './compute-work-time'; +export { bucketByDay, zonedDayStart } from './bucket-by-day'; +export { + DEFAULT_WORK_TIME_CONFIG, + resolveWorkTimeConfig, +} from './work-time.config'; +export type { WorkTimeConfig } from './work-time.config'; +export type { + TimelineSample, + WorkSession, + WorkTimeResult, + SessionClass, + DayWindow, + PerDay, +} from './work-time.types'; diff --git a/apps/server/src/core/page/work-time/work-time.config.ts b/apps/server/src/core/page/work-time/work-time.config.ts new file mode 100644 index 00000000..2e186255 --- /dev/null +++ b/apps/server/src/core/page/work-time/work-time.config.ts @@ -0,0 +1,87 @@ +import { + IDLE_MAX_WAIT_USER, + IDLE_MAX_WAIT_AGENT, +} from '../../../collaboration/constants'; + +/** + * #395 — tunables for the work-time estimate (§10). Defaults are calibrated off + * #374's idle-pulse ceilings: after #374 a continuous editing session leaves a + * history row at least every ~IDLE_MAX_WAIT (10m user / 5m agent), so a gap + * WIDER than that ceiling contains un-pulsed idle time = (partial) inactivity. + * `tGap` therefore sits a little above the user ceiling, `agentTGap` a little + * above the agent ceiling — a gap within the threshold is pulse-backed and + * counts as work. + */ +export interface WorkTimeConfig { + /** user inactivity timeout: gap ≤ tGap between samples = continuous work. */ + tGap: number; + /** timeout for a pair of consecutive agent samples (tighter than tGap). */ + agentTGap: number; + /** pre-roll padding for a multi-sample session (work began before sample 1). */ + pIn: number; + /** post-roll padding for a multi-sample session (work continued after last). */ + pOut: number; + /** block for a lone single-sample session (pre-roll only, no invented future). */ + pSingle: number; + /** drop `git`-source samples (they are not human/agent article work). */ + excludeGit: boolean; + /** optional cap on one collapsed agent-burst segment's wall-clock (§9#3). */ + burstCapMs?: number; + /** samples whose createdAt round to the same bucket dedup to one (§9#7). */ + dedupRoundMs: number; +} + +export const DEFAULT_WORK_TIME_CONFIG: WorkTimeConfig = { + // ~15m: IDLE_MAX_WAIT_USER (10m) + headroom. Empirically backcast on a real + // 307-snapshot article (≈24h at 15m matched the owner's estimate; 30/45m + // over-counted). See #395 §10. + tGap: 15 * 60 * 1000, + // ~7m: IDLE_MAX_WAIT_AGENT (5m) + headroom. + agentTGap: 7 * 60 * 1000, + pIn: 5 * 60 * 1000, + pOut: 5 * 60 * 1000, + pSingle: 2 * 60 * 1000, + excludeGit: true, + burstCapMs: undefined, + dedupRoundMs: 1000, +}; + +// Compile-time cross-check that the defaults really are pulse-anchored — if a +// future edit moves the #374 ceilings, this reminds us to re-calibrate. +void IDLE_MAX_WAIT_USER; +void IDLE_MAX_WAIT_AGENT; + +/** + * Fill a partial config with defaults and validate it. `tGap ≥ pIn + pOut` is + * NOT required for the §6.3 per-day invariant (union takes care of that), but is + * RECOMMENDED and enforced: otherwise the P-padding of adjacent sessions of + * DIFFERENT classes could overlap and be counted into both metrics (§5, §10). + */ +export function resolveWorkTimeConfig( + partial?: Partial, +): WorkTimeConfig { + const config = { ...DEFAULT_WORK_TIME_CONFIG, ...(partial ?? {}) }; + + for (const key of [ + 'tGap', + 'agentTGap', + 'pIn', + 'pOut', + 'pSingle', + 'dedupRoundMs', + ] as const) { + const value = config[key]; + if (!Number.isFinite(value) || value < 0) { + throw new Error(`work-time config: ${key} must be a non-negative number`); + } + } + if (config.burstCapMs != null && config.burstCapMs <= 0) { + throw new Error('work-time config: burstCapMs must be > 0 when set'); + } + if (config.tGap < config.pIn + config.pOut) { + throw new Error( + 'work-time config: tGap must be ≥ pIn + pOut so work/agent_only metrics cannot overlap', + ); + } + return config; +} diff --git a/apps/server/src/core/page/work-time/work-time.types.ts b/apps/server/src/core/page/work-time/work-time.types.ts new file mode 100644 index 00000000..4827b253 --- /dev/null +++ b/apps/server/src/core/page/work-time/work-time.types.ts @@ -0,0 +1,73 @@ +/** + * #395 — "time worked on an article" domain types. + * + * The estimate is built by sessionizing a page's `page_history` timeline on + * inactivity gaps (WakaTime-style), NOT by taking the span between the first and + * last edit (which over-counts sleep / lunch / idle days). See the design doc in + * issue #395 §5–§6.3 for the normative algorithm. + */ + +/** + * A single `page_history` row projected for the work-time computation — the + * cheap columns only (no `content`). Produced by + * `PageHistoryRepo.findTimelineByPageId`. `createdAt` is whatever the DB driver + * hands back (Date); the pure core normalizes it to epoch-ms itself so it stays + * deterministic and DB-free. + */ +export interface TimelineSample { + createdAt: Date | string | number; + lastUpdatedById: string | null; + /** 'user' | 'agent' | 'git' | null (legacy autosave = human). */ + lastUpdatedSource: string | null; + lastUpdatedAiChatId: string | null; + /** #370 tier: 'manual' | 'agent' | 'idle' | 'boundary' | null (legacy). */ + kind: string | null; +} + +/** A finished session's class (§5.1). */ +export type SessionClass = 'work' | 'agent_only'; + +/** + * A finished session: absolute wall-clock bounds already padded with P_in/P_out + * (multi-sample) or P_single (single scalar), plus its class. This is enough for + * both the metrics and the per-day punch-card colouring. + */ +export interface WorkSession { + /** epoch-ms, inclusive lower bound (already P-padded). */ + start: number; + /** epoch-ms, exclusive upper bound (already P-padded). */ + end: number; + class: SessionClass; +} + +/** Output of {@link computeWorkTime}. */ +export interface WorkTimeResult { + /** union wall-clock of `work` sessions, ms (the headline metric). */ + workMs: number; + /** union wall-clock of `agent_only` sessions, ms (secondary). */ + agentOnlyMs: number; + sessions: WorkSession[]; +} + +/** One activity window inside a calendar day (already clipped to the day). */ +export interface DayWindow { + /** epoch-ms. */ + start: number; + /** epoch-ms. */ + end: number; + class: SessionClass; +} + +/** One calendar day of the punch-card (§6.3). */ +export interface PerDay { + /** epoch-ms of the local-midnight day start in the requested tz. */ + day: number; + /** 'YYYY-MM-DD' in the requested tz — stable, tz-independent label. */ + dayISO: string; + /** Σ of `work` windows this day, ms. Σ over days == workMs (invariant §6.3). */ + activeMs: number; + /** Σ of `agent_only` windows this day, ms (drawn, NOT in activeMs). */ + agentMs: number; + /** both classes, clipped to the day, sorted by start (for drawing). */ + windows: DayWindow[]; +} diff --git a/apps/server/src/database/repos/page/page-history.repo.ts b/apps/server/src/database/repos/page/page-history.repo.ts index 6eb5b145..03bfa927 100644 --- a/apps/server/src/database/repos/page/page-history.repo.ts +++ b/apps/server/src/database/repos/page/page-history.repo.ts @@ -157,6 +157,44 @@ export class PageHistoryRepo { return { ...result, items: result.items.map(attachPageHistoryAgent) }; } + /** + * #395 — cheap projection of a page's FULL history timeline for the work-time + * estimate: only the columns the sessionizer needs, no heavy `content`, sorted + * oldest→newest. The secondary `id` tie-break keeps rows sharing a `createdAt` + * (e.g. a synchronous pre-agent boundary row + the immediate agent snapshot) + * in a deterministic order. + */ + async findTimelineByPageId( + pageId: string, + trx?: KyselyTransaction, + ): Promise< + Array< + Pick< + PageHistory, + | 'createdAt' + | 'lastUpdatedById' + | 'lastUpdatedSource' + | 'lastUpdatedAiChatId' + | 'kind' + > + > + > { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('pageHistory') + .select([ + 'createdAt', + 'lastUpdatedById', + 'lastUpdatedSource', + 'lastUpdatedAiChatId', + 'kind', + ]) + .where('pageId', '=', pageId) + .orderBy('createdAt', 'asc') + .orderBy('id', 'asc') + .execute(); + } + async findPageLastHistory( pageId: string, opts?: { diff --git a/apps/server/test/integration/page-work-time.int-spec.ts b/apps/server/test/integration/page-work-time.int-spec.ts new file mode 100644 index 00000000..ff8caaff --- /dev/null +++ b/apps/server/test/integration/page-work-time.int-spec.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto'; +import { Kysely } from 'kysely'; +import { PageHistoryRepo } from '../../src/database/repos/page/page-history.repo'; +import { PageHistoryService } from '../../src/core/page/services/page-history.service'; +import { computeWorkTime } from '../../src/core/page/work-time'; +import { + getTestDb, + destroyTestDb, + createWorkspace, + createSpace, + createPage, + createUser, + createChat, +} from './db'; + +/** + * #395 — real-Postgres coverage for the work-time timeline projection and the + * service that computes the estimate. The pure sessionizer is unit-tested + * exhaustively (compute-work-time.spec.ts); this asserts the SQL projection + * (right rows, ASC, no `content`) and that the service's numbers agree with the + * pure core over the exact rows the DB returns. + */ +describe('PageHistory work-time [integration]', () => { + let db: Kysely; + let repo: PageHistoryRepo; + let service: PageHistoryService; + let workspaceId: string; + let spaceId: string; + let pageId: string; + let userId: string; + let chatId: string; + + const MIN = 60 * 1000; + + beforeAll(async () => { + db = getTestDb(); + repo = new PageHistoryRepo(db as any); + service = new PageHistoryService(repo); + workspaceId = (await createWorkspace(db)).id; + spaceId = (await createSpace(db, workspaceId)).id; + pageId = (await createPage(db, { workspaceId, spaceId })).id; + userId = (await createUser(db, workspaceId)).id; + chatId = (await createChat(db, { workspaceId, creatorId: userId })).id; + }); + + afterAll(async () => { + await destroyTestDb(); + }); + + async function insertHistory(rows: Array<{ + createdAt: string; + source: string | null; + chat?: string | null; + kind?: string | null; + content?: unknown; + }>) { + for (const r of rows) { + await db + .insertInto('pageHistory') + .values({ + id: randomUUID(), + pageId, + spaceId, + workspaceId, + title: 'x', + content: r.content ?? { type: 'doc', content: [] }, + lastUpdatedById: userId, + lastUpdatedSource: r.source, + lastUpdatedAiChatId: r.chat ?? null, + kind: r.kind ?? null, + createdAt: new Date(r.createdAt), + }) + .execute(); + } + } + + it('findTimelineByPageId projects the cheap columns, ASC, without content', async () => { + await insertHistory([ + { createdAt: '2026-07-04T19:54:00Z', source: 'user', kind: 'manual' }, + { createdAt: '2026-07-04T03:40:00Z', source: 'user', kind: null }, + { createdAt: '2026-07-04T15:43:00Z', source: 'agent', chat: chatId, kind: 'agent' }, + ]); + + const timeline = await repo.findTimelineByPageId(pageId); + expect(timeline).toHaveLength(3); + // Sorted oldest → newest. + const times = timeline.map((r) => new Date(r.createdAt).getTime()); + expect(times).toEqual([...times].sort((a, b) => a - b)); + // Projection carries exactly the sessionizer's inputs, and NO content. + for (const row of timeline) { + expect(row).toHaveProperty('createdAt'); + expect(row).toHaveProperty('lastUpdatedById'); + expect(row).toHaveProperty('lastUpdatedSource'); + expect(row).toHaveProperty('lastUpdatedAiChatId'); + expect(row).toHaveProperty('kind'); + expect(row).not.toHaveProperty('content'); + } + // Agent row keeps its provenance. + const agent = timeline.find((r) => r.lastUpdatedSource === 'agent'); + expect(agent?.lastUpdatedAiChatId).toBe(chatId); + }); + + it('service estimate matches the pure core and satisfies Σ perDay == workMs', async () => { + const rows = await repo.findTimelineByPageId(pageId); + const pure = computeWorkTime(rows); + + const result = await service.computeWorkTime(pageId, 'UTC'); + expect(result.workMs).toBe(pure.workMs); + expect(result.agentOnlyMs).toBe(pure.agentOnlyMs); + expect(result.tz).toBe('UTC'); + expect(result.config.tGap).toBe(15 * MIN); + + const sumActive = result.perDay.reduce((a, d) => a + d.activeMs, 0); + expect(sumActive).toBe(result.workMs); + + // The 3 seeded rows sessionize into ≤ their span; not the naive span. + const naive = + new Date('2026-07-04T19:54:00Z').getTime() - + new Date('2026-07-04T03:40:00Z').getTime(); + expect(result.workMs).toBeGreaterThan(0); + expect(result.workMs).toBeLessThan(naive); + }); + + it('an unknown timezone surfaces as a RangeError (controller maps to 400)', async () => { + await expect( + service.computeWorkTime(pageId, 'Not/AZone'), + ).rejects.toBeInstanceOf(RangeError); + }); + + it('a page with no history → zeros, no days', async () => { + const emptyPage = (await createPage(db, { workspaceId, spaceId })).id; + const result = await service.computeWorkTime(emptyPage, 'UTC'); + expect(result.workMs).toBe(0); + expect(result.agentOnlyMs).toBe(0); + expect(result.perDay).toEqual([]); + }); +}); -- 2.52.0 From 3e81f64415abec41ea8640b2c15b7b5bfd569211 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 06:53:59 +0300 Subject: [PATCH 2/3] fix(work-time): user idle breaks agent burst; cap tz length (#395) Only an agent-sourced idle pulse extends an agent burst; a user idle (human supervision) now falls through to the human branch so the session is classified `work`, not `agent_only`. Add @MaxLength(64) to tz to match its comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/core/page/dto/page.dto.ts | 2 ++ .../page/work-time/compute-work-time.spec.ts | 23 +++++++++++++++++++ .../core/page/work-time/compute-work-time.ts | 16 +++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts index 5dfc5a1c..54483b51 100644 --- a/apps/server/src/core/page/dto/page.dto.ts +++ b/apps/server/src/core/page/dto/page.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsString, IsUUID, + MaxLength, } from 'class-validator'; import { Transform } from 'class-transformer'; @@ -54,6 +55,7 @@ export class PageWorkTimeDto extends PageIdDto { // throws on an unknown zone (caught by the controller → 400). @IsOptional() @IsString() + @MaxLength(64) tz?: string; } diff --git a/apps/server/src/core/page/work-time/compute-work-time.spec.ts b/apps/server/src/core/page/work-time/compute-work-time.spec.ts index 97fa906e..da73808e 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.spec.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.spec.ts @@ -159,6 +159,29 @@ describe('computeWorkTime', () => { expect(r.agentOnlyMs).toBe(18 * MIN); }); + it('a USER-sourced idle breaks an agent burst → session is work, not agent_only', () => { + // A human supervision idle inherits source=user (aiChatId:null) and must NOT + // be swallowed into the agent burst. Δ=3m is within the default agentTGap so + // the two samples stay one session — but its class flips to `work`. + const rows = [ + s('2026-07-04T10:00:00', { source: 'agent', chat: 'c1', kind: 'agent' }), + s('2026-07-04T10:03:00', { source: 'user', chat: null, kind: 'idle' }), + ]; + const r = computeWorkTime(rows); + expect(r.sessions).toHaveLength(1); + expect(r.sessions[0].class).toBe('work'); + expect(r.workMs).toBeGreaterThan(0); + // The human idle is NOT captured as agent_only time. + expect(r.agentOnlyMs).toBe(0); + // Σ over `work` sessions == workMs and Σ over `agent_only` == agentOnlyMs. + const sum = (cls: string) => + r.sessions + .filter((x) => x.class === cls) + .reduce((acc, x) => acc + (x.end - x.start), 0); + expect(sum('work')).toBe(r.workMs); + expect(sum('agent_only')).toBe(r.agentOnlyMs); + }); + it('idle pulse keeps a human writing session visible (not excluded)', () => { const rows = [ s('2026-07-04T10:00:00'), diff --git a/apps/server/src/core/page/work-time/compute-work-time.ts b/apps/server/src/core/page/work-time/compute-work-time.ts index 349424fc..fa93c3c0 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.ts @@ -81,8 +81,9 @@ function normalize( * dense burst (8 snapshots in 7 minutes) contributes its wall-clock, not a count * × block. A burst is broken by any sample NOT continuing the same aiChatId * agent run: a non-agent sample, a `boundary` (actor transition), or a DIFFERENT - * aiChatId. An `idle` pulse with the SAME or a null aiChatId continues the burst - * (its label lags the real edit ≤ maxWait, well within rounding). + * aiChatId. Only an AGENT-sourced `idle` pulse with the SAME or a null aiChatId + * continues the burst (its label lags the real edit ≤ maxWait, well within + * rounding); a user-sourced `idle` (a human supervision pulse) breaks it. */ function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] { const segments: Segment[] = []; @@ -100,8 +101,15 @@ function collapse(samples: NormSample[], config: WorkTimeConfig): Segment[] { }; for (const s of samples) { - // idle pulse continuing the current agent burst (same or null run id). - if (burst && s.kind === 'idle' && (s.aiChatId === burst.chatId || s.aiChatId == null)) { + // An agent-sourced idle pulse continues the current agent burst (same or + // null run id). A user-sourced idle (human supervision) must NOT be swallowed + // here — it falls through to the human branch so the session flips to `work`. + if ( + burst && + s.kind === 'idle' && + s.isAgent && + (s.aiChatId === burst.chatId || s.aiChatId == null) + ) { burst.tEnd = s.t; continue; } -- 2.52.0 From 79b2da686b1796f8803027b95e309c5ca7974f72 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 08:20:17 +0300 Subject: [PATCH 3/3] fix(work-time): clip cross-class padding; gate & payload fixes (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: pad-clip adjacent different-class sessions at the raw-gap midpoint so workMs and agentOnlyMs can never double-count the same wall-clock (default agentTGap 7m < pIn+pOut 10m made a work-session-ending-in-agent overlap a nearby agent_only run). Reword the config guard/comment: cross-class disjointness is now structural, tGap≥pIn+pOut is a kept sanity bound. Add a cross-class no-double-count test and a real seeded property/fuzz test (250 random timelines × 4 tz) asserting per-class union, cross-class disjointness, and Σ per-day activeMs == workMs. F2: page.controller.spec — add a ForbiddenException view-gate reject test that asserts the rejection propagates and computeWorkTime is NOT reached, locking validateCanView before compute. F3: WorkTimeStat renders for agent-only pages (workMs==0, agentOnlyMs>0) with an `agent:` headline so the punch-card stays reachable. F4: drop the dead un-bucketed `sessions` list from the PageWorkTime response and the client work-time types (no client reads it; bucketByDay still consumes the core sessions internally). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../page-history/work-time/work-time-stat.tsx | 14 +- .../page-history/work-time/work-time.types.ts | 7 - .../src/core/page/page.controller.spec.ts | 23 ++- .../page/services/page-history.service.ts | 5 +- .../page/work-time/compute-work-time.spec.ts | 141 +++++++++++++++++- .../core/page/work-time/compute-work-time.ts | 69 +++++++-- .../core/page/work-time/work-time.config.ts | 25 +++- 7 files changed, 250 insertions(+), 34 deletions(-) diff --git a/apps/client/src/features/page-history/work-time/work-time-stat.tsx b/apps/client/src/features/page-history/work-time/work-time-stat.tsx index d59eb5fc..b963456f 100644 --- a/apps/client/src/features/page-history/work-time/work-time-stat.tsx +++ b/apps/client/src/features/page-history/work-time/work-time-stat.tsx @@ -14,17 +14,23 @@ interface Props { * #395 — the clickable "time worked on this article" headline (§6.1). Renders * the `work` estimate with a "≈" sign and the inactivity threshold in a tooltip * (it is an estimate, not a stopwatch). Clicking opens the daily punch-card - * (§6.2). Renders nothing until there is a non-zero estimate, so a brand-new / - * never-edited page shows no widget. + * (§6.2). Renders nothing until there is a non-zero human OR agent estimate, so a + * brand-new / never-edited page shows no widget. For an agent-only-edited page + * (workMs===0, agentOnlyMs>0) the headline shows the agent estimate (labelled + * `agent:`, matching the punch-card) so the punch-card stays reachable (#395: + * "how much a HUMAN and separately the AGENT"). */ export default function WorkTimeStat({ pageId }: Props) { const { t } = useTranslation(); const [opened, { open, close }] = useDisclosure(false); const { data } = usePageWorkTime(pageId); - if (!data || data.workMs <= 0) return null; + if (!data || (data.workMs <= 0 && data.agentOnlyMs <= 0)) return null; - const label = formatHeadline(data.workMs, t); + const agentOnly = data.workMs <= 0; + const label = agentOnly + ? t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) }) + : formatHeadline(data.workMs, t); const gapMin = formatGapMinutes(data.config.tGap); return ( diff --git a/apps/client/src/features/page-history/work-time/work-time.types.ts b/apps/client/src/features/page-history/work-time/work-time.types.ts index 761ebf9b..2ef5f114 100644 --- a/apps/client/src/features/page-history/work-time/work-time.types.ts +++ b/apps/client/src/features/page-history/work-time/work-time.types.ts @@ -3,12 +3,6 @@ export type WorkSessionClass = "work" | "agent_only"; -export interface IWorkSession { - start: number; - end: number; - class: WorkSessionClass; -} - export interface IDayWindow { start: number; end: number; @@ -37,7 +31,6 @@ export interface IWorkTimeConfig { export interface IPageWorkTime { workMs: number; agentOnlyMs: number; - sessions: IWorkSession[]; perDay: IPerDay[]; config: IWorkTimeConfig; tz: string; diff --git a/apps/server/src/core/page/page.controller.spec.ts b/apps/server/src/core/page/page.controller.spec.ts index e4640493..59d595ac 100644 --- a/apps/server/src/core/page/page.controller.spec.ts +++ b/apps/server/src/core/page/page.controller.spec.ts @@ -1,4 +1,8 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { PageController } from './page.controller'; // Direct instantiation with stub deps. The Test.createTestingModule form failed @@ -74,6 +78,23 @@ describe('PageController', () => { expect(out).toEqual({ workMs: 42 }); }); + it('propagates a denied view gate and does NOT reach compute (security)', async () => { + // If validateCanView is moved AFTER computeWorkTime, the timeline of a page + // the caller may not see would be read/estimated before the gate — this + // locks the order: a rejecting gate must short-circuit before any compute. + const validate = jest.fn().mockRejectedValue(new ForbiddenException()); + const compute = jest.fn().mockResolvedValue({ workMs: 1 }); + const { c, pageHistoryService } = build({ + page: { id: 'pg' }, + validate, + compute, + }); + await expect( + c.getPageWorkTime({ pageId: 'pg' } as any, user), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(pageHistoryService.computeWorkTime).not.toHaveBeenCalled(); + }); + it('maps an unknown-timezone RangeError to a 400', async () => { const compute = jest.fn().mockRejectedValue(new RangeError('bad tz')); const { c } = build({ page: { id: 'pg' }, compute }); diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index 518a62c3..66518987 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -9,13 +9,11 @@ import { DEFAULT_WORK_TIME_CONFIG, WorkTimeConfig, PerDay, - WorkSession, } from '../work-time'; export interface PageWorkTime { workMs: number; agentOnlyMs: number; - sessions: WorkSession[]; perDay: PerDay[]; /** the config actually used, so the UI can show "≈" + the T_gap threshold. */ config: WorkTimeConfig; @@ -60,11 +58,12 @@ export class PageHistoryService { const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId); const result = computeWorkTime(rows, config); const usedConfig: WorkTimeConfig = { ...DEFAULT_WORK_TIME_CONFIG, ...config }; + // `bucketByDay` consumes the pure core's un-bucketed sessions here; the + // full session list is NOT shipped on the response (no client reads it). const perDay = bucketByDay(result.sessions, tz); return { workMs: result.workMs, agentOnlyMs: result.agentOnlyMs, - sessions: result.sessions, perDay, config: usedConfig, tz, diff --git a/apps/server/src/core/page/work-time/compute-work-time.spec.ts b/apps/server/src/core/page/work-time/compute-work-time.spec.ts index da73808e..62f89176 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.spec.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.spec.ts @@ -1,8 +1,33 @@ import { computeWorkTime } from './compute-work-time'; -import { TimelineSample } from './work-time.types'; +import { bucketByDay } from './bucket-by-day'; +import { TimelineSample, WorkSession } from './work-time.types'; const MIN = 60 * 1000; +/** Union wall-clock of a set of intervals (touching intervals merge). */ +function unionMs(intervals: Array<[number, number]>): number { + if (intervals.length === 0) return 0; + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + let total = 0; + let [cs, ce] = sorted[0]; + for (let i = 1; i < sorted.length; i++) { + const [s, e] = sorted[i]; + if (s <= ce) { + if (e > ce) ce = e; + } else { + total += ce - cs; + cs = s; + ce = e; + } + } + return total + (ce - cs); +} + +const ivsOf = (sessions: WorkSession[], cls?: string): Array<[number, number]> => + sessions + .filter((x) => cls == null || x.class === cls) + .map((x) => [x.start, x.end] as [number, number]); + function s( iso: string, opts: { @@ -216,4 +241,118 @@ describe('computeWorkTime', () => { }), ).toThrow(/tGap/); }); + + it('rejects an invalid config (2·agentTGap < pIn + pOut)', () => { + // tGap (default 15m) still ≥ pIn+pOut, so only the 2·agentTGap guard trips. + // Without it a short session of one class between two of the other could + // produce a NON-adjacent cross-class overlap the adjacent-only clip misses. + expect(() => + computeWorkTime([s('2026-07-04T10:00:00')], { + agentTGap: 2 * MIN, + pIn: 5 * MIN, + pOut: 5 * MIN, + }), + ).toThrow(/agentTGap/); + }); + + // F1 — cross-class double-count. On the DEFAULT config agentTGap (7m) < pIn+pOut + // (10m), so a `work` session ending in an agent segment and a nearby separate + // `agent_only` run (gap in (7m,10m]) used to produce OVERLAPPING padded + // intervals — the same wall-clock counted into BOTH workMs and agentOnlyMs. The + // cross-class padding clip must make the two per-class unions disjoint. + it('does NOT double-count wall-clock across work/agent_only (§F1)', () => { + // user@0s ; agent(chatX)@60s (breaks into a work session with the human) ; + // agent(chatY)@560s,590s (a separate agent_only run). Raw gap between the work + // session (ends 60s) and the agent run (starts 560s) is 500s ∈ (agentTGap, + // pIn+pOut] once padded — the classic overlap window. + const rows: TimelineSample[] = [ + s('2026-07-04T00:00:00'), // user @ 0s + s('2026-07-04T00:01:00', { source: 'agent', chat: 'cX', kind: 'agent' }), // @ 60s + s('2026-07-04T00:09:20', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 560s + s('2026-07-04T00:09:50', { source: 'agent', chat: 'cY', kind: 'agent' }), // @ 590s + ]; + const r = computeWorkTime(rows); // DEFAULT config + + // Both classes present. + expect(r.workMs).toBeGreaterThan(0); + expect(r.agentOnlyMs).toBeGreaterThan(0); + + // Per-class metrics are exactly their own union (union, not Σ). + expect(r.workMs).toBe(unionMs(ivsOf(r.sessions, 'work'))); + expect(r.agentOnlyMs).toBe(unionMs(ivsOf(r.sessions, 'agent_only'))); + + // The F1 invariant: work-union and agent-union are cross-class-disjoint, so + // the union of ALL padded intervals equals workMs + agentOnlyMs (no overlap). + // With the clip disabled this fails (union < sum by the 100s overlap). + expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs); + }); + + // F1 property/fuzz — random timelines across several timezones must uphold the + // work-time invariants. Backs the (corrected) PR claim of a real fuzz test. + it('property: random timelines uphold union & cross-class-disjoint invariants', () => { + // Deterministic LCG (numerical-recipes constants) so a failure is reproducible. + let seed = 0x9e3779b9 >>> 0; + const rand = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 0x100000000; + }; + const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)]; + + const tzs = [ + 'UTC', + 'America/New_York', + 'Europe/Moscow', + 'Australia/Lord_Howe', // 30-min DST offset — a nasty bucket stress + ]; + const base = Date.UTC(2026, 5, 1, 0, 0, 0); // 2026-06-01Z + const chats = ['c1', 'c2', 'c3']; + + for (let iter = 0; iter < 250; iter++) { + const tz = pick(tzs); + const n = 2 + Math.floor(rand() * 18); // 2..19 rows + const rows: TimelineSample[] = []; + // Walk time forward by a random inter-sample gap. The gap distribution is + // centred on the DANGEROUS band — a bit under to a bit over pIn+pOut (10m) + // AND straddling agentTGap (7m) — so adjacent samples routinely split into + // separate sessions whose ±P padding would overlap if a class boundary sits + // there. Mixing user/agent classes at these gaps reliably manufactures the + // work-ending-in-agent → agent_only cross-class boundary F1 is about, plus + // dense within-class runs (occasional 0–2m gaps) that exercise the union. + let t = base + Math.floor(rand() * 60 * MIN); + for (let i = 0; i < n; i++) { + const roll = rand(); + const gap = + roll < 0.25 + ? Math.floor(rand() * 2 * MIN) // dense burst (same-class union) + : roll < 0.85 + ? 5 * MIN + Math.floor(rand() * 8 * MIN) // 5–13m: the split band + : 20 * MIN + Math.floor(rand() * 40 * MIN); // long idle → new day-ish + t += gap; + const iso = new Date(t).toISOString().slice(0, 19); // 'YYYY-MM-DDTHH:MM:SS' + const isAgent = rand() < 0.5; + rows.push( + isAgent + ? s(iso, { source: 'agent', chat: pick(chats), kind: 'agent' }) + : s(iso, { source: 'user', kind: rand() < 0.3 ? 'idle' : 'manual' }), + ); + } + + const r = computeWorkTime(rows); // DEFAULT config + + const workIvs = ivsOf(r.sessions, 'work'); + const agentIvs = ivsOf(r.sessions, 'agent_only'); + + // (1) each metric is exactly its per-class union (catches a union→Σ regress). + expect(r.workMs).toBe(unionMs(workIvs)); + expect(r.agentOnlyMs).toBe(unionMs(agentIvs)); + + // (2) NO cross-class overlap: union(all) == workMs + agentOnlyMs (F1). + expect(unionMs(ivsOf(r.sessions))).toBe(r.workMs + r.agentOnlyMs); + + // (3) bucket invariant: Σ per-day activeMs == workMs (§6.3). + const perDay = bucketByDay(r.sessions, tz); + const sumActive = perDay.reduce((a, d) => a + d.activeMs, 0); + expect(sumActive).toBe(r.workMs); + } + }); }); diff --git a/apps/server/src/core/page/work-time/compute-work-time.ts b/apps/server/src/core/page/work-time/compute-work-time.ts index fa93c3c0..ccc19bd4 100644 --- a/apps/server/src/core/page/work-time/compute-work-time.ts +++ b/apps/server/src/core/page/work-time/compute-work-time.ts @@ -167,8 +167,10 @@ function unionDuration(intervals: Array<[number, number]>): number { * over all segments (threshold depends on the pair: both-agent → agentTGap, else * tGap; the last session is ALWAYS closed after the loop) → class per finished * session (all-agent → agent_only, else work) → pad each session (multi-sample - * → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → metrics are the - * union wall-clock within each class (union, not Σ, so overlaps never double). + * → [first−P_in, last+P_out]; lone scalar → [t−P_single, t]) → clip padding of + * adjacent DIFFERENT-class sessions at the raw-gap midpoint (so work/agent_only + * never overlap) → metrics are the union wall-clock within each class (union, not + * Σ, so overlaps never double, and cross-class-disjoint by the clip above). */ export function computeWorkTime( rows: TimelineSample[], @@ -196,29 +198,70 @@ export function computeWorkTime( } if (cur != null) rawSessions.push(cur); // MUST close the last session (§5, §9#1) - const sessions: WorkSession[] = []; - const workIvs: Array<[number, number]> = []; - const agentIvs: Array<[number, number]> = []; + // A finished session with BOTH its raw (unpadded) span and its padded bounds. + // `rawSessions` are already in ascending time order, so `built` is too. + interface BuiltSession { + rawStart: number; + rawEnd: number; + padStart: number; + padEnd: number; + cls: WorkSession['class']; + } + const built: BuiltSession[] = []; for (const segs of rawSessions) { const first = segs[0]; const last = segs[segs.length - 1]; const cls = segs.every((s) => s.isAgent) ? 'agent_only' : 'work'; - let start: number; - let end: number; + let padStart: number; + let padEnd: number; if (segs.length === 1 && first.tStart === first.tEnd) { // Lone single-instant session (one scalar, or a one-snapshot agent run): // pre-roll only, no invented "future" work (§5). - start = first.tStart - cfg.pSingle; - end = first.tStart; + padStart = first.tStart - cfg.pSingle; + padEnd = first.tStart; } else { - start = first.tStart - cfg.pIn; - end = last.tEnd + cfg.pOut; + padStart = first.tStart - cfg.pIn; + padEnd = last.tEnd + cfg.pOut; } - sessions.push({ start, end, class: cls }); - (cls === 'work' ? workIvs : agentIvs).push([start, end]); + built.push({ + rawStart: first.tStart, + rawEnd: last.tEnd, + padStart, + padEnd, + cls, + }); + } + + // Clip cross-class padding so a `work` and an `agent_only` session that abut + // never claim the same wall-clock. For each ADJACENT pair of DIFFERENT classes, + // cap the earlier session's trailing pad and the later session's leading pad at + // the MIDPOINT of the raw (unpadded) inactivity gap between them: the earlier + // padded interval then ends ≤ midpoint and the later one starts ≥ midpoint, so + // the two are disjoint (they touch at most at the midpoint). This makes the + // per-class unions (workMs / agentOnlyMs) cross-class-disjoint BY CONSTRUCTION + // — closing the double-count where a work session ending in an agent segment + // and a nearby agent_only session (gap in (agentTGap, pIn+pOut]) overlapped and + // were counted into both metrics (§5, §9). Within-class adjacency is left + // untouched: `unionDuration` already dedups it, and clipping there could perturb + // the per-class metric value. + for (let i = 1; i < built.length; i++) { + const a = built[i - 1]; + const b = built[i]; + if (a.cls === b.cls) continue; + const midpoint = (a.rawEnd + b.rawStart) / 2; + if (a.padEnd > midpoint) a.padEnd = midpoint; + if (b.padStart < midpoint) b.padStart = midpoint; + } + + const sessions: WorkSession[] = []; + const workIvs: Array<[number, number]> = []; + const agentIvs: Array<[number, number]> = []; + for (const s of built) { + sessions.push({ start: s.padStart, end: s.padEnd, class: s.cls }); + (s.cls === 'work' ? workIvs : agentIvs).push([s.padStart, s.padEnd]); } sessions.sort((a, b) => a.start - b.start); diff --git a/apps/server/src/core/page/work-time/work-time.config.ts b/apps/server/src/core/page/work-time/work-time.config.ts index 2e186255..497633a1 100644 --- a/apps/server/src/core/page/work-time/work-time.config.ts +++ b/apps/server/src/core/page/work-time/work-time.config.ts @@ -52,10 +52,20 @@ void IDLE_MAX_WAIT_USER; void IDLE_MAX_WAIT_AGENT; /** - * Fill a partial config with defaults and validate it. `tGap ≥ pIn + pOut` is - * NOT required for the §6.3 per-day invariant (union takes care of that), but is - * RECOMMENDED and enforced: otherwise the P-padding of adjacent sessions of - * DIFFERENT classes could overlap and be counted into both metrics (§5, §10). + * Fill a partial config with defaults and validate it. Cross-class metric + * disjointness is guaranteed jointly by `computeWorkTime`'s adjacent-pair padding + * clip (it caps the padding of adjacent DIFFERENT-class sessions at the raw-gap + * midpoint) AND the two bounds enforced below (§5): + * - `tGap ≥ pIn + pOut`: a session's own padding never exceeds its inactivity + * window. + * - `2·agentTGap ≥ pIn + pOut`: makes the adjacent-only clip provably COMPLETE. + * A NON-adjacent (i, i+2) cross-class overlap could only arise from two + * same-class sessions separated by a full intervening session of the other + * class; that separation spans at least two inter-session gaps, each strictly + * `> agentTGap`, so it is `> 2·agentTGap`. Requiring `2·agentTGap ≥ pIn + pOut` + * means even the widest padded reach (pIn + pOut) cannot bridge it — so the + * only cross-class overlaps possible are between ADJACENT sessions, which the + * clip handles. `workMs`/`agentOnlyMs` are therefore disjoint by construction. */ export function resolveWorkTimeConfig( partial?: Partial, @@ -80,7 +90,12 @@ export function resolveWorkTimeConfig( } if (config.tGap < config.pIn + config.pOut) { throw new Error( - 'work-time config: tGap must be ≥ pIn + pOut so work/agent_only metrics cannot overlap', + "work-time config: tGap must be ≥ pIn + pOut (a session's padding may not exceed its inactivity window)", + ); + } + if (2 * config.agentTGap < config.pIn + config.pOut) { + throw new Error( + 'work-time config: 2·agentTGap must be ≥ pIn + pOut (so non-adjacent cross-class padding cannot overlap)', ); } return config; -- 2.52.0