mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
Compare commits
6 Commits
f9ae76a87d
...
v2.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3c44fcdc | |||
| c17dad6f1f | |||
| e54338f815 | |||
| 7f1a02ee41 | |||
| 54958d0f42 | |||
| 6071dccec4 |
@@ -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
|
||||
@@ -90,9 +90,9 @@ The date, storage, rollover, task and undo logic lives in `src/lib/` as plain mo
|
||||
### Managing Tasks
|
||||
- **Add a task**: Type in the input field and press Enter
|
||||
- **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
|
||||
- **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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "negotium-todo",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "negotium-todo",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "negotium-todo",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.2",
|
||||
"description": "A clean, minimal to-do list application with smooth animations and dark/light mode support",
|
||||
"type": "module",
|
||||
"main": "index.html",
|
||||
|
||||
+42
-14
@@ -94,12 +94,9 @@
|
||||
|
||||
let editingId = $state(null);
|
||||
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) {
|
||||
if (suppressClick || draggedId) return;
|
||||
if (draggedId) return;
|
||||
editingId = task.id;
|
||||
editingText = task.text;
|
||||
}
|
||||
@@ -139,8 +136,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 +180,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() {
|
||||
@@ -454,10 +475,6 @@
|
||||
|
||||
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
|
||||
// transition eases the card into its slot. Nothing else on the list moves,
|
||||
// because nothing else changed.
|
||||
@@ -745,11 +762,21 @@
|
||||
aria-label="Edit {task.text}"
|
||||
/>
|
||||
{:else}
|
||||
<button class="task-text" onclick={() => startEditing(task)} title="Click to edit">
|
||||
{task.text}
|
||||
</button>
|
||||
<span class="task-text">{task.text}</span>
|
||||
{/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
|
||||
class="row-btn defer-btn"
|
||||
onclick={() => deferTask(task.id)}
|
||||
@@ -781,6 +808,7 @@
|
||||
<line x1="14" y1="11" x2="14" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
+93
-27
@@ -280,6 +280,9 @@ html.dark .day-option.selected {
|
||||
|
||||
.task-item {
|
||||
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;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
@@ -345,6 +348,8 @@ html.dark .task-item.dragging {
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* Pads the 24px box out to the 32px first-line band set by the row buttons. */
|
||||
margin-block: 4px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
@@ -383,12 +388,9 @@ html.dark .task-item.dragging {
|
||||
.task-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
cursor: text;
|
||||
/* Pads the 24px line box out to the 32px first-line band the row buttons
|
||||
set, so the text centres against the checkbox. */
|
||||
padding-block: 4px;
|
||||
font-size: 16px;
|
||||
/* 24px line box, the same height as the checkbox, so the two align on the
|
||||
first line without nudging either. */
|
||||
@@ -408,6 +410,8 @@ html.dark .task-item.dragging {
|
||||
.task-edit {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* The one place text stays selectable, so it can still be copied out. */
|
||||
user-select: text;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
@@ -420,6 +424,15 @@ html.dark .task-item.dragging {
|
||||
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 {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -437,6 +450,11 @@ html.dark .task-item.dragging {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: var(--hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.defer-btn:hover {
|
||||
background-color: var(--hover);
|
||||
color: var(--accent);
|
||||
@@ -608,6 +626,18 @@ html.dark .data-status.error {
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* Smaller drawn box, same 44px target from the overlay below, and the
|
||||
margin keeps it in the 32px first-line band. */
|
||||
.row-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-block: 3px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.checkbox::after,
|
||||
.row-btn::after {
|
||||
content: '';
|
||||
@@ -639,7 +669,7 @@ html.dark .data-status.error {
|
||||
itself, so it stays aligned with the checkbox on the first line. */
|
||||
.task-text {
|
||||
padding-block: 10px;
|
||||
margin-block: -10px;
|
||||
margin-block: -6px;
|
||||
}
|
||||
|
||||
.clear-completed,
|
||||
@@ -664,37 +694,59 @@ html.dark .data-status.error {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: calc(16px + env(safe-area-inset-top)) 24px 16px;
|
||||
padding: calc(12px + env(safe-area-inset-top)) 20px 12px;
|
||||
}
|
||||
|
||||
/* One row rather than two. Stacking cost a whole row of height on the
|
||||
screen with the least of it to spare. */
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
.main {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Wraps rather than stacking unconditionally, so the date only drops to
|
||||
its own line when it genuinely cannot share one. */
|
||||
.content-header {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 2px 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
/* Both row actions stay visible on touch because there is no hover to
|
||||
reveal them, and at this width they were eating 44% of the row. Tighter
|
||||
gaps and padding hand that back to the text. */
|
||||
.task-item {
|
||||
padding: 20px;
|
||||
min-height: 48px;
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
@@ -708,6 +760,21 @@ html.dark .data-status.error {
|
||||
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
|
||||
}
|
||||
|
||||
/* The wordmark is what stops the header fitting on one line here: it needs
|
||||
422px of row and there are 343. The logo carries the identity, and the
|
||||
installed app already has the name under its icon. Kept in the
|
||||
accessibility tree so the page still has its h1. */
|
||||
.app-title {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
@@ -726,7 +793,6 @@ html.dark .data-status.error {
|
||||
longer exists, while five newer controls had no focus ring at all. */
|
||||
.checkbox:focus-visible,
|
||||
.row-btn:focus-visible,
|
||||
.task-text:focus-visible,
|
||||
.task-edit:focus-visible,
|
||||
.theme-toggle:focus-visible,
|
||||
.day-option:focus-visible,
|
||||
|
||||
Reference in New Issue
Block a user