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
+14
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -368,6 +368,14 @@
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user