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} +
+ +