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. -->
{t('done.hint')}
+{t('done.empty')}
+ {/if} +