From 0ee72d399c281003077266c97c1aba0eb9ae8998 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 07:10:51 +0200 Subject: [PATCH] feat(web): board and calendar views, saved filters and label pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 23 +- internal/recurrence/recurrence.go | 10 +- internal/recurrence/recurrence_test.go | 6 +- web/src/lib/api.js | 25 ++ web/src/lib/components/BoardView.svelte | 220 +++++++++++++++ web/src/lib/components/CalendarView.svelte | 257 ++++++++++++++++++ web/src/lib/components/Sidebar.svelte | 41 +++ web/src/lib/components/TaskRow.svelte | 30 +- web/src/routes/filter/[id]/+page.svelte | 104 +++++++ web/src/routes/projekt/[id]/+page.svelte | 108 ++++++-- web/web/src/routes/etiket/[navn]/+page.svelte | 74 +++++ 11 files changed, 867 insertions(+), 31 deletions(-) create mode 100644 web/src/lib/components/BoardView.svelte create mode 100644 web/src/lib/components/CalendarView.svelte create mode 100644 web/src/routes/filter/[id]/+page.svelte create mode 100644 web/web/src/routes/etiket/[navn]/+page.svelte diff --git a/README.md b/README.md index 1aeadec..e07d5b5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/recurrence/recurrence.go b/internal/recurrence/recurrence.go index a2564b0..bc7ab37 100644 --- a/internal/recurrence/recurrence.go +++ b/internal/recurrence/recurrence.go @@ -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"] { diff --git a/internal/recurrence/recurrence_test.go b/internal/recurrence/recurrence_test.go index 4748287..df4961b 100644 --- a/internal/recurrence/recurrence_test.go +++ b/internal/recurrence/recurrence_test.go @@ -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) { diff --git a/web/src/lib/api.js b/web/src/lib/api.js index dd07dd1..d49240d 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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}` : ''}`), diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte new file mode 100644 index 0000000..4cde61e --- /dev/null +++ b/web/src/lib/components/BoardView.svelte @@ -0,0 +1,220 @@ + + +
+ {#each columns as column (column.id)} + {@const tasks = tasksIn(column.id)} + +
onDragOver(e, column.id)} + ondragleave={() => (overColumn = null)} + ondrop={(e) => onDrop(e, column.id)} + > +
+

{column.name}

+ {tasks.length} +
+ +
+ {#each tasks as task (task.id)} + +
onDragStart(e, task)} + ondragend={() => (draggingId = null)} + > + +
+ {/each} + + {#if !tasks.length} +

Tom

+ {/if} +
+
+ {/each} +
+ + diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte new file mode 100644 index 0000000..bce1446 --- /dev/null +++ b/web/src/lib/components/CalendarView.svelte @@ -0,0 +1,257 @@ + + +
+
+ +

{monthName}

+ + +
+ + + +
+ {#each grid as date (date.toISOString())} + {@const tasks = tasksOn(date)} + {@const outside = date.getMonth() !== cursor.getMonth()} +
+ {date.getDate()} + + {#each tasks.slice(0, 3) as task (task.id)} + + {/each} + + {#if tasks.length > 3} + +{tasks.length - 3} mere + {/if} +
+ {/each} +
+
+ + diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 633d7f4..5766eea 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -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 @@ {/if} + {#if filters.length} +
+

Filtre

+ {#each filters as filter (filter.id)} + + + {filter.name} + + {/each} +
+ {/if} + + {#if labels.length} +
+

Etiketter

+ {#each labels.filter((l) => l.task_count > 0) as label (label.id)} + + + {label.name} + {label.task_count} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/TaskRow.svelte b/web/src/lib/components/TaskRow.svelte index 6f30839..7031fb3 100644 --- a/web/src/lib/components/TaskRow.svelte +++ b/web/src/lib/components/TaskRow.svelte @@ -79,13 +79,24 @@ {task.description} {/if} - {#if due || task.labels?.length} + {#if due || task.labels?.length || task.recurrence_text} {#if due} {due.text}{#if time} {time}{/if} {/if} + {#if task.recurrence_text} + + + {task.recurrence_text} + + {/if} {#each task.labels ?? [] as label} {label} {/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); diff --git a/web/src/routes/filter/[id]/+page.svelte b/web/src/routes/filter/[id]/+page.svelte new file mode 100644 index 0000000..a6e0d34 --- /dev/null +++ b/web/src/routes/filter/[id]/+page.svelte @@ -0,0 +1,104 @@ + + +
+
+

{filter?.name ?? 'Filter'}

+ {#if filter} + {filter.query} + {/if} +
+ + {#if error} +

{error}

+ {:else} + {#each open as task (task.id)} + + {/each} + + {#if !open.length} +

+ + Ingenting matcher lige nu. +

+ {/if} + {/if} +
+ + diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 9deef0c..29511a8 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -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}

{project.name}

+
+ {#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]} + + {/each} +
+ {#if !project.is_inbox}