feat: add Zustand stores with undo/redo and debounced persistence

- App store: theme, view routing, board list, settings
- Board store: all card/column/label/checklist/attachment mutations
- zundo temporal middleware for undo/redo (50 step limit)
- Debounced saves (500ms) with immediate flush on close
This commit is contained in:
Your Name
2026-02-15 18:41:27 +02:00
parent 8b49f2afd1
commit bf5e9ff8b6
2 changed files with 538 additions and 0 deletions

469
src/stores/board-store.ts Normal file
View File

@@ -0,0 +1,469 @@
import { create } from "zustand";
import { temporal } from "zundo";
import { ulid } from "ulid";
import type {
Board,
Card,
Label,
ChecklistItem,
Attachment,
ColumnWidth,
} from "@/types/board";
import { saveBoard, loadBoard } from "@/lib/storage";
interface BoardState {
board: Board | null;
saving: boolean;
lastSaved: number | null;
}
interface BoardActions {
openBoard: (boardId: string) => Promise<void>;
closeBoard: () => void;
addColumn: (title: string) => void;
updateColumnTitle: (columnId: string, title: string) => void;
deleteColumn: (columnId: string) => void;
moveColumn: (fromIndex: number, toIndex: number) => void;
setColumnWidth: (columnId: string, width: ColumnWidth) => void;
addCard: (columnId: string, title: string) => string;
updateCard: (cardId: string, updates: Partial<Card>) => void;
deleteCard: (cardId: string) => void;
moveCard: (
cardId: string,
fromColumnId: string,
toColumnId: string,
toIndex: number
) => void;
addLabel: (name: string, color: string) => void;
updateLabel: (labelId: string, updates: Partial<Label>) => void;
deleteLabel: (labelId: string) => void;
toggleCardLabel: (cardId: string, labelId: string) => void;
addChecklistItem: (cardId: string, text: string) => void;
toggleChecklistItem: (cardId: string, itemId: string) => void;
updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
deleteChecklistItem: (cardId: string, itemId: string) => void;
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
removeAttachment: (cardId: string, attachmentId: string) => void;
updateBoardTitle: (title: string) => void;
updateBoardColor: (color: string) => void;
updateBoardSettings: (settings: Board["settings"]) => void;
}
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
function now(): string {
return new Date().toISOString();
}
function debouncedSave(
board: Board,
set: (partial: Partial<BoardState>) => void
): void {
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
set({ saving: true });
try {
await saveBoard(board);
set({ saving: false, lastSaved: Date.now() });
} catch {
set({ saving: false });
}
}, 500);
}
function mutate(
get: () => BoardState & BoardActions,
set: (partial: Partial<BoardState>) => void,
updater: (board: Board) => Board
): void {
const { board } = get();
if (!board) return;
const updated = updater(board);
set({ board: updated });
debouncedSave(updated, set);
}
export const useBoardStore = create<BoardState & BoardActions>()(
temporal(
(set, get) => ({
board: null,
saving: false,
lastSaved: null,
openBoard: async (boardId: string) => {
const board = await loadBoard(boardId);
set({ board, saving: false, lastSaved: null });
},
closeBoard: () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
saveTimeout = null;
const { board } = get();
if (board) saveBoard(board);
}
set({ board: null, saving: false, lastSaved: null });
},
// -- Column actions --
addColumn: (title: string) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: [
...b.columns,
{
id: ulid(),
title,
cardIds: [],
width: "standard" as ColumnWidth,
},
],
}));
},
updateColumnTitle: (columnId, title) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, title } : c
),
}));
},
deleteColumn: (columnId) => {
mutate(get, set, (b) => {
const col = b.columns.find((c) => c.id === columnId);
if (!col) return b;
const newCards = { ...b.cards };
col.cardIds.forEach((id) => delete newCards[id]);
return {
...b,
updatedAt: now(),
columns: b.columns.filter((c) => c.id !== columnId),
cards: newCards,
};
});
},
moveColumn: (fromIndex, toIndex) => {
mutate(get, set, (b) => {
const cols = [...b.columns];
const [moved] = cols.splice(fromIndex, 1);
cols.splice(toIndex, 0, moved);
return { ...b, updatedAt: now(), columns: cols };
});
},
setColumnWidth: (columnId, width) => {
mutate(get, set, (b) => ({
...b,
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, width } : c
),
}));
},
// -- Card actions --
addCard: (columnId, title) => {
const cardId = ulid();
const card: Card = {
id: cardId,
title,
description: "",
labels: [],
checklist: [],
dueDate: null,
attachments: [],
createdAt: now(),
updatedAt: now(),
};
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
cards: { ...b.cards, [cardId]: card },
columns: b.columns.map((c) =>
c.id === columnId
? { ...c, cardIds: [...c.cardIds, cardId] }
: c
),
}));
return cardId;
},
updateCard: (cardId, updates) => {
mutate(get, set, (b) => {
if (!b.cards[cardId]) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...b.cards[cardId],
...updates,
updatedAt: now(),
},
},
};
});
},
deleteCard: (cardId) => {
mutate(get, set, (b) => {
const newCards = { ...b.cards };
delete newCards[cardId];
return {
...b,
updatedAt: now(),
cards: newCards,
columns: b.columns.map((c) => ({
...c,
cardIds: c.cardIds.filter((id) => id !== cardId),
})),
};
});
},
moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) => {
if (c.id === fromColumnId && c.id === toColumnId) {
const ids = c.cardIds.filter((id) => id !== cardId);
ids.splice(toIndex, 0, cardId);
return { ...c, cardIds: ids };
}
if (c.id === fromColumnId) {
return {
...c,
cardIds: c.cardIds.filter((id) => id !== cardId),
};
}
if (c.id === toColumnId) {
const ids = [...c.cardIds];
ids.splice(toIndex, 0, cardId);
return { ...c, cardIds: ids };
}
return c;
}),
}));
},
// -- Label actions --
addLabel: (name, color) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
labels: [...b.labels, { id: ulid(), name, color }],
}));
},
updateLabel: (labelId, updates) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
labels: b.labels.map((l) =>
l.id === labelId ? { ...l, ...updates } : l
),
}));
},
deleteLabel: (labelId) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
labels: b.labels.filter((l) => l.id !== labelId),
cards: Object.fromEntries(
Object.entries(b.cards).map(([id, card]) => [
id,
{
...card,
labels: card.labels.filter((l) => l !== labelId),
},
])
),
}));
},
toggleCardLabel: (cardId, labelId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const labels = card.labels.includes(labelId)
? card.labels.filter((l) => l !== labelId)
: [...card.labels, labelId];
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: { ...card, labels, updatedAt: now() },
},
};
});
},
// -- Checklist actions --
addChecklistItem: (cardId, text) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
const item: ChecklistItem = { id: ulid(), text, checked: false };
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
checklist: [...card.checklist, item],
updatedAt: now(),
},
},
};
});
},
toggleChecklistItem: (cardId, itemId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.map((item) =>
item.id === itemId
? { ...item, checked: !item.checked }
: item
),
},
},
};
});
},
updateChecklistItem: (cardId, itemId, text) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.map((item) =>
item.id === itemId ? { ...item, text } : item
),
},
},
};
});
},
deleteChecklistItem: (cardId, itemId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
updatedAt: now(),
checklist: card.checklist.filter(
(item) => item.id !== itemId
),
},
},
};
});
},
// -- Attachment actions --
addAttachment: (cardId, attachment) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
updatedAt: now(),
attachments: [
...card.attachments,
{ ...attachment, id: ulid() },
],
},
},
};
});
},
removeAttachment: (cardId, attachmentId) => {
mutate(get, set, (b) => {
const card = b.cards[cardId];
if (!card) return b;
return {
...b,
updatedAt: now(),
cards: {
...b.cards,
[cardId]: {
...card,
updatedAt: now(),
attachments: card.attachments.filter(
(a) => a.id !== attachmentId
),
},
},
};
});
},
// -- Board metadata --
updateBoardTitle: (title) => {
mutate(get, set, (b) => ({ ...b, title, updatedAt: now() }));
},
updateBoardColor: (color) => {
mutate(get, set, (b) => ({ ...b, color, updatedAt: now() }));
},
updateBoardSettings: (settings) => {
mutate(get, set, (b) => ({ ...b, settings, updatedAt: now() }));
},
}),
{
limit: 50,
partialize: (state) => ({ board: state.board }),
}
)
);