From 642f50e9df0c3fa38bc511bc5542bfa12f3ba21a Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 20:02:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(page-history):=20=D1=80=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=20=D0=B4=D0=B5=D1=81=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D1=82=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BE=D0=BA=20+=20=D0=BC=D0=B8=D0=BD=D0=B8-=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8C=20heatmap=20(#568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Натягиваю прототип 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 », иначе круглый аватар), бейдж 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) --- NewDesign/PageHistoryModal.tsx | 362 ------------------ .../public/locales/en-US/translation.json | 10 +- .../components/css/history.module.css | 127 ++++++ .../page-history/components/history-item.tsx | 24 +- .../components/history-modal-body.tsx | 111 ------ .../components/history-modal-desktop.tsx | 228 +++++++++++ .../page-history/components/history-modal.tsx | 20 +- .../components/history-nav-panel.tsx | 196 ++++++++++ .../components/mini-calendar.test.tsx | 56 +++ .../page-history/components/mini-calendar.tsx | 177 +++++++++ .../components/revision-row.test.tsx | 87 +++++ .../page-history/components/revision-row.tsx | 115 ++++++ .../page-history/queries/day-counts-query.ts | 29 ++ .../services/day-counts-service.ts | 20 + .../page-history/utils/history-kind-meta.ts | 27 ++ .../page-history/utils/revision-row.test.ts | 142 +++++++ .../page-history/utils/revision-row.ts | 137 +++++++ apps/server/src/core/page/dto/page.dto.ts | 11 + .../src/core/page/page.controller.spec.ts | 85 ++++ apps/server/src/core/page/page.controller.ts | 28 ++ .../page/services/page-history.service.ts | 18 + .../src/core/page/work-time/bucket-by-day.ts | 2 +- .../core/page/work-time/day-counts.spec.ts | 70 ++++ .../src/core/page/work-time/day-counts.ts | 42 ++ apps/server/src/core/page/work-time/index.ts | 4 +- 25 files changed, 1622 insertions(+), 506 deletions(-) delete mode 100644 NewDesign/PageHistoryModal.tsx delete mode 100644 apps/client/src/features/page-history/components/history-modal-body.tsx create mode 100644 apps/client/src/features/page-history/components/history-modal-desktop.tsx create mode 100644 apps/client/src/features/page-history/components/history-nav-panel.tsx create mode 100644 apps/client/src/features/page-history/components/mini-calendar.test.tsx create mode 100644 apps/client/src/features/page-history/components/mini-calendar.tsx create mode 100644 apps/client/src/features/page-history/components/revision-row.test.tsx create mode 100644 apps/client/src/features/page-history/components/revision-row.tsx create mode 100644 apps/client/src/features/page-history/queries/day-counts-query.ts create mode 100644 apps/client/src/features/page-history/services/day-counts-service.ts create mode 100644 apps/client/src/features/page-history/utils/history-kind-meta.ts create mode 100644 apps/client/src/features/page-history/utils/revision-row.test.ts create mode 100644 apps/client/src/features/page-history/utils/revision-row.ts create mode 100644 apps/server/src/core/page/work-time/day-counts.spec.ts create mode 100644 apps/server/src/core/page/work-time/day-counts.ts diff --git a/NewDesign/PageHistoryModal.tsx b/NewDesign/PageHistoryModal.tsx deleted file mode 100644 index 9be7c386..00000000 --- a/NewDesign/PageHistoryModal.tsx +++ /dev/null @@ -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'; - * - * setOpen(false)} - * revisions={revisions} // Revision[] - * selectedId={selId} - * onSelect={setSelId} // клик по ревизии → рендер справа - * renderVersion={(rev) => } // ваш рендер страницы - * 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; - /** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */ - 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 = { - 'Корректор': '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 ( - - {/* аватар/глиф */} - {isAgent ? ( - - {a.role[0]} - - ) : ( - - {a.name[0].toUpperCase()} - - )} - {/* время */} - - {rev.atLabel} - - {/* автор + via */} - - - {isAgent ? a.role : a.name} - - {isAgent && · via {a.triggeredBy}} - - {/* бейдж — только SAVED */} - {rev.saved && SAVED} - - ); -} - -/* ─────────────────────────── Мини-календарь ─────────────────────────── */ - -function MiniCalendar({ cal }: { cal: NonNullable }) { - const { colorScheme } = useMantineColorScheme(); - const dark = colorScheme === 'dark'; - return ( - ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - - - {cal.monthLabel} - - - - - - {cal.weekdays.map((w) => ( - {w} - ))} - - - {cal.days.map((d, i) => { - const h = heat(d.count, dark); - return ( - 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, - }} - > - - {d.label} - - - ); - })} - - {/* легенда heatmap */} - - fewer - {['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => ( - - ))} - more revisions - - - ); -} - -/* ─────────────────────────── Окно ─────────────────────────── */ - -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 ( - - {/* ── single-row toolbar ── */} - ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - Page history - ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} /> - - {selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'} - selected version - - - - - {/* diff cluster */} - ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}> - - {changeNav && ( - <> - {changeNav.index} / {changeNav.total} - - - - - - )} - - - ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} /> - - - - - - {/* ── body ── */} - - {/* left: nav panel */} - ({ 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' })}> - ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} /> - - - {calendar && } - - - {groups.map((g) => ( - - - {g.head} - - {g.items.map((r) => ( - onSelect(r.id)} /> - ))} - - ))} - - - - {/* right: rendered version */} - - {isEmpty ? ( - - No earlier versions - У страницы пока одна версия — сравнивать не с чем. - - ) : ( - - {renderVersion(selected)} - - )} - - - - ); -} - -export default PageHistoryModal; diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0b90b2c7..1f1acd09 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1470,5 +1470,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" } diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css index a4be3819..a64411b7 100644 --- a/apps/client/src/features/page-history/components/css/history.module.css +++ b/apps/client/src/features/page-history/components/css/history.module.css @@ -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; +} diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index aa76b4f2..27efb726 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -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 diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx deleted file mode 100644 index 5673c82a..00000000 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ /dev/null @@ -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(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 ( -
- - -
- -
- {activeHistoryId && } -
-
- - {activeHistoryId && activeHistoryPrevId && ( - - - setHighlightChanges(e.currentTarget.checked)} - styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} - /> - {highlightChanges && diffCounts && diffCounts.total > 0 && ( - - - {currentChangeIndex} of {diffCounts.total} - - - - - - - - - )} - - - )} -
-
- ); -} diff --git a/apps/client/src/features/page-history/components/history-modal-desktop.tsx b/apps/client/src/features/page-history/components/history-modal-desktop.tsx new file mode 100644 index 00000000..591a3a12 --- /dev/null +++ b/apps/client/src/features/page-history/components/history-modal-desktop.tsx @@ -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(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(); + 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 ( +
+ + + {t("Page history")} + + + + + {selectedLabel} + + + {t("Selected version")} + + + + + + + setHighlightChanges(e.currentTarget.checked)} + label={t("Highlight changes")} + styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }} + /> + {showDiffNav && ( + + + {currentChangeIndex} {t("of")} {diffCounts.total} + + + + + + + + + )} + + + + + + + + + + +
+ + + + + {activeHistoryId && } + + +
+
+ ); +} diff --git a/apps/client/src/features/page-history/components/history-modal.tsx b/apps/client/src/features/page-history/components/history-modal.tsx index 05768638..3e48ffef 100644 --- a/apps/client/src/features/page-history/components/history-modal.tsx +++ b/apps/client/src/features/page-history/components/history-modal.tsx @@ -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 ( - - - - {t("Page history")} - - - - - - + + setModalOpen(false)} + /> diff --git a/apps/client/src/features/page-history/components/history-nav-panel.tsx b/apps/client/src/features/page-history/components/history-nav-panel.tsx new file mode 100644 index 00000000..01f81b7b --- /dev/null +++ b/apps/client/src/features/page-history/components/history-nav-panel.tsx @@ -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; + 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(null); + const loadMoreRef = useRef(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 ( + + + setOnlyVersions(e.currentTarget.checked)} + label={t("Only versions")} + /> + + + + + + {onlyVersions && groups.length === 0 && ( +
+ + {t("No saved versions yet.")} + +
+ )} + {groups.map((group) => ( + + + {dayGroupLabel(group, tz, now, t)} + + {group.rows.map((row) => ( + + ))} + + ))} + {hasNextPage &&
} + {isFetchingNextPage && ( +
+ +
+ )} + + + ); +} diff --git a/apps/client/src/features/page-history/components/mini-calendar.test.tsx b/apps/client/src/features/page-history/components/mini-calendar.test.tsx new file mode 100644 index 00000000..00f9b4e3 --- /dev/null +++ b/apps/client/src/features/page-history/components/mini-calendar.test.tsx @@ -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, onPickDay = vi.fn()) { + const todayISO = isoDayInTz(new Date(), TZ); + return { + todayISO, + onPickDay, + ...render( + + + , + ), + }; +} + +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); + }); +}); diff --git a/apps/client/src/features/page-history/components/mini-calendar.tsx b/apps/client/src/features/page-history/components/mini-calendar.tsx new file mode 100644 index 00000000..c9e29a18 --- /dev/null +++ b/apps/client/src/features/page-history/components/mini-calendar.tsx @@ -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; + 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 ( + + + step(-1)} + > + + + + {monthLabel} + + step(1)} + > + + + + + + + {weekdays.map((w, i) => ( + + {w} + + ))} + + + + {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 ( + 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} + + ); + })} + + + ); +} diff --git a/apps/client/src/features/page-history/components/revision-row.test.tsx b/apps/client/src/features/page-history/components/revision-row.test.tsx new file mode 100644 index 00000000..93b7aba3 --- /dev/null +++ b/apps/client/src/features/page-history/components/revision-row.test.tsx @@ -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( + + + , + ), + }; +} + +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 ', 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"); + }); +}); diff --git a/apps/client/src/features/page-history/components/revision-row.tsx b/apps/client/src/features/page-history/components/revision-row.tsx new file mode 100644 index 00000000..712d3aaf --- /dev/null +++ b/apps/client/src/features/page-history/components/revision-row.tsx @@ -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 "; 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 ( + 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 ? ( + + {row.agentEmoji ?? row.agentName?.[0]?.toUpperCase() ?? "A"} + + ) : ( + + )} + + + {row.atLabel} + + + + + {row.isAgent ? row.agentName : row.authorName} + + {row.isAgent && row.launcherName && ( + + · {t("via")} {row.launcherName} + + )} + + + {row.saved && ( + + {t("Saved")} + + )} + + ); +}); + +export default RevisionRow; diff --git a/apps/client/src/features/page-history/queries/day-counts-query.ts b/apps/client/src/features/page-history/queries/day-counts-query.ts new file mode 100644 index 00000000..afa94a75 --- /dev/null +++ b/apps/client/src/features/page-history/queries/day-counts-query.ts @@ -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 { + const tz = viewerTimezone(); + return useQuery({ + queryKey: ["page-history-day-counts", pageId, tz], + queryFn: () => getPageHistoryDayCounts(pageId, tz), + enabled: !!pageId, + staleTime: DAY_COUNTS_STALE_TIME, + }); +} diff --git a/apps/client/src/features/page-history/services/day-counts-service.ts b/apps/client/src/features/page-history/services/day-counts-service.ts new file mode 100644 index 00000000..963864a0 --- /dev/null +++ b/apps/client/src/features/page-history/services/day-counts-service.ts @@ -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 { + const req = await api.post( + "/pages/history/day-counts", + { pageId, tz }, + ); + return req.data; +} diff --git a/apps/client/src/features/page-history/utils/history-kind-meta.ts b/apps/client/src/features/page-history/utils/history-kind-meta.ts new file mode 100644 index 00000000..80809ea7 --- /dev/null +++ b/apps/client/src/features/page-history/utils/history-kind-meta.ts @@ -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 }; + } +} diff --git a/apps/client/src/features/page-history/utils/revision-row.test.ts b/apps/client/src/features/page-history/utils/revision-row.test.ts new file mode 100644 index 00000000..b55bf00d --- /dev/null +++ b/apps/client/src/features/page-history/utils/revision-row.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/client/src/features/page-history/utils/revision-row.ts b/apps/client/src/features/page-history/utils/revision-row.ts new file mode 100644 index 00000000..0b19d9c3 --- /dev/null +++ b/apps/client/src/features/page-history/utils/revision-row.ts @@ -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 "; 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, + 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; +} diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts index 4153de11..8224f4da 100644 --- a/apps/server/src/core/page/dto/page.dto.ts +++ b/apps/server/src/core/page/dto/page.dto.ts @@ -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() diff --git a/apps/server/src/core/page/page.controller.spec.ts b/apps/server/src/core/page/page.controller.spec.ts index 59d595ac..b3df4ec7 100644 --- a/apps/server/src/core/page/page.controller.spec.ts +++ b/apps/server/src/core/page/page.controller.spec.ts @@ -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'); + }); + }); }); diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 2e207ca5..59017354 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -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( diff --git a/apps/server/src/core/page/services/page-history.service.ts b/apps/server/src/core/page/services/page-history.service.ts index 66518987..2554f063 100644 --- a/apps/server/src/core/page/services/page-history.service.ts +++ b/apps/server/src/core/page/services/page-history.service.ts @@ -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 { + const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId); + return countRevisionsByDay(rows, tz); + } } diff --git a/apps/server/src/core/page/work-time/bucket-by-day.ts b/apps/server/src/core/page/work-time/bucket-by-day.ts index d52758f7..c0e8f1c2 100644 --- a/apps/server/src/core/page/work-time/bucket-by-day.ts +++ b/apps/server/src/core/page/work-time/bucket-by-day.ts @@ -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)}`; diff --git a/apps/server/src/core/page/work-time/day-counts.spec.ts b/apps/server/src/core/page/work-time/day-counts.spec.ts new file mode 100644 index 00000000..59c821bc --- /dev/null +++ b/apps/server/src/core/page/work-time/day-counts.spec.ts @@ -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 }, + ]); + }); +}); diff --git a/apps/server/src/core/page/work-time/day-counts.ts b/apps/server/src/core/page/work-time/day-counts.ts new file mode 100644 index 00000000..b71bc5ee --- /dev/null +++ b/apps/server/src/core/page/work-time/day-counts.ts @@ -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>, + tz: string, +): DayCount[] { + const tally = new Map(); + 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)); +} diff --git a/apps/server/src/core/page/work-time/index.ts b/apps/server/src/core/page/work-time/index.ts index 62f475e4..b39e9aac 100644 --- a/apps/server/src/core/page/work-time/index.ts +++ b/apps/server/src/core/page/work-time/index.ts @@ -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,