4 Commits

Author SHA1 Message Date
Aculix Technologies cd3c44fcdc chore: 2.2.2 2026-08-16 18:58:42 +05:30
Aculix Technologies c17dad6f1f fix: give editing its own button and hand the text back to dragging
Making the task text clickable turned it into a button, and the drag
handler skips anything inside a button. That quietly cost the row its
best drag handle: press and move across the text and nothing picked the
card up, so you got a text selection instead. Verified before changing
anything, dragging from the text did nothing while dragging from the row's
padding worked.

Editing moves to a pencil next to the arrow and the bin, and the text goes
back to being text. Rows also stop being selectable, since the row is a
drag handle and highlighting words across one is never what you meant. The
edit field keeps selection so text can still be copied out of it.

A third button costs width on a phone, where all three stay visible
because there is no hover. Grouping the actions so their gaps stop
multiplying across the row, and drawing them at 26px, keeps most of it
back: 199px of text becomes 187, 24 characters a line becomes 23. The
44px tap targets come from the overlay underneath, so a smaller drawn
button does not shrink them.

Also drops the click-after-drag guard, which only existed because the text
was clickable.
2026-08-16 18:57:59 +05:30
Aculix Technologies e54338f815 chore: 2.2.1 2026-08-16 18:51:21 +05:30
Aculix Technologies 7f1a02ee41 fix: moving a task between days is no longer silent
Deferring was the one action that changed your list and said nothing about
it. The task disappeared, no message, and Cmd/Ctrl+Z would not bring it
back because the undo stack only recorded deletes and clear-completed.

That was worse than it sounds. The app had just taught people, via the bar
that appears when something is deleted, that things which vanish come with
a way back. Deferring quietly broke that promise, and anyone who misread
the arrow would reasonably believe they had destroyed the task.

It now uses the same bar, saying which way the task went, and undo sends
it back to the row it left rather than the bottom of the list. That needed
an insert position on moveTaskToDay, which is tested along with the round
trip, clamping and the default append.

Undo for a move is handled separately from applyUndo, which edits a single
day's array and cannot express something that spans two.
2026-08-16 15:01:21 +05:30
7 changed files with 126 additions and 37 deletions
+4 -4
View File
@@ -14,7 +14,7 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
### Core Functionality ### Core Functionality
-**Add, complete, and delete tasks** with smooth animations -**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 - 📅 **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 - 🔄 **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 - ✏️ **Edit a task** - Fix a typo without deleting and retyping it
@@ -90,9 +90,9 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
### Managing Tasks ### Managing Tasks
- **Add a task**: Type in the input field and press Enter - **Add a task**: Type in the input field and press Enter
- **Complete a task**: Click the checkbox next to the task - **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 - **Edit a task**: Click the pencil on the task. 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 - **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 - **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+↓` - **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 - **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) - **Escape**: Clear input field (when input is focused)
- **Space/Enter**: Toggle task completion (when a task's checkbox is focused) - **Space/Enter**: Toggle task completion (when a task's checkbox is focused)
- **Delete**: Delete the focused task - **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+↓**: Move the focused task up or down
- **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today - **Alt+→ / Alt+←**: Send the focused task to Tomorrow, or bring it back to Today
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.2.0", "version": "2.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.2.0", "version": "2.2.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "negotium-todo", "name": "negotium-todo",
"version": "2.2.0", "version": "2.2.2",
"description": "A clean, minimal to-do list application with smooth animations and dark/light mode support", "description": "A clean, minimal to-do list application with smooth animations and dark/light mode support",
"type": "module", "type": "module",
"main": "index.html", "main": "index.html",
+44 -16
View File
@@ -94,12 +94,9 @@
let editingId = $state(null); let editingId = $state(null);
let editingText = $state(''); let editingText = $state('');
// A drag ends with a click event, which would otherwise drop the row you
// just moved straight into edit mode.
let suppressClick = false;
function startEditing(task) { function startEditing(task) {
if (suppressClick || draggedId) return; if (draggedId) return;
editingId = task.id; editingId = task.id;
editingText = task.text; editingText = task.text;
} }
@@ -139,8 +136,21 @@
* which day you are looking at, so one control covers both. */ * which day you are looking at, so one control covers both. */
function deferTask(taskId) { function deferTask(taskId) {
cancelEdit(); 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 +180,19 @@
function undo() { function undo() {
const entry = undoStack.pop(); const entry = undoStack.pop();
if (entry) setTasks(applyUndo(tasks, entry));
dismissUndo(); 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() { function toggleTheme() {
@@ -454,10 +475,6 @@
if (!wasActive) return; if (!wasActive) return;
// Swallow the click the browser fires after this drag.
suppressClick = true;
setTimeout(() => { suppressClick = false; }, 0);
// Clearing the inline transform while the settling class supplies a // Clearing the inline transform while the settling class supplies a
// transition eases the card into its slot. Nothing else on the list moves, // transition eases the card into its slot. Nothing else on the list moves,
// because nothing else changed. // because nothing else changed.
@@ -745,11 +762,21 @@
aria-label="Edit {task.text}" aria-label="Edit {task.text}"
/> />
{:else} {:else}
<button class="task-text" onclick={() => startEditing(task)} title="Click to edit"> <span class="task-text">{task.text}</span>
{task.text}
</button>
{/if} {/if}
<div class="row-actions">
<button
class="row-btn edit-btn"
onclick={() => startEditing(task)}
title="Edit"
aria-label="Edit {task.text}"
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button <button
class="row-btn defer-btn" class="row-btn defer-btn"
onclick={() => deferTask(task.id)} onclick={() => deferTask(task.id)}
@@ -779,8 +806,9 @@
<path d="M19,6V20A2,2 0 0,1 17,22H7A2,2 0 0,1 5,20V6M8,6V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M19,6V20A2,2 0 0,1 17,22H7A2,2 0 0,1 5,20V6M8,6V4A2,2 0 0,1 10,2H14A2,2 0 0,1 16,4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="10" y1="11" x2="10" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <line x1="10" y1="11" x2="10" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</button> </button>
</div>
</li> </li>
{/each} {/each}
</ul> </ul>
+12 -2
View File
@@ -9,8 +9,12 @@
* A task already sitting at the destination is not duplicated. It still leaves * 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 * the source day, which is what someone dragging a stray copy around would
* expect. * 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) const source = storage.loadTasks(fromKey)
if (fromKey === toKey) return source if (fromKey === toKey) return source
@@ -22,7 +26,13 @@ export function moveTaskToDay(storage, fromKey, toKey, taskId) {
const alreadyThere = destination.some(task => task.id === taskId) const alreadyThere = destination.some(task => task.id === taskId)
storage.saveTasks(fromKey, remaining) 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 return remaining
} }
+36
View File
@@ -81,3 +81,39 @@ describe('moveTaskToDay', () => {
expect(storage.loadTasks(TODAY)).toEqual([]) 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([])
})
})
+27 -12
View File
@@ -280,6 +280,9 @@ html.dark .day-option.selected {
.task-item { .task-item {
display: flex; display: flex;
/* The row is a drag handle, so pressing and moving across it should carry
the card rather than highlight words. */
user-select: none;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 16px;
padding: 16px 20px; padding: 16px 20px;
@@ -385,16 +388,9 @@ html.dark .task-item.dragging {
.task-text { .task-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
text-align: left;
background: transparent;
border: none;
padding-inline: 0;
/* Pads the 24px line box out to the 32px first-line band the row buttons /* Pads the 24px line box out to the 32px first-line band the row buttons
set, so the text centres against the checkbox. Declared as a longhand set, so the text centres against the checkbox. */
because a `padding: 0` shorthand here would reset it. */
padding-block: 4px; padding-block: 4px;
font-family: inherit;
cursor: text;
font-size: 16px; font-size: 16px;
/* 24px line box, the same height as the checkbox, so the two align on the /* 24px line box, the same height as the checkbox, so the two align on the
first line without nudging either. */ first line without nudging either. */
@@ -414,6 +410,8 @@ html.dark .task-item.dragging {
.task-edit { .task-edit {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
/* The one place text stays selectable, so it can still be copied out. */
user-select: text;
font-family: inherit; font-family: inherit;
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
@@ -426,6 +424,15 @@ html.dark .task-item.dragging {
outline: none; outline: none;
} }
/* One group rather than three loose children, so the gaps between the actions
do not multiply across the row on a narrow screen. */
.row-actions {
display: flex;
align-items: flex-start;
gap: 2px;
flex-shrink: 0;
}
.row-btn { .row-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -443,6 +450,11 @@ html.dark .task-item.dragging {
flex-shrink: 0; flex-shrink: 0;
} }
.edit-btn:hover {
background-color: var(--hover);
color: var(--accent);
}
.defer-btn:hover { .defer-btn:hover {
background-color: var(--hover); background-color: var(--hover);
color: var(--accent); color: var(--accent);
@@ -617,9 +629,13 @@ html.dark .data-status.error {
/* Smaller drawn box, same 44px target from the overlay below, and the /* Smaller drawn box, same 44px target from the overlay below, and the
margin keeps it in the 32px first-line band. */ margin keeps it in the 32px first-line band. */
.row-btn { .row-btn {
width: 28px; width: 26px;
height: 28px; height: 26px;
margin-block: 2px; margin-block: 3px;
}
.row-actions {
gap: 0;
} }
.checkbox::after, .checkbox::after,
@@ -777,7 +793,6 @@ html.dark .data-status.error {
longer exists, while five newer controls had no focus ring at all. */ longer exists, while five newer controls had no focus ring at all. */
.checkbox:focus-visible, .checkbox:focus-visible,
.row-btn:focus-visible, .row-btn:focus-visible,
.task-text:focus-visible,
.task-edit:focus-visible, .task-edit:focus-visible,
.theme-toggle:focus-visible, .theme-toggle:focus-visible,
.day-option:focus-visible, .day-option:focus-visible,