feat(tasks): undo a completion, and a place to find what was closed
Reported from use: "I just closed a task by accident, and there is no undo and no list of closed tasks." Both halves were true. Completing is one click on a small circle beside a row you were only reading, and it takes the row off the screen — so the mistake and the evidence of it leave together. There was no name left to search for and nowhere to look: the project page can show its own closed tasks, but only if you already know which project it was in. The toast covers the mistake you notice at once. It names the task, so you can see which one you hit, and stands for twelve seconds rather than five — five is enough to read "could not save" and not enough to notice, decide and reach the mouse. A toast with an action also gets two targets: the message dismisses and the action acts, which were one button before and would otherwise put "yes, put it back" and "go away" in the same place. `/faerdige` covers the one you notice tomorrow. A place rather than a toggle in every view, because somebody hunting for a task they closed does not know which view they closed it in. Newest first — a record of what is finished is read backwards from now, not by a due date the task no longer has — and grouped by the day, because "I dag" and "I går" is how anybody would describe it. A recurring task is offered no undo. Completing it moves it to its next date rather than closing it, so there is nothing to reverse, and an undo that quietly rolled the date back would be a different act from the one it claims to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0dbf7a40bf
commit
a52ad9817f
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n } from './i18n.svelte.js';
|
||||
import { i18n, t } from './i18n.svelte.js';
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -278,8 +278,36 @@ class AppState {
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
* place latency would be felt all day.
|
||||
*/
|
||||
/**
|
||||
* Closes a task, and offers to put it back.
|
||||
*
|
||||
* Completing is one click on a small circle next to a row you were only
|
||||
* reading, and it takes the row off the screen — so the mistake and the
|
||||
* evidence of it leave together. Somebody who clicks the wrong one is left with
|
||||
* no name to search for and no list to find it in.
|
||||
*
|
||||
* The undo is offered where the mistake happened rather than filed somewhere to
|
||||
* be looked up. It is the same call `reopen` makes; the only new thing is that
|
||||
* the interface says it is available.
|
||||
*
|
||||
* A recurring task is deliberately not offered one. Completing it moves it to
|
||||
* its next date rather than closing it, so there is nothing to undo — and an
|
||||
* "undo" that silently rolled the date back would be a different act from the
|
||||
* one it claims to reverse.
|
||||
*/
|
||||
async complete(id) {
|
||||
const task = this.get(id);
|
||||
const repeats = Boolean(task?.recurrence_rule);
|
||||
const what = task?.content ?? '';
|
||||
|
||||
await this.#optimistic(id, { completed: true }, () => api.completeTask(id));
|
||||
|
||||
if (!repeats) {
|
||||
this.toast(t('task.completedUndo', { what: truncate(what) }), {
|
||||
action: t('task.undo'),
|
||||
onaction: () => this.reopen(id)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async reopen(id) {
|
||||
@@ -569,12 +597,22 @@ class AppState {
|
||||
|
||||
// --- toasts -------------------------------------------------------------------
|
||||
|
||||
toast(message) {
|
||||
/**
|
||||
* Says something, and optionally offers one thing to do about it.
|
||||
*
|
||||
* An action gets longer on screen than a plain message: five seconds is enough
|
||||
* to read "could not save", and not enough to notice you closed the wrong task,
|
||||
* decide, and reach the mouse.
|
||||
*/
|
||||
toast(message, { action, onaction } = {}) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.toasts.push({ id, message });
|
||||
setTimeout(() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
}, 5000);
|
||||
this.toasts.push({ id, message, action, onaction });
|
||||
setTimeout(
|
||||
() => {
|
||||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||
},
|
||||
action ? 12000 : 5000
|
||||
);
|
||||
}
|
||||
|
||||
dismissToast(id) {
|
||||
@@ -582,6 +620,12 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||
function truncate(text, max = 40) {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > max ? trimmed.slice(0, max - 1).trimEnd() + '…' : trimmed;
|
||||
}
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user