From 737cd67965d50012fef8d1c275521af1ced91b23 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:31:02 +0100 Subject: [PATCH] feat: compare two page versions (#2385) --- .../public/locales/en-US/translation.json | 8 +- .../page-history/atoms/history-atoms.ts | 11 +- .../components/css/history.module.css | 31 ++- .../components/history-editor.tsx | 1 - .../page-history/components/history-item.tsx | 188 +++++++++++++----- .../page-history/components/history-list.tsx | 97 ++++++++- .../components/history-modal-body.tsx | 58 +++++- .../components/history-modal-mobile.tsx | 2 +- .../page-history/components/history-view.tsx | 18 +- .../page-history/hooks/use-history-reset.ts | 29 ++- .../hooks/use-history-restore.tsx | 100 ++++++---- .../queries/page-history-query.ts | 8 + .../utils/resolve-compare-pair.test.ts | 32 +++ .../utils/resolve-compare-pair.ts | 18 ++ 14 files changed, 486 insertions(+), 115 deletions(-) create mode 100644 apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts create mode 100644 apps/client/src/features/page-history/utils/resolve-compare-pair.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index aaa340181..e99f4b761 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1291,5 +1291,11 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Compare", + "Compare versions": "Compare versions", + "Select version from {{date}}": "Select version from {{date}}", + "Version actions for {{date}}": "Version actions for {{date}}", + "Comparing {{newer}} and {{older}}": "Comparing {{newer}} and {{older}}", + "Exit compare": "Exit compare" } diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts index 2acf163d5..76e0d54cc 100644 --- a/apps/client/src/features/page-history/atoms/history-atoms.ts +++ b/apps/client/src/features/page-history/atoms/history-atoms.ts @@ -6,4 +6,13 @@ export const activeHistoryPrevIdAtom = atom(""); export const highlightChangesAtom = atom(true); export type DiffCounts = { added: number; deleted: number; total: number }; -export const diffCountsAtom = atom(null); +export const diffCountsAtom = atom( + null as DiffCounts | null, +); + +export type ComparePair = { newerId: string; olderId: string }; +export const compareModeAtom = atom(false); +export const compareSelectionAtom = atom([]); +export const comparePairAtom = atom( + null as ComparePair | null, +); 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 a4be38194..cd3186323 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 @@ -1,7 +1,7 @@ .history { - display: block; + display: flex; + align-items: center; width: 100%; - padding: var(--mantine-spacing-md); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); @mixin hover { @@ -12,6 +12,28 @@ } } +.historyButton { + flex: 1; + min-width: 0; + color: inherit; +} + +.compareCheckbox { + padding-left: var(--mantine-spacing-xs); +} + +.itemMenu { + opacity: 0; + margin-right: var(--mantine-spacing-xs); +} + +.history:hover .itemMenu, +.history:focus-within .itemMenu, +.history.active .itemMenu, +.itemMenu[aria-expanded="true"] { + opacity: 1; +} + .historyEditor { :global(.ProseMirror) { padding: 0 !important; @@ -77,3 +99,8 @@ flex: 1; padding: rem(16px) rem(40px); } + +.compareBanner { + border-bottom: rem(1px) solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx index c7fa07036..3773f6bcf 100644 --- a/apps/client/src/features/page-history/components/history-editor.tsx +++ b/apps/client/src/features/page-history/components/history-editor.tsx @@ -170,7 +170,6 @@ export function HistoryEditor({ } const total = addedCount + deletedCount; - // @ts-ignore setDiffCounts({ added: addedCount, deleted: deletedCount, total }); editor.setOptions({ 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 cc56b1911..2143fc305 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,10 +1,21 @@ -import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; +import { + Text, + Group, + UnstyledButton, + Avatar, + Tooltip, + ActionIcon, + Checkbox, + Menu, +} from "@mantine/core"; +import { IconDots } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; import classes from "./css/history.module.css"; import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; const MAX_VISIBLE_AVATARS = 5; @@ -15,6 +26,13 @@ interface HistoryItemProps { onHover?: (id: string, index: number) => void; onHoverEnd?: () => void; isActive: boolean; + compareMode: boolean; + isChecked: boolean; + isCheckboxDisabled: boolean; + canCompare: boolean; + onToggleCompare: (id: string) => void; + onStartCompare: (id: string) => void; + onRestore?: (id: string, index: number) => void; } const HistoryItem = memo(function HistoryItem({ @@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({ onHover, onHoverEnd, isActive, + compareMode, + isChecked, + isCheckboxDisabled, + canCompare, + onToggleCompare, + onStartCompare, + onRestore, }: HistoryItemProps) { + const { t } = useTranslation(); + const date = formattedDate(new Date(historyItem.createdAt)); + const handleClick = useCallback(() => { - onSelect(historyItem.id, index); - }, [onSelect, historyItem.id, index]); + if (compareMode) { + onToggleCompare(historyItem.id); + } else { + onSelect(historyItem.id, index); + } + }, [compareMode, onToggleCompare, onSelect, historyItem.id, index]); const handleMouseEnter = useCallback(() => { onHover?.(historyItem.id, index); @@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({ const hasContributors = contributors && contributors.length > 0; return ( - - {formattedDate(new Date(historyItem.createdAt))} + {compareMode && ( + onToggleCompare(historyItem.id)} + aria-label={t("Select version from {{date}}", { date })} + /> + )} - - {hasContributors ? ( - <> - - - {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( - - - - ))} - {contributors.length > MAX_VISIBLE_AVATARS && ( - ( -
{c.name}
+ + {date} + + + {hasContributors ? ( + <> + + + {contributors + .slice(0, MAX_VISIBLE_AVATARS) + .map((contributor) => ( + + + ))} - > - - +{contributors.length - MAX_VISIBLE_AVATARS} - -
- )} -
-
- {contributors.length === 1 && ( + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
{c.name}
+ ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
+ )} + + + {contributors.length === 1 && ( + + {contributors[0].name} + + )} + + ) : ( + <> + - {contributors[0].name} + {historyItem.lastUpdatedBy?.name} + + )} +
+
+ + {!compareMode && ( + + + e.stopPropagation()} + > + + + + + onStartCompare(historyItem.id)} + > + {t("Compare")} + + {onRestore && ( + onRestore(historyItem.id, index)}> + {t("Restore")} + )} - - ) : ( - <> - - - {historyItem.lastUpdatedBy?.name} - - - )} - - + + + )} + ); }); diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 4024901b3..ac761ba82 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + compareModeAtom, + comparePairAtom, + compareSelectionAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; +import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair"; import { useAtom, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { @@ -32,6 +36,9 @@ function HistoryList({ pageId }: Props) { const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); + const [compareMode, setCompareMode] = useAtom(compareModeAtom); + const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom); + const setComparePair = useSetAtom(comparePairAtom); const { data: pageHistoryData, @@ -79,10 +86,58 @@ function HistoryList({ pageId }: Props) { const handleSelect = useCallback( (id: string, index: number) => { + setComparePair(null); setActiveHistoryId(id); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); }, - [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + [historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair], + ); + + const handleToggleCompare = useCallback( + (id: string) => { + setCompareSelection((prev) => { + if (prev.includes(id)) return prev.filter((item) => item !== id); + if (prev.length >= 2) return prev; + return [...prev, id]; + }); + }, + [setCompareSelection], + ); + + const handleStartCompare = useCallback( + (id: string) => { + setComparePair(null); + setCompareMode(true); + setCompareSelection([id]); + }, + [setComparePair, setCompareMode, setCompareSelection], + ); + + const handleCancelCompare = useCallback(() => { + setCompareMode(false); + setCompareSelection([]); + }, [setCompareMode, setCompareSelection]); + + const handleConfirmCompare = useCallback(() => { + const pair = resolveComparePair(historyItems, compareSelection); + if (!pair) return; + setComparePair(pair); + setCompareMode(false); + setCompareSelection([]); + }, [ + historyItems, + compareSelection, + setComparePair, + setCompareMode, + setCompareSelection, + ]); + + const handleRestoreItem = useCallback( + (id: string, index: number) => { + handleSelect(id, index); + confirmRestore(id); + }, + [handleSelect, confirmRestore], ); useEffect(() => { @@ -138,6 +193,16 @@ function HistoryList({ pageId }: Props) { onHover={handleHover} onHoverEnd={clearPrefetchTimeout} isActive={historyItem.id === activeHistoryId} + compareMode={compareMode} + isChecked={compareSelection.includes(historyItem.id)} + isCheckboxDisabled={ + !compareSelection.includes(historyItem.id) && + compareSelection.length >= 2 + } + canCompare={historyItems.length >= 2} + onToggleCompare={handleToggleCompare} + onStartCompare={handleStartCompare} + onRestore={canRestore ? handleRestoreItem : undefined} /> ))} {hasNextPage &&
} @@ -148,22 +213,44 @@ function HistoryList({ pageId }: Props) { )} - {canRestore && ( + {compareMode ? ( <> - + ) : ( + canRestore && ( + <> + + + + + + + ) )}
); 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 index 5673c82a1..ef2820e89 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + CloseButton, Group, Paper, ScrollArea, @@ -12,17 +13,20 @@ import { useAtom, useAtomValue } from "jotai"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + comparePairAtom, diffCountsAtom, highlightChangesAtom, } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; -import { useRef } from "react"; +import { useMemo, useRef } from "react"; import { IconChevronUp, IconChevronDown } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useDiffNavigation, useHistoryReset, } from "@/features/page-history/hooks"; +import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query"; +import { formattedDate } from "@/lib/time"; interface Props { pageId: string; @@ -36,6 +40,28 @@ export default function HistoryModalBody({ pageId }: Props) { const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom); const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom); const diffCounts = useAtomValue(diffCountsAtom); + const [comparePair, setComparePair] = useAtom(comparePairAtom); + + const { data: pageHistoryData } = usePageHistoryListQuery(pageId); + const historyItems = useMemo( + () => pageHistoryData?.pages.flatMap((page) => page.items) ?? [], + [pageHistoryData], + ); + + const compareLabel = useMemo(() => { + if (!comparePair) return null; + const newerItem = historyItems.find( + (item) => item.id === comparePair.newerId, + ); + const olderItem = historyItems.find( + (item) => item.id === comparePair.olderId, + ); + if (!newerItem || !olderItem) return null; + return t("Comparing {{newer}} and {{older}}", { + newer: formattedDate(new Date(newerItem.createdAt)), + older: formattedDate(new Date(olderItem.createdAt)), + }); + }, [comparePair, historyItems, t]); useHistoryReset(pageId); const { currentChangeIndex, handlePrevChange, handleNextChange } = @@ -50,6 +76,25 @@ export default function HistoryModalBody({ pageId }: Props) {
+ {comparePair && ( + + + {compareLabel ?? t("Compare versions")} + + setComparePair(null)} + /> + + )} +
- {activeHistoryId && } + {comparePair ? ( + + ) : ( + activeHistoryId && + )}
- {activeHistoryId && activeHistoryPrevId && ( + {(comparePair || (activeHistoryId && activeHistoryPrevId)) && ( setHistoryModalOpen(false)}> {t("Cancel")} - + )} diff --git a/apps/client/src/features/page-history/components/history-view.tsx b/apps/client/src/features/page-history/components/history-view.tsx index ed8a41f9a..028fa4ac1 100644 --- a/apps/client/src/features/page-history/components/history-view.tsx +++ b/apps/client/src/features/page-history/components/history-view.tsx @@ -7,21 +7,29 @@ import { activeHistoryPrevIdAtom, } from "@/features/page-history/atoms/history-atoms"; -function HistoryView() { +interface Props { + historyId?: string; + prevHistoryId?: string; +} + +function HistoryView({ historyId, prevHistoryId }: Props) { const { t } = useTranslation(); - const historyId = useAtomValue(activeHistoryIdAtom); - const prevHistoryId = useAtomValue(activeHistoryPrevIdAtom); + const activeId = useAtomValue(activeHistoryIdAtom); + const activePrevId = useAtomValue(activeHistoryPrevIdAtom); + + const resolvedId = historyId ?? activeId; + const resolvedPrevId = prevHistoryId ?? activePrevId; const { data, isLoading: isLoadingCurrent, isError: isErrorCurrent, - } = usePageHistoryQuery(historyId); + } = usePageHistoryQuery(resolvedId); const { data: prevData, isLoading: isLoadingPrev, isError: isErrorPrev, - } = usePageHistoryQuery(prevHistoryId); + } = usePageHistoryQuery(resolvedPrevId); if (isLoadingCurrent || isLoadingPrev) { return <>; diff --git a/apps/client/src/features/page-history/hooks/use-history-reset.ts b/apps/client/src/features/page-history/hooks/use-history-reset.ts index 15ae05874..cda0cbaa1 100644 --- a/apps/client/src/features/page-history/hooks/use-history-reset.ts +++ b/apps/client/src/features/page-history/hooks/use-history-reset.ts @@ -3,22 +3,45 @@ import { useEffect } from "react"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + compareModeAtom, + comparePairAtom, + compareSelectionAtom, diffCountsAtom, } from "@/features/page-history/atoms/history-atoms"; /** * Resets history state when pageId changes. - * Clears active selection and diff counts. + * Clears active selection, diff counts, and compare state. + * Compare state also resets on unmount so reopening the modal starts clean. */ export function useHistoryReset(pageId: string) { const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom); const [, setDiffCounts] = useAtom(diffCountsAtom); + const [, setCompareMode] = useAtom(compareModeAtom); + const [, setCompareSelection] = useAtom(compareSelectionAtom); + const [, setComparePair] = useAtom(comparePairAtom); useEffect(() => { + const resetCompare = () => { + setCompareMode(false); + setCompareSelection([]); + setComparePair(null); + }; + setActiveHistoryId(""); setActiveHistoryPrevId(""); - // @ts-ignore setDiffCounts(null); - }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]); + resetCompare(); + + return resetCompare; + }, [ + pageId, + setActiveHistoryId, + setActiveHistoryPrevId, + setDiffCounts, + setCompareMode, + setCompareSelection, + setComparePair, + ]); } diff --git a/apps/client/src/features/page-history/hooks/use-history-restore.tsx b/apps/client/src/features/page-history/hooks/use-history-restore.tsx index f457c696a..17c4eb983 100644 --- a/apps/client/src/features/page-history/hooks/use-history-restore.tsx +++ b/apps/client/src/features/page-history/hooks/use-history-restore.tsx @@ -1,4 +1,4 @@ -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; @@ -9,7 +9,8 @@ import { activeHistoryIdAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; -import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query"; +import { fetchPageHistory } from "@/features/page-history/queries/page-history-query"; +import { IPageHistory } from "@/features/page-history/types/page.types"; import { pageEditorAtom, titleEditorAtom, @@ -25,8 +26,6 @@ export function useHistoryRestore() { const { t } = useTranslation(); const activeHistoryId = useAtomValue(activeHistoryIdAtom); - const { data: activeHistoryData } = usePageHistoryQuery(activeHistoryId); - const mainEditor = useAtomValue(pageEditorAtom); const mainEditorTitle = useAtomValue(titleEditorAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); @@ -40,47 +39,66 @@ export function useHistoryRestore() { SpaceCaslSubject.Page, ); - const handleRestore = useCallback(() => { - if (!activeHistoryData) return; - if ( - !mainEditor || - mainEditor.isDestroyed || - !mainEditorTitle || - mainEditorTitle.isDestroyed - ) { - return; - } + const handleRestore = useCallback( + async (historyId: string) => { + let historyData: IPageHistory; + try { + historyData = await fetchPageHistory(historyId); + } catch { + notifications.show({ + message: t("Error fetching page data."), + color: "red", + }); + return; + } - mainEditorTitle - .chain() - .clearContent() - .setContent(activeHistoryData.title, { emitUpdate: true }) - .run(); + if ( + !mainEditor || + mainEditor.isDestroyed || + !mainEditorTitle || + mainEditorTitle.isDestroyed + ) { + return; + } - mainEditor - .chain() - .clearContent() - .setContent(activeHistoryData.content) - .run(); + mainEditorTitle + .chain() + .clearContent() + .setContent(historyData.title, { emitUpdate: true }) + .run(); - setHistoryModalOpen(false); - notifications.show({ message: t("Successfully restored") }); - }, [activeHistoryData, mainEditor, mainEditorTitle, setHistoryModalOpen, t]); + mainEditor + .chain() + .clearContent() + .setContent(historyData.content) + .run(); - const confirmRestore = useCallback(() => { - modals.openConfirmModal({ - title: t("Please confirm your action"), - children: ( - - {t( - "Are you sure you want to restore this version? Any changes not versioned will be lost.", - )} - - ), - labels: { confirm: t("Confirm"), cancel: t("Cancel") }, - onConfirm: handleRestore, - }); - }, [t, handleRestore]); + setHistoryModalOpen(false); + notifications.show({ message: t("Successfully restored") }); + }, + [mainEditor, mainEditorTitle, setHistoryModalOpen, t], + ); + + const confirmRestore = useCallback( + (historyId?: string) => { + const targetId = historyId ?? activeHistoryId; + if (!targetId) return; + + modals.openConfirmModal({ + title: t("Please confirm your action"), + children: ( + + {t( + "Are you sure you want to restore this version? Any changes not versioned will be lost.", + )} + + ), + labels: { confirm: t("Confirm"), cancel: t("Cancel") }, + onConfirm: () => handleRestore(targetId), + }); + }, + [t, handleRestore, activeHistoryId], + ); return { canRestore, confirmRestore }; } diff --git a/apps/client/src/features/page-history/queries/page-history-query.ts b/apps/client/src/features/page-history/queries/page-history-query.ts index 0fbfc6c9e..312d8aff7 100644 --- a/apps/client/src/features/page-history/queries/page-history-query.ts +++ b/apps/client/src/features/page-history/queries/page-history-query.ts @@ -23,6 +23,14 @@ export function prefetchPageHistory(historyId: string) { }); } +export function fetchPageHistory(historyId: string): Promise { + return queryClient.fetchQuery({ + queryKey: ["page-history", historyId], + queryFn: () => getPageHistoryById(historyId), + staleTime: HISTORY_STALE_TIME, + }); +} + export function usePageHistoryListQuery( pageId: string, ): UseInfiniteQueryResult, unknown>> { diff --git a/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts b/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts new file mode 100644 index 000000000..449188f02 --- /dev/null +++ b/apps/client/src/features/page-history/utils/resolve-compare-pair.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveComparePair } from "./resolve-compare-pair"; + +// list is newest-first, matching usePageHistoryListQuery order +const items = [{ id: "v3" }, { id: "v2" }, { id: "v1" }]; + +describe("resolveComparePair", () => { + it("orders newer before older regardless of selection order", () => { + expect(resolveComparePair(items, ["v1", "v3"])).toEqual({ + newerId: "v3", + olderId: "v1", + }); + expect(resolveComparePair(items, ["v3", "v1"])).toEqual({ + newerId: "v3", + olderId: "v1", + }); + }); + + it("returns null unless exactly two versions are selected", () => { + expect(resolveComparePair(items, [])).toBeNull(); + expect(resolveComparePair(items, ["v1"])).toBeNull(); + expect(resolveComparePair(items, ["v1", "v2", "v3"])).toBeNull(); + }); + + it("returns null when a selected id is not in the list", () => { + expect(resolveComparePair(items, ["v1", "missing"])).toBeNull(); + }); + + it("returns null when the same id is selected twice", () => { + expect(resolveComparePair(items, ["v2", "v2"])).toBeNull(); + }); +}); diff --git a/apps/client/src/features/page-history/utils/resolve-compare-pair.ts b/apps/client/src/features/page-history/utils/resolve-compare-pair.ts new file mode 100644 index 000000000..d1d914346 --- /dev/null +++ b/apps/client/src/features/page-history/utils/resolve-compare-pair.ts @@ -0,0 +1,18 @@ +import { ComparePair } from "@/features/page-history/atoms/history-atoms"; + +/** + * Resolves which of the two selected versions is newer using their position + * in the history list (list is newest-first: lower index = newer). + */ +export function resolveComparePair( + historyItems: { id: string }[], + selection: string[], +): ComparePair | null { + if (selection.length !== 2) return null; + const indexA = historyItems.findIndex((item) => item.id === selection[0]); + const indexB = historyItems.findIndex((item) => item.id === selection[1]); + if (indexA === -1 || indexB === -1 || indexA === indexB) return null; + return indexA < indexB + ? { newerId: selection[0], olderId: selection[1] } + : { newerId: selection[1], olderId: selection[0] }; +}