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:
co-authored by
Claude Opus 5
parent
4d8b70918b
commit
626ea9aaec
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user