diff --git a/src/lib/tasks.js b/src/lib/tasks.js new file mode 100644 index 0000000..3a51245 --- /dev/null +++ b/src/lib/tasks.js @@ -0,0 +1,41 @@ +/** crypto.randomUUID requires a secure context, and Negotium over plain HTTP + * on a LAN is a real self-hosted deployment shape — hence the fallback. */ +function newId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function createTask(text, now = Date.now()) { + return { id: newId(), text, completed: false, createdAt: now } +} + +export function addTask(tasks, text, now = Date.now()) { + const trimmed = text.trim() + if (!trimmed) return tasks + return [...tasks, createTask(trimmed, now)] +} + +export function toggleTask(tasks, id) { + return tasks.map((task) => (task.id === id ? { ...task, completed: !task.completed } : task)) +} + +export function deleteTask(tasks, id) { + return tasks.filter((task) => task.id !== id) +} + +export function reorderTask(tasks, from, to) { + if (from === to) return tasks + if (from < 0 || from >= tasks.length) return tasks + if (to < 0 || to >= tasks.length) return tasks + + const next = [...tasks] + const [moved] = next.splice(from, 1) + next.splice(to, 0, moved) + return next +} + +export function clearCompleted(tasks) { + return tasks.filter((task) => !task.completed) +} diff --git a/src/lib/tasks.test.js b/src/lib/tasks.test.js new file mode 100644 index 0000000..1bce4ba --- /dev/null +++ b/src/lib/tasks.test.js @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest' +import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted } from './tasks.js' + +const task = (id, completed = false) => ({ id, text: id, completed }) + +describe('addTask', () => { + it('appends a task', () => { + const result = addTask([], 'buy milk') + expect(result).toHaveLength(1) + expect(result[0].text).toBe('buy milk') + expect(result[0].completed).toBe(false) + }) + + it('trims surrounding whitespace', () => { + expect(addTask([], ' spaced ')[0].text).toBe('spaced') + }) + + it('rejects whitespace-only input', () => { + const before = [task('a')] + expect(addTask(before, ' ')).toBe(before) + }) + + it('rejects empty input', () => { + const before = [task('a')] + expect(addTask(before, '')).toBe(before) + }) + + it('assigns unique ids', () => { + const one = addTask([], 'a') + const two = addTask(one, 'b') + expect(two[0].id).not.toBe(two[1].id) + }) + + it('assigns unique ids even when added in the same millisecond', () => { + let tasks = [] + for (let i = 0; i < 50; i += 1) tasks = addTask(tasks, `task ${i}`, 1_000_000) + expect(new Set(tasks.map((t) => t.id)).size).toBe(50) + }) + + it('records the supplied creation time', () => { + expect(addTask([], 'x', 1_234_567)[0].createdAt).toBe(1_234_567) + }) + + it('does not mutate the input array', () => { + const before = [task('a')] + addTask(before, 'b') + expect(before).toHaveLength(1) + }) +}) + +describe('toggleTask', () => { + it('flips completion', () => { + expect(toggleTask([task('a')], 'a')[0].completed).toBe(true) + }) + + it('flips back', () => { + expect(toggleTask([task('a', true)], 'a')[0].completed).toBe(false) + }) + + it('ignores an unknown id', () => { + expect(toggleTask([task('a')], 'zzz')[0].completed).toBe(false) + }) + + it('does not mutate the input array', () => { + const before = [task('a')] + toggleTask(before, 'a') + expect(before[0].completed).toBe(false) + }) +}) + +describe('deleteTask', () => { + it('removes the matching task', () => { + expect(deleteTask([task('a'), task('b')], 'a').map((t) => t.id)).toEqual(['b']) + }) + + it('ignores an unknown id', () => { + expect(deleteTask([task('a')], 'zzz')).toHaveLength(1) + }) +}) + +describe('reorderTask', () => { + const three = [task('a'), task('b'), task('c')] + + it('moves an item later', () => { + expect(reorderTask(three, 0, 2).map((t) => t.id)).toEqual(['b', 'c', 'a']) + }) + + it('moves an item earlier', () => { + expect(reorderTask(three, 2, 0).map((t) => t.id)).toEqual(['c', 'a', 'b']) + }) + + it('moves an item into the middle', () => { + expect(reorderTask(three, 0, 1).map((t) => t.id)).toEqual(['b', 'a', 'c']) + }) + + it('is a no-op when indices match', () => { + expect(reorderTask(three, 1, 1)).toBe(three) + }) + + it('is a no-op for an out-of-range destination', () => { + expect(reorderTask(three, 0, 9)).toBe(three) + }) + + it('is a no-op for an out-of-range source', () => { + expect(reorderTask(three, -1, 0)).toBe(three) + }) + + it('does not mutate the input array', () => { + const before = [task('a'), task('b')] + reorderTask(before, 0, 1) + expect(before.map((t) => t.id)).toEqual(['a', 'b']) + }) +}) + +describe('clearCompleted', () => { + it('removes completed tasks', () => { + const result = clearCompleted([task('a', true), task('b'), task('c', true)]) + expect(result.map((t) => t.id)).toEqual(['b']) + }) + + it('is a no-op when nothing is completed', () => { + expect(clearCompleted([task('a')])).toHaveLength(1) + }) + + it('can empty the list entirely', () => { + expect(clearCompleted([task('a', true)])).toEqual([]) + }) +}) diff --git a/src/lib/undo.js b/src/lib/undo.js new file mode 100644 index 0000000..a879b6d --- /dev/null +++ b/src/lib/undo.js @@ -0,0 +1,51 @@ +/** Bounded stack of reversible operations. Kept separate from tasks.js so that + * module stays purely functional while this one holds the state. */ +export function createUndoStack(limit = 10) { + const entries = [] + + return { + push(entry) { + entries.push(entry) + if (entries.length > limit) entries.shift() + }, + pop() { + return entries.length > 0 ? entries.pop() : null + }, + get size() { + return entries.length + }, + clear() { + entries.length = 0 + }, + } +} + +/** + * Reverses one entry against the current task list. + * + * Indices are clamped because the list may have changed since the entry was + * recorded — a task deleted from position 5 can be restored into a list that + * has since shrunk to two items, and landing at the end beats throwing. + * + * `clearCompleted` entries must record `removed` in ascending index order, so + * re-inserting front to back puts each task back where it was. + */ +export function applyUndo(tasks, entry) { + if (!entry) return tasks + + if (entry.type === 'delete') { + const next = [...tasks] + next.splice(Math.min(entry.index, next.length), 0, entry.task) + return next + } + + if (entry.type === 'clearCompleted') { + const next = [...tasks] + for (const { task, index } of entry.removed) { + next.splice(Math.min(index, next.length), 0, task) + } + return next + } + + return tasks +} diff --git a/src/lib/undo.test.js b/src/lib/undo.test.js new file mode 100644 index 0000000..11a0edf --- /dev/null +++ b/src/lib/undo.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { createUndoStack, applyUndo } from './undo.js' + +const task = (id, completed = false) => ({ id, text: id, completed }) + +describe('createUndoStack', () => { + it('pops the most recent entry', () => { + const stack = createUndoStack() + stack.push({ type: 'delete', task: task('a'), index: 0 }) + stack.push({ type: 'delete', task: task('b'), index: 1 }) + expect(stack.pop().task.id).toBe('b') + }) + + it('returns null when empty', () => { + expect(createUndoStack().pop()).toBe(null) + }) + + it('reports its size', () => { + const stack = createUndoStack() + expect(stack.size).toBe(0) + stack.push({ type: 'delete', task: task('a'), index: 0 }) + expect(stack.size).toBe(1) + }) + + it('is bounded, discarding the oldest entries', () => { + const stack = createUndoStack(3) + for (const id of ['a', 'b', 'c', 'd']) { + stack.push({ type: 'delete', task: task(id), index: 0 }) + } + expect(stack.size).toBe(3) + expect(stack.pop().task.id).toBe('d') + expect(stack.pop().task.id).toBe('c') + expect(stack.pop().task.id).toBe('b') + expect(stack.pop()).toBe(null) + }) + + it('clears', () => { + const stack = createUndoStack() + stack.push({ type: 'delete', task: task('a'), index: 0 }) + stack.clear() + expect(stack.size).toBe(0) + }) +}) + +describe('applyUndo', () => { + it('restores a deleted task at its original index', () => { + const after = [task('a'), task('c')] + const entry = { type: 'delete', task: task('b'), index: 1 } + expect(applyUndo(after, entry).map((t) => t.id)).toEqual(['a', 'b', 'c']) + }) + + it('restores a task deleted from the front', () => { + const entry = { type: 'delete', task: task('a'), index: 0 } + expect(applyUndo([task('b')], entry).map((t) => t.id)).toEqual(['a', 'b']) + }) + + it('restores at the end when the list has since shrunk', () => { + const entry = { type: 'delete', task: task('b'), index: 5 } + expect(applyUndo([task('a')], entry).map((t) => t.id)).toEqual(['a', 'b']) + }) + + it('restores a cleared batch in original positions', () => { + const after = [task('b')] + const entry = { + type: 'clearCompleted', + removed: [ + { task: task('a', true), index: 0 }, + { task: task('c', true), index: 2 }, + ], + } + expect(applyUndo(after, entry).map((t) => t.id)).toEqual(['a', 'b', 'c']) + }) + + it('restores a batch cleared from an entirely completed list', () => { + const entry = { + type: 'clearCompleted', + removed: [ + { task: task('a', true), index: 0 }, + { task: task('b', true), index: 1 }, + ], + } + expect(applyUndo([], entry).map((t) => t.id)).toEqual(['a', 'b']) + }) + + it('is a no-op for a null entry', () => { + const tasks = [task('a')] + expect(applyUndo(tasks, null)).toBe(tasks) + }) + + it('is a no-op for an unrecognised entry type', () => { + const tasks = [task('a')] + expect(applyUndo(tasks, { type: 'nonsense' })).toBe(tasks) + }) + + it('does not mutate the input array', () => { + const before = [task('a')] + applyUndo(before, { type: 'delete', task: task('b'), index: 0 }) + expect(before).toHaveLength(1) + }) +})