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 ## What works today
**Phase 1 is complete and phase 2 is most of the way there.** You can install it, **Phases 1 and 3 are complete; phase 2 is most of the way there.** You can install
create an account, add tasks by typing a sentence, share a project and watch it, create an account, add tasks by typing a sentence, share a project, watch
somebody else's changes appear live. somebody else's changes appear live, set up repeating chores and reminders, save
filters, and subscribe to it from Apple Calendar.
| Area | State | | Area | State |
|---|---| |---|---|
@@ -56,11 +57,17 @@ somebody else's changes appear live.
| Sharing: invites, roles, members | Done, tested | | Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done | | Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | 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 | | 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 Not yet built: comments and attachments, Web Push, and everything from phase 4
supports it), Web Push, and everything from phase 3 onwards. onwards.
Search finds `grøn` when you type `gron`, because Unicode does not consider `ø` an 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. 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 ## Roadmap
1. **MVP** — auth, projects, sections, tasks, sub-tasks, priorities, due dates, 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, 2. **Sharing** — invites, roles, assignees, comments, attachments, activity log,
realtime sync, notifications. realtime sync, notifications.
3. **Pro parity** — recurring tasks, reminders, filter queries, board and calendar 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. 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, 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. 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, ",") { for _, code := range strings.Split(days, ",") {
names = append(names, danishDay(code)) 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"] { switch parts["FREQ"] {
+4 -2
View File
@@ -270,8 +270,9 @@ func TestDescribe(t *testing.T) {
"FREQ=DAILY;INTERVAL=3": "hver 3. dag", "FREQ=DAILY;INTERVAL=3": "hver 3. dag",
"FREQ=WEEKLY": "hver uge", "FREQ=WEEKLY": "hver uge",
"FREQ=WEEKLY;INTERVAL=2": "hver anden uge", "FREQ=WEEKLY;INTERVAL=2": "hver anden uge",
"FREQ=WEEKLY;BYDAY=MO": "hver uge mandag", "FREQ=WEEKLY;BYDAY=MO": "hver mandag",
"FREQ=WEEKLY;BYDAY=MO,TH": "hver uge mandag og torsdag", "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=MO,TU,WE,TH,FR": "hverdage",
"FREQ=WEEKLY;BYDAY=SA,SU": "hver weekend", "FREQ=WEEKLY;BYDAY=SA,SU": "hver weekend",
"FREQ=MONTHLY": "hver måned", "FREQ=MONTHLY": "hver måned",
@@ -293,6 +294,7 @@ func TestDescribeRoundTrip(t *testing.T) {
phrases := []string{ phrases := []string{
"hver dag", "hver anden dag", "hver uge", "hver anden uge", "hver dag", "hver anden dag", "hver uge", "hver anden uge",
"hverdage", "hver weekend", "hver måned", "hvert år", "hverdage", "hver weekend", "hver måned", "hvert år",
"hver mandag", "hver mandag og torsdag",
} }
for _, phrase := range phrases { for _, phrase := range phrases {
t.Run(phrase, func(t *testing.T) { t.Run(phrase, func(t *testing.T) {
+25
View File
@@ -150,6 +150,31 @@ export const api = {
quickAddPreview: (text, signal) => quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(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 // --- views
today: () => get('/today'), today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`), 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 = '/'; 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 shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $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); let current = $derived($page.url.pathname);
@@ -112,6 +120,39 @@
</div> </div>
{/if} {/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"> <div class="foot">
<!-- A quiet indicator, not an alarm: losing the socket for a moment is <!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. --> normal, and the app keeps working without it. -->
+29 -1
View File
@@ -79,13 +79,24 @@
<span class="description">{task.description}</span> <span class="description">{task.description}</span>
{/if} {/if}
{#if due || task.labels?.length} {#if due || task.labels?.length || task.recurrence_text}
<span class="meta"> <span class="meta">
{#if due} {#if due}
<span class="due" data-tone={due.tone}> <span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if} {due.text}{#if time}&nbsp;{time}{/if}
</span> </span>
{/if} {/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} {#each task.labels ?? [] as label}
<span class="label">{label}</span> <span class="label">{label}</span>
{/each} {/each}
@@ -229,6 +240,23 @@
color: var(--ink-muted); 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 { .label {
color: var(--ink-muted); color: var(--ink-muted);
background: var(--surface-raised); 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>
+89 -19
View File
@@ -5,6 +5,8 @@
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 BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
let project = $state(null); let project = $state(null);
let sections = $state([]); 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 canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
let isOwner = $derived(project?.role === 'owner'); let isOwner = $derived(project?.role === 'owner');
@@ -70,6 +88,16 @@
{#if project} {#if project}
<header> <header>
<h1>{project.name}</h1> <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} {#if !project.is_inbox}
<button class="share" onclick={() => (showShare = !showShare)}> <button class="share" onclick={() => (showShare = !showShare)}>
{project.shared ? `Delt · ${project.member_count}` : 'Del'} {project.shared ? `Delt · ${project.member_count}` : 'Del'}
@@ -134,30 +162,36 @@
<QuickAdd projectId={project.id} /> <QuickAdd projectId={project.id} />
{/if} {/if}
<section> {#if mode === 'board'}
{#each unsectioned as task (task.id)} <BoardView {project} {sections} {canEdit} />
<TaskRow {task} /> {:else if mode === 'calendar'}
{/each} <CalendarView />
</section> {:else}
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section> <section>
<h2>{section.name}</h2> {#each unsectioned as task (task.id)}
{#each tasks as task (task.id)}
<TaskRow {task} /> <TaskRow {task} />
{/each} {/each}
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
</section> </section>
{/each}
{#if !open.length} {#each sections as section (section.id)}
<p class="clear"> {@const tasks = open.filter((t) => t.section_id === section.id)}
<span class="rune" aria-hidden="true">ᚹ</span> <section>
Ingenting her endnu. <h2>{section.name}</h2>
</p> {#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}
{#if project.role === 'viewer'} {#if project.role === 'viewer'}
@@ -175,6 +209,12 @@
padding: var(--s6) var(--s4) var(--s8); 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 { header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -189,6 +229,36 @@
overflow-wrap: anywhere; 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 { .share {
flex: none; flex: none;
font-size: var(--text-sm); 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>