7 Commits

Author SHA1 Message Date
Aculix Technologies f9ae76a87d fix: focus rings and tap targets for the new controls
A pass over what the last four changes added.

The focus-visible rule named a handful of controls by hand and had gone
stale. It still pointed at .today-btn, which no longer exists, and none of
the five newer controls were listed, so tabbing to the defer arrow, either
day option, the task text, the Undo button or the dismiss cross showed
nothing at all. They are all covered now, and the row buttons reveal
themselves when focused, since a ring on a transparent button is not much
use.

Tap targets on touch: the day options came out 36px tall because the
switch's own padding and border ate into its height, and the task text was
24. The switch is 46 with the padding removed so its buttons get the full
44, and the theme toggle matches so the header stays level. The text grows
by padding with the same amount taken back as negative margin, so it is
44px to a finger without moving off the first line.

Also drops the fade on the carry-over notice. It was hiding whether the
thing was actually unmounting, and a fade earns very little on something
that appears once a day.

Checked at both widths and in both themes: contrast passes on every new
element, nothing scrolls sideways, and a fresh load runs the whole feature
set with no console errors.
2026-08-16 03:41:07 +05:30
Aculix Technologies 1198158abf fix: say what carry-over did, and stop the day control being a riddle
Two pieces of copy that were making the app harder to read than it needs
to be.

Carry-over happened in total silence. Open the app after a weekend and
unfamiliar tasks were sitting in Today with nothing explaining where they
came from, which reads as a bug rather than the feature it is. A line now
says how many were brought forward, on the day it happened, and it can be
dismissed.

The day control was a single button labelled with the day you were already
on, which then took you somewhere else. Clicking it was the only way to
find out what it did, and the date underneath already said where you were.
It is two options now with the current one marked, so the control states
both where you are and what the alternative is without being touched.

Also drops labelFor from dates.js along with its tests. It existed to
label that one button and nothing uses it now.
2026-08-16 03:36:16 +05:30
Aculix Technologies d97550feb8 feat: offer undo at the moment something is removed
Undo existed but nothing said so. The only route was Cmd/Ctrl+Z, which was
documented in a README nobody reads mid-task, and which does not exist on
a phone at all. On touch there was simply no way to get a deleted task
back.

Deleting a task or clearing completed ones now brings up a short-lived bar
with a real Undo button, so touch has a route for the first time. It also
shows the keyboard shortcut, which is the point: someone learns it once,
here, at the only moment they actually care.

It uses the existing tokens, sits out of the way at the bottom, clears
itself after seven seconds and goes as soon as it is used. The shortcut
badge is hidden where no keyboard exists rather than advertising a chord
nobody can press.
2026-08-16 03:31:08 +05:30
Aculix Technologies 02c8d6f6b0 feat: edit a task in place
Fixing a typo meant deleting the task and typing it again, which also cost
you its position in the list. Click the text to edit it. Enter saves,
Escape cancels, clicking away saves.

The rename rule lives in tasks.js and is tested. It refuses blank input
rather than treating it as a delete, since selecting all and hitting enter
by accident should give you your task back, and it returns the original
array untouched when nothing changed so an idle edit does not write to
storage.

The interesting part was what editing collides with. Keystrokes are
stopped from reaching the row, or Delete would remove the task you are
typing into and Alt+Arrow would reorder it mid-edit. A drag cannot start
from inside the field, and the click a drag leaves behind is swallowed, or
dropping a row would open it for editing. Switching day, deferring a task
and the midnight rollover all close an open editor, so it cannot be left
hanging over a row that is no longer there.
2026-08-16 03:28:48 +05:30
Aculix Technologies 38af47aeb6 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.
2026-08-16 03:25:35 +05:30
Aculix Technologies bfffb4ac02 chore: 2.1.1 2026-08-16 02:39:57 +05:30
Aculix Technologies b846717db9 fix: touch reachability, contrast and alignment
A pass over desktop, tablet and phone widths, measuring rather than
eyeballing.

The one that matters: a task could not be deleted on a phone. The delete
button is revealed on hover, touch devices report hover: none, so it sat
at zero opacity with nothing to reveal it. The README told people to hover
over a task and click it. It is now always visible where hover does not
exist.

Touch targets were under the 44px minimum: the checkbox at 24, delete at
32, the footer links at 23 tall. They now get 44px hit areas from overlays
that leave the drawn size alone, behind a pointer: coarse query so a mouse
keeps small precise targets and the full row stays draggable.

Two contrast failures in light mode. The date was the brand blue on the
near-white background at 3.54:1, so text now uses a darker --accent-text
and reads 5.14:1. Completed rows carried opacity 0.8 over an already
mid-contrast blue, which came to 3.34:1; the tint, the strikethrough and
the colour say "done" well enough without it, and dropping it gives 4.78:1.
Both themes now pass AA everywhere measured.

The checkbox sat vertically centred, so on a task wrapping to five lines
it floated in the middle of the block. Rows align to the top and task text
gets a 24px line box, matching the checkbox exactly, which also spaces
wrapped lines better.

Reduced motion was only honoured by the empty-state drawing. Svelte's
transitions run in JavaScript and never saw the media query, so tasks
still flew in on a stagger. The component reads the preference directly
and collapses its durations, and CSS covers the rest. The lift on a
dragged card stays, since it tracks the pointer rather than playing at you.

Long unbroken words already wrapped and no breakpoint scrolled sideways.
2026-08-16 02:36:48 +05:30
11 changed files with 708 additions and 81 deletions
+10 -5
View File
@@ -14,9 +14,11 @@ Built with Svelte for speed and simplicity. No overwhelming features, no endless
### Core Functionality
-**Add, complete, and delete tasks** with smooth animations
- ↩️ **Undo** - `Cmd/Ctrl+Z` brings back a deleted task, in its original position
- ↩️ **Undo** - Deleted 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
- ➡️ **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
@@ -88,16 +90,18 @@ 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
- **Delete a task**: Hover over a task and click the delete icon, or press `Delete` with the task focused
- **Undo a delete**: Press `Cmd/Ctrl+Z`. The task returns to where it was
- **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
- **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
### Date Management
- **Switch between Today and Tomorrow**: Click the date button in the header
- **Switch between Today and Tomorrow**: Pick either one in the header. The current day is the highlighted one
- **Plan ahead**: Add tasks to Tomorrow's list before you need them
- **Separate lists**: Today and Tomorrow maintain independent task lists
- **Carry-over**: When a new day begins, whatever you didn't finish moves into Today. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you.
- **Carry-over**: When a new day begins, whatever you didn't finish moves into Today, and the app tells you how many tasks it brought forward so they don't look like they appeared from nowhere. Completed tasks are cleared away with the day they belonged to. It works across gaps too. If you don't open Negotium for a week, everything still outstanding is waiting for you.
- **While it's open**: The app notices the day change on its own, so a tab left open overnight rolls over without a reload.
### Installing It
@@ -128,6 +132,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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "negotium-todo",
"version": "2.1.0",
"version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "negotium-todo",
"version": "2.1.0",
"version": "2.1.1",
"license": "MIT",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "negotium-todo",
"version": "2.1.0",
"version": "2.1.1",
"description": "A clean, minimal to-do list application with smooth animations and dark/light mode support",
"type": "module",
"main": "index.html",
+222 -19
View File
@@ -5,13 +5,14 @@
import { cubicOut } from 'svelte/easing';
import './style.css';
import { toKey, addDays, fromKey, labelFor, formatLong } from './lib/dates.js';
import { toKey, addDays, fromKey, formatLong } from './lib/dates.js';
import { createStorage } from './lib/storage.js';
import { rollover, msUntilNextMidnight } from './lib/rollover.js';
import * as taskOps from './lib/tasks.js';
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();
@@ -23,7 +24,19 @@
storage.migrateLegacyKeys();
const bootNow = new Date();
const bootTodayKey = toKey(bootNow);
const beforeRollover = storage.loadTasks(bootTodayKey).length;
let tasks = $state(rollover(storage, bootNow));
/** How many unfinished tasks were pulled forward from earlier days on this
* load. The app's cleverest behaviour used to happen in complete silence,
* so returning after a weekend looked like a bug rather than a feature. */
let carriedOver = $state(Math.max(0, tasks.length - beforeRollover));
function dismissCarriedOver() {
carriedOver = 0;
}
let newTask = $state('');
// Seeded from the class the pre-paint script in index.html already set, so
// there is one source of truth and no post-mount correction to flash.
@@ -61,6 +74,7 @@
if (index === -1) return;
undoStack.push({ type: 'delete', task: tasks[index], index });
setTasks(taskOps.deleteTask(tasks, id));
showUndo('Task deleted');
}
function clearCompleted() {
@@ -73,17 +87,110 @@
if (removed.length === 0) return;
undoStack.push({ type: 'clearCompleted', removed });
setTasks(taskOps.clearCompleted(tasks));
showUndo(removed.length === 1 ? '1 completed task cleared' : `${removed.length} completed tasks cleared`);
}
const viewingToday = $derived(selectedKey === todayKey);
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;
editingId = task.id;
editingText = task.text;
}
function commitEdit() {
if (editingId === null) return;
// renameTask refuses blank input, so an accidental select-all and enter
// leaves the task as it was rather than wiping it.
setTasks(taskOps.renameTask(tasks, editingId, editingText));
editingId = null;
}
function cancelEdit() {
editingId = null;
}
function handleEditKeydown(event) {
// Stop these reaching the row, which would delete or reorder the task
// being typed into.
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}
function focusOnMount(node) {
node.focus();
node.select();
}
/** "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) {
cancelEdit();
const destination = viewingToday ? tomorrowKey() : todayKey;
tasks = moveTaskToDay(storage, selectedKey, destination, taskId);
}
/**
* Undo used to be reachable only by Cmd/Ctrl+Z, which nothing announced and
* which does not exist on a phone at all. This is the one moment it is worth
* saying something: right after work disappears. It carries the shortcut too,
* so a keyboard user learns it once, here, rather than from a README.
*/
let undoNotice = $state(null);
let undoNoticeTimer = null;
const UNDO_NOTICE_MS = 7000;
const shortcutLabel = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)
? '\u2318Z'
: 'Ctrl+Z';
function showUndo(message) {
undoNotice = message;
clearTimeout(undoNoticeTimer);
undoNoticeTimer = setTimeout(() => { undoNotice = null; }, UNDO_NOTICE_MS);
}
function dismissUndo() {
clearTimeout(undoNoticeTimer);
undoNotice = null;
}
function undo() {
const entry = undoStack.pop();
if (entry) setTasks(applyUndo(tasks, entry));
dismissUndo();
}
function toggleTheme() {
darkMode = !darkMode;
}
// Svelte's transitions are driven in JavaScript, so the CSS media query in
// style.css cannot reach them. Read the same preference here and collapse the
// durations. The lift on a dragged card stays: it tracks the pointer rather
// than playing at you, and losing it would make dragging harder to follow.
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let reduceMotion = $state(motionQuery.matches);
const flyIn = (index) =>
reduceMotion ? { duration: 0 } : { y: -10, duration: 300, delay: index * 30, easing: cubicOut };
const flyOut = (index) =>
reduceMotion ? { duration: 0 } : { x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut };
let fileInput;
let status = $state('');
let statusIsError = $state(false);
@@ -170,6 +277,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);
}
}
@@ -271,6 +389,7 @@
if (event.pointerType === 'mouse' && event.button !== 0) return;
// Let the checkbox and delete button have their clicks.
if (event.target.closest('button')) return;
if (event.target.closest('.task-edit')) return;
drag = {
id: tasks[index].id,
@@ -335,6 +454,10 @@
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.
@@ -363,8 +486,11 @@
return toKey(addDays(fromKey(todayKey), 1));
}
function switchDate() {
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey;
function showDay(key) {
if (key === selectedKey) return;
cancelEdit();
dismissUndo();
selectedKey = key;
tasks = storage.loadTasks(selectedKey);
}
@@ -377,11 +503,15 @@
* they follow it there, which preserves the existing mental model.
*/
function runRollover() {
cancelEdit();
const now = new Date();
const wasViewingToday = selectedKey === todayKey;
todayKey = toKey(now);
const before = storage.loadTasks(todayKey).length;
const todayTasks = rollover(storage, now);
const moved = todayTasks.length - before;
if (moved > 0) carriedOver = moved;
if (wasViewingToday) {
selectedKey = todayKey;
@@ -399,12 +529,15 @@
}, msUntilNextMidnight(new Date()));
}
function handleMotionPreference(event) {
reduceMotion = event.matches;
}
function handleVisibility() {
if (document.visibilityState === 'visible') runRollover();
}
const currentDateDisplay = $derived(formatLong(selectedKey));
const buttonText = $derived(labelFor(selectedKey, todayKey));
const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
const completedTasks = $derived(tasks.filter(task => task.completed).length);
@@ -426,11 +559,13 @@
scheduleMidnight();
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('focus', runRollover);
motionQuery.addEventListener('change', handleMotionPreference);
return () => {
clearTimeout(midnightTimer);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('focus', runRollover);
motionQuery.removeEventListener('change', handleMotionPreference);
};
});
</script>
@@ -452,15 +587,26 @@
</div>
<div class="header-actions">
<button class="today-btn" aria-label="Switch between Today and Tomorrow" onclick={switchDate}>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
</svg>
{buttonText}
</button>
<!-- Both options are visible with the current one marked, so the
control no longer has to be clicked to find out what it does. -->
<div class="day-switch" role="group" aria-label="Choose a day">
<button
class="day-option"
class:selected={viewingToday}
aria-pressed={viewingToday}
onclick={() => showDay(todayKey)}
>
Today
</button>
<button
class="day-option"
class:selected={!viewingToday}
aria-pressed={!viewingToday}
onclick={() => showDay(tomorrowKey())}
>
Tomorrow
</button>
</div>
<button
class="theme-toggle"
@@ -515,10 +661,25 @@
{/if}
</div>
{#if carriedOver > 0 && viewingToday}
<div class="carried-notice">
<span>
{carriedOver === 1
? 'One unfinished task carried over from an earlier day.'
: `${carriedOver} unfinished tasks carried over from earlier days.`}
</span>
<button class="carried-dismiss" onclick={dismissCarriedOver} aria-label="Dismiss">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
{/if}
<div class="task-list">
{#key selectedKey}
{#if tasks.length === 0}
<div class="empty-state" transition:fade={{ duration: 200 }}>
<div class="empty-state" transition:fade={{ duration: reduceMotion ? 0 : 200 }}>
<svg class="empty-art" viewBox="0 0 120 120" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<g class="empty-art-motes" stroke="currentColor" stroke-width="3" stroke-linecap="round">
<path d="M40 34 L40 34" />
@@ -550,9 +711,9 @@
class:dragging={task.id === draggedId}
class:settling={task.id === settlingId}
style={rowStyle(task.id)}
animate:flip={{ duration: task.id === draggedId ? 0 : 240, easing: cubicOut }}
in:fly={{ y: -10, duration: 300, delay: index * 30, easing: cubicOut }}
out:fly={{ x: 30, opacity: 0, duration: 250, delay: index * 20, easing: cubicOut }}
animate:flip={{ duration: (reduceMotion || task.id === draggedId) ? 0 : 240, easing: cubicOut }}
in:fly={flyIn(index)}
out:fly={flyOut(index)}
onpointerdown={(e) => handlePointerDown(e, index)}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
@@ -573,10 +734,43 @@
{/if}
</button>
<span class="task-text">{task.text}</span>
{#if task.id === editingId}
<input
class="task-edit"
type="text"
bind:value={editingText}
onkeydown={handleEditKeydown}
onblur={commitEdit}
use:focusOnMount
aria-label="Edit {task.text}"
/>
{:else}
<button class="task-text" onclick={() => startEditing(task)} title="Click to edit">
{task.text}
</button>
{/if}
<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}"
>
@@ -612,3 +806,12 @@
</div>
</main>
</div>
{#if undoNotice}
<div class="undo-toast" role="status" aria-live="polite" transition:fly={{ y: reduceMotion ? 0 : 12, duration: reduceMotion ? 0 : 200, easing: cubicOut }}>
<span class="undo-message">{undoNotice}</span>
<button class="undo-action" onclick={undo}>
Undo <kbd class="undo-key">{shortcutLabel}</kbd>
</button>
</div>
{/if}
-6
View File
@@ -28,12 +28,6 @@ export function isKey(value) {
return KEY_PATTERN.test(value)
}
export function labelFor(dateKey, todayKey) {
if (dateKey === todayKey) return 'Today'
if (dateKey === toKey(addDays(fromKey(todayKey), 1))) return 'Tomorrow'
return fromKey(dateKey).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function formatLong(dateKey) {
return fromKey(dateKey).toLocaleDateString('en-US', {
weekday: 'long',
+1 -23
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { toKey, fromKey, addDays, isKey, labelFor, formatLong } from './dates.js'
import { toKey, fromKey, addDays, isKey, formatLong } from './dates.js'
describe('toKey', () => {
it('formats a date as local YYYY-MM-DD', () => {
@@ -60,28 +60,6 @@ describe('isKey', () => {
})
})
describe('labelFor', () => {
it('labels today', () => {
expect(labelFor('2026-08-15', '2026-08-15')).toBe('Today')
})
it('labels tomorrow', () => {
expect(labelFor('2026-08-16', '2026-08-15')).toBe('Tomorrow')
})
it('labels tomorrow across a month boundary', () => {
expect(labelFor('2026-09-01', '2026-08-31')).toBe('Tomorrow')
})
it('labels tomorrow across a year boundary', () => {
expect(labelFor('2027-01-01', '2026-12-31')).toBe('Tomorrow')
})
it('falls back to a short date for other days', () => {
expect(labelFor('2026-08-20', '2026-08-15')).toBe('Aug 20')
})
})
describe('formatLong', () => {
it('renders the long-form date used in the header', () => {
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
+28
View File
@@ -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
}
+83
View File
@@ -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 days 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([])
})
})
+18
View File
@@ -39,3 +39,21 @@ export function reorderTask(tasks, from, to) {
export function clearCompleted(tasks) {
return tasks.filter((task) => !task.completed)
}
/**
* Rewrites a task's text in place, keeping its id, position and completion.
*
* Blank input is refused rather than treated as a delete. Someone who selects
* all and hits enter by accident should get their task back, not lose it, and
* an empty row would be unreadable anyway. Returning the original array when
* nothing changed also keeps a pointless write out of storage.
*/
export function renameTask(tasks, id, text) {
const trimmed = text.trim()
if (!trimmed) return tasks
const current = tasks.find((task) => task.id === id)
if (!current || current.text === trimmed) return tasks
return tasks.map((task) => (task.id === id ? { ...task, text: trimmed } : task))
}
+55 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted } from './tasks.js'
import { addTask, toggleTask, deleteTask, reorderTask, clearCompleted, renameTask } from './tasks.js'
const task = (id, completed = false) => ({ id, text: id, completed })
@@ -126,3 +126,57 @@ describe('clearCompleted', () => {
expect(clearCompleted([task('a', true)])).toEqual([])
})
})
describe('renameTask', () => {
const task = (id, text = id, completed = false) => ({ id, text, completed, createdAt: 1 })
it('replaces the text', () => {
expect(renameTask([task('a', 'old')], 'a', 'new')[0].text).toBe('new')
})
it('trims what it is given', () => {
expect(renameTask([task('a', 'old')], 'a', ' spaced ')[0].text).toBe('spaced')
})
it('refuses to blank a task', () => {
const before = [task('a', 'keep me')]
expect(renameTask(before, 'a', '')).toBe(before)
expect(renameTask(before, 'a', ' ')).toBe(before)
})
it('keeps completion, id and creation time', () => {
const before = [{ id: 'a', text: 'old', completed: true, createdAt: 99 }]
expect(renameTask(before, 'a', 'new')[0]).toEqual({ id: 'a', text: 'new', completed: true, createdAt: 99 })
})
it('leaves the other tasks alone', () => {
const result = renameTask([task('a'), task('b'), task('c')], 'b', 'changed')
expect(result.map(t => t.text)).toEqual(['a', 'changed', 'c'])
})
it('keeps the task in place', () => {
const result = renameTask([task('a'), task('b')], 'a', 'changed')
expect(result.map(t => t.id)).toEqual(['a', 'b'])
})
it('ignores an unknown id', () => {
const before = [task('a')]
expect(renameTask(before, 'zzz', 'nope')).toBe(before)
})
it('is a no-op when the text has not changed', () => {
const before = [task('a', 'same')]
expect(renameTask(before, 'a', 'same')).toBe(before)
})
it('handles emoji and non-latin text', () => {
expect(renameTask([task('a')], 'a', '買い物 🛒')[0].text).toBe('買い物 🛒')
expect(renameTask([task('a')], 'a', 'اشتر الحليب')[0].text).toBe('اشتر الحليب')
})
it('does not mutate the input array', () => {
const before = [task('a', 'old')]
renameTask(before, 'a', 'new')
expect(before[0].text).toBe('old')
})
})
+288 -24
View File
@@ -4,6 +4,9 @@
--text-primary: #1A1A1A;
--text-secondary: #666666;
--accent: #607afb;
/* Darker sibling of --accent for text. The brand blue reads 3.54:1 on the
light background, under the 4.5:1 needed for body-sized text. */
--accent-text: #4C5FD5;
--border: #E0E0E0;
--hover: #F5F5F5;
--completed-bg: #EEF1FF;
@@ -18,6 +21,7 @@ html.dark {
--text-primary: #E0E0E0;
--text-secondary: #999999;
--accent: #7B93FF;
--accent-text: #8FA5FF;
--border: #333333;
--hover: #2A2A2A;
--completed-bg: rgba(96, 122, 251, 0.15);
@@ -88,30 +92,45 @@ body {
gap: 16px;
}
.today-btn {
/* Both days are shown with the current one marked, so the control states
where you are and what the alternative is without being clicked. */
.day-switch {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 200ms ease;
padding: 3px;
gap: 2px;
height: 40px;
transition: border-color 200ms ease;
}
.today-btn:hover {
.day-option {
padding: 0 14px;
height: 100%;
border: none;
border-radius: 6px;
background: transparent;
font-family: inherit;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: background-color 200ms ease, color 200ms ease;
white-space: nowrap;
}
.day-option:hover {
color: var(--text-primary);
}
.day-option.selected {
background-color: var(--hover);
border-color: var(--accent);
color: var(--accent);
color: var(--text-primary);
font-weight: 500;
}
.today-btn svg {
width: 16px;
height: 16px;
html.dark .day-option.selected {
background-color: var(--hover);
}
.theme-toggle {
@@ -177,7 +196,7 @@ body {
.date-display {
font-size: 16px;
font-weight: 500;
color: var(--accent);
color: var(--accent-text);
}
.task-input-container {
@@ -261,7 +280,7 @@ body {
.task-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 16px;
padding: 16px 20px;
background-color: var(--bg-surface);
@@ -321,7 +340,6 @@ html.dark .task-item.dragging {
.task-item.completed {
background-color: var(--completed-bg);
opacity: 0.8;
}
.checkbox {
@@ -364,7 +382,17 @@ 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;
font-size: 16px;
/* 24px line box, the same height as the checkbox, so the two align on the
first line without nudging either. */
line-height: 1.5;
color: var(--text-primary);
transition: all 300ms ease;
word-break: break-word;
@@ -375,10 +403,28 @@ html.dark .task-item.dragging {
color: var(--completed-text);
}
.delete-btn {
/* Sized and spaced to match .task-text exactly, so committing an edit does
not shift the row by a pixel. */
.task-edit {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: 16px;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0 6px;
margin: 0 -7px;
outline: none;
}
.row-btn {
width: 32px;
height: 32px;
border: none;
position: relative;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
@@ -391,11 +437,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;
}
@@ -405,7 +456,7 @@ html.dark .task-item.dragging {
transform: scale(1.1);
}
.delete-btn svg {
.row-btn svg {
width: 16px;
height: 16px;
}
@@ -461,11 +512,23 @@ html.dark .task-item.dragging {
}
/* Decorative only, so respect a reduced-motion preference. */
/* Svelte's transitions run in JavaScript and never see this query, so
App.svelte checks the same preference and zeroes its durations. This half
covers everything driven by CSS. */
@media (prefers-reduced-motion: reduce) {
.empty-art-box,
.empty-art-motes path {
animation: none;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
.empty-state p {
@@ -528,6 +591,65 @@ html.dark .data-status.error {
color: #ff8a8a;
}
/* Touch devices.
*
* The delete button is revealed on hover, which does not exist here. Without
* this it stays at zero opacity and a task cannot be deleted on a phone at
* all, which is what the README told people to do.
*
* The rest widens hit areas to the 44px minimum. The overlays are positioned
* so nothing moves: the controls keep the size they were drawn at, they are
* 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) {
.row-btn {
opacity: 1;
}
}
@media (pointer: coarse) {
.checkbox::after,
.row-btn::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
/* 46 rather than 44, because the switch's own padding and border eat into
the height its buttons actually get. The theme toggle follows so the two
stay level in the header. */
.day-switch {
height: 46px;
padding: 0;
}
.day-option {
border-radius: 7px;
}
.theme-toggle {
height: 46px;
}
/* Grows the tappable area of the task text to 44px without moving the text
itself, so it stays aligned with the checkbox on the first line. */
.task-text {
padding-block: 10px;
margin-block: -10px;
}
.clear-completed,
.data-link {
display: inline-flex;
align-items: center;
min-height: 44px;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -599,16 +721,158 @@ html.dark .data-status.error {
}
}
/* Every control, listed once. The previous version named a handful by hand
and had already gone stale: it still pointed at .today-btn, which no
longer exists, while five newer controls had no focus ring at all. */
.checkbox:focus-visible,
.delete-btn:focus-visible,
.row-btn:focus-visible,
.task-text:focus-visible,
.task-edit:focus-visible,
.theme-toggle:focus-visible,
.today-btn:focus-visible,
.clear-completed:focus-visible {
.day-option:focus-visible,
.clear-completed:focus-visible,
.data-link:focus-visible,
.undo-action:focus-visible,
.carried-dismiss:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Hidden controls cannot show a focus ring, so reveal them when tabbed to. */
.row-btn:focus-visible {
opacity: 1;
}
html {
scroll-behavior: smooth;
}
/* Shown only after something is removed, and only long enough to act on.
It carries a real button because Cmd/Ctrl+Z does not exist on a phone,
so on touch this is the only way back. */
.undo-toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(24px + env(safe-area-inset-bottom));
z-index: 200;
display: flex;
align-items: center;
gap: 16px;
max-width: calc(100vw - 32px);
padding: 12px 12px 12px 18px;
border-radius: 12px;
background-color: var(--bg-surface);
border: 1px solid var(--border);
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.16),
0 4px 8px rgba(0, 0, 0, 0.08);
font-size: 14px;
color: var(--text-primary);
}
html.dark .undo-toast {
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.55),
0 4px 8px rgba(0, 0, 0, 0.4);
}
.undo-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.undo-action {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 6px 12px;
border: none;
border-radius: 8px;
background: transparent;
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: var(--accent-text);
cursor: pointer;
transition: background-color 200ms ease;
}
.undo-action:hover {
background-color: var(--hover);
}
/* Teaching the shortcut is the point, so it only shows where one exists. */
.undo-key {
font-family: var(--font-family);
font-size: 12px;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
@media (pointer: coarse) {
.undo-key {
display: none;
}
.undo-action {
min-height: 44px;
}
}
/* Says out loud what rollover just did. Without it, opening the app after a
few days away looks like tasks appearing from nowhere. */
.carried-notice {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 10px 10px 10px 14px;
border-radius: 8px;
background-color: var(--completed-bg);
color: var(--completed-text);
font-size: 14px;
}
.carried-notice span {
flex: 1;
min-width: 0;
}
.carried-dismiss {
flex-shrink: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 200ms ease, background-color 200ms ease;
}
.carried-dismiss:hover {
opacity: 1;
background-color: var(--hover);
}
.carried-dismiss svg {
width: 16px;
height: 16px;
}
@media (pointer: coarse) {
.carried-dismiss {
width: 44px;
height: 44px;
}
}