feat(tasks): finished work can be looked at, and is out of the way by default

A task list is what is left to do, so closing something has to take it off the
plan. But "what did I get done" is a real question and the answer was nowhere:
once a task was closed it left the interface entirely.

They come back in one list at the bottom rather than back among the open ones in
their sections. A closed task has stopped being work and started being a record,
and putting the record in the middle of the plan makes the plan longer without
making it say more.

Off by default, and deliberately: a list that opens full of things already done is
a list you have to read past before you can use it.

In localStorage rather than on the account, like the sidebar's width and unlike a
folded group. It is a way of *looking*, not a statement about the work — somebody
reviewing their week wants it on for ten minutes, and a setting that followed them
to every device is one they would have to turn off twice.

`loadTasks` asks for closed tasks when the setting is on, rather than each caller
deciding. A view added later cannot then forget and quietly ignore it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:49:58 +02:00
co-authored by Claude Opus 5
parent 4d8b70918b
commit 626ea9aaec
6 changed files with 182 additions and 10 deletions
+58
View File
@@ -1145,6 +1145,64 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
expect(trouble).toEqual([]); expect(trouble).toEqual([]);
}); });
/**
* Finished tasks can be looked at, and are out of the way by default.
*
* A task list is what is left to do, so closing something has to remove it from
* the plan — but "what did I get done" is a real question and the answer was
* nowhere. They come back in one list at the bottom rather than back among the
* open ones in their sections: a closed task has stopped being work and started
* being a record, and putting the record back in the middle makes the plan longer
* without making it say more.
*
* The setting is in localStorage, so this also checks it survives a reload — the
* half that a toggle held only in component state would fail.
*/
test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Loftet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Loftet' })).toBeVisible();
for (const what of ['rydde op', 'male gavlen']) {
await page.getByLabel('Ny opgave').fill(what);
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText(what, { exact: true })).toBeVisible();
}
// Close one. It leaves the list, which is the behaviour being protected here.
await page
.locator('.row')
.filter({ hasText: 'rydde op' })
.getByRole('button', { name: 'Markér som færdig' })
.click();
await expect(page.getByText('rydde op', { exact: true })).toBeHidden();
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
// And it can be looked at again, under its own heading at the bottom.
await page.getByRole('button', { name: 'Vis færdige' }).click();
const done = page.locator('section.done');
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
// Still exactly where it was in the plan — reopened tasks are the only ones
// that come back up.
await expect(done.getByText('male gavlen', { exact: true })).toHaveCount(0);
// The choice is in localStorage, so it holds across a reload.
await page.reload();
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Skjul færdige' }).click();
await expect(page.locator('section.done')).toHaveCount(0);
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
/** /**
* The sidebar never scrolls sideways. * The sidebar never scrolls sideways.
* *
+5 -1
View File
@@ -583,5 +583,9 @@ export const da = {
'push.deniedByBrowser': 'push.deniedByBrowser':
'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.', 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.',
'push.notEnabled': 'Notifikationer blev ikke slået til.', 'push.notEnabled': 'Notifikationer blev ikke slået til.',
'push.noVapidKey': 'Serveren har ingen VAPID-nøgle.' 'push.noVapidKey': 'Serveren har ingen VAPID-nøgle.',
'view.done': 'Færdige',
'view.showDone': 'Vis færdige',
'view.hideDone': 'Skjul færdige'
}; };
+5 -1
View File
@@ -578,5 +578,9 @@ export const en = {
'push.deniedByBrowser': 'push.deniedByBrowser':
'The browser has blocked notifications for this site. It has to be turned on in the browser’s own settings.', 'The browser has blocked notifications for this site. It has to be turned on in the browser’s own settings.',
'push.notEnabled': 'Notifications were not turned on.', 'push.notEnabled': 'Notifications were not turned on.',
'push.noVapidKey': 'The server has no VAPID key.' 'push.noVapidKey': 'The server has no VAPID key.',
'view.done': 'Done',
'view.showDone': 'Show done',
'view.hideDone': 'Hide done'
}; };
+43 -1
View File
@@ -192,8 +192,19 @@ class AppState {
// --- tasks ------------------------------------------------------------------ // --- tasks ------------------------------------------------------------------
/**
* Loads a slice of tasks into the store.
*
* Closed ones come along when the view is showing them — asked for here rather
* than at every call site, so a view added later cannot forget and quietly
* ignore the setting. A caller that genuinely wants only what is open passes
* `completed: 'exclude'`.
*/
async loadTasks(params) { async loadTasks(params) {
const { tasks } = await api.listTasks(params); const { tasks } = await api.listTasks({
completed: completedView.shown ? 'include' : undefined,
...params
});
this.tasks = tasks; this.tasks = tasks;
return tasks; return tasks;
} }
@@ -661,6 +672,37 @@ function read() {
export const upcomingView = new UpcomingView(); export const upcomingView = new UpcomingView();
/**
* Whether the lists show what has already been finished.
*
* In localStorage rather than on the account, like the sidebar's width and unlike
* a folded group. It is a way of *looking* rather than a statement about the work:
* somebody reviewing what they got done this week wants it on for ten minutes, and
* a setting that followed them to every device would be a setting they had to turn
* off twice.
*
* Off by default, and deliberately. A task list is what is left to do; a list that
* opens full of things already done is a list that has to be read past before it
* can be used.
*/
class CompletedView {
shown = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:completed') === 'show'
);
toggle() {
this.shown = !this.shown;
try {
localStorage.setItem('verdande:completed', this.shown ? 'show' : 'hide');
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const completedView = new CompletedView();
/** /**
* The themes on offer. * The themes on offer.
* *
+9 -6
View File
@@ -4,6 +4,7 @@
import { app } from '$lib/stores.svelte.js'; import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte'; import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte';
import { t, tag } from '$lib/i18n.svelte.js';
let loaded = $state(false); let loaded = $state(false);
@@ -36,16 +37,18 @@
); );
let liveToday = $derived(app.tasks.filter((t) => !t.completed && t.due_date === isoToday)); let liveToday = $derived(app.tasks.filter((t) => !t.completed && t.due_date === isoToday));
let date = new Date().toLocaleDateString('da-DK', { let date = $derived(
weekday: 'long', new Date().toLocaleDateString(tag(), {
day: 'numeric', weekday: 'long',
month: 'long' day: 'numeric',
}); month: 'long'
})
);
</script> </script>
<div class="view"> <div class="view">
<header> <header>
<h1>I dag</h1> <h1>{t('nav.today')}</h1>
<p>{date}</p> <p>{date}</p>
</header> </header>
+62 -1
View File
@@ -3,11 +3,12 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js'; import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js'; import { app, completedView } from '$lib/stores.svelte.js';
import { COLORS, colorVar } from '$lib/colors.js'; import { COLORS, colorVar } from '$lib/colors.js';
import { eventName, eventDetail } from '$lib/events.js'; import { eventName, eventDetail } from '$lib/events.js';
import { TASK, carries, dragged, accept } from '$lib/dnd.js'; import { TASK, carries, dragged, accept } from '$lib/dnd.js';
import TaskList from '$lib/components/TaskList.svelte'; import TaskList from '$lib/components/TaskList.svelte';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte';
import BoardView from '$lib/components/BoardView.svelte'; import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte';
@@ -116,6 +117,27 @@
); );
let unsectioned = $derived(open.filter((t) => !t.section_id)); let unsectioned = $derived(open.filter((t) => !t.section_id));
/**
* What has been finished here, newest first.
*
* In one list at the bottom rather than back among the open ones in their
* sections. A closed task has stopped being work and started being a record,
* and putting the record back in the middle of the plan makes the plan longer
* without making it say more.
*/
let done = $derived(
app.tasks
.filter((t) => t.completed && !t.parent_id && t.project_id === project?.id)
.sort((a, b) => (b.completed_at ?? '').localeCompare(a.completed_at ?? ''))
);
// Reloads when the setting changes, because whether closed tasks are in the
// store at all is decided by the request.
$effect(() => {
completedView.shown;
if (project) app.loadTasks({ project_id: project.id });
});
let editing = $state(false); let editing = $state(false);
let choosingColor = $state(false); let choosingColor = $state(false);
@@ -359,6 +381,13 @@
></button> ></button>
{/if} {/if}
<button
class="ghost"
onclick={() => completedView.toggle()}
aria-pressed={completedView.shown}
>
{completedView.shown ? t('view.hideDone') : t('view.showDone')}
</button>
<div class="views" role="group" aria-label={t('view.mode')}> <div class="views" role="group" aria-label={t('view.mode')}>
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]} {#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
<button <button
@@ -571,6 +600,16 @@
</section> </section>
{/each} {/each}
<!-- After the plan, not inside it. -->
{#if completedView.shown && done.length}
<section class="done">
<div class="section-head"><h2>{t('view.done')}</h2></div>
{#each done as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{/if}
{#if canEdit} {#if canEdit}
<section class="add-section"> <section class="add-section">
{#if addingSection} {#if addingSection}
@@ -625,6 +664,28 @@
</div> </div>
<style> <style>
/* A quiet control: it changes what you are looking at, not what is there. It
sits beside the view switcher because it is the same kind of decision. */
.ghost {
font-size: var(--text-sm);
color: var(--ink-faint);
padding: var(--s1) var(--s2);
border-radius: var(--radius);
flex: none;
}
.ghost:hover,
.ghost[aria-pressed='true'] {
color: var(--ink-muted);
background: var(--surface);
}
/* Recessed, because it is a record rather than a plan. The rows keep their own
completed styling; this is about the section around them. */
.done {
opacity: 0.75;
}
.view { .view {
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;