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:
Kristian Wind
2026-08-18 11:18:01 +02:00
co-authored by Claude Opus 5
parent 0dbf7a40bf
commit a52ad9817f
8 changed files with 318 additions and 12 deletions
+52 -3
View File
@@ -160,9 +160,25 @@
had succeeded. Nothing else earns an interruption. -->
<div class="toasts" role="status" aria-live="polite">
{#each app.toasts as toast (toast.id)}
<button class="toast" onclick={() => app.dismissToast(toast.id)}>
{toast.message}
</button>
<div class="toast">
<!-- The message dismisses, the action acts. They were one button before,
which is fine while the only thing a toast says is "that failed" —
and wrong the moment it offers to undo something, because then a
click meaning "yes, put it back" and a click meaning "go away" land
in the same place. -->
<button class="body" onclick={() => app.dismissToast(toast.id)}>
{toast.message}
</button>
{#if toast.action}
<button
class="action"
onclick={() => {
app.dismissToast(toast.id);
toast.onaction?.();
}}>{toast.action}</button
>
{/if}
</div>
{/each}
</div>
{/if}
@@ -304,6 +320,29 @@
z-index: 40;
}
.toast .body {
flex: 1;
min-width: 0;
text-align: left;
color: inherit;
font: inherit;
}
/* Set apart, because it is the one thing in a toast that does something rather
than says something. */
.toast .action {
flex: none;
padding: var(--s1) var(--s2);
margin: calc(var(--s1) * -1) calc(var(--s1) * -1) calc(var(--s1) * -1) 0;
border-radius: var(--radius-sm);
font-weight: 560;
color: var(--accent);
}
.toast .action:hover {
background: var(--surface);
}
.toasts {
position: fixed;
bottom: var(--s4);
@@ -317,8 +356,14 @@
}
.toast {
display: flex;
align-items: center;
gap: var(--s3);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
/* The red edge said "something went wrong", which was true while that was
all a toast could say. An undo is not a failure — it is the app offering
to put something back — so the edge follows the message. */
border-left: 2px solid var(--danger);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
@@ -329,6 +374,10 @@
text-align: left;
}
.toast:has(.action) {
border-left-color: var(--accent);
}
/* Below the tablet breakpoint the sidebar becomes a drawer. The layout is
mobile-first in behaviour even though the rules read desktop-first: the
drawer is the exception, and writing it as one keeps it in a single block. */
+121
View File
@@ -0,0 +1,121 @@
<script>
/**
* Færdige: everything that has been closed, newest first.
*
* Completing a task 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. Somebody who clicked the wrong one had no name 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 that appears on completing offers an undo, which covers the mistake
* you notice at once. This covers the one you notice tomorrow.
*
* Newest first, decided by the server. A record of what has been finished is
* read backwards from now, not by a due date the task no longer has.
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
let loaded = $state(false);
$effect(() => {
app.loadTasks({ completed: 'only', limit: 200 }).then(() => (loaded = true));
});
// Read back out of the store, so a task reopened from here leaves the list at
// once rather than on the next load — which is the whole reason to be here.
let done = $derived(app.tasks.filter((task) => task.completed));
/**
* Grouped by the day it was closed.
*
* A flat list of two hundred rows says when nothing. "I dag" and "I går" are
* how anybody looking for something they just closed would describe it.
*/
let days = $derived.by(() => {
const groups = new Map();
for (const task of done) {
const key = (task.completed_at ?? '').slice(0, 10);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(task);
}
return [...groups.entries()].map(([date, tasks]) => ({ date, tasks }));
});
function heading(date) {
if (!date) return t('done.unknownDay');
const day = new Date(date + 'T00:00:00');
const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000);
if (diff === 0) return t('task.today');
if (diff === -1) return t('task.yesterday');
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'long' });
}
</script>
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
<div class="view">
<header>
<h1>{t('done.title')}</h1>
<p>{t('done.hint')}</p>
</header>
{#each days as day (day.date)}
<section>
<h2>{heading(day.date)}</h2>
{#each day.tasks as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{/each}
{#if loaded && !done.length}
<p class="empty">{t('done.empty')}</p>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
header {
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
}
header p {
margin: var(--s1) 0 0;
font-size: var(--text-sm);
color: var(--ink-muted);
}
section {
margin-bottom: var(--s5);
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
padding-bottom: var(--s2);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
}
.empty {
font-size: var(--text-sm);
color: var(--ink-faint);
}
</style>