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
+15 -8
View File
@@ -40,9 +40,10 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
## What works today
**Phase 1 is complete and phase 2 is most of the way there.** You can install it,
create an account, add tasks by typing a sentence, share a project and watch
somebody else's changes appear live.
**Phases 1 and 3 are complete; phase 2 is most of the way there.** You can install
it, create an account, add tasks by typing a sentence, share a project, watch
somebody else's changes appear live, set up repeating chores and reminders, save
filters, and subscribe to it from Apple Calendar.
| Area | State |
|---|---|
@@ -56,11 +57,17 @@ somebody else's changes appear live.
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Recurring tasks, reminders, saved filters, board/calendar | Planned — phase 3 |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
| Board and calendar views | Done |
| ICS calendar feed, task durations | Done, tested |
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| Import/export, CalDAV, mail-to-task, Gmail, MCP, AI | Planned — phases 4–5 |
Not yet built: comments and attachments, drag-and-drop in the interface (the API
supports it), Web Push, and everything from phase 3 onwards.
Not yet built: comments and attachments, Web Push, and everything from phase 4
onwards.
Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an
accented `o` and a Danish-first app that cannot do this is broken for its own users.
@@ -145,11 +152,11 @@ is part of it.
## Roadmap
1. **MVP** — auth, projects, sections, tasks, sub-tasks, priorities, due dates,
quick add, Today and Upcoming, search, keyboard shortcuts.
quick add, Today and Upcoming, search, keyboard shortcuts. *(done)*
2. **Sharing** — invites, roles, assignees, comments, attachments, activity log,
realtime sync, notifications.
3. **Pro parity** — recurring tasks, reminders, filter queries, board and calendar
views, durations, ICS feeds, templates, automatic backups.
views, durations, ICS feeds, templates, automatic backups. *(done)*
4. **Import/export** — Todoist CSV and official export in, CSV/JSON/ICS out.
5. **Integrations** — CalDAV server, mail-to-task, Gmail, MCP server for Claude,
and a provider-agnostic AI layer that also talks to a local model.
+9 -1
View File
@@ -298,7 +298,15 @@ func Describe(rule string) string {
for _, code := range strings.Split(days, ",") {
names = append(names, danishDay(code))
}
return every(interval, "uge") + " " + strings.Join(names, " og ")
joined := strings.Join(names, " og ")
// "hver mandag", not "hver uge mandag". A weekly rule naming 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.
if interval <= 1 {
return "hver " + joined
}
return every(interval, "uge") + " " + joined
}
switch parts["FREQ"] {
+4 -2
View File
@@ -270,8 +270,9 @@ func TestDescribe(t *testing.T) {
"FREQ=DAILY;INTERVAL=3": "hver 3. dag",
"FREQ=WEEKLY": "hver uge",
"FREQ=WEEKLY;INTERVAL=2": "hver anden uge",
"FREQ=WEEKLY;BYDAY=MO": "hver uge mandag",
"FREQ=WEEKLY;BYDAY=MO,TH": "hver uge mandag og torsdag",
"FREQ=WEEKLY;BYDAY=MO": "hver mandag",
"FREQ=WEEKLY;BYDAY=MO,TH": "hver mandag og torsdag",
"FREQ=WEEKLY;BYDAY=FR;INTERVAL=2": "hver anden uge fredag",
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR": "hverdage",
"FREQ=WEEKLY;BYDAY=SA,SU": "hver weekend",
"FREQ=MONTHLY": "hver måned",
@@ -293,6 +294,7 @@ func TestDescribeRoundTrip(t *testing.T) {
phrases := []string{
"hver dag", "hver anden dag", "hver uge", "hver anden uge",
"hverdage", "hver weekend", "hver måned", "hvert år",
"hver mandag", "hver mandag og torsdag",
}
for _, phrase := range phrases {
t.Run(phrase, func(t *testing.T) {
+25
View File
@@ -150,6 +150,31 @@ export const api = {
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
// --- filters and labels
listFilters: () => get('/filters'),
createFilter: (data) => post('/filters', data),
updateFilter: (id, data) => patch(`/filters/${id}`, data),
deleteFilter: (id) => del(`/filters/${id}`),
runFilter: (id) => get(`/filters/${id}/tasks`),
previewFilter: (query) => get(`/filters/preview?query=${encodeURIComponent(query)}`),
listLabels: () => get('/labels'),
createLabel: (name) => post('/labels', { name }),
deleteLabel: (id) => del(`/labels/${id}`),
// --- reminders, feed and templates
listReminders: (taskId) => get(`/tasks/${taskId}/reminders`),
createReminder: (taskId, data) => post(`/tasks/${taskId}/reminders`, data),
deleteReminder: (id) => del(`/reminders/${id}`),
feed: () => get('/feed'),
rotateFeed: () => post('/feed/rotate'),
listTemplates: () => get('/templates'),
saveTemplate: (data) => post('/templates', data),
useTemplate: (id, data) => post(`/templates/${id}/use`, data),
deleteTemplate: (id) => del(`/templates/${id}`),
// --- views
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
+220
View File
@@ -0,0 +1,220 @@
<script>
/**
* Kanban: one column per section, plus one for everything not filed under a
* section. Dragging a card between columns moves the task.
*
* The drag uses the native HTML drag-and-drop API rather than pointer events.
* It is fiddlier to style, but it is the one that works with a trackpad, a
* mouse and a screen reader's drag affordance without reimplementing any of
* them — and it gives keyboard users the browser's own behaviour for free.
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import TaskRow from './TaskRow.svelte';
let { project, sections, canEdit } = $props();
let draggingId = $state(null);
let overColumn = $state(null);
// The unsectioned column comes first, as it does in the list view: it is where
// quick add puts things, so it is where new work appears.
let columns = $derived([
{ id: '', name: 'Uden sektion' },
...sections.map((s) => ({ id: s.id, name: s.name }))
]);
const tasksIn = (sectionId) =>
app.tasks
.filter((t) => !t.completed && !t.parent_id && (t.section_id ?? '') === sectionId)
.sort((a, b) => a.sort_order - b.sort_order);
function onDragStart(event, task) {
if (!canEdit) return;
draggingId = task.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', task.id);
}
function onDragOver(event, sectionId) {
if (!canEdit || !draggingId) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
overColumn = sectionId;
}
async function onDrop(event, sectionId) {
event.preventDefault();
overColumn = null;
const id = draggingId;
draggingId = null;
if (!id || !canEdit) return;
const task = app.tasks.find((t) => t.id === id);
if (!task || (task.section_id ?? '') === sectionId) return;
// Optimistic: the card is in the new column before the request goes out,
// because a card that snaps back for 200ms reads as a failed drop.
const previous = { ...task };
const index = app.tasks.findIndex((t) => t.id === id);
app.tasks[index] = { ...task, section_id: sectionId };
try {
// Dropped at the end of the column: the task after it is nothing, and
// the one before it is whatever was last there.
const existing = tasksIn(sectionId).filter((t) => t.id !== id);
const after = existing.at(-1);
const updated = await api.moveTask(id, {
project_id: project.id,
section_id: sectionId,
after_id: after?.id ?? ''
});
const i = app.tasks.findIndex((t) => t.id === id);
if (i >= 0) app.tasks[i] = updated;
} catch (e) {
const i = app.tasks.findIndex((t) => t.id === id);
if (i >= 0) app.tasks[i] = previous;
app.toast('Kunne ikke flytte opgaven.');
}
}
</script>
<div class="board">
{#each columns as column (column.id)}
{@const tasks = tasksIn(column.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class="column"
class:over={overColumn === column.id}
ondragover={(e) => onDragOver(e, column.id)}
ondragleave={() => (overColumn = null)}
ondrop={(e) => onDrop(e, column.id)}
>
<header>
<h2>{column.name}</h2>
<span class="count">{tasks.length}</span>
</header>
<div class="cards">
{#each tasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card"
class:dragging={draggingId === task.id}
draggable={canEdit}
ondragstart={(e) => onDragStart(e, task)}
ondragend={() => (draggingId = null)}
>
<TaskRow {task} />
</div>
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
</div>
</section>
{/each}
</div>
<style>
.board {
display: flex;
gap: var(--s3);
align-items: flex-start;
overflow-x: auto;
padding-bottom: var(--s4);
/* Columns snap as you swipe on a phone, so a half-column never sits at
the edge looking like a rendering fault. */
scroll-snap-type: x proximity;
}
.column {
flex: 0 0 300px;
max-width: 300px;
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: var(--s3);
scroll-snap-align: start;
transition: border-color var(--fast) var(--ease);
}
/* The drop target is shown by the column it would land in, not by a line
between cards: the column is the decision being made here. */
.column.over {
border-color: var(--accent);
background: var(--accent-sunken);
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s2);
padding: 0 var(--s2) var(--s3);
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
.cards {
display: flex;
flex-direction: column;
gap: var(--s2);
min-height: 60px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 var(--s2);
cursor: grab;
transition:
opacity var(--fast) var(--ease),
border-color var(--fast) var(--ease);
}
.card:hover {
border-color: var(--line-strong);
}
.card.dragging {
opacity: 0.4;
cursor: grabbing;
}
/* The row draws its own bottom border for the list view, which is a stray
line inside a card. */
.card :global(.row) {
border-bottom: none;
}
.card :global(.row:hover) {
background: transparent;
}
.empty {
margin: 0;
padding: var(--s3) var(--s2);
color: var(--ink-faint);
font-size: var(--text-sm);
text-align: center;
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<script>
/**
* A month grid for one project.
*
* Weeks start on Monday. That is not a preference — it is ISO 8601 and it is
* what every Danish calendar does, and a Sunday-first grid makes a Dane
* misread the whole month at a glance.
*/
import { app } from '$lib/stores.svelte.js';
let { onselect } = $props();
let cursor = $state(startOfMonth(new Date()));
function startOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function iso(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
}
// The grid always runs whole weeks, so it starts on the Monday on or before the
// first of the month and ends on the Sunday on or after the last.
let grid = $derived.by(() => {
const first = startOfMonth(cursor);
const start = new Date(first);
// getDay() is Sunday-based; this converts to "days since Monday".
start.setDate(first.getDate() - ((first.getDay() + 6) % 7));
const days = [];
const cell = new Date(start);
// Six weeks covers every possible month layout, including a 31-day month
// that begins on a Sunday.
for (let i = 0; i < 42; i++) {
days.push(new Date(cell));
cell.setDate(cell.getDate() + 1);
}
return days;
});
const todayISO = iso(new Date());
const tasksOn = (date) =>
app.tasks.filter((t) => !t.completed && t.due_date === iso(date));
const monthName = $derived(
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
);
function step(months) {
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
}
</script>
<div class="calendar">
<header>
<button onclick={() => step(-1)} aria-label="Forrige måned">‹</button>
<h2>{monthName}</h2>
<button onclick={() => step(1)} aria-label="Næste måned">›</button>
<button class="today" onclick={() => (cursor = startOfMonth(new Date()))}>I dag</button>
</header>
<div class="weekdays" aria-hidden="true">
{#each ['man', 'tir', 'ons', 'tor', 'fre', 'lør', 'søn'] as day}
<span>{day}</span>
{/each}
</div>
<div class="grid">
{#each grid as date (date.toISOString())}
{@const tasks = tasksOn(date)}
{@const outside = date.getMonth() !== cursor.getMonth()}
<div class="day" class:outside class:today={iso(date) === todayISO}>
<span class="number">{date.getDate()}</span>
{#each tasks.slice(0, 3) as task (task.id)}
<button class="chip" data-priority={task.priority} onclick={() => onselect?.(task)}>
{task.content}
</button>
{/each}
{#if tasks.length > 3}
<span class="more">+{tasks.length - 3} mere</span>
{/if}
</div>
{/each}
</div>
</div>
<style>
header {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s4);
}
h2 {
font-size: var(--text-lg);
text-transform: capitalize;
min-width: 180px;
}
header button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--text-lg);
line-height: 1;
}
header button:hover {
background: var(--surface);
color: var(--ink);
}
.today {
width: auto;
margin-left: auto;
padding: 0 var(--s3);
font-size: var(--text-sm);
border: 1px solid var(--line);
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
margin-bottom: var(--s2);
}
.weekdays span {
font-size: var(--text-xs);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 var(--s2);
}
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.day {
background: var(--ground);
min-height: 92px;
padding: var(--s2);
display: flex;
flex-direction: column;
gap: 2px;
}
/* Days from the neighbouring months stay visible but recede — removing them
would leave ragged holes at the corners of the grid. */
.day.outside {
background: var(--surface-sunken);
}
.day.outside .number {
color: var(--ink-faint);
opacity: 0.5;
}
.number {
font-size: var(--text-xs);
color: var(--ink-muted);
}
.day.today .number {
color: var(--accent-ink);
background: var(--accent);
border-radius: var(--radius-full);
width: 18px;
height: 18px;
display: grid;
place-items: center;
font-weight: 560;
}
.chip {
font-size: var(--text-xs);
text-align: left;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
background: var(--surface-raised);
border-left: 2px solid var(--line-strong);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip[data-priority='1'] {
border-left-color: var(--p1);
}
.chip[data-priority='2'] {
border-left-color: var(--p2);
}
.chip[data-priority='3'] {
border-left-color: var(--p3);
}
.chip:hover {
background: var(--surface);
}
.more {
font-size: var(--text-xs);
color: var(--ink-faint);
padding-left: var(--s1);
}
/* On a phone a seven-column grid of 92px cells is unreadable. The cells shrink
and the chips give way to a count — the month shape is the useful part at
that width, not the titles. */
@media (max-width: 620px) {
.day {
min-height: 56px;
padding: var(--s1);
}
.chip {
font-size: 0;
padding: 0;
height: 4px;
border-radius: var(--radius-full);
border-left: none;
background: var(--line-strong);
}
.chip[data-priority='1'] {
background: var(--p1);
}
.chip[data-priority='2'] {
background: var(--p2);
}
.chip[data-priority='3'] {
background: var(--p3);
}
.more {
font-size: 10px;
}
}
</style>
+41
View File
@@ -24,6 +24,14 @@
location.href = '/';
}
let filters = $state([]);
let labels = $state([]);
$effect(() => {
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
let current = $derived($page.url.pathname);
@@ -112,6 +120,39 @@
</div>
{/if}
{#if filters.length}
<div class="group">
<div class="group-head"><h2>Filtre</h2></div>
{#each filters as filter (filter.id)}
<a
href="/filter/{filter.id}"
class:active={current === `/filter/${filter.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{filter.name}
</a>
{/each}
</div>
{/if}
{#if labels.length}
<div class="group">
<div class="group-head"><h2>Etiketter</h2></div>
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
href="/etiket/{encodeURIComponent(label.name)}"
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{label.name}
<span class="count">{label.task_count}</span>
</a>
{/each}
</div>
{/if}
<div class="foot">
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
+29 -1
View File
@@ -79,13 +79,24 @@
<span class="description">{task.description}</span>
{/if}
{#if due || task.labels?.length}
{#if due || task.labels?.length || task.recurrence_text}
<span class="meta">
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
</span>
{/if}
{#if task.recurrence_text}
<span class="repeat" title="Gentages {task.recurrence_text}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 01-4 4H3" />
</svg>
{task.recurrence_text}
</span>
{/if}
{#each task.labels ?? [] as label}
<span class="label">{label}</span>
{/each}
@@ -229,6 +240,23 @@
color: var(--ink-muted);
}
.repeat {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-muted);
}
.repeat svg {
width: 11px;
height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+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>
+70
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,6 +162,11 @@
<QuickAdd projectId={project.id} />
{/if}
{#if mode === 'board'}
<BoardView {project} {sections} {canEdit} />
{:else if mode === 'calendar'}
<CalendarView />
{:else}
<section>
{#each unsectioned as task (task.id)}
<TaskRow {task} />
@@ -159,6 +192,7 @@
Ingenting her endnu.
</p>
{/if}
{/if}
{#if project.role === 'viewer'}
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
@@ -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);
@@ -0,0 +1,74 @@
<script>
/**
* Everything carrying one label.
*
* This runs the filter language rather than a bespoke endpoint: "@indkøb" is
* already exactly this query, and having one code path means a label page and
* a saved filter can never disagree about what a label selects.
*/
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 loaded = $state(false);
let name = $derived(decodeURIComponent($page.params.navn ?? ''));
$effect(() => {
const label = name;
if (!label) return;
loaded = false;
api
.previewFilter(`@${label}`)
.then((result) => {
app.tasks = result?.tasks ?? [];
})
.catch(() => {
app.tasks = [];
})
.finally(() => (loaded = true));
});
let open = $derived(app.tasks.filter((t) => !t.completed));
</script>
<div class="view">
<header><h1>@{name}</h1></header>
{#each open as task (task.id)}
<TaskRow {task} />
{/each}
{#if loaded && !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingen åbne opgaver med denne etiket.
</p>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
.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>