feat(redesign): [P7] import P2 data model + Jest setup + unified deps
- src/lib/migrate.js: v1->v2 migration - src/store/boardStore.js: canonical useBoardStore hook (P2 ratified by orchestrator) - Jest + RTL + jsdom + @dnd-kit + identity-obj-proxy (merged from P5's package.json)
This commit is contained in:
9
babel.config.js
Normal file
9
babel.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Babel config is consumed ONLY by Jest via babel-jest.
|
||||
// Vite uses esbuild and ignores babel.config.js for builds,
|
||||
// so this does not interfere with the dev server.
|
||||
export default {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
['@babel/preset-react', { runtime: 'automatic' }],
|
||||
],
|
||||
};
|
||||
16
jest.config.js
Normal file
16
jest.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
transform: {
|
||||
'^.+\\.(js|jsx)$': 'babel-jest',
|
||||
},
|
||||
testMatch: ['<rootDir>/src/**/*.test.{js,jsx}'],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json'],
|
||||
testEnvironmentOptions: {
|
||||
// jsdom 22+ doesn't ship crypto.randomUUID by default in some envs
|
||||
customExportConditions: [''],
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@dnd-kit)/)',
|
||||
],
|
||||
};
|
||||
1
jest.setup.js
Normal file
1
jest.setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
6245
package-lock.json
generated
6245
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -6,14 +6,28 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
106
src/lib/migrate.js
Normal file
106
src/lib/migrate.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// One-shot migration from v1 (flat array of todos under "ultra-todo-items")
|
||||
// to v2 (normalized boards/lists/cards under "ultra-todo-v2-state").
|
||||
//
|
||||
// v1 shape: Array<{ id, text, completed, dueDate }>
|
||||
// v2 shape:
|
||||
// {
|
||||
// schemaVersion: 2,
|
||||
// activeBoardId: string,
|
||||
// boards: { [id]: { id, name, listIds: [listId,...] } },
|
||||
// boardOrder: [boardId,...],
|
||||
// lists: { [id]: { id, boardId, name, cardIds: [cardId,...] } },
|
||||
// cards: { [id]: { id, boardId, listId, title, description, dueDate, createdAt } },
|
||||
// }
|
||||
//
|
||||
// Migration runs synchronously and is idempotent: if the v2 key already holds
|
||||
// a valid v2 state, that state is returned unchanged. The v1 key is never
|
||||
// deleted so the user can roll back.
|
||||
|
||||
export const V1_STORAGE_KEY = 'ultra-todo-items';
|
||||
export const V2_STORAGE_KEY = 'ultra-todo-v2-state';
|
||||
|
||||
const BOARD_NAME = 'Inbox';
|
||||
const LIST_NAME = 'Todo';
|
||||
|
||||
function makeId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function readJSON(key) {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJSON(key, value) {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function emptyState() {
|
||||
const boardId = makeId();
|
||||
const listId = makeId();
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: boardId,
|
||||
boards: {
|
||||
[boardId]: { id: boardId, name: BOARD_NAME, listIds: [listId] },
|
||||
},
|
||||
boardOrder: [boardId],
|
||||
lists: {
|
||||
[listId]: { id: listId, boardId, name: LIST_NAME, cardIds: [] },
|
||||
},
|
||||
cards: {},
|
||||
};
|
||||
}
|
||||
|
||||
function migrateFromV1(v1Array) {
|
||||
const state = emptyState();
|
||||
const boardId = state.boardOrder[0];
|
||||
const listId = state.boards[boardId].listIds[0];
|
||||
const now = Date.now();
|
||||
|
||||
for (const item of v1Array) {
|
||||
if (!item || typeof item.id !== 'string') continue;
|
||||
const card = {
|
||||
id: item.id,
|
||||
boardId,
|
||||
listId,
|
||||
title: typeof item.text === 'string' ? item.text : '',
|
||||
description: '',
|
||||
dueDate: item.dueDate ?? null,
|
||||
createdAt: now,
|
||||
};
|
||||
state.cards[item.id] = card;
|
||||
state.lists[listId].cardIds.push(item.id);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
const existing = readJSON(V2_STORAGE_KEY);
|
||||
if (existing && existing.schemaVersion === 2) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const v1Raw = window.localStorage.getItem(V1_STORAGE_KEY);
|
||||
const v1 = v1Raw == null ? null : readJSON(V1_STORAGE_KEY);
|
||||
|
||||
let state;
|
||||
if (Array.isArray(v1) && v1.length > 0) {
|
||||
state = migrateFromV1(v1);
|
||||
} else {
|
||||
state = emptyState();
|
||||
}
|
||||
|
||||
writeJSON(V2_STORAGE_KEY, state);
|
||||
// v1 key is intentionally left intact for rollback safety.
|
||||
return state;
|
||||
}
|
||||
118
src/lib/migrate.test.js
Normal file
118
src/lib/migrate.test.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { migrate, V2_STORAGE_KEY, V1_STORAGE_KEY } from './migrate';
|
||||
|
||||
// jsdom provides window.localStorage, but jest isolates it per test by default.
|
||||
// We clear it explicitly between tests so migration behavior is deterministic.
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('migrate()', () => {
|
||||
test('returns the v2 state untouched when schemaVersion is already 2', () => {
|
||||
const existing = {
|
||||
schemaVersion: 2,
|
||||
activeBoardId: 'b1',
|
||||
boards: { b1: { id: 'b1', name: 'Personal', listIds: [] } },
|
||||
boardOrder: ['b1'],
|
||||
lists: {},
|
||||
cards: {},
|
||||
};
|
||||
window.localStorage.setItem(V2_STORAGE_KEY, JSON.stringify(existing));
|
||||
|
||||
const result = migrate();
|
||||
|
||||
expect(result).toEqual(existing);
|
||||
// v2 key is preserved
|
||||
expect(JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY))).toEqual(existing);
|
||||
});
|
||||
|
||||
test('creates a fresh empty Inbox board when neither v1 nor v2 exists', () => {
|
||||
const result = migrate();
|
||||
|
||||
expect(result.schemaVersion).toBe(2);
|
||||
expect(result.activeBoardId).toBeTruthy();
|
||||
expect(result.boardOrder).toHaveLength(1);
|
||||
const boardId = result.boardOrder[0];
|
||||
expect(result.boards[boardId].name).toBe('Inbox');
|
||||
// One default "Todo" list inside the inbox board
|
||||
expect(result.boards[boardId].listIds).toHaveLength(1);
|
||||
const listId = result.boards[boardId].listIds[0];
|
||||
expect(result.lists[listId].name).toBe('Todo');
|
||||
expect(result.lists[listId].boardId).toBe(boardId);
|
||||
expect(result.lists[listId].cardIds).toEqual([]);
|
||||
expect(result.cards).toEqual({});
|
||||
});
|
||||
|
||||
test('persists the fresh v2 state to localStorage under the v2 key', () => {
|
||||
migrate();
|
||||
const stored = JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY));
|
||||
expect(stored.schemaVersion).toBe(2);
|
||||
expect(stored.boardOrder).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('migrates v1 array of todos into a single Inbox board with one Todo list', () => {
|
||||
const v1 = [
|
||||
{ id: 'a', text: 'Buy milk', completed: false, dueDate: '2026-06-30' },
|
||||
{ id: 'b', text: 'Walk dog', completed: true, dueDate: null },
|
||||
{ id: 'c', text: 'Read book', completed: false, dueDate: '2026-07-01' },
|
||||
];
|
||||
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
|
||||
|
||||
const result = migrate();
|
||||
|
||||
expect(result.schemaVersion).toBe(2);
|
||||
// single board, single list
|
||||
expect(result.boardOrder).toHaveLength(1);
|
||||
const boardId = result.boardOrder[0];
|
||||
expect(result.boards[boardId].name).toBe('Inbox');
|
||||
expect(result.boards[boardId].listIds).toHaveLength(1);
|
||||
const listId = result.boards[boardId].listIds[0];
|
||||
expect(result.lists[listId].name).toBe('Todo');
|
||||
// three cards preserved in order
|
||||
expect(result.lists[listId].cardIds).toEqual(['a', 'b', 'c']);
|
||||
expect(Object.keys(result.cards)).toHaveLength(3);
|
||||
// text -> title, completed dropped, dueDate preserved
|
||||
expect(result.cards.a).toMatchObject({
|
||||
id: 'a',
|
||||
boardId,
|
||||
listId,
|
||||
title: 'Buy milk',
|
||||
dueDate: '2026-06-30',
|
||||
});
|
||||
expect(result.cards.b.title).toBe('Walk dog');
|
||||
expect(result.cards.b.dueDate).toBeNull();
|
||||
expect(result.cards.c.title).toBe('Read book');
|
||||
// createdAt is set on every migrated card
|
||||
for (const card of Object.values(result.cards)) {
|
||||
expect(typeof card.createdAt).toBe('number');
|
||||
expect(card.createdAt).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('v1 key is left intact after migration (rollback safety)', () => {
|
||||
const v1 = [{ id: 'x', text: 'Hello', completed: false, dueDate: null }];
|
||||
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
|
||||
migrate();
|
||||
expect(JSON.parse(window.localStorage.getItem(V1_STORAGE_KEY))).toEqual(v1);
|
||||
});
|
||||
|
||||
test('does not re-migrate when called twice (idempotent)', () => {
|
||||
const v1 = [{ id: 'a', text: 'one', completed: false, dueDate: null }];
|
||||
window.localStorage.setItem(V1_STORAGE_KEY, JSON.stringify(v1));
|
||||
|
||||
const first = migrate();
|
||||
const second = migrate();
|
||||
|
||||
expect(first).toEqual(second);
|
||||
// v1 still there, v2 unchanged
|
||||
expect(JSON.parse(window.localStorage.getItem(V1_STORAGE_KEY))).toEqual(v1);
|
||||
expect(JSON.parse(window.localStorage.getItem(V2_STORAGE_KEY))).toEqual(second);
|
||||
});
|
||||
|
||||
test('handles corrupted v1 JSON gracefully and starts fresh', () => {
|
||||
window.localStorage.setItem(V1_STORAGE_KEY, 'not-json{');
|
||||
const result = migrate();
|
||||
expect(result.schemaVersion).toBe(2);
|
||||
expect(result.boardOrder).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
266
src/store/boardStore.js
Normal file
266
src/store/boardStore.js
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { migrate, V2_STORAGE_KEY } from '../lib/migrate';
|
||||
|
||||
// Pure action helpers — each returns a new state object. No mutation, no async.
|
||||
// Keeping them outside the component avoids stale-closure traps with useState's
|
||||
// functional updater and makes every transition trivially testable in isolation.
|
||||
|
||||
function makeId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function persist(state) {
|
||||
try {
|
||||
window.localStorage.setItem(V2_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// localStorage may be unavailable (private mode, quota). Swallow — in-memory
|
||||
// state remains usable for the session.
|
||||
}
|
||||
}
|
||||
|
||||
function addBoard(state, name) {
|
||||
const id = makeId();
|
||||
const board = { id, name, listIds: [] };
|
||||
return {
|
||||
...state,
|
||||
boards: { ...state.boards, [id]: board },
|
||||
boardOrder: [...state.boardOrder, id],
|
||||
};
|
||||
}
|
||||
|
||||
function renameBoard(state, id, name) {
|
||||
if (!state.boards[id]) return state;
|
||||
return {
|
||||
...state,
|
||||
boards: { ...state.boards, [id]: { ...state.boards[id], name } },
|
||||
};
|
||||
}
|
||||
|
||||
function deleteBoard(state, id) {
|
||||
if (!state.boards[id]) return state;
|
||||
const board = state.boards[id];
|
||||
const lists = { ...state.lists };
|
||||
for (const listId of board.listIds) {
|
||||
delete lists[listId];
|
||||
}
|
||||
const cards = { ...state.cards };
|
||||
for (const card of Object.values(state.cards)) {
|
||||
if (card.boardId === id) delete cards[card.id];
|
||||
}
|
||||
const boards = { ...state.boards };
|
||||
delete boards[id];
|
||||
return {
|
||||
...state,
|
||||
boards,
|
||||
lists,
|
||||
cards,
|
||||
boardOrder: state.boardOrder.filter((b) => b !== id),
|
||||
activeBoardId: state.activeBoardId === id ? (state.boardOrder.find((b) => b !== id) ?? null) : state.activeBoardId,
|
||||
};
|
||||
}
|
||||
|
||||
function setActiveBoard(state, id) {
|
||||
if (!state.boards[id]) return state;
|
||||
return { ...state, activeBoardId: id };
|
||||
}
|
||||
|
||||
function addList(state, boardId, name) {
|
||||
if (!state.boards[boardId]) return state;
|
||||
const id = makeId();
|
||||
const list = { id, boardId, name, cardIds: [] };
|
||||
return {
|
||||
...state,
|
||||
lists: { ...state.lists, [id]: list },
|
||||
boards: {
|
||||
...state.boards,
|
||||
[boardId]: {
|
||||
...state.boards[boardId],
|
||||
listIds: [...state.boards[boardId].listIds, id],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renameList(state, listId, name) {
|
||||
if (!state.lists[listId]) return state;
|
||||
return {
|
||||
...state,
|
||||
lists: { ...state.lists, [listId]: { ...state.lists[listId], name } },
|
||||
};
|
||||
}
|
||||
|
||||
function deleteList(state, listId) {
|
||||
const list = state.lists[listId];
|
||||
if (!list) return state;
|
||||
const board = state.boards[list.boardId];
|
||||
if (!board) return state;
|
||||
const cards = { ...state.cards };
|
||||
for (const cardId of list.cardIds) {
|
||||
delete cards[cardId];
|
||||
}
|
||||
const lists = { ...state.lists };
|
||||
delete lists[listId];
|
||||
return {
|
||||
...state,
|
||||
lists,
|
||||
cards,
|
||||
boards: {
|
||||
...state.boards,
|
||||
[board.id]: {
|
||||
...board,
|
||||
listIds: board.listIds.filter((l) => l !== listId),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveList(state, boardId, fromIdx, toIdx) {
|
||||
const board = state.boards[boardId];
|
||||
if (!board) return state;
|
||||
if (fromIdx === toIdx) return state;
|
||||
if (fromIdx < 0 || fromIdx >= board.listIds.length) return state;
|
||||
if (toIdx < 0 || toIdx >= board.listIds.length) return state;
|
||||
const next = board.listIds.slice();
|
||||
const [moved] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, moved);
|
||||
return {
|
||||
...state,
|
||||
boards: { ...state.boards, [boardId]: { ...board, listIds: next } },
|
||||
};
|
||||
}
|
||||
|
||||
function addCard(state, listId, { title, description, dueDate } = {}) {
|
||||
const list = state.lists[listId];
|
||||
if (!list) return state;
|
||||
const id = makeId();
|
||||
const card = {
|
||||
id,
|
||||
boardId: list.boardId,
|
||||
listId,
|
||||
title: title ?? '',
|
||||
description: description ?? '',
|
||||
dueDate: dueDate ?? null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
cards: { ...state.cards, [id]: card },
|
||||
lists: {
|
||||
...state.lists,
|
||||
[listId]: { ...list, cardIds: [...list.cardIds, id] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function updateCard(state, cardId, patch) {
|
||||
const card = state.cards[cardId];
|
||||
if (!card) return state;
|
||||
return {
|
||||
...state,
|
||||
cards: { ...state.cards, [cardId]: { ...card, ...patch } },
|
||||
};
|
||||
}
|
||||
|
||||
function deleteCard(state, cardId) {
|
||||
const card = state.cards[cardId];
|
||||
if (!card) return state;
|
||||
const list = state.lists[card.listId];
|
||||
const cards = { ...state.cards };
|
||||
delete cards[cardId];
|
||||
if (!list) return { ...state, cards };
|
||||
return {
|
||||
...state,
|
||||
cards,
|
||||
lists: {
|
||||
...state.lists,
|
||||
[list.id]: { ...list, cardIds: list.cardIds.filter((c) => c !== cardId) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveCard(state, cardId, toListId, toIndex) {
|
||||
const card = state.cards[cardId];
|
||||
if (!card) return state;
|
||||
const fromList = state.lists[card.listId];
|
||||
const toList = state.lists[toListId];
|
||||
if (!toList) return state;
|
||||
|
||||
const nextCards = { ...state.cards };
|
||||
let nextLists = { ...state.lists };
|
||||
|
||||
// Remove from source list (if any)
|
||||
if (fromList) {
|
||||
nextLists = {
|
||||
...nextLists,
|
||||
[fromList.id]: {
|
||||
...fromList,
|
||||
cardIds: fromList.cardIds.filter((c) => c !== cardId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Insert into target list at the requested index (clamped)
|
||||
const targetIds = (nextLists[toListId]?.cardIds ?? toList.cardIds).slice();
|
||||
const clampedIndex = Math.max(0, Math.min(toIndex, targetIds.length));
|
||||
targetIds.splice(clampedIndex, 0, cardId);
|
||||
nextLists = {
|
||||
...nextLists,
|
||||
[toListId]: { ...toList, cardIds: targetIds },
|
||||
};
|
||||
|
||||
// Card knows its new home (denormalized for easy cleanup)
|
||||
nextCards[cardId] = { ...card, listId: toListId, boardId: toList.boardId };
|
||||
|
||||
return { ...state, cards: nextCards, lists: nextLists };
|
||||
}
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'addBoard': return addBoard(state, action.name);
|
||||
case 'renameBoard': return renameBoard(state, action.id, action.name);
|
||||
case 'deleteBoard': return deleteBoard(state, action.id);
|
||||
case 'setActiveBoard': return setActiveBoard(state, action.id);
|
||||
case 'addList': return addList(state, action.boardId, action.name);
|
||||
case 'renameList': return renameList(state, action.listId, action.name);
|
||||
case 'deleteList': return deleteList(state, action.listId);
|
||||
case 'moveList': return moveList(state, action.boardId, action.fromIdx, action.toIdx);
|
||||
case 'addCard': return addCard(state, action.listId, action.data);
|
||||
case 'updateCard': return updateCard(state, action.cardId, action.patch);
|
||||
case 'deleteCard': return deleteCard(state, action.cardId);
|
||||
case 'moveCard': return moveCard(state, action.cardId, action.toListId, action.toIndex);
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useBoardStore() {
|
||||
// Seed from migration synchronously so the first render already has data.
|
||||
const [state, setState] = useState(() => migrate());
|
||||
|
||||
const dispatch = useCallback((action) => {
|
||||
setState((prev) => {
|
||||
const next = reducer(prev, action);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const actions = {
|
||||
addBoard: (name) => dispatch({ type: 'addBoard', name }),
|
||||
renameBoard: (id, name) => dispatch({ type: 'renameBoard', id, name }),
|
||||
deleteBoard: (id) => dispatch({ type: 'deleteBoard', id }),
|
||||
setActiveBoard: (id) => dispatch({ type: 'setActiveBoard', id }),
|
||||
addList: (boardId, name) => dispatch({ type: 'addList', boardId, name }),
|
||||
renameList: (listId, name) => dispatch({ type: 'renameList', listId, name }),
|
||||
deleteList: (listId) => dispatch({ type: 'deleteList', listId }),
|
||||
moveList: (boardId, fromIdx, toIdx) => dispatch({ type: 'moveList', boardId, fromIdx, toIdx }),
|
||||
addCard: (listId, data) => dispatch({ type: 'addCard', listId, data }),
|
||||
updateCard: (cardId, patch) => dispatch({ type: 'updateCard', cardId, patch }),
|
||||
deleteCard: (cardId) => dispatch({ type: 'deleteCard', cardId }),
|
||||
moveCard: (cardId, toListId, toIndex) => dispatch({ type: 'moveCard', cardId, toListId, toIndex }),
|
||||
};
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
41
src/store/boardStore.migration.test.js
Normal file
41
src/store/boardStore.migration.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBoardStore } from './boardStore';
|
||||
|
||||
// End-to-end-ish: write a v1 array to localStorage, mount the store, verify
|
||||
// the migrated v2 state matches the spec, and verify the v1 key is untouched.
|
||||
|
||||
describe('useBoardStore() — migration integration', () => {
|
||||
test('loading the store migrates v1 data into a single Inbox/Todo board', () => {
|
||||
const v1 = [
|
||||
{ id: 'todo-1', text: 'Ship P2', completed: false, dueDate: '2026-07-04' },
|
||||
{ id: 'todo-2', text: 'Buy milk', completed: true, dueDate: null },
|
||||
];
|
||||
window.localStorage.setItem('ultra-todo-items', JSON.stringify(v1));
|
||||
|
||||
const { result } = renderHook(() => useBoardStore());
|
||||
const { state } = result.current;
|
||||
|
||||
expect(state.schemaVersion).toBe(2);
|
||||
expect(state.boardOrder).toHaveLength(1);
|
||||
const boardId = state.boardOrder[0];
|
||||
expect(state.boards[boardId].name).toBe('Inbox');
|
||||
expect(state.boards[boardId].listIds).toHaveLength(1);
|
||||
const listId = state.boards[boardId].listIds[0];
|
||||
expect(state.lists[listId].name).toBe('Todo');
|
||||
|
||||
expect(state.lists[listId].cardIds).toEqual(['todo-1', 'todo-2']);
|
||||
expect(state.cards['todo-1']).toMatchObject({
|
||||
id: 'todo-1',
|
||||
boardId,
|
||||
listId,
|
||||
title: 'Ship P2',
|
||||
description: '',
|
||||
dueDate: '2026-07-04',
|
||||
});
|
||||
expect(state.cards['todo-2'].title).toBe('Buy milk');
|
||||
expect(state.cards['todo-2'].dueDate).toBeNull();
|
||||
|
||||
// v1 key still intact
|
||||
expect(JSON.parse(window.localStorage.getItem('ultra-todo-items'))).toEqual(v1);
|
||||
});
|
||||
});
|
||||
226
src/store/boardStore.test.js
Normal file
226
src/store/boardStore.test.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useBoardStore } from './boardStore';
|
||||
|
||||
function getFreshStore() {
|
||||
const { result } = renderHook(() => useBoardStore());
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('useBoardStore() — initial state', () => {
|
||||
test('seeds a single Inbox board with a Todo list when storage is empty', () => {
|
||||
const result = getFreshStore();
|
||||
const { state } = result.current;
|
||||
expect(state.schemaVersion).toBe(2);
|
||||
expect(state.boardOrder).toHaveLength(1);
|
||||
const boardId = state.boardOrder[0];
|
||||
expect(state.boards[boardId].name).toBe('Inbox');
|
||||
expect(state.boards[boardId].listIds).toHaveLength(1);
|
||||
const listId = state.boards[boardId].listIds[0];
|
||||
expect(state.lists[listId].name).toBe('Todo');
|
||||
expect(state.activeBoardId).toBe(boardId);
|
||||
});
|
||||
|
||||
test('persists the seeded state to localStorage on mount', () => {
|
||||
renderHook(() => useBoardStore());
|
||||
const stored = JSON.parse(window.localStorage.getItem('ultra-todo-v2-state'));
|
||||
expect(stored).toBeTruthy();
|
||||
expect(stored.schemaVersion).toBe(2);
|
||||
expect(stored.boardOrder).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('board actions', () => {
|
||||
test('addBoard appends to boardOrder and inserts into boards map', () => {
|
||||
const result = getFreshStore();
|
||||
act(() => result.current.actions.addBoard('Work'));
|
||||
const { state } = result.current;
|
||||
expect(state.boardOrder).toHaveLength(2);
|
||||
const newId = state.boardOrder[1];
|
||||
expect(state.boards[newId].name).toBe('Work');
|
||||
expect(state.boards[newId].listIds).toEqual([]);
|
||||
});
|
||||
|
||||
test('renameBoard updates the board name in place', () => {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
act(() => result.current.actions.renameBoard(boardId, 'Personal'));
|
||||
expect(result.current.state.boards[boardId].name).toBe('Personal');
|
||||
});
|
||||
|
||||
test('deleteBoard removes the board, its lists, and its cards', () => {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
act(() => result.current.actions.addBoard('ToDelete'));
|
||||
const toDelete = result.current.state.boardOrder[1];
|
||||
act(() => result.current.actions.addList(toDelete, 'Trash List'));
|
||||
const trashListId = result.current.state.boards[toDelete].listIds[0];
|
||||
act(() => result.current.actions.addCard(trashListId, { title: 'soon gone' }));
|
||||
const cardId = result.current.state.lists[trashListId].cardIds[0];
|
||||
|
||||
act(() => result.current.actions.deleteBoard(toDelete));
|
||||
|
||||
const { state } = result.current;
|
||||
expect(state.boardOrder).not.toContain(toDelete);
|
||||
expect(state.boards[toDelete]).toBeUndefined();
|
||||
expect(state.lists[trashListId]).toBeUndefined();
|
||||
expect(state.cards[cardId]).toBeUndefined();
|
||||
// default Inbox board still present
|
||||
expect(state.boardOrder).toContain(boardId);
|
||||
});
|
||||
|
||||
test('setActiveBoard updates activeBoardId', () => {
|
||||
const result = getFreshStore();
|
||||
act(() => result.current.actions.addBoard('Other'));
|
||||
const otherId = result.current.state.boardOrder[1];
|
||||
act(() => result.current.actions.setActiveBoard(otherId));
|
||||
expect(result.current.state.activeBoardId).toBe(otherId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list actions', () => {
|
||||
test('addList appends a list id to the board in order', () => {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
act(() => result.current.actions.addList(boardId, 'Doing'));
|
||||
act(() => result.current.actions.addList(boardId, 'Done'));
|
||||
const { state } = result.current;
|
||||
expect(state.boards[boardId].listIds).toHaveLength(3);
|
||||
const [todoId, doingId, doneId] = state.boards[boardId].listIds;
|
||||
expect(state.lists[doingId].name).toBe('Doing');
|
||||
expect(state.lists[doneId].name).toBe('Done');
|
||||
expect(state.lists[doingId].boardId).toBe(boardId);
|
||||
expect(state.lists[todoId].name).toBe('Todo');
|
||||
});
|
||||
|
||||
test('renameList updates the list name in place', () => {
|
||||
const result = getFreshStore();
|
||||
const listId = result.current.state.boards[result.current.state.boardOrder[0]].listIds[0];
|
||||
act(() => result.current.actions.renameList(listId, 'Backlog'));
|
||||
expect(result.current.state.lists[listId].name).toBe('Backlog');
|
||||
});
|
||||
|
||||
test('deleteList removes the list and its cards, removes id from parent board', () => {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
act(() => result.current.actions.addList(boardId, 'Temp'));
|
||||
const listId = result.current.state.boards[boardId].listIds[1];
|
||||
act(() => result.current.actions.addCard(listId, { title: 'bye' }));
|
||||
const cardId = result.current.state.lists[listId].cardIds[0];
|
||||
|
||||
act(() => result.current.actions.deleteList(listId));
|
||||
|
||||
const { state } = result.current;
|
||||
expect(state.lists[listId]).toBeUndefined();
|
||||
expect(state.cards[cardId]).toBeUndefined();
|
||||
expect(state.boards[boardId].listIds).not.toContain(listId);
|
||||
});
|
||||
|
||||
test('moveList reorders the listIds array on the board', () => {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
act(() => result.current.actions.addList(boardId, 'A'));
|
||||
act(() => result.current.actions.addList(boardId, 'B'));
|
||||
const [todo, a, b] = result.current.state.boards[boardId].listIds;
|
||||
|
||||
act(() => result.current.actions.moveList(boardId, 2, 0));
|
||||
|
||||
const order = result.current.state.boards[boardId].listIds;
|
||||
expect(order).toEqual([b, todo, a]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('card actions', () => {
|
||||
function setup() {
|
||||
const result = getFreshStore();
|
||||
const boardId = result.current.state.boardOrder[0];
|
||||
const listId = result.current.state.boards[boardId].listIds[0];
|
||||
return { result, boardId, listId };
|
||||
}
|
||||
|
||||
test('addCard appends a card to the list and records title/createdAt', () => {
|
||||
const { result, listId } = setup();
|
||||
act(() => result.current.actions.addCard(listId, { title: 'Task one' }));
|
||||
const { state } = result.current;
|
||||
expect(state.lists[listId].cardIds).toHaveLength(1);
|
||||
const cardId = state.lists[listId].cardIds[0];
|
||||
expect(state.cards[cardId].title).toBe('Task one');
|
||||
expect(state.cards[cardId].listId).toBe(listId);
|
||||
expect(state.cards[cardId].boardId).toBe(state.boardOrder[0]);
|
||||
expect(typeof state.cards[cardId].createdAt).toBe('number');
|
||||
});
|
||||
|
||||
test('addCard preserves optional description and dueDate', () => {
|
||||
const { result, listId } = setup();
|
||||
act(() => result.current.actions.addCard(listId, {
|
||||
title: 'Plan',
|
||||
description: 'a longer note',
|
||||
dueDate: '2026-07-15',
|
||||
}));
|
||||
const cardId = result.current.state.lists[listId].cardIds[0];
|
||||
expect(result.current.state.cards[cardId].description).toBe('a longer note');
|
||||
expect(result.current.state.cards[cardId].dueDate).toBe('2026-07-15');
|
||||
});
|
||||
|
||||
test('updateCard patches the given fields in place', () => {
|
||||
const { result, listId } = setup();
|
||||
act(() => result.current.actions.addCard(listId, { title: 'old' }));
|
||||
const cardId = result.current.state.lists[listId].cardIds[0];
|
||||
act(() => result.current.actions.updateCard(cardId, { title: 'new', dueDate: '2026-08-01' }));
|
||||
expect(result.current.state.cards[cardId].title).toBe('new');
|
||||
expect(result.current.state.cards[cardId].dueDate).toBe('2026-08-01');
|
||||
});
|
||||
|
||||
test('deleteCard removes the card from its list and from the cards map', () => {
|
||||
const { result, listId } = setup();
|
||||
act(() => result.current.actions.addCard(listId, { title: 'gone' }));
|
||||
const cardId = result.current.state.lists[listId].cardIds[0];
|
||||
act(() => result.current.actions.deleteCard(cardId));
|
||||
expect(result.current.state.cards[cardId]).toBeUndefined();
|
||||
expect(result.current.state.lists[listId].cardIds).not.toContain(cardId);
|
||||
});
|
||||
|
||||
test('moveCard updates the card\'s listId, boardId, and target index', () => {
|
||||
const { result, boardId, listId } = setup();
|
||||
// build a second list with a few existing cards
|
||||
act(() => result.current.actions.addList(boardId, 'Other'));
|
||||
const otherListId = result.current.state.boards[boardId].listIds[1];
|
||||
act(() => result.current.actions.addCard(otherListId, { title: 'preexisting 1' }));
|
||||
act(() => result.current.actions.addCard(otherListId, { title: 'preexisting 2' }));
|
||||
const [pre1, pre2] = result.current.state.lists[otherListId].cardIds;
|
||||
// add a card to the first list, then move it to index 1 of the second list
|
||||
act(() => result.current.actions.addCard(listId, { title: 'moving' }));
|
||||
const movingId = result.current.state.lists[listId].cardIds[0];
|
||||
|
||||
act(() => result.current.actions.moveCard(movingId, otherListId, 1));
|
||||
|
||||
const { state } = result.current;
|
||||
expect(state.lists[listId].cardIds).toEqual([]);
|
||||
expect(state.lists[otherListId].cardIds).toEqual([pre1, movingId, pre2]);
|
||||
expect(state.cards[movingId].listId).toBe(otherListId);
|
||||
expect(state.cards[movingId].boardId).toBe(boardId);
|
||||
});
|
||||
|
||||
test('moveCard moving within the same list reorders the card', () => {
|
||||
const { result, listId } = setup();
|
||||
act(() => result.current.actions.addCard(listId, { title: 'a' }));
|
||||
act(() => result.current.actions.addCard(listId, { title: 'b' }));
|
||||
act(() => result.current.actions.addCard(listId, { title: 'c' }));
|
||||
const [a, b, c] = result.current.state.lists[listId].cardIds;
|
||||
act(() => result.current.actions.moveCard(c, listId, 0));
|
||||
expect(result.current.state.lists[listId].cardIds).toEqual([c, a, b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
test('writes the v2 state to localStorage on every state change', () => {
|
||||
const result = getFreshStore();
|
||||
act(() => result.current.actions.addBoard('Work'));
|
||||
const stored = JSON.parse(window.localStorage.getItem('ultra-todo-v2-state'));
|
||||
expect(stored.boardOrder).toHaveLength(2);
|
||||
expect(stored.boards[stored.boardOrder[1]].name).toBe('Work');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user