From 626ea9aaec6a1a950f580cc5ce33a31a08e4715d Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 09:49:58 +0200 Subject: [PATCH] feat(tasks): finished work can be looked at, and is out of the way by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/e2e/smoke.spec.js | 58 ++++++++++++++++++++++ web/src/lib/locales/da.js | 6 ++- web/src/lib/locales/en.js | 6 ++- web/src/lib/stores.svelte.js | 44 ++++++++++++++++- web/src/routes/+page.svelte | 15 +++--- web/src/routes/projekt/[id]/+page.svelte | 63 +++++++++++++++++++++++- 6 files changed, 182 insertions(+), 10 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index ade06bd..631a143 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1145,6 +1145,64 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) = 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. * diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index aae8caa..be96652 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -583,5 +583,9 @@ export const da = { 'push.deniedByBrowser': 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.', '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' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 71f62d1..2c238f3 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -578,5 +578,9 @@ export const en = { 'push.deniedByBrowser': '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.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' }; diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 5281210..63e8763 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -192,8 +192,19 @@ class AppState { // --- 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) { - const { tasks } = await api.listTasks(params); + const { tasks } = await api.listTasks({ + completed: completedView.shown ? 'include' : undefined, + ...params + }); this.tasks = tasks; return tasks; } @@ -661,6 +672,37 @@ function read() { 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. * diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 79d4078..5c37276 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -4,6 +4,7 @@ import { app } from '$lib/stores.svelte.js'; import TaskRow from '$lib/components/TaskRow.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; + import { t, tag } from '$lib/i18n.svelte.js'; let loaded = $state(false); @@ -36,16 +37,18 @@ ); let liveToday = $derived(app.tasks.filter((t) => !t.completed && t.due_date === isoToday)); - let date = new Date().toLocaleDateString('da-DK', { - weekday: 'long', - day: 'numeric', - month: 'long' - }); + let date = $derived( + new Date().toLocaleDateString(tag(), { + weekday: 'long', + day: 'numeric', + month: 'long' + }) + );
-

I dag

+

{t('nav.today')}

{date}

diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 94812e1..e9ed300 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -3,11 +3,12 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; 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 { eventName, eventDetail } from '$lib/events.js'; import { TASK, carries, dragged, accept } from '$lib/dnd.js'; import TaskList from '$lib/components/TaskList.svelte'; + import TaskRow from '$lib/components/TaskRow.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import BoardView from '$lib/components/BoardView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte'; @@ -116,6 +117,27 @@ ); 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 choosingColor = $state(false); @@ -359,6 +381,13 @@ > {/if} +
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}