feat(web): board and calendar views, saved filters and label pages

The board is one column per section with native drag-and-drop, which
works with a trackpad, a mouse and a screen reader without any of them
being reimplemented. Dropping is optimistic: a card that snaps back for
200ms reads as a failed drop.

Weeks in the calendar start on Monday. That is ISO 8601 and what every
Danish calendar does; a Sunday-first grid makes a Dane misread the month
at a glance. On a phone the chips give way to coloured bars — at that
width the month's shape is the useful part, not the titles.

Label pages run the filter language rather than a bespoke endpoint, so a
label page and a saved filter can never disagree about what a label
selects.

fix(recurrence): "hver mandag", not "hver uge mandag"

A weekly rule that names its days does not also need the word for the
interval. That is how the phrase is said, and it is what the parser
accepts back — the round trip through Describe now holds for weekday
rules too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:10:51 +02:00
co-authored by Claude Opus 5
parent 7e4c730049
commit 0ee72d399c
11 changed files with 867 additions and 31 deletions
+104
View File
@@ -0,0 +1,104 @@
<script>
/** A saved filter's results. */
import { page } from '$app/stores';
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
let filter = $state(null);
let error = $state('');
let id = $derived($page.params.id);
$effect(() => {
const filterID = id;
if (!filterID) return;
error = '';
api
.listFilters()
.then(({ filters }) => {
filter = filters.find((f) => f.id === filterID) ?? null;
return api.runFilter(filterID);
})
.then((result) => {
app.tasks = result?.tasks ?? [];
})
.catch((e) => {
error = e.fields?.query ?? 'Filteret kunne ikke køres.';
app.tasks = [];
});
});
let open = $derived(app.tasks.filter((t) => !t.completed));
</script>
<div class="view">
<header>
<h1>{filter?.name ?? 'Filter'}</h1>
{#if filter}
<code>{filter.query}</code>
{/if}
</header>
{#if error}
<p class="error">{error}</p>
{:else}
{#each open as task (task.id)}
<TaskRow {task} />
{/each}
{#if !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting matcher lige nu.
</p>
{/if}
{/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);
}
code {
display: inline-block;
margin-top: var(--s2);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-faint);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 2px var(--s2);
}
.error {
padding: var(--s4);
background: var(--danger-sunken);
color: var(--danger);
border-radius: var(--radius);
font-size: var(--text-sm);
}
.clear {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
padding: var(--s8) var(--s4);
color: var(--ink-faint);
font-size: var(--text-sm);
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
opacity: 0.5;
}
</style>
+89 -19
View File
@@ -5,6 +5,8 @@
import { app } from '$lib/stores.svelte.js';
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';
let project = $state(null);
let sections = $state([]);
@@ -41,6 +43,22 @@
}
});
// The chosen view is kept locally as well as on the project, so switching is
// instant and only persists once the request lands.
let view = $state(null);
let mode = $derived(view ?? project?.view_mode ?? 'list');
async function setView(next) {
view = next;
if (project?.role === 'owner') {
try {
await api.updateProject(project.id, { view_mode: next });
} catch {
// A view that did not persist is a small thing; it still switched.
}
}
}
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
let isOwner = $derived(project?.role === 'owner');
@@ -70,6 +88,16 @@
{#if project}
<header>
<h1>{project.name}</h1>
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<button
class:active={mode === value}
onclick={() => setView(value)}
aria-pressed={mode === value}>{label}</button
>
{/each}
</div>
{#if !project.is_inbox}
<button class="share" onclick={() => (showShare = !showShare)}>
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
@@ -134,30 +162,36 @@
<QuickAdd projectId={project.id} />
{/if}
<section>
{#each unsectioned as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
{#if mode === 'board'}
<BoardView {project} {sections} {canEdit} />
{:else if mode === 'calendar'}
<CalendarView />
{:else}
<section>
<h2>{section.name}</h2>
{#each tasks as task (task.id)}
{#each unsectioned as task (task.id)}
<TaskRow {task} />
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
</section>
{/each}
{#if !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting her endnu.
</p>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section>
<h2>{section.name}</h2>
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
</section>
{/each}
{#if !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting her endnu.
</p>
{/if}
{/if}
{#if project.role === 'viewer'}
@@ -175,6 +209,12 @@
padding: var(--s6) var(--s4) var(--s8);
}
/* A board and a month grid need the room; a list is easier to read narrow. */
.view:has(.board),
.view:has(.calendar) {
max-width: 1400px;
}
header {
display: flex;
align-items: center;
@@ -189,6 +229,36 @@
overflow-wrap: anywhere;
}
.views {
display: flex;
gap: 1px;
flex: none;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.views button {
padding: var(--s1) var(--s3);
font-size: var(--text-sm);
background: var(--ground);
color: var(--ink-muted);
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.views button:hover {
color: var(--ink);
}
.views button.active {
background: var(--surface-raised);
color: var(--ink);
font-weight: 500;
}
.share {
flex: none;
font-size: var(--text-sm);