Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { DeckSave } from "#/lib/deck";
import { groupSaveHistoryItems, isAutoSaveLabel } from "./SaveHistoryPanel";

function save(id: string, label: string): DeckSave {
return {
cards: [],
id,
label,
savedAt: "2026-05-15T00:00:00.000Z",
};
}

describe("isAutoSaveLabel", () => {
it("matches generated save labels", () => {
expect(isAutoSaveLabel("Save #1")).toBe(true);
expect(isAutoSaveLabel("Save #001")).toBe(true);
});

it("does not match user labels", () => {
expect(isAutoSaveLabel("save #1")).toBe(false);
expect(isAutoSaveLabel("Save #1 edited")).toBe(false);
expect(isAutoSaveLabel("Release Save #1")).toBe(false);
});
});

describe("groupSaveHistoryItems", () => {
it("keeps every save visible when collapse is disabled", () => {
const saves = [save("3", "Save #3"), save("2", "Release A"), save("1", "Save #1")];

expect(groupSaveHistoryItems(saves, false)).toEqual([
{ kind: "save", save: saves[0] },
{ kind: "save", save: saves[1] },
{ kind: "save", save: saves[2] },
]);
});

it("collapses a single unnamed run", () => {
const saves = [save("3", "Save #3"), save("2", "Save #2"), save("1", "Release A")];

expect(groupSaveHistoryItems(saves, true)).toEqual([
{ count: 2, id: "collapsed-3-2", kind: "collapsed" },
{ kind: "save", save: saves[2] },
]);
});

it("collapses separate unnamed runs independently", () => {
const saves = [
save("9", "Save #9"),
save("8", "Save #8"),
save("7", "Release A"),
save("6", "Save #7"),
save("5", "Release B"),
];

expect(groupSaveHistoryItems(saves, true)).toEqual([
{ count: 2, id: "collapsed-9-2", kind: "collapsed" },
{ kind: "save", save: saves[2] },
{ count: 1, id: "collapsed-6-1", kind: "collapsed" },
{ kind: "save", save: saves[4] },
]);
});

it("keeps all named saves visible", () => {
const saves = [save("2", "Release B"), save("1", "Release A")];

expect(groupSaveHistoryItems(saves, true)).toEqual([
{ kind: "save", save: saves[0] },
{ kind: "save", save: saves[1] },
]);
});

it("collapses all unnamed saves into one group", () => {
const saves = [save("3", "Save #3"), save("2", "Save #2"), save("1", "Save #1")];

expect(groupSaveHistoryItems(saves, true)).toEqual([
{ count: 3, id: "collapsed-3-3", kind: "collapsed" },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@ import { Alert } from "#/components/ui/Alert";
import { Button } from "#/components/ui/Button";
import type { DeckItem, DeckSave } from "#/lib/deck";

export type SaveHistoryItem =
| { kind: "save"; save: DeckSave }
| { count: number; id: string; kind: "collapsed" };

export function isAutoSaveLabel(label: string) {
return /^Save #\d+$/.test(label);
}

export function groupSaveHistoryItems(
savesNewestFirst: DeckSave[],
collapseUnnamed: boolean,
): SaveHistoryItem[] {
if (!collapseUnnamed) {
return savesNewestFirst.map((save) => ({ kind: "save", save }));
}

const items: SaveHistoryItem[] = [];
let collapsedRun: DeckSave[] = [];

function flushCollapsedRun() {
if (collapsedRun.length === 0) return;
items.push({
count: collapsedRun.length,
id: `collapsed-${collapsedRun[0].id}-${collapsedRun.length}`,
kind: "collapsed",
});
collapsedRun = [];
}

for (const save of savesNewestFirst) {
if (isAutoSaveLabel(save.label)) {
collapsedRun.push(save);
continue;
}

flushCollapsedRun();
items.push({ kind: "save", save });
}

flushCollapsedRun();
return items;
}

type SaveHistoryPanelProps = {
deck: DeckItem;
onLoadSave: (save: DeckSave) => void;
Expand All @@ -20,11 +63,14 @@ export function SaveHistoryPanel({
onBackToEditor,
}: SaveHistoryPanelProps) {
const [compareMode, setCompareMode] = useState(false);
const [collapseUnnamedSaves, setCollapseUnnamedSaves] = useState(true);
const [pendingLoadSaveId, setPendingLoadSaveId] = useState<string | null>(null);
const [selectedSaves, setSelectedSaves] = useState<string[]>([]);

// Newest first
const saves = [...deck.saves].reverse();
const hasAutoSaves = saves.some((save) => isAutoSaveLabel(save.label));
const historyItems = groupSaveHistoryItems(saves, collapseUnnamedSaves);

function toggleCompareMode() {
setCompareMode(!compareMode);
Expand Down Expand Up @@ -87,6 +133,15 @@ export function SaveHistoryPanel({
</p>
</div>
<div className="flex items-center gap-2">
{hasAutoSaves && (
<Button
onClick={() => setCollapseUnnamedSaves((current) => !current)}
size="sm"
variant="secondary"
>
{collapseUnnamedSaves ? "Show unnamed saves" : "Collapse unnamed saves"}
</Button>
)}
{saves.length >= 2 && (
<Button
onClick={toggleCompareMode}
Expand Down Expand Up @@ -122,7 +177,20 @@ export function SaveHistoryPanel({

{/* Saves list */}
<div className="space-y-2">
{saves.map((save) => {
{historyItems.map((item) => {
if (item.kind === "collapsed") {
return (
<div key={item.id} className="flex items-center gap-3 py-2 text-sm text-zinc-500">
<div className="h-px flex-1 bg-zinc-800" />
<span>
{item.count} unnamed save{item.count === 1 ? "" : "s"} collapsed
</span>
<div className="h-px flex-1 bg-zinc-800" />
</div>
);
}

const { save } = item;
const isSelected = selectedSaves.includes(save.id);
const isPendingLoad = pendingLoadSaveId === save.id;
const selectionOrder = selectedSaves.indexOf(save.id) + 1;
Expand Down