mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: move a task between Today and Tomorrow
The app is built around two days and had no way to move anything between them. "I won't get to this today" is the most obvious thing someone wants from a two-day list, and the only route was deleting the task and typing it again somewhere else. Each row gets an arrow next to delete. On Today it sends the task forward, on Tomorrow it points back, so one control covers both directions and the label says which one you are getting. Alt+Right and Alt+Left do the same from the keyboard, and each only acts in the direction that makes sense from the day you are on. The move itself is in src/lib/defer.js with tests, since it touches two date keys at once and the failure modes are worth pinning down: a missing id, the same day twice, an empty destination, and a task that somehow already exists on the other side.
This commit is contained in:
@@ -17,6 +17,7 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
|
||||
- ↩️ **Undo** - `Cmd/Ctrl+Z` brings back a deleted task, in 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
|
||||
- ➡️ **Push a task to tomorrow** - Didn't get to it? Move it across without retyping it
|
||||
- 🎯 **Reorder by drag or keyboard** - Drag with a mouse, long-press and drag on touch, or use `Alt+↑`/`Alt+↓`
|
||||
- 💾 **Persistent storage** - All tasks saved locally in your browser
|
||||
- 📊 **Task statistics** - See how many tasks remain at a glance
|
||||
@@ -90,6 +91,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
|
||||
- **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**: Press `Cmd/Ctrl+Z`. 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
|
||||
|
||||
@@ -128,6 +130,7 @@ Import only ever adds. Tasks already present are left alone, so importing the sa
|
||||
- **Delete**: Delete the focused task
|
||||
- **Cmd/Ctrl+Z**: Undo the last delete or clear-completed
|
||||
- **Alt+↑ / Alt+↓**: Move the focused task up or down
|
||||
- **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today
|
||||
|
||||
Backspace no longer deletes a task. It is too easily pressed by accident, and deletions used to be unrecoverable.
|
||||
|
||||
|
||||
+41
-1
@@ -12,6 +12,7 @@
|
||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
|
||||
import { moveTaskToDay } from './lib/defer.js';
|
||||
|
||||
const storage = createStorage();
|
||||
const undoStack = createUndoStack();
|
||||
@@ -75,6 +76,15 @@
|
||||
setTasks(taskOps.clearCompleted(tasks));
|
||||
}
|
||||
|
||||
const viewingToday = $derived(selectedKey === todayKey);
|
||||
|
||||
/** "Not today, tomorrow" and its reverse. Which direction depends only on
|
||||
* which day you are looking at, so one control covers both. */
|
||||
function deferTask(taskId) {
|
||||
const destination = viewingToday ? tomorrowKey() : todayKey;
|
||||
tasks = moveTaskToDay(storage, selectedKey, destination, taskId);
|
||||
}
|
||||
|
||||
function undo() {
|
||||
const entry = undoStack.pop();
|
||||
if (entry) setTasks(applyUndo(tasks, entry));
|
||||
@@ -183,6 +193,17 @@
|
||||
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
event.preventDefault();
|
||||
moveTask(taskId, event.key === 'ArrowUp' ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Right sends a task forward to Tomorrow, left brings it back. Only the
|
||||
// one that makes sense from here does anything.
|
||||
if (event.altKey && event.key === 'ArrowRight' && viewingToday) {
|
||||
event.preventDefault();
|
||||
deferTask(taskId);
|
||||
} else if (event.altKey && event.key === 'ArrowLeft' && !viewingToday) {
|
||||
event.preventDefault();
|
||||
deferTask(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +616,26 @@
|
||||
<span class="task-text">{task.text}</span>
|
||||
|
||||
<button
|
||||
class="delete-btn"
|
||||
class="row-btn defer-btn"
|
||||
onclick={() => deferTask(task.id)}
|
||||
title={viewingToday ? 'Move to Tomorrow' : 'Move to Today'}
|
||||
aria-label={viewingToday ? `Move ${task.text} to Tomorrow` : `Move ${task.text} to Today`}
|
||||
>
|
||||
{#if viewingToday}
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 6L19 12L13 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 6L5 12L11 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="row-btn delete-btn"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
aria-label="Delete {task.text}"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Moves one task from one day to another and returns what is left on the
|
||||
* source day.
|
||||
*
|
||||
* This is the verb a two-day app is built around: "not today, tomorrow". Both
|
||||
* days are written before returning, so a caller only has to reload the day it
|
||||
* is showing.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function moveTaskToDay(storage, fromKey, toKey, taskId) {
|
||||
const source = storage.loadTasks(fromKey)
|
||||
if (fromKey === toKey) return source
|
||||
|
||||
const moving = source.find(task => task.id === taskId)
|
||||
if (!moving) return source
|
||||
|
||||
const remaining = source.filter(task => task.id !== taskId)
|
||||
const destination = storage.loadTasks(toKey)
|
||||
const alreadyThere = destination.some(task => task.id === taskId)
|
||||
|
||||
storage.saveTasks(fromKey, remaining)
|
||||
if (!alreadyThere) storage.saveTasks(toKey, [...destination, moving])
|
||||
|
||||
return remaining
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createStorage, createMemoryStore } from './storage.js'
|
||||
import { moveTaskToDay } from './defer.js'
|
||||
|
||||
const task = (id, text = id) => ({ id, text, completed: false, createdAt: 1000 })
|
||||
|
||||
const setup = (seed = {}) => {
|
||||
const backend = createMemoryStore()
|
||||
for (const [day, tasks] of Object.entries(seed)) {
|
||||
backend.setItem(`negotium-tasks-${day}`, JSON.stringify(tasks))
|
||||
}
|
||||
return createStorage(backend)
|
||||
}
|
||||
|
||||
const TODAY = '2026-08-16'
|
||||
const TOMORROW = '2026-08-17'
|
||||
|
||||
describe('moveTaskToDay', () => {
|
||||
it('removes the task from the source day', () => {
|
||||
const storage = setup({ [TODAY]: [task('a'), task('b')] })
|
||||
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('appends the task to the destination day', () => {
|
||||
const storage = setup({ [TODAY]: [task('a')], [TOMORROW]: [task('existing')] })
|
||||
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['existing', 'a'])
|
||||
})
|
||||
|
||||
it('works when the destination day has nothing stored yet', () => {
|
||||
const storage = setup({ [TODAY]: [task('a')] })
|
||||
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('carries the task across unchanged', () => {
|
||||
const original = { id: 'a', text: 'buy milk', completed: true, createdAt: 42 }
|
||||
const storage = setup({ [TODAY]: [original] })
|
||||
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(storage.loadTasks(TOMORROW)[0]).toEqual(original)
|
||||
})
|
||||
|
||||
it('returns the source day’s remaining tasks', () => {
|
||||
const storage = setup({ [TODAY]: [task('a'), task('b')] })
|
||||
const result = moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(result.map(t => t.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('leaves the other tasks in their original order', () => {
|
||||
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'])
|
||||
})
|
||||
|
||||
it('is a no-op for an id that is not there', () => {
|
||||
const storage = setup({ [TODAY]: [task('a')] })
|
||||
const result = moveTaskToDay(storage, TODAY, TOMORROW, 'zzz')
|
||||
expect(result.map(t => t.id)).toEqual(['a'])
|
||||
expect(storage.loadTasks(TOMORROW)).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op when source and destination are the same day', () => {
|
||||
const storage = setup({ [TODAY]: [task('a')] })
|
||||
const result = moveTaskToDay(storage, TODAY, TODAY, 'a')
|
||||
expect(result.map(t => t.id)).toEqual(['a'])
|
||||
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('moves back the other way just as well', () => {
|
||||
const storage = setup({ [TOMORROW]: [task('a')], [TODAY]: [task('b')] })
|
||||
moveTaskToDay(storage, TOMORROW, TODAY, 'a')
|
||||
expect(storage.loadTasks(TODAY).map(t => t.id)).toEqual(['b', 'a'])
|
||||
expect(storage.loadTasks(TOMORROW)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not duplicate a task already present at the destination', () => {
|
||||
const storage = setup({ [TODAY]: [task('a')], [TOMORROW]: [task('a')] })
|
||||
moveTaskToDay(storage, TODAY, TOMORROW, 'a')
|
||||
expect(storage.loadTasks(TOMORROW).map(t => t.id)).toEqual(['a'])
|
||||
expect(storage.loadTasks(TODAY)).toEqual([])
|
||||
})
|
||||
})
|
||||
+11
-6
@@ -381,7 +381,7 @@ html.dark .task-item.dragging {
|
||||
color: var(--completed-text);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
.row-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
@@ -398,11 +398,16 @@ html.dark .task-item.dragging {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.defer-btn:hover {
|
||||
background-color: var(--hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* focus-within matters as much as hover here: the button is opacity 0 by
|
||||
default, so a keyboard user tabbing to it previously saw nothing at all:
|
||||
the focus outline was drawn on an invisible element. */
|
||||
.task-item:hover .delete-btn,
|
||||
.task-item:focus-within .delete-btn {
|
||||
.task-item:hover .row-btn,
|
||||
.task-item:focus-within .row-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -412,7 +417,7 @@ html.dark .task-item.dragging {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.delete-btn svg {
|
||||
.row-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -558,14 +563,14 @@ html.dark .data-status.error {
|
||||
* just easier to hit. Kept behind the media query so a mouse still gets small,
|
||||
* precise targets and the full row stays available to drag. */
|
||||
@media (hover: none) {
|
||||
.delete-btn {
|
||||
.row-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.checkbox::after,
|
||||
.delete-btn::after {
|
||||
.row-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
Reference in New Issue
Block a user