c674db2b2f
Оценка времени работы над страницей как сессионизация истории 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) <noreply@anthropic.com>
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
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<any>;
|
|
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([]);
|
|
});
|
|
});
|