diff --git a/README.md b/README.md index 229a950..d9f4807 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless ### Core Functionality - ✅ **Add, complete, and delete tasks** with smooth animations -- ↩️ **Undo** - Deleted something by mistake? An Undo button appears, and `Cmd/Ctrl+Z` works too. The task returns to its original position +- ↩️ **Undo** - Deleted or moved something by mistake? An Undo button appears, and `Cmd/Ctrl+Z` works too. The task returns to its original position - 📅 **Today & Tomorrow lists** - Plan ahead with separate task lists - 🔄 **Unfinished work carries over** - When a new day begins, tasks you didn't finish move to Today; completed ones are cleared away - ✏️ **Edit a task** - Fix a typo without deleting and retyping it @@ -92,7 +92,7 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo - **Complete a task**: Click the checkbox next to the task - **Edit a task**: Click its text. Enter saves, Escape cancels, and clicking away saves too - **Delete a task**: Click the delete icon on the task (it shows on hover, and is always visible on touch), or press `Delete` with the task focused -- **Undo a delete**: An Undo button appears for a few seconds after anything is removed. `Cmd/Ctrl+Z` does the same. Either way the task returns to where it was +- **Undo**: An Undo button appears for a few seconds after a task is deleted, cleared or moved to another day. `Cmd/Ctrl+Z` does the same. Either way the task returns to where it was - **Move a task to Tomorrow**: Click the arrow on the task, or press `Alt+→`. From Tomorrow, the arrow points back and `Alt+←` returns it to Today - **Reorder tasks**: Drag with a mouse, long-press then drag on touch, or focus a task and press `Alt+↑`/`Alt+↓` - **Clear input**: Press Escape while the input is focused @@ -130,7 +130,7 @@ Import only ever adds. Tasks already present are left alone, so importing the sa - **Escape**: Clear input field (when input is focused) - **Space/Enter**: Toggle task completion (when a task's checkbox is focused) - **Delete**: Delete the focused task -- **Cmd/Ctrl+Z**: Undo the last delete or clear-completed +- **Cmd/Ctrl+Z**: Undo the last delete, clear-completed, or move between days - **Alt+↑ / Alt+↓**: Move the focused task up or down - **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today diff --git a/src/App.svelte b/src/App.svelte index 0f0a80f..ddf60e5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -139,8 +139,21 @@ * which day you are looking at, so one control covers both. */ function deferTask(taskId) { cancelEdit(); - const destination = viewingToday ? tomorrowKey() : todayKey; - tasks = moveTaskToDay(storage, selectedKey, destination, taskId); + + const index = tasks.findIndex(task => task.id === taskId); + if (index === -1) return; + + const movingToTomorrow = viewingToday; + const from = selectedKey; + const to = movingToTomorrow ? tomorrowKey() : todayKey; + + tasks = moveTaskToDay(storage, from, to, taskId); + + // A task vanishing from the list with nothing said about it reads as a + // delete, especially right after the app has taught you that deletes come + // with a way back. It gets the same treatment. + undoStack.push({ type: 'move', id: taskId, from, to, index }); + showUndo(movingToTomorrow ? 'Moved to Tomorrow' : 'Moved to Today'); } /** @@ -170,8 +183,19 @@ function undo() { const entry = undoStack.pop(); - if (entry) setTasks(applyUndo(tasks, entry)); dismissUndo(); + if (!entry) return; + + // A move spans two days, so it cannot be expressed as an edit to the one + // list applyUndo works on. It is sent back to the row it came from, which + // is why the index was recorded. + if (entry.type === 'move') { + moveTaskToDay(storage, entry.to, entry.from, entry.id, entry.index); + tasks = storage.loadTasks(selectedKey); + return; + } + + setTasks(applyUndo(tasks, entry)); } function toggleTheme() { diff --git a/src/lib/defer.js b/src/lib/defer.js index 000413b..ab21495 100644 --- a/src/lib/defer.js +++ b/src/lib/defer.js @@ -9,8 +9,12 @@ * A task already sitting at the destination is not duplicated. It still leaves * the source day, which is what someone dragging a stray copy around would * expect. + * + * `insertAt` places the task at a position rather than at the end. Undoing a + * move needs it: a task sent to Tomorrow should come back to the row it left, + * not to the bottom of the list. */ -export function moveTaskToDay(storage, fromKey, toKey, taskId) { +export function moveTaskToDay(storage, fromKey, toKey, taskId, insertAt = null) { const source = storage.loadTasks(fromKey) if (fromKey === toKey) return source @@ -22,7 +26,13 @@ export function moveTaskToDay(storage, fromKey, toKey, taskId) { const alreadyThere = destination.some(task => task.id === taskId) storage.saveTasks(fromKey, remaining) - if (!alreadyThere) storage.saveTasks(toKey, [...destination, moving]) + + if (!alreadyThere) { + const next = [...destination] + const at = insertAt === null ? next.length : Math.max(0, Math.min(insertAt, next.length)) + next.splice(at, 0, moving) + storage.saveTasks(toKey, next) + } return remaining } diff --git a/src/lib/defer.test.js b/src/lib/defer.test.js index 06c448c..6677f7e 100644 --- a/src/lib/defer.test.js +++ b/src/lib/defer.test.js @@ -81,3 +81,39 @@ describe('moveTaskToDay', () => { expect(storage.loadTasks(TODAY)).toEqual([]) }) }) + +describe('moveTaskToDay with an insert position', () => { + it('inserts at the given index rather than appending', () => { + const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a'), task('b'), task('c')] }) + moveTaskToDay(storage, TOMORROW, TODAY, 'x', 1) + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x', 'b', 'c']) + }) + + it('puts a task back at the front', () => { + const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] }) + moveTaskToDay(storage, TOMORROW, TODAY, 'x', 0) + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['x', 'a']) + }) + + it('clamps an index past the end', () => { + const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] }) + moveTaskToDay(storage, TOMORROW, TODAY, 'x', 99) + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x']) + }) + + it('still appends when no index is given', () => { + const storage = setup({ [TOMORROW]: [task('x')], [TODAY]: [task('a')] }) + moveTaskToDay(storage, TOMORROW, TODAY, 'x') + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'x']) + }) + + it('round-trips a defer back to where it started', () => { + const storage = setup({ [TODAY]: [task('a'), task('b'), task('c')] }) + moveTaskToDay(storage, TODAY, TOMORROW, 'b') + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'c']) + + moveTaskToDay(storage, TOMORROW, TODAY, 'b', 1) + expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a', 'b', 'c']) + expect(storage.loadTasks(TOMORROW)).toEqual([]) + }) +})