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:
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user