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:
co-authored by
Claude Opus 5
parent
7e4c730049
commit
0ee72d399c
@@ -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.
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}` : ''}`),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -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} {time}{/if}
|
{due.text}{#if time} {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);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user