From a52ad9817f6fa7ae8c07b1780350597c163ac119 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 11:18:01 +0200 Subject: [PATCH] feat(tasks): undo a completion, and a place to find what was closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/store/tasks.go | 15 +++- web/e2e/smoke.spec.js | 55 ++++++++++++ web/src/lib/components/Sidebar.svelte | 8 ++ web/src/lib/locales/da.js | 10 ++- web/src/lib/locales/en.js | 10 ++- web/src/lib/stores.svelte.js | 56 ++++++++++-- web/src/routes/+layout.svelte | 55 +++++++++++- web/src/routes/faerdige/+page.svelte | 121 ++++++++++++++++++++++++++ 8 files changed, 318 insertions(+), 12 deletions(-) create mode 100644 web/src/routes/faerdige/+page.svelte diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 72f405f..9d86156 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -189,7 +189,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas FROM tasks t JOIN projects p ON p.id = t.project_id WHERE ` + strings.Join(where, " AND ") + ` - ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at` + ORDER BY ` + order(f) if f.Limit > 0 { query += ` LIMIT ?` @@ -346,6 +346,19 @@ const taskCounts = ` WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL), (SELECT count(*) FROM attachments a WHERE a.task_id = t.id)` +// order is how a list of tasks is sorted, which depends on what the list is for. +// +// A plan is read by when it is due and how urgent it is. A record of what has been +// finished is read backwards from now — "what did I just do" and "what did I close +// by mistake" are both questions about the most recent thing, and neither is +// answered by a due date the task no longer has. +func order(f TaskFilter) string { + if f.CompletedOnly { + return "t.completed_at DESC, t.id DESC" + } + return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at" +} + func scanTask(rows *sql.Rows) (Task, error) { var t Task var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index b04e5c3..06934cf 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1392,6 +1392,61 @@ test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * A task closed by mistake can be got back, two ways. + * + * 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. + * + * The toast covers the mistake you notice at once. Færdige covers the one you + * notice tomorrow, and it is a place rather than a toggle because somebody hunting + * for a task they closed does not know which view they closed it in. + */ +test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + await page.getByLabel('Ny opgave').fill('rens tagrenden i dag'); + await page.getByLabel('Ny opgave').press('Enter'); + const row = page.locator('.row').filter({ hasText: 'rens tagrenden' }); + await expect(row).toBeVisible(); + + // Closed by mistake. + await row.getByRole('button', { name: 'Markér som færdig' }).click(); + await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden(); + + // The toast names it, so you can tell which one you hit. + const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' }); + await expect(toast).toBeVisible(); + await toast.getByRole('button', { name: 'Fortryd' }).click(); + await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible(); + + // Now the other half: close it again and walk away from the toast. + await page + .locator('.row') + .filter({ hasText: 'rens tagrenden' }) + .getByRole('button', { name: 'Markér som færdig' }) + .click(); + await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden(); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('link', { name: 'Færdige' }).click(); + await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible(); + + const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' }); + await expect(closed).toBeVisible(); + // Grouped by the day it was closed, which is how anybody would describe it. + await expect(page.getByRole('heading', { name: 'I dag', level: 2 })).toBeVisible(); + + // And it reopens from here, leaving the list at once. + await closed.getByRole('button', { name: 'Genåbn opgave' }).click(); + await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toHaveCount(0); + + expect(trouble).toEqual([]); +}); + /** * The sidebar never scrolls sideways. * diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 6bdcd0d..d6ee883 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -368,6 +368,14 @@ {t('nav.delegated')} {/if} + + + + {t('done.title')} + {#if app.inbox} {@render projectRow(app.inbox, false)} {/if} diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 5700d0f..06a2add 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -605,5 +605,13 @@ export const da = { 'view.addTask': '+ Tilføj opgave', 'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også', - 'view.addTaskIn': 'Ny opgave i {name}' + 'view.addTaskIn': 'Ny opgave i {name}', + + 'task.completedUndo': '“{what}” er lukket.', + 'task.undo': 'Fortryd', + + 'done.title': 'Færdige', + 'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.', + 'done.empty': 'Du har ikke lukket noget endnu.', + 'done.unknownDay': 'Uden dato' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 2228ac2..e5bbe5b 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -600,5 +600,13 @@ export const en = { 'view.addTask': '+ Add a task', 'view.addTaskHere': 'Write a task — a date, p1 and @label work here too', - 'view.addTaskIn': 'New task in {name}' + 'view.addTaskIn': 'New task in {name}', + + 'task.completedUndo': '“{what}” is done.', + 'task.undo': 'Undo', + + 'done.title': 'Done', + 'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.', + 'done.empty': 'You have not closed anything yet.', + 'done.unknownDay': 'No date' }; diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 63e8763..e50c740 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -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(); /** diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index cb513e8..ed9c3fa 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -160,9 +160,25 @@ had succeeded. Nothing else earns an interruption. -->
{#each app.toasts as toast (toast.id)} - +
+ + + {#if toast.action} + + {/if} +
{/each}
{/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. */ diff --git a/web/src/routes/faerdige/+page.svelte b/web/src/routes/faerdige/+page.svelte new file mode 100644 index 0000000..887d974 --- /dev/null +++ b/web/src/routes/faerdige/+page.svelte @@ -0,0 +1,121 @@ + + +{t('done.title')} — verdande + +
+
+

{t('done.title')}

+

{t('done.hint')}

+
+ + {#each days as day (day.date)} +
+

{heading(day.date)}

+ {#each day.tasks as task (task.id)} + + {/each} +
+ {/each} + + {#if loaded && !done.length} +

{t('done.empty')}

+ {/if} +
+ +