feat(page-history): редизайн десктопного окна истории — плотный список + мини-календарь heatmap (#568)

Натягиваю прототип NewDesign/PageHistoryModal на существующую подсистему истории,
переиспользуя всю механику (запросы, дифф, restore, права, пагинация) и добавляя
единственный дешёвый агрегат бэка — «число version-ревизий по дням» под heatmap.

Клиент:
- Новый десктоп-body history-modal-desktop.tsx: одно-строчная шапка (заголовок,
  лейбл выбранной версии, Highlight changes + N/M дифф-навигация, Restore, закрытие)
  над телом «левая навигация 280px | правый рендер версии».
- history-nav-panel.tsx: фильтр «Only versions» + мини-календарь + ПЛОТНЫЙ список
  (одна ревизия = одна строка), группировка по дням (data-day якоря), jump-to-day с
  корректной догрузкой страниц по границе dayISO старейшей строки (не «N страниц»),
  недостижимый день → тост, без зацикливания.
- revision-row.tsx + чистый адаптер utils/revision-row.ts: глиф по идентичности
  (agent → квадратный глиф роли + «via <launcher>», иначе круглый аватар), бейдж
  SAVED только для kind==='manual'; автосейвы приглушены. Два ортогональных сигнала
  сохранены (identity vs intentionality).
- mini-calendar.tsx: heatmap-интенсивность = version-ревизии за день, навигация
  месяцев клиентская, pick-day по dayISO; fail-open на пустую сетку.
- usePageHistoryDayCounts + day-counts-service; historyKindMeta вынесен в чистый
  utils/history-kind-meta.ts (тот же предикат для фильтра и бейджа).
- resolvePrevSnapshotId по-прежнему считается на ПОЛНОМ плоском списке; restore и
  дифф-навигация переиспользуют useHistoryRestore/useDiffNavigation без изменений.
- Мобильная модалка (history-modal-mobile.tsx) не тронута; орфанный
  history-modal-body.tsx удалён.

Сервер:
- POST /pages/history/day-counts {pageId, tz} → [{dayISO, count}] за всю историю,
  view-gated (validateCanView), неверный tz → 400 (как /history/time).
- computeDayCounts + чистый countRevisionsByDay (считает только version-строки),
  переиспользует tz-ядро zonedDayStart/isoDay из work-time/bucket-by-day.ts.

Тесты: клиент vitest (адаптер kind→бейдж/глиф, dense-row рендер, heatmap/календарь);
сервер jest (баркетер + эндпоинт: view-gate, tz→400, группировка по дням).

Удалён прототип NewDesign/PageHistoryModal.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:02:15 +03:00
parent e074b101c7
commit 6035cf182c
25 changed files with 1622 additions and 506 deletions
-362
View File
@@ -1,362 +0,0 @@
/**
* PageHistoryModal — редизайн окна «Page history».
* Mantine v7. Light/dark. Полноразмерное модальное окно.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
*
* Ключевые решения:
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
* круглого аватара пользователя + «via <кто запустил>».
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
* - Текущая версия помечена; Restore для неё недоступен.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { PageHistoryModal, Revision } from './PageHistoryModal';
*
* <PageHistoryModal
* opened={open}
* onClose={() => setOpen(false)}
* revisions={revisions} // Revision[]
* selectedId={selId}
* onSelect={setSelId} // клик по ревизии → рендер справа
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
* highlightChanges={hl}
* onToggleHighlight={setHl}
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
* onlySaved={onlySaved}
* onToggleOnlySaved={setOnlySaved}
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
* />
*
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
*
* ─────────────────────────────────────────────────────────────────────────
* ФОРМАТ ДАННЫХ
* ─────────────────────────────────────────────────────────────────────────
* interface Revision {
* id: string;
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
* author: { name: string } & (
* | { kind: 'human' }
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
* );
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
* }
*
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
*
* ─────────────────────────────────────────────────────────────────────────
* СОСТОЯНИЯ (нарисованы/поддержаны)
* ─────────────────────────────────────────────────────────────────────────
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
* - Пустая история: одна версия / нет ревизий → EmptyState.
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
*/
import { useMemo, useState, useCallback } from 'react';
import {
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
ScrollArea, useMantineTheme, useMantineColorScheme,
} from '@mantine/core';
/* ─────────────────────────── Типы ─────────────────────────── */
export type Author =
| { name: string; kind: 'human' }
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
export interface Revision {
id: string;
at: Date | string;
atLabel: string; // предформатированное «5:35AM»
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
saved: boolean;
author: Author;
isCurrent?: boolean;
}
export interface CalendarDay {
label: string; // «12»
inMonth: boolean;
count: number; // число ревизий за день (для heatmap)
selected?: boolean;
date: Date | string;
}
export interface PageHistoryModalProps {
opened: boolean;
onClose: () => void;
revisions: Revision[];
selectedId: string | null;
onSelect: (id: string) => void;
renderVersion: (rev: Revision | null) => React.ReactNode;
highlightChanges: boolean;
onToggleHighlight: (v: boolean) => void;
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
onlySaved: boolean;
onToggleOnlySaved: (v: boolean) => void;
onRestore: (rev: Revision) => Promise<void>;
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
calendar?: {
monthLabel: string; // «July 2025»
weekdays: string[]; // ['Mon',…,'Sun']
days: CalendarDay[]; // обычно 35/42 ячейки
onPrevMonth: () => void;
onNextMonth: () => void;
onToday: () => void;
onPickDay: (d: CalendarDay) => void;
};
}
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
function userColor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return USER_PALETTE[h % USER_PALETTE.length];
}
const AGENT_GRAD: Record<string, string> = {
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
};
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
/** heatmap: число ревизий → фон/текст ячейки календаря. */
function heat(n: number, dark: boolean) {
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
return { bg: '#4c8dff', fg: '#ffffff' };
}
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const a = rev.author;
const isAgent = a.kind === 'agent';
return (
<Group
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
onClick={onSelect}
style={{
cursor: 'pointer', borderRadius: 7,
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
}}
>
{/* аватар/глиф */}
{isAgent ? (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.role[0]}
</Box>
) : (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.name[0].toUpperCase()}
</Box>
)}
{/* время */}
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
{rev.atLabel}
</Text>
{/* автор + via */}
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
{isAgent ? a.role : a.name}
</Text>
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
</Group>
{/* бейдж — только SAVED */}
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
</Group>
);
}
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
return (
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} mb={6}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}></ActionIcon>
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}></ActionIcon>
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
</Group>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
{cal.weekdays.map((w) => (
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
))}
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
{cal.days.map((d, i) => {
const h = heat(d.count, dark);
return (
<Box
key={i} onClick={() => cal.onPickDay(d)}
style={{
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: d.inMonth ? h.bg : 'transparent',
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
}}
>
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
{d.label}
</Text>
</Box>
);
})}
</Box>
{/* легенда heatmap */}
<Group gap={6} mt={8} align="center">
<Text fz={9.5} c="dimmed">fewer</Text>
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
))}
<Text fz={9.5} c="dimmed">more revisions</Text>
</Group>
</Box>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
export function PageHistoryModal(props: PageHistoryModalProps) {
const {
opened, onClose, revisions, selectedId, onSelect, renderVersion,
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
onRestore, calendar,
} = props;
const [restoring, setRestoring] = useState(false);
const selected = revisions.find((r) => r.id === selectedId) ?? null;
const isEmpty = revisions.length <= 1;
// группировка по dayGroup, с фильтром Only saved
const groups = useMemo(() => {
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
const map: { head: string; items: Revision[] }[] = [];
for (const r of list) {
let g = map.find((x) => x.head === r.dayGroup);
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
g.items.push(r);
}
return map;
}, [revisions, onlySaved]);
const restore = useCallback(async () => {
if (!selected || selected.isCurrent) return;
setRestoring(true);
try { await onRestore(selected); } finally { setRestoring(false); }
}, [selected, onRestore]);
return (
<Modal
opened={opened} onClose={onClose} withCloseButton={false}
size="80rem" radius="lg" padding={0}
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
>
{/* ── single-row toolbar ── */}
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Text fz={16} fw={600}>Page history</Text>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
<Text fz={10.5} c="dimmed">selected version</Text>
</Stack>
<Box style={{ flex: 1 }} />
{/* diff cluster */}
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
Highlight changes
</Button>
{changeNav && (
<>
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
<Box style={{ display: 'flex' }}>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}></ActionIcon>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}></ActionIcon>
</Box>
</>
)}
</Group>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
Restore this version
</Button>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* ── body ── */}
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* left: nav panel */}
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
</Group>
{calendar && <MiniCalendar cal={calendar} />}
<ScrollArea style={{ flex: 1 }}>
{groups.map((g) => (
<Box key={g.head}>
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
{g.head}
</Text>
{g.items.map((r) => (
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
))}
</Box>
))}
</ScrollArea>
</Stack>
{/* right: rendered version */}
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
{isEmpty ? (
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
<Text fw={600} fz={14}>No earlier versions</Text>
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия сравнивать не с чем.</Text>
</Stack>
) : (
<Box p="26px 0" maw={660} mx="auto">
{renderVersion(selected)}
</Box>
)}
</ScrollArea>
</Box>
</Modal>
);
}
export default PageHistoryModal;
@@ -1469,5 +1469,13 @@
"≈ {{minutes}}m": "≈ {{minutes}}m",
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
"{{hours}}h": "{{hours}}h",
"{{minutes}}m": "{{minutes}}m"
"{{minutes}}m": "{{minutes}}m",
"Selected version": "Selected version",
"via": "via",
"of": "of",
"Previous change": "Previous change",
"Next change": "Next change",
"Previous month": "Previous month",
"Next month": "Next month",
"No revisions found for that day": "No revisions found for that day"
}
@@ -77,3 +77,130 @@
flex: 1;
padding: rem(16px) rem(40px);
}
/* #568 — redesigned desktop history window */
.desktopRoot {
display: flex;
flex-direction: column;
height: 78vh;
max-height: rem(760px);
}
.desktopToolbar {
flex: none;
height: rem(56px);
padding: 0 rem(16px);
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.desktopBody {
flex: 1;
display: flex;
min-height: 0;
}
.navPanel {
flex: none;
width: rem(280px);
min-height: 0;
display: flex;
flex-direction: column;
border-right: rem(1px) solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
.navFilterRow {
flex: none;
height: rem(44px);
padding: 0 rem(16px);
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.rightView {
flex: 1;
min-width: 0;
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.dayHeading {
position: sticky;
top: 0;
z-index: 2;
padding: rem(9px) rem(16px) rem(5px);
font-size: rem(10.5px);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--mantine-color-dimmed);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
.revisionRow {
margin: rem(1px) rem(6px);
border-radius: rem(7px);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
}
.revisionRowActive {
background-color: light-dark(
var(--mantine-color-blue-0),
var(--mantine-color-dark-5)
);
@mixin hover {
background-color: light-dark(
var(--mantine-color-blue-0),
var(--mantine-color-dark-5)
);
}
}
/* mini-calendar heatmap */
.calDay {
position: relative;
height: rem(26px);
border-radius: rem(6px);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: rem(10.5px);
font-weight: 500;
}
.calHeat0 {
background: transparent;
}
.calHeat1 {
background: light-dark(#e7f0ff, rgba(34, 139, 230, 0.2));
color: light-dark(#1971c2, #74c0fc);
}
.calHeat2 {
background: light-dark(#a5c8ff, rgba(34, 139, 230, 0.45));
color: light-dark(#0b3d91, #dbeafe);
}
.calHeat3 {
background: #4c8dff;
color: #ffffff;
}
.calDaySelected {
box-shadow: inset 0 0 0 rem(2px)
light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
font-weight: 700;
}
.calDayOutside {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
cursor: default;
}
@@ -16,28 +16,14 @@ import { memo, useCallback } from "react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
// #568 — historyKindMeta moved to a pure module; re-exported here so existing
// importers (history-list, this file) keep their import path unchanged.
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
export { historyKindMeta };
const MAX_VISIBLE_AVATARS = 5;
/**
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
* marks the intentional points (manual / agent); autosaves (boundary / idle /
* legacy null) are non-versions and get dimmed in the list.
*/
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
switch (kind) {
case "manual":
return { labelKey: "Saved", color: "blue", version: true };
case "agent":
return { labelKey: "Agent version", color: "violet", version: true };
case "boundary":
return { labelKey: "Boundary", color: "gray", version: false };
default: // "idle" | null | undefined (legacy autosave)
return { labelKey: "Autosave", color: "gray", version: false };
}
}
interface HistoryItemProps {
historyItem: IPageHistory;
// The previous snapshot for diff/restore is resolved by id from the FULL list
@@ -1,111 +0,0 @@
import {
ActionIcon,
Group,
Paper,
ScrollArea,
Switch,
Text,
} from "@mantine/core";
import HistoryList from "@/features/page-history/components/history-list";
import classes from "./css/history.module.css";
import { useAtom, useAtomValue } from "jotai";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
import { useRef } from "react";
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useDiffNavigation,
useHistoryReset,
} from "@/features/page-history/hooks";
interface Props {
pageId: string;
}
export default function HistoryModalBody({ pageId }: Props) {
const { t } = useTranslation();
const scrollViewportRef = useRef<HTMLDivElement>(null);
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
return (
<div className={classes.sidebarFlex}>
<nav className={classes.sidebar}>
<div className={classes.sidebarMain}>
<HistoryList pageId={pageId} />
</div>
</nav>
<div style={{ position: "relative", flex: 1 }}>
<ScrollArea
h={650}
w="100%"
scrollbarSize={5}
viewportRef={scrollViewportRef}
>
<div className={classes.sidebarRightSection}>
{activeHistoryId && <HistoryView />}
</div>
</ScrollArea>
{activeHistoryId && activeHistoryPrevId && (
<Paper
shadow="md"
radius="xl"
px="md"
py="xs"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
}}
>
<Group gap="md" wrap="nowrap">
<Switch
label={t("Highlight changes")}
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{highlightChanges && diffCounts && diffCounts.total > 0 && (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} of {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
</Paper>
)}
</div>
</div>
);
}
@@ -0,0 +1,228 @@
import {
ActionIcon,
Box,
Button,
Divider,
Group,
ScrollArea,
Stack,
Switch,
Text,
} from "@mantine/core";
import {
IconChevronDown,
IconChevronUp,
IconX,
} from "@tabler/icons-react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
import { usePageHistoryDayCounts } from "@/features/page-history/queries/day-counts-query";
import HistoryView from "@/features/page-history/components/history-view";
import HistoryNavPanel from "@/features/page-history/components/history-nav-panel";
import {
useDiffNavigation,
useHistoryReset,
useHistoryRestore,
} from "@/features/page-history/hooks";
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
import {
dayGroupLabel,
toRevisionRow,
} from "@/features/page-history/utils/revision-row";
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
import classes from "./css/history.module.css";
interface Props {
pageId: string;
onClose: () => void;
}
/**
* #568 — redesigned DESKTOP page-history window: a single-row header (title,
* selected-version label, Highlight-changes toggle + N/M diff navigation, Restore,
* close) over a two-pane body — LEFT navigation panel (calendar heatmap + dense
* revision list), RIGHT the rendered version with change highlighting.
*
* Mechanics are REUSED verbatim: the same list query + atoms + diff engine +
* restore hook as the original body. Only the layout and row density change.
* The mobile branch (history-modal-mobile) is untouched.
*/
export default function HistoryModalDesktop({ pageId, onClose }: Props) {
const { t } = useTranslation();
const tz = useMemo(() => viewerTimezone(), []);
const now = useMemo(() => new Date(), []);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
const [onlyVersions, setOnlyVersions] = useState(false);
useHistoryReset(pageId);
const { canRestore, confirmRestore } = useHistoryRestore();
const { currentChangeIndex, handlePrevChange, handleNextChange } =
useDiffNavigation(scrollViewportRef);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
usePageHistoryListQuery(pageId);
const historyItems = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
// fail-open: heatmap counts degrade to an empty grid on error; the list and
// restore keep working.
const { data: dayCounts } = usePageHistoryDayCounts(pageId);
const counts = useMemo(() => {
const map = new Map<string, number>();
for (const d of dayCounts ?? []) map.set(d.dayISO, d.count);
return map;
}, [dayCounts]);
const handleSelect = useCallback(
(id: string) => {
setActiveHistoryId(id);
// Baseline = true previous snapshot in the FULL flat list (never the
// filtered/grouped neighbour), so diff/restore stay correct under "Only
// versions".
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
},
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
);
useEffect(() => {
if (historyItems.length > 0 && !activeHistoryId) {
setActiveHistoryId(historyItems[0].id);
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
}
}, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]);
const selectedRow = useMemo(() => {
const item = historyItems.find((i) => i.id === activeHistoryId);
return item ? toRevisionRow(item, tz) : null;
}, [historyItems, activeHistoryId, tz]);
const selectedLabel = selectedRow
? `${dayGroupLabel(selectedRow, tz, now, t)} · ${selectedRow.atLabel}`
: "—";
// #568 §C — Restore disabled for the newest snapshot (index 0, globally newest
// even mid-pagination) and when the viewer lacks Manage-Page.
const isNewestSelected =
!!activeHistoryId && historyItems[0]?.id === activeHistoryId;
const restoreDisabled = !canRestore || isNewestSelected || !selectedRow;
const showDiffNav =
highlightChanges && !!diffCounts && diffCounts.total > 0;
return (
<div className={classes.desktopRoot}>
<Group className={classes.desktopToolbar} gap={14} wrap="nowrap">
<Text fz={16} fw={600} style={{ flex: "none" }}>
{t("Page history")}
</Text>
<Divider orientation="vertical" my={14} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fz={12} fw={600} truncate>
{selectedLabel}
</Text>
<Text fz={10.5} c="dimmed">
{t("Selected version")}
</Text>
</Stack>
<Box style={{ flex: 1 }} />
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
<Switch
size="sm"
checked={highlightChanges}
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
label={t("Highlight changes")}
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
/>
{showDiffNav && (
<Group gap={4} wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{currentChangeIndex} {t("of")} {diffCounts.total}
</Text>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Previous change")}
onClick={handlePrevChange}
>
<IconChevronUp size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Next change")}
onClick={handleNextChange}
>
<IconChevronDown size={16} />
</ActionIcon>
</Group>
)}
</Group>
<Divider orientation="vertical" my={14} />
<Button
size="compact-md"
onClick={confirmRestore}
disabled={restoreDisabled}
style={{ flex: "none" }}
>
{t("Restore")}
</Button>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
aria-label={t("Close")}
onClick={onClose}
>
<IconX size={18} />
</ActionIcon>
</Group>
<div className={classes.desktopBody}>
<HistoryNavPanel
fullItems={historyItems}
activeId={activeHistoryId}
onSelect={handleSelect}
fetchNextPage={fetchNextPage}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
counts={counts}
selectedDayISO={selectedRow?.dayISO ?? null}
tz={tz}
onlyVersions={onlyVersions}
setOnlyVersions={setOnlyVersions}
/>
<ScrollArea
className={classes.rightView}
viewportRef={scrollViewportRef}
scrollbarSize={5}
>
<Box p="26px 0" maw={720} mx="auto">
{activeHistoryId && <HistoryView />}
</Box>
</ScrollArea>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
import { Modal, Text } from "@mantine/core";
import { useAtom } from "jotai";
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalDesktop from "@/features/page-history/components/history-modal-desktop";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
@@ -45,6 +45,9 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
);
}
// #568 — the redesigned desktop window carries its OWN single-row header
// (title + selected label + diff nav + Restore + close), so the Modal chrome is
// dropped for desktop and the body renders edge-to-edge.
return (
<Modal.Root
size={1400}
@@ -54,16 +57,11 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>
<Modal.Title>
<Text size="md" fw={500}>
{t("Page history")}
</Text>
</Modal.Title>
<Modal.CloseButton aria-label={t("Close")} />
</Modal.Header>
<Modal.Body>
<HistoryModalBody pageId={pageId} />
<Modal.Body p={0}>
<HistoryModalDesktop
pageId={pageId}
onClose={() => setModalOpen(false)}
/>
</Modal.Body>
</Modal.Content>
</Modal.Root>
@@ -0,0 +1,196 @@
import { Box, Center, Group, Loader, ScrollArea, Switch, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { IPageHistory } from "@/features/page-history/types/page.types";
import {
dayGroupLabel,
groupRevisionsByDay,
isoDayInTz,
toRevisionRow,
} from "@/features/page-history/utils/revision-row";
import RevisionRow from "@/features/page-history/components/revision-row";
import MiniCalendar from "@/features/page-history/components/mini-calendar";
import classes from "./css/history.module.css";
// react-query's fetchNextPage returns the accumulated infinite result; typed
// loosely here to avoid re-importing its generics.
type FetchNextPage = () => Promise<{
data?: { pages: Array<{ items: IPageHistory[] }> };
hasNextPage?: boolean;
}>;
interface Props {
fullItems: IPageHistory[];
activeId: string;
onSelect: (id: string) => void;
onHover?: (id: string) => void;
onHoverEnd?: () => void;
fetchNextPage: FetchNextPage;
hasNextPage: boolean;
isFetchingNextPage: boolean;
counts: Map<string, number>;
selectedDayISO: string | null;
tz: string;
onlyVersions: boolean;
setOnlyVersions: (v: boolean) => void;
}
/**
* #568 — left navigation panel: "Only versions" filter + mini-calendar heatmap +
* the DENSE, day-grouped revision list. The grouping is presentational only —
* selection still resolves the previous snapshot from the FULL flat list (done
* in the parent), so diff/restore never target a filtered neighbour.
*/
export default function HistoryNavPanel({
fullItems,
activeId,
onSelect,
onHover,
onHoverEnd,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
counts,
selectedDayISO,
tz,
onlyVersions,
setOnlyVersions,
}: Props) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const groups = useMemo(() => {
const rows = fullItems
.map((item) => toRevisionRow(item, tz))
// "Only versions": same predicate as the badge (manual/agent), so the
// filter and the heatmap describe the same set.
.filter((row) => (onlyVersions ? row.version : true));
return groupRevisionsByDay(rows);
}, [fullItems, tz, onlyVersions]);
// Bottom-sentinel auto-load (mirrors history-list.tsx).
useEffect(() => {
const sentinel = loadMoreRef.current;
if (!sentinel || !hasNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
const scrollToDay = useCallback((dayISO: string): boolean => {
const viewport = viewportRef.current;
if (!viewport) return false;
// ISO 'YYYY-MM-DD' is attribute-selector safe (digits + hyphens).
const el = viewport.querySelector(`[data-day="${dayISO}"]`);
if (el instanceof HTMLElement) {
viewport.scrollTo({ top: Math.max(el.offsetTop - 8, 0), behavior: "smooth" });
return true;
}
return false;
}, []);
const handlePickDay = useCallback(
async (targetDayISO: string) => {
if (scrollToDay(targetDayISO)) return;
// Not in the loaded pages yet — the list is newest-first, so pull pages
// until the OLDEST loaded day is on/older than the target (a correct
// dayISO boundary, NOT a fixed page count), or pagination is exhausted.
let items = fullItems;
let more = hasNextPage;
let guard = 0;
while (more && guard < 200) {
const last = items[items.length - 1];
const oldestDay = last
? isoDayInTz(new Date(last.createdAt), tz)
: null;
if (oldestDay && oldestDay <= targetDayISO) break;
const res = await fetchNextPage();
items = res.data?.pages.flatMap((p) => p.items) ?? items;
more = res.hasNextPage ?? false;
guard++;
}
// Let React paint the newly-loaded rows before scrolling to the anchor.
requestAnimationFrame(() => {
if (!scrollToDay(targetDayISO)) {
// Unreachable day (exhausted pagination): explicit no-op + toast, never
// a silent hang.
notifications.show({
message: t("No revisions found for that day"),
color: "gray",
});
}
});
},
[scrollToDay, fullItems, hasNextPage, fetchNextPage, tz, t],
);
const now = useMemo(() => new Date(), []);
return (
<Box className={classes.navPanel}>
<Group className={classes.navFilterRow} justify="space-between" wrap="nowrap">
<Switch
size="xs"
checked={onlyVersions}
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
label={t("Only versions")}
/>
</Group>
<MiniCalendar
counts={counts}
selectedDayISO={selectedDayISO}
onPickDay={handlePickDay}
tz={tz}
/>
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} scrollbarSize={5}>
{onlyVersions && groups.length === 0 && (
<Center py="md">
<Text size="sm" c="dimmed">
{t("No saved versions yet.")}
</Text>
</Center>
)}
{groups.map((group) => (
<Box key={group.dayISO}>
<Text
className={classes.dayHeading}
data-day={group.dayISO}
data-testid="day-heading"
>
{dayGroupLabel(group, tz, now, t)}
</Text>
{group.rows.map((row) => (
<RevisionRow
key={row.id}
row={row}
selected={row.id === activeId}
onSelect={onSelect}
onHover={onHover}
onHoverEnd={onHoverEnd}
/>
))}
</Box>
))}
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
{isFetchingNextPage && (
<Center py="sm">
<Loader size="sm" />
</Center>
)}
</ScrollArea>
</Box>
);
}
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import MiniCalendar from "./mini-calendar";
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const TZ = "UTC";
function renderCal(counts: Map<string, number>, onPickDay = vi.fn()) {
const todayISO = isoDayInTz(new Date(), TZ);
return {
todayISO,
onPickDay,
...render(
<MantineProvider>
<MiniCalendar
counts={counts}
selectedDayISO={todayISO}
onPickDay={onPickDay}
tz={TZ}
/>
</MantineProvider>,
),
};
}
describe("MiniCalendar (#568 heatmap)", () => {
it("renders a 6x7 grid and highlights the selected day", () => {
const { todayISO } = renderCal(new Map());
const cells = screen.getAllByTestId("calendar-day");
expect(cells).toHaveLength(42);
const today = cells.find((c) => c.getAttribute("data-day") === todayISO)!;
expect(today.className).toContain("calDaySelected");
});
it("applies a heat class scaled to the day's revision count", () => {
const day = isoDayInTz(new Date(), TZ);
const { todayISO } = renderCal(new Map([[day, 7]]));
const today = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === todayISO)!;
// 7 revisions → top tier (calHeat3).
expect(today.className).toContain("calHeat3");
});
it("picking an in-month day delegates the dayISO to onPickDay", () => {
const { todayISO, onPickDay } = renderCal(new Map());
const today = screen
.getAllByTestId("calendar-day")
.find((c) => c.getAttribute("data-day") === todayISO)!;
fireEvent.click(today);
expect(onPickDay).toHaveBeenCalledWith(todayISO);
});
});
@@ -0,0 +1,177 @@
import { ActionIcon, Box, Button, Group, Text } from "@mantine/core";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
heatLevel,
isoDayInTz,
} from "@/features/page-history/utils/revision-row";
import classes from "./css/history.module.css";
import clsx from "clsx";
interface CalendarCell {
date: Date;
dayISO: string;
label: string;
inMonth: boolean;
}
/** Build a Monday-first 6×7 grid for `viewYear`/`viewMonth` (0-based month).
* Cells are dated at local noon so `isoDayInTz` never straddles a DST midnight. */
function buildGrid(
viewYear: number,
viewMonth: number,
tz: string,
): CalendarCell[] {
const first = new Date(viewYear, viewMonth, 1, 12);
// JS getDay(): 0=Sun..6=Sat → shift to Monday-first offset.
const offset = (first.getDay() + 6) % 7;
const cells: CalendarCell[] = [];
for (let i = 0; i < 42; i++) {
const date = new Date(viewYear, viewMonth, 1 - offset + i, 12);
cells.push({
date,
dayISO: isoDayInTz(date, tz),
label: String(date.getDate()),
inMonth: date.getMonth() === viewMonth,
});
}
return cells;
}
interface Props {
/** dayISO → version-revision count (heatmap intensity). */
counts: Map<string, number>;
selectedDayISO: string | null;
onPickDay: (dayISO: string) => void;
tz: string;
}
/**
* #568 — mini-calendar heatmap + date-jumper. Cell intensity = revisions that
* day (`counts`); month navigation is purely client-side (whole history already
* loaded). Picking a day delegates to `onPickDay(dayISO)` which scrolls/loads the
* dense list to that day. Empty `counts` (fail-open) simply renders a blank grid.
*/
export default function MiniCalendar({
counts,
selectedDayISO,
onPickDay,
tz,
}: Props) {
const { t } = useTranslation();
const now = new Date();
const [view, setView] = useState({
year: now.getFullYear(),
month: now.getMonth(),
});
const cells = useMemo(
() => buildGrid(view.year, view.month, tz),
[view.year, view.month, tz],
);
const monthLabel = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(new Date(view.year, view.month, 1)),
[view.year, view.month],
);
const weekdays = useMemo(() => {
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
// 2024-01-01 is a Monday — build a Monday-first header.
return Array.from({ length: 7 }, (_, i) =>
fmt.format(new Date(2024, 0, 1 + i)),
);
}, []);
const step = (delta: number) =>
setView((v) => {
const m = v.month + delta;
return {
year: v.year + Math.floor(m / 12),
month: ((m % 12) + 12) % 12,
};
});
return (
<Box p="10px 12px 8px" className={classes.navFilterRow} style={{ height: "auto" }}>
<Group gap={6} mb={6} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Previous month")}
onClick={() => step(-1)}
>
<IconChevronLeft size={16} />
</ActionIcon>
<Text fz={12} fw={600} style={{ flex: 1, textAlign: "center" }}>
{monthLabel}
</Text>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={t("Next month")}
onClick={() => step(1)}
>
<IconChevronRight size={16} />
</ActionIcon>
<Button
variant="subtle"
size="compact-xs"
onClick={() =>
setView({ year: now.getFullYear(), month: now.getMonth() })
}
>
{t("Today")}
</Button>
</Group>
<Box style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
{weekdays.map((w, i) => (
<Text key={i} ta="center" fz={9} fw={600} c="dimmed">
{w}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7,1fr)",
gap: 2,
marginTop: 2,
}}
>
{cells.map((cell) => {
const count = counts.get(cell.dayISO) ?? 0;
const level = cell.inMonth ? heatLevel(count) : 0;
const selected = cell.inMonth && cell.dayISO === selectedDayISO;
return (
<Box
key={cell.dayISO}
data-testid="calendar-day"
data-day={cell.dayISO}
onClick={() => cell.inMonth && onPickDay(cell.dayISO)}
className={clsx(
classes.calDay,
classes[`calHeat${level}` as keyof typeof classes],
{
[classes.calDaySelected]: selected,
[classes.calDayOutside]: !cell.inMonth,
},
)}
>
{cell.label}
</Box>
);
})}
</Box>
</Box>
);
}
@@ -0,0 +1,87 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import RevisionRow from "./revision-row";
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// react-i18next with no initialized instance returns the key as the label, so
// t("Saved") renders "Saved" and t("via") renders "via".
function renderRow(row: RevisionRowData, onSelect = vi.fn()) {
return {
onSelect,
...render(
<MantineProvider>
<RevisionRow row={row} selected={false} onSelect={onSelect} />
</MantineProvider>,
),
};
}
const base: RevisionRowData = {
id: "1",
ts: 0,
atLabel: "5:35 AM",
dayISO: "2026-07-12",
isAgent: false,
authorName: "Alice",
authorAvatarUrl: "",
saved: false,
version: true,
};
describe("RevisionRow (#568 dense row)", () => {
it("human manual version: avatar + name + time + SAVED badge", () => {
renderRow({ ...base, saved: true });
expect(screen.getByText("5:35 AM")).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
// SAVED badge only when saved (kind === manual).
expect(screen.getByText("Saved")).toBeDefined();
expect(screen.queryByTestId("revision-agent-glyph")).toBeNull();
});
it("autosave (not a version): dimmed, NO SAVED badge", () => {
const { container } = renderRow({ ...base, saved: false, version: false });
expect(screen.queryByText("Saved")).toBeNull();
const rowEl = container.querySelector(
'[data-testid="revision-row"]',
) as HTMLElement;
expect(rowEl.style.opacity).toBe("0.55");
});
it("agent version: square role glyph + 'via <launcher>', NO SAVED badge", () => {
renderRow({
...base,
isAgent: true,
agentName: "Corrector",
agentEmoji: "🛠",
launcherName: "Bob",
saved: false,
});
// Square role glyph carries the emoji.
const glyph = screen.getByTestId("revision-agent-glyph");
expect(glyph.textContent).toBe("🛠");
expect(screen.getByText("Corrector")).toBeDefined();
// "· via Bob" provenance.
expect(screen.getByText(/via/)).toBeDefined();
expect(screen.getByText(/Bob/)).toBeDefined();
// Agent versions are marked by the glyph, not a duplicate SAVED badge.
expect(screen.queryByText("Saved")).toBeNull();
});
it("clicking the row selects it by id", () => {
const onSelect = vi.fn();
const { container } = renderRow(base, onSelect);
fireEvent.click(
container.querySelector('[data-testid="revision-row"]') as HTMLElement,
);
expect(onSelect).toHaveBeenCalledWith("1");
});
it("carries a data-day anchor for jump-to-day scrolling", () => {
const { container } = renderRow(base);
const rowEl = container.querySelector('[data-testid="revision-row"]');
expect(rowEl?.getAttribute("data-day")).toBe("2026-07-12");
});
});
@@ -0,0 +1,115 @@
import { Badge, Box, Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { memo } from "react";
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
import classes from "./css/history.module.css";
import clsx from "clsx";
interface Props {
row: RevisionRowData;
selected: boolean;
onSelect: (id: string) => void;
onHover?: (id: string) => void;
onHoverEnd?: () => void;
}
/**
* #568 — one revision = one dense row: `glyph · time · author · [SAVED]`.
* - Agent identity (`row.isAgent`) → a SQUARE role glyph (emoji or initial) +
* "via <launcher>"; a human → the round CustomAvatar. (Multi-contributor
* stacks are deliberately dropped in the dense list — one glyph per row.)
* - SAVED badge only when `row.saved` (kind === 'manual'); agent versions rely
* on the glyph, autosaves are dimmed (`!row.version`), neither gets a badge.
*/
const RevisionRow = memo(function RevisionRow({
row,
selected,
onSelect,
onHover,
onHoverEnd,
}: Props) {
const { t } = useTranslation();
return (
<Group
gap={8}
wrap="nowrap"
h={30}
px={12}
data-testid="revision-row"
data-day={row.dayISO}
onClick={() => onSelect(row.id)}
onMouseEnter={() => onHover?.(row.id)}
onMouseLeave={onHoverEnd}
className={clsx(classes.revisionRow, {
[classes.revisionRowActive]: selected,
})}
// #370 — dim autosnapshots so intentional versions stand out.
style={{ opacity: row.version ? 1 : 0.55, cursor: "pointer" }}
>
{row.isAgent ? (
<Box
data-testid="revision-agent-glyph"
style={{
flex: "none",
width: 18,
height: 18,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
background: "var(--mantine-color-violet-light)",
color: "var(--mantine-color-violet-filled)",
}}
aria-label={row.agentName}
>
{row.agentEmoji ?? row.agentName?.[0]?.toUpperCase() ?? "A"}
</Box>
) : (
<CustomAvatar
size={18}
avatarUrl={row.authorAvatarUrl}
name={row.authorName}
/>
)}
<Text
fz={12.5}
c="dimmed"
style={{ flex: "none", minWidth: 62 }}
data-testid="revision-time"
>
{row.atLabel}
</Text>
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Text fz={12} fw={row.isAgent ? 600 : 400} truncate>
{row.isAgent ? row.agentName : row.authorName}
</Text>
{row.isAgent && row.launcherName && (
<Text fz={11} c="dimmed" truncate>
· {t("via")} {row.launcherName}
</Text>
)}
</Group>
{row.saved && (
<Badge
size="sm"
radius="sm"
variant="light"
color="blue"
tt="uppercase"
>
{t("Saved")}
</Badge>
)}
</Group>
);
});
export default RevisionRow;
@@ -0,0 +1,29 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
getPageHistoryDayCounts,
IPageHistoryDayCount,
} from "@/features/page-history/services/day-counts-service";
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
const DAY_COUNTS_STALE_TIME = 5 * 60 * 1000;
/**
* #568 — revisions-per-day aggregate for the mini-calendar heatmap. The whole
* history is fetched in one request (no `month` param) so month navigation is
* purely client-side. `tz` is the SAME viewer zone the dense list groups days
* in, so a heatmap cell and its list rows can never drift onto different days.
*
* fail-open: the caller renders an empty grid on error and the list/restore keep
* working (the heatmap is navigational sugar, never a gate).
*/
export function usePageHistoryDayCounts(
pageId: string,
): UseQueryResult<IPageHistoryDayCount[], Error> {
const tz = viewerTimezone();
return useQuery({
queryKey: ["page-history-day-counts", pageId, tz],
queryFn: () => getPageHistoryDayCounts(pageId, tz),
enabled: !!pageId,
staleTime: DAY_COUNTS_STALE_TIME,
});
}
@@ -0,0 +1,20 @@
import api from "@/lib/api-client";
/** #568 — one calendar day of the page-history heatmap: the number of *version*
* revisions (manual/agent) on that day, keyed by 'YYYY-MM-DD' in the viewer tz.
* Same set as the "Only versions" filter, so a lit day always has a list row. */
export interface IPageHistoryDayCount {
dayISO: string;
count: number;
}
export async function getPageHistoryDayCounts(
pageId: string,
tz: string,
): Promise<IPageHistoryDayCount[]> {
const req = await api.post<IPageHistoryDayCount[]>(
"/pages/history/day-counts",
{ pageId, tz },
);
return req.data;
}
@@ -0,0 +1,27 @@
/**
* #370 — map a snapshot's intentionality tier to its badge metadata. `version:
* true` marks the intentional points (manual / agent); autosaves (boundary /
* idle / legacy null) are non-versions and get dimmed in the list.
*
* Extracted from history-item.tsx (#568) into a pure, UI-free module so the dense
* revision-row adapter and the "Only versions" filter reuse the EXACT same
* predicate as the badge — they can never drift when a new kind is added.
*/
export type HistoryKindMeta = {
labelKey: string;
color: string;
version: boolean;
};
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
switch (kind) {
case "manual":
return { labelKey: "Saved", color: "blue", version: true };
case "agent":
return { labelKey: "Agent version", color: "violet", version: true };
case "boundary":
return { labelKey: "Boundary", color: "gray", version: false };
default: // "idle" | null | undefined (legacy autosave)
return { labelKey: "Autosave", color: "gray", version: false };
}
}
@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import {
dayGroupLabel,
groupRevisionsByDay,
heatLevel,
isoDayInTz,
toRevisionRow,
RevisionRowData,
} from "./revision-row";
import { IPageHistory } from "@/features/page-history/types/page.types";
function hist(partial: Partial<IPageHistory>): IPageHistory {
return {
id: "id",
createdAt: "2026-07-12T05:35:00Z",
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
...partial,
} as IPageHistory;
}
describe("toRevisionRow — kind → badge/glyph mapping (#568/#370)", () => {
it("manual → SAVED badge (saved) and a version, human glyph", () => {
const row = toRevisionRow(
hist({ kind: "manual", lastUpdatedSource: "user" }),
"UTC",
);
expect(row.saved).toBe(true);
expect(row.version).toBe(true);
expect(row.isAgent).toBe(false);
expect(row.authorName).toBe("Alice");
});
it("agent → NO saved badge but IS a version, agent glyph + launcher (via)", () => {
const row = toRevisionRow(
hist({
kind: "agent",
lastUpdatedSource: "agent",
agent: { name: "Corrector", emoji: "🛠" },
launcher: { name: "Bob" },
}),
"UTC",
);
// SAVED is only for manual — the agent version relies on its glyph.
expect(row.saved).toBe(false);
expect(row.version).toBe(true);
expect(row.isAgent).toBe(true);
expect(row.agentName).toBe("Corrector");
expect(row.agentEmoji).toBe("🛠");
expect(row.launcherName).toBe("Bob");
});
it("agent AUTOSAVE keeps agent identity (glyph) but is not a version, no badge", () => {
// Two orthogonal signals: identity=agent, intentionality=idle (autosave).
const row = toRevisionRow(
hist({
kind: "idle",
lastUpdatedSource: "agent",
agent: { name: "Factchecker", emoji: null },
launcher: { name: "Bob" },
}),
"UTC",
);
expect(row.isAgent).toBe(true); // identity preserved
expect(row.saved).toBe(false);
expect(row.version).toBe(false); // dimmed autosave
});
it("autosave (null kind, human) → not a version, dimmed, no badge, human glyph", () => {
const row = toRevisionRow(
hist({ kind: null, lastUpdatedSource: "user" }),
"UTC",
);
expect(row.saved).toBe(false);
expect(row.version).toBe(false);
expect(row.isAgent).toBe(false);
});
it("source=agent WITHOUT resolved agent → human glyph (no square glyph)", () => {
const row = toRevisionRow(
hist({ kind: "manual", lastUpdatedSource: "agent", agent: null }),
"UTC",
);
expect(row.isAgent).toBe(false);
});
});
describe("isoDayInTz — stable per-row day key in a tz", () => {
it("formats YYYY-MM-DD and shifts with the tz", () => {
// 02:00 UTC is the previous calendar day in America/New_York (22:00).
const d = new Date("2026-07-04T02:00:00Z");
expect(isoDayInTz(d, "UTC")).toBe("2026-07-04");
expect(isoDayInTz(d, "America/New_York")).toBe("2026-07-03");
});
});
describe("groupRevisionsByDay — contiguous day buckets, order preserved", () => {
it("buckets newest-first rows into day groups", () => {
const rows: RevisionRowData[] = [
{ dayISO: "2026-07-12", ts: 3 } as RevisionRowData,
{ dayISO: "2026-07-12", ts: 2 } as RevisionRowData,
{ dayISO: "2026-07-11", ts: 1 } as RevisionRowData,
];
const groups = groupRevisionsByDay(rows);
expect(groups.map((g) => g.dayISO)).toEqual(["2026-07-12", "2026-07-11"]);
expect(groups[0].rows).toHaveLength(2);
expect(groups[1].rows).toHaveLength(1);
});
});
describe("dayGroupLabel — relative Today/Yesterday else absolute", () => {
const now = new Date("2026-07-12T12:00:00Z");
it("Today / Yesterday are relative and i18n-able", () => {
expect(dayGroupLabel({ dayISO: "2026-07-12", ts: 0 }, "UTC", now)).toBe(
"Today",
);
expect(dayGroupLabel({ dayISO: "2026-07-11", ts: 0 }, "UTC", now)).toBe(
"Yesterday",
);
});
it("older days fall back to an absolute label (not Today/Yesterday)", () => {
const label = dayGroupLabel(
{ dayISO: "2026-07-04", ts: Date.UTC(2026, 6, 4, 12) },
"UTC",
now,
);
expect(label).not.toBe("Today");
expect(label).not.toBe("Yesterday");
expect(label).toMatch(/4/); // day number present
});
});
describe("heatLevel — prototype heat() thresholds", () => {
it("maps counts to 0/1/2/3 tiers", () => {
expect(heatLevel(0)).toBe(0);
expect(heatLevel(1)).toBe(1);
expect(heatLevel(2)).toBe(1);
expect(heatLevel(3)).toBe(2);
expect(heatLevel(4)).toBe(2);
expect(heatLevel(5)).toBe(3);
expect(heatLevel(99)).toBe(3);
});
});
@@ -0,0 +1,137 @@
import { IPageHistory } from "@/features/page-history/types/page.types";
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
/**
* #568 — pure `IPageHistory → dense-row` adapter for the redesigned page-history
* panel. No React / Mantine here so it is unit-tested without mounting.
*
* TWO orthogonal signals are honored (per #370/#300):
* - GLYPH is by AUTHOR IDENTITY: `lastUpdatedSource === 'agent'` with an
* `agent` present → a square role glyph + "via <launcher>"; otherwise the
* human's round avatar. (An agent AUTOSAVE keeps its agent identity.)
* - BADGE is by INTENTIONALITY, simplified to a single SAVED badge: only
* `kind === 'manual'`. Agent versions are distinguished by the glyph (no
* duplicate badge); autosaves are dimmed (`version === false`).
*/
/** 'YYYY-MM-DD' for `date` in `tz` — the stable per-row key used for day
* grouping, jump-to-day and matching heatmap cells. Matches the server's
* `isoDay` format (en-CA yields ISO order), so both sides agree. */
export function isoDayInTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
/** Localized clock label for `date` in `tz` (e.g. "5:35 AM"). */
export function timeLabelInTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat(undefined, {
timeZone: tz,
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export interface RevisionRowData {
id: string;
/** epoch-ms, kept for jump-to-day boundary math. */
ts: number;
atLabel: string;
dayISO: string;
/** author-identity glyph: true → square agent role glyph + "via launcher". */
isAgent: boolean;
agentName?: string;
agentEmoji?: string | null;
launcherName?: string | null;
authorName?: string;
authorAvatarUrl?: string;
/** intentionality badge: SAVED shown only when true (kind === 'manual'). */
saved: boolean;
/** historyKindMeta().version — non-versions (autosaves) are dimmed. */
version: boolean;
}
export function toRevisionRow(item: IPageHistory, tz: string): RevisionRowData {
const date = new Date(item.createdAt);
const isAgent = item.lastUpdatedSource === "agent" && !!item.agent;
return {
id: item.id,
ts: date.getTime(),
atLabel: timeLabelInTz(date, tz),
dayISO: isoDayInTz(date, tz),
isAgent,
agentName: item.agent?.name,
agentEmoji: item.agent?.emoji,
launcherName: item.launcher?.name ?? null,
authorName: item.lastUpdatedBy?.name,
authorAvatarUrl: item.lastUpdatedBy?.avatarUrl,
saved: item.kind === "manual",
version: historyKindMeta(item.kind).version,
};
}
export interface RevisionDayGroup {
dayISO: string;
/** Representative epoch-ms of the group (first row) for label formatting. */
ts: number;
rows: RevisionRowData[];
}
/**
* Group already-ordered (newest-first) rows into contiguous day buckets. The
* grouping is PURELY presentational — diff/restore still resolve the previous
* snapshot from the FULL flat list, never from a group. Rows for one day always
* arrive contiguous because the list is time-ordered, so a single pass suffices.
*/
export function groupRevisionsByDay(
rows: RevisionRowData[],
): RevisionDayGroup[] {
const groups: RevisionDayGroup[] = [];
for (const row of rows) {
const last = groups[groups.length - 1];
if (last && last.dayISO === row.dayISO) {
last.rows.push(row);
} else {
groups.push({ dayISO: row.dayISO, ts: row.ts, rows: [row] });
}
}
return groups;
}
/**
* Relative day-group heading: "Today" / "Yesterday" (i18n via `t`) else an
* absolute "Mon 12 Jul" (locale-formatted, no year to stay compact). `now` and
* `tz` are injected so the function stays pure/testable.
*/
export function dayGroupLabel(
group: Pick<RevisionDayGroup, "dayISO" | "ts">,
tz: string,
now: Date,
t: (key: string) => string = (k) => k,
): string {
const todayISO = isoDayInTz(now, tz);
const yesterdayISO = isoDayInTz(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
tz,
);
if (group.dayISO === todayISO) return t("Today");
if (group.dayISO === yesterdayISO) return t("Yesterday");
return new Intl.DateTimeFormat(undefined, {
timeZone: tz,
weekday: "short",
day: "numeric",
month: "short",
}).format(new Date(group.ts));
}
/** heatmap intensity tier for a day's revision count (thresholds from the
* prototype's `heat()`): 0 none, 1 few (≤2), 2 some (≤4), 3 many. */
export function heatLevel(count: number): 0 | 1 | 2 | 3 {
if (count <= 0) return 0;
if (count <= 2) return 1;
if (count <= 4) return 2;
return 3;
}
+11
View File
@@ -59,6 +59,17 @@ export class PageWorkTimeDto extends PageIdDto {
tz?: string;
}
export class PageHistoryDayCountsDto extends PageIdDto {
// #568 — viewer IANA timezone the revisions-per-day heatmap is bucketed in.
// Optional (falls back to UTC server-side); length-capped so a bogus value
// cannot bloat the request. The value only ever reaches Intl.DateTimeFormat,
// which throws on an unknown zone (caught by the controller → 400).
@IsOptional()
@IsString()
@MaxLength(64)
tz?: string;
}
export class DeletePageDto extends PageIdDto {
@IsOptional()
@IsBoolean()
@@ -111,4 +111,89 @@ describe('PageController', () => {
).rejects.toThrow('db down');
});
});
// #568 — the revisions-per-day heatmap endpoint must be gated exactly like
// /history and /history/time (view-gated, bad tz → 400).
describe('getPageHistoryDayCounts', () => {
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 = {
computeDayCounts:
overrides.compute ?? jest.fn().mockResolvedValue([]),
};
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.getPageHistoryDayCounts({ 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([{ dayISO: '2026-07-04', count: 3 }]);
const { c } = build({ page: { id: 'pg' }, validate, compute });
const out = await c.getPageHistoryDayCounts(
{ pageId: 'pg', tz: 'Europe/Moscow' } as any,
user,
);
expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user);
expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow');
expect(out).toEqual([{ dayISO: '2026-07-04', count: 3 }]);
});
it('propagates a denied view gate and does NOT reach compute (security)', async () => {
const validate = jest.fn().mockRejectedValue(new ForbiddenException());
const compute = jest.fn().mockResolvedValue([]);
const { c, pageHistoryService } = build({
page: { id: 'pg' },
validate,
compute,
});
await expect(
c.getPageHistoryDayCounts({ pageId: 'pg' } as any, user),
).rejects.toBeInstanceOf(ForbiddenException);
expect(pageHistoryService.computeDayCounts).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 });
await expect(
c.getPageHistoryDayCounts({ 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.getPageHistoryDayCounts({ pageId: 'pg' } as any, user),
).rejects.toThrow('db down');
});
});
});
@@ -18,6 +18,7 @@ import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import {
DeletePageDto,
PageHistoryDayCountsDto,
PageHistoryIdDto,
PageIdDto,
PageInfoDto,
@@ -558,6 +559,33 @@ export class PageController {
}
}
@HttpCode(HttpStatus.OK)
@Post('/history/day-counts')
async getPageHistoryDayCounts(
@Body() dto: PageHistoryDayCountsDto,
@AuthUser() user: User,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
// #568 — same view gate as /history and /history/time: never expose a page's
// revision counts (even without content) to a non-viewer.
await this.pageAccessService.validateCanView(page, user);
try {
return await this.pageHistoryService.computeDayCounts(page.id, dto.tz);
} catch (e) {
// Intl.DateTimeFormat throws RangeError on an unknown IANA zone; surface
// it as a 400 rather than a 500 (same contract as /history/time).
if (e instanceof RangeError) {
throw new BadRequestException('Invalid timezone');
}
throw e;
}
}
@HttpCode(HttpStatus.OK)
@Post('/history/info')
async getPageHistoryInfo(
@@ -6,9 +6,11 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
import {
computeWorkTime,
bucketByDay,
countRevisionsByDay,
DEFAULT_WORK_TIME_CONFIG,
WorkTimeConfig,
PerDay,
DayCount,
} from '../work-time';
export interface PageWorkTime {
@@ -69,4 +71,20 @@ export class PageHistoryService {
tz,
};
}
/**
* #568 — "revisions per day" aggregate for the page-history mini-calendar
* heatmap. Reads only the cheap timeline projection (no `content`) — the same
* source as computeWorkTime — and tallies VERSION rows (manual/agent) into the
* viewer's calendar days, reusing the shared tz core. Whole history in one
* request (no `month` param) so month navigation is purely client-side and the
* counts always match the "Only versions" list.
*
* `tz` is the viewer's IANA zone; an unknown zone makes the Intl-backed core
* throw a RangeError, which the controller maps to a 400 (like /history/time).
*/
async computeDayCounts(pageId: string, tz = 'UTC'): Promise<DayCount[]> {
const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId);
return countRevisionsByDay(rows, tz);
}
}
@@ -100,7 +100,7 @@ function nextDayStart(dayStart: number, tz: string): number {
return zonedDayStart(dayStart + 26 * 60 * 60 * 1000, tz);
}
function isoDay(dayStart: number, tz: string): string {
export 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)}`;
@@ -0,0 +1,70 @@
import { countRevisionsByDay } from './day-counts';
import { TimelineSample } from './work-time.types';
function row(createdAt: string, kind: string | null): TimelineSample {
return {
createdAt,
kind,
lastUpdatedById: 'h',
lastUpdatedSource: 'user',
lastUpdatedAiChatId: null,
};
}
describe('countRevisionsByDay (#568 heatmap aggregate)', () => {
it('empty input → no days', () => {
expect(countRevisionsByDay([], 'UTC')).toEqual([]);
});
it('counts only VERSION rows (manual/agent); ignores autosnapshots', () => {
const rows: TimelineSample[] = [
row('2026-07-04T10:00:00Z', 'manual'),
row('2026-07-04T11:00:00Z', 'agent'),
row('2026-07-04T12:00:00Z', 'idle'), // ignored
row('2026-07-04T13:00:00Z', 'boundary'), // ignored
row('2026-07-04T14:00:00Z', null), // legacy autosave, ignored
];
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
{ dayISO: '2026-07-04', count: 2 },
]);
});
it('groups by calendar day and returns days ascending', () => {
const rows: TimelineSample[] = [
row('2026-07-06T09:00:00Z', 'manual'),
row('2026-07-04T09:00:00Z', 'manual'),
row('2026-07-04T20:00:00Z', 'agent'),
row('2026-07-05T01:00:00Z', 'manual'),
];
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
{ dayISO: '2026-07-04', count: 2 },
{ dayISO: '2026-07-05', count: 1 },
{ dayISO: '2026-07-06', count: 1 },
]);
});
it('buckets in the requested tz — the same instant can fall on a different day', () => {
// 2026-07-04T02:00:00Z is still 2026-07-03 (22:00) in America/New_York.
const rows: TimelineSample[] = [row('2026-07-04T02:00:00Z', 'manual')];
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
{ dayISO: '2026-07-04', count: 1 },
]);
expect(countRevisionsByDay(rows, 'America/New_York')).toEqual([
{ dayISO: '2026-07-03', count: 1 },
]);
});
it('throws a RangeError on an unknown tz (mapped to 400 by the controller)', () => {
const rows: TimelineSample[] = [row('2026-07-04T10:00:00Z', 'manual')];
expect(() => countRevisionsByDay(rows, 'Mars/Phobos')).toThrow(RangeError);
});
it('tolerates a Date instance for createdAt (driver may hand back Date)', () => {
const rows = [
{ ...row('x', 'manual'), createdAt: new Date('2026-07-04T10:00:00Z') },
];
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
{ dayISO: '2026-07-04', count: 1 },
]);
});
});
@@ -0,0 +1,42 @@
import { TimelineSample } from './work-time.types';
import { zonedDayStart, isoDay } from './bucket-by-day';
/** One calendar day of the page-history heatmap (#568): the number of
* *version* revisions (kind ∈ {manual, agent}) that landed on that day. */
export interface DayCount {
/** 'YYYY-MM-DD' in the requested tz — the same stable key the client uses to
* group the dense revision list, so a heatmap cell always maps to a list row. */
dayISO: string;
count: number;
}
/**
* #568 — pure, tz-aware "revisions per day" bucketer for the mini-calendar
* heatmap. Reuses the already-tested tz core (`zonedDayStart` + `isoDay` from
* bucket-by-day.ts) so DST/day boundaries stay identical to the work-time
* punch-card — no copy-pasted date math.
*
* Only VERSION rows are counted (`kind === 'manual' | 'agent'`): the heatmap and
* the client's "Only versions" filter then describe the same set, so every lit
* day has a visible target in the list (issue criterion 6). Autosnapshots
* (idle/boundary/legacy null) are ignored.
*
* Returns days ascending by `dayISO`; days with no version revision are omitted
* (the client renders a full month grid and treats a missing day as count 0).
*/
export function countRevisionsByDay(
rows: ReadonlyArray<Pick<TimelineSample, 'createdAt' | 'kind'>>,
tz: string,
): DayCount[] {
const tally = new Map<string, number>();
for (const row of rows) {
if (row.kind !== 'manual' && row.kind !== 'agent') continue;
const ms = new Date(row.createdAt).getTime();
if (Number.isNaN(ms)) continue;
const key = isoDay(zonedDayStart(ms, tz), tz);
tally.set(key, (tally.get(key) ?? 0) + 1);
}
return [...tally.entries()]
.map(([dayISO, count]) => ({ dayISO, count }))
.sort((a, b) => (a.dayISO < b.dayISO ? -1 : a.dayISO > b.dayISO ? 1 : 0));
}
+3 -1
View File
@@ -1,5 +1,7 @@
export { computeWorkTime } from './compute-work-time';
export { bucketByDay, zonedDayStart } from './bucket-by-day';
export { bucketByDay, zonedDayStart, isoDay } from './bucket-by-day';
export { countRevisionsByDay } from './day-counts';
export type { DayCount } from './day-counts';
export {
DEFAULT_WORK_TIME_CONFIG,
resolveWorkTimeConfig,