mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: add pure task operations and bounded undo stack
Task operations become pure functions over arrays. Ids move from Date.now() to crypto.randomUUID(), with a fallback for non-secure contexts, since people do self-host this over plain HTTP on a LAN. Undo gets its own module so tasks.js stays pure. It covers single deletes and clear-completed batches, and clamps indices because the list can change between recording an entry and undoing it.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user