mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
Compare commits
11 Commits
v2.1.1
..
cd3c44fcdc
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3c44fcdc | |||
| c17dad6f1f | |||
| e54338f815 | |||
| 7f1a02ee41 | |||
| 54958d0f42 | |||
| 6071dccec4 | |||
| f9ae76a87d | |||
| 1198158abf | |||
| d97550feb8 | |||
| 02c8d6f6b0 | |||
| 38af47aeb6 |
@@ -14,9 +14,11 @@ 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** - `Cmd/Ctrl+Z` brings back a deleted task, in 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
|
||||||
|
- ➡️ **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+↓`
|
- 🎯 **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
|
- 💾 **Persistent storage** - All tasks saved locally in your browser
|
||||||
- 📊 **Task statistics** - See how many tasks remain at a glance
|
- 📊 **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
|
### 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 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**: Press `Cmd/Ctrl+Z`. 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+↓`
|
- **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
|
||||||
|
|
||||||
### Date Management
|
### 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
|
- **Plan ahead**: Add tasks to Tomorrow's list before you need them
|
||||||
- **Separate lists**: Today and Tomorrow maintain independent task lists
|
- **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.
|
- **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
|
### Installing It
|
||||||
@@ -126,8 +130,9 @@ 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
|
||||||
|
|
||||||
Backspace no longer deletes a task. It is too easily pressed by accident, and deletions used to be unrecoverable.
|
Backspace no longer deletes a task. It is too easily pressed by accident, and deletions used to be unrecoverable.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "negotium-todo",
|
"name": "negotium-todo",
|
||||||
"version": "2.1.1",
|
"version": "2.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "negotium-todo",
|
"name": "negotium-todo",
|
||||||
"version": "2.1.1",
|
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "negotium-todo",
|
"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",
|
"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",
|
||||||
|
|||||||
+226
-14
@@ -5,13 +5,14 @@
|
|||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import './style.css';
|
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 { createStorage } from './lib/storage.js';
|
||||||
import { rollover, msUntilNextMidnight } from './lib/rollover.js';
|
import { rollover, msUntilNextMidnight } from './lib/rollover.js';
|
||||||
import * as taskOps from './lib/tasks.js';
|
import * as taskOps from './lib/tasks.js';
|
||||||
import { createUndoStack, applyUndo } from './lib/undo.js';
|
import { createUndoStack, applyUndo } from './lib/undo.js';
|
||||||
import { shouldHandleUndo } from './lib/shortcuts.js';
|
import { shouldHandleUndo } from './lib/shortcuts.js';
|
||||||
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
|
import { buildExport, serialize, parseImport, mergeImport } from './lib/backup.js';
|
||||||
|
import { moveTaskToDay } from './lib/defer.js';
|
||||||
|
|
||||||
const storage = createStorage();
|
const storage = createStorage();
|
||||||
const undoStack = createUndoStack();
|
const undoStack = createUndoStack();
|
||||||
@@ -23,7 +24,19 @@
|
|||||||
storage.migrateLegacyKeys();
|
storage.migrateLegacyKeys();
|
||||||
const bootNow = new Date();
|
const bootNow = new Date();
|
||||||
|
|
||||||
|
const bootTodayKey = toKey(bootNow);
|
||||||
|
const beforeRollover = storage.loadTasks(bootTodayKey).length;
|
||||||
|
|
||||||
let tasks = $state(rollover(storage, bootNow));
|
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('');
|
let newTask = $state('');
|
||||||
// Seeded from the class the pre-paint script in index.html already set, so
|
// 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.
|
// there is one source of truth and no post-mount correction to flash.
|
||||||
@@ -61,6 +74,7 @@
|
|||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
undoStack.push({ type: 'delete', task: tasks[index], index });
|
undoStack.push({ type: 'delete', task: tasks[index], index });
|
||||||
setTasks(taskOps.deleteTask(tasks, id));
|
setTasks(taskOps.deleteTask(tasks, id));
|
||||||
|
showUndo('Task deleted');
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCompleted() {
|
function clearCompleted() {
|
||||||
@@ -73,11 +87,112 @@
|
|||||||
if (removed.length === 0) return;
|
if (removed.length === 0) return;
|
||||||
undoStack.push({ type: 'clearCompleted', removed });
|
undoStack.push({ type: 'clearCompleted', removed });
|
||||||
setTasks(taskOps.clearCompleted(tasks));
|
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('');
|
||||||
|
|
||||||
|
function startEditing(task) {
|
||||||
|
if (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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
function undo() {
|
||||||
const entry = undoStack.pop();
|
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() {
|
function toggleTheme() {
|
||||||
@@ -183,6 +298,17 @@
|
|||||||
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveTask(taskId, event.key === 'ArrowUp' ? -1 : 1);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +410,7 @@
|
|||||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
// Let the checkbox and delete button have their clicks.
|
// Let the checkbox and delete button have their clicks.
|
||||||
if (event.target.closest('button')) return;
|
if (event.target.closest('button')) return;
|
||||||
|
if (event.target.closest('.task-edit')) return;
|
||||||
|
|
||||||
drag = {
|
drag = {
|
||||||
id: tasks[index].id,
|
id: tasks[index].id,
|
||||||
@@ -376,8 +503,11 @@
|
|||||||
return toKey(addDays(fromKey(todayKey), 1));
|
return toKey(addDays(fromKey(todayKey), 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchDate() {
|
function showDay(key) {
|
||||||
selectedKey = selectedKey === todayKey ? tomorrowKey() : todayKey;
|
if (key === selectedKey) return;
|
||||||
|
cancelEdit();
|
||||||
|
dismissUndo();
|
||||||
|
selectedKey = key;
|
||||||
tasks = storage.loadTasks(selectedKey);
|
tasks = storage.loadTasks(selectedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +520,15 @@
|
|||||||
* they follow it there, which preserves the existing mental model.
|
* they follow it there, which preserves the existing mental model.
|
||||||
*/
|
*/
|
||||||
function runRollover() {
|
function runRollover() {
|
||||||
|
cancelEdit();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const wasViewingToday = selectedKey === todayKey;
|
const wasViewingToday = selectedKey === todayKey;
|
||||||
|
|
||||||
todayKey = toKey(now);
|
todayKey = toKey(now);
|
||||||
|
const before = storage.loadTasks(todayKey).length;
|
||||||
const todayTasks = rollover(storage, now);
|
const todayTasks = rollover(storage, now);
|
||||||
|
const moved = todayTasks.length - before;
|
||||||
|
if (moved > 0) carriedOver = moved;
|
||||||
|
|
||||||
if (wasViewingToday) {
|
if (wasViewingToday) {
|
||||||
selectedKey = todayKey;
|
selectedKey = todayKey;
|
||||||
@@ -421,7 +555,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentDateDisplay = $derived(formatLong(selectedKey));
|
const currentDateDisplay = $derived(formatLong(selectedKey));
|
||||||
const buttonText = $derived(labelFor(selectedKey, todayKey));
|
|
||||||
|
|
||||||
const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
|
const remainingTasks = $derived(tasks.filter(task => !task.completed).length);
|
||||||
const completedTasks = $derived(tasks.filter(task => task.completed).length);
|
const completedTasks = $derived(tasks.filter(task => task.completed).length);
|
||||||
@@ -471,15 +604,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="today-btn" aria-label="Switch between Today and Tomorrow" onclick={switchDate}>
|
<!-- Both options are visible with the current one marked, so the
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
control no longer has to be clicked to find out what it does. -->
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
<div class="day-switch" role="group" aria-label="Choose a day">
|
||||||
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/>
|
<button
|
||||||
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/>
|
class="day-option"
|
||||||
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/>
|
class:selected={viewingToday}
|
||||||
</svg>
|
aria-pressed={viewingToday}
|
||||||
{buttonText}
|
onclick={() => showDay(todayKey)}
|
||||||
|
>
|
||||||
|
Today
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="day-option"
|
||||||
|
class:selected={!viewingToday}
|
||||||
|
aria-pressed={!viewingToday}
|
||||||
|
onclick={() => showDay(tomorrowKey())}
|
||||||
|
>
|
||||||
|
Tomorrow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="theme-toggle"
|
class="theme-toggle"
|
||||||
@@ -534,6 +678,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="task-list">
|
||||||
{#key selectedKey}
|
{#key selectedKey}
|
||||||
{#if tasks.length === 0}
|
{#if tasks.length === 0}
|
||||||
@@ -592,10 +751,53 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#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}
|
||||||
<span class="task-text">{task.text}</span>
|
<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
|
<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)}
|
onclick={() => deleteTask(task.id)}
|
||||||
aria-label="Delete {task.text}"
|
aria-label="Delete {task.text}"
|
||||||
>
|
>
|
||||||
@@ -606,6 +808,7 @@
|
|||||||
<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>
|
||||||
@@ -631,3 +834,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ export function isKey(value) {
|
|||||||
return KEY_PATTERN.test(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) {
|
export function formatLong(dateKey) {
|
||||||
return fromKey(dateKey).toLocaleDateString('en-US', {
|
return fromKey(dateKey).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
|
|||||||
+1
-23
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('toKey', () => {
|
||||||
it('formats a date as local YYYY-MM-DD', () => {
|
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', () => {
|
describe('formatLong', () => {
|
||||||
it('renders the long-form date used in the header', () => {
|
it('renders the long-form date used in the header', () => {
|
||||||
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
|
expect(formatLong('2026-08-15')).toBe('Saturday, August 15, 2026')
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* `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, insertAt = null) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,3 +39,21 @@ export function reorderTask(tasks, from, to) {
|
|||||||
export function clearCompleted(tasks) {
|
export function clearCompleted(tasks) {
|
||||||
return tasks.filter((task) => !task.completed)
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 })
|
const task = (id, completed = false) => ({ id, text: id, completed })
|
||||||
|
|
||||||
@@ -126,3 +126,57 @@ describe('clearCompleted', () => {
|
|||||||
expect(clearCompleted([task('a', true)])).toEqual([])
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+314
-44
@@ -92,30 +92,45 @@ body {
|
|||||||
gap: 16px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--text-secondary);
|
padding: 3px;
|
||||||
font-size: 14px;
|
gap: 2px;
|
||||||
cursor: pointer;
|
|
||||||
transition: all 200ms ease;
|
|
||||||
height: 40px;
|
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);
|
background-color: var(--hover);
|
||||||
border-color: var(--accent);
|
color: var(--text-primary);
|
||||||
color: var(--accent);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-btn svg {
|
html.dark .day-option.selected {
|
||||||
width: 16px;
|
background-color: var(--hover);
|
||||||
height: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
@@ -265,6 +280,9 @@ body {
|
|||||||
|
|
||||||
.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;
|
||||||
@@ -330,6 +348,8 @@ html.dark .task-item.dragging {
|
|||||||
.checkbox {
|
.checkbox {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 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: 2px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -367,6 +387,10 @@ html.dark .task-item.dragging {
|
|||||||
|
|
||||||
.task-text {
|
.task-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
/* 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;
|
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. */
|
||||||
@@ -381,7 +405,35 @@ html.dark .task-item.dragging {
|
|||||||
color: var(--completed-text);
|
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;
|
||||||
|
/* 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;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 0 -7px;
|
||||||
|
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;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -398,11 +450,21 @@ html.dark .task-item.dragging {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defer-btn:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* focus-within matters as much as hover here: the button is opacity 0 by
|
/* 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:
|
default, so a keyboard user tabbing to it previously saw nothing at all:
|
||||||
the focus outline was drawn on an invisible element. */
|
the focus outline was drawn on an invisible element. */
|
||||||
.task-item:hover .delete-btn,
|
.task-item:hover .row-btn,
|
||||||
.task-item:focus-within .delete-btn {
|
.task-item:focus-within .row-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +474,7 @@ html.dark .task-item.dragging {
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn svg {
|
.row-btn svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
@@ -558,14 +620,26 @@ html.dark .data-status.error {
|
|||||||
* just easier to hit. Kept behind the media query so a mouse still gets small,
|
* 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. */
|
* precise targets and the full row stays available to drag. */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.delete-btn {
|
.row-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
@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,
|
.checkbox::after,
|
||||||
.delete-btn::after {
|
.row-btn::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -575,9 +649,27 @@ html.dark .data-status.error {
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-btn,
|
/* 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 {
|
.theme-toggle {
|
||||||
height: 44px;
|
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: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-completed,
|
.clear-completed,
|
||||||
@@ -602,37 +694,59 @@ html.dark .data-status.error {
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.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 {
|
.header-content {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
align-items: flex-start;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
width: 100%;
|
margin-left: auto;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.main {
|
||||||
font-size: 28px;
|
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 {
|
.task-item {
|
||||||
padding: 20px;
|
padding: 14px 16px;
|
||||||
min-height: 48px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input {
|
.task-input {
|
||||||
@@ -646,6 +760,21 @@ html.dark .data-status.error {
|
|||||||
padding: calc(12px + env(safe-area-inset-top)) 16px 12px;
|
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 {
|
.container {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
@@ -659,16 +788,157 @@ 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,
|
.checkbox:focus-visible,
|
||||||
.delete-btn:focus-visible,
|
.row-btn:focus-visible,
|
||||||
|
.task-edit:focus-visible,
|
||||||
.theme-toggle:focus-visible,
|
.theme-toggle:focus-visible,
|
||||||
.today-btn:focus-visible,
|
.day-option:focus-visible,
|
||||||
.clear-completed:focus-visible {
|
.clear-completed:focus-visible,
|
||||||
|
.data-link:focus-visible,
|
||||||
|
.undo-action:focus-visible,
|
||||||
|
.carried-dismiss:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden controls cannot show a focus ring, so reveal them when tabbed to. */
|
||||||
|
.row-btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user