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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}` : ''}`),
|
||||
|
||||
@@ -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 = '/';
|
||||
}
|
||||
|
||||
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. -->
|
||||
|
||||
@@ -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} {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);
|
||||
|
||||
@@ -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 TaskRow from '$lib/components/TaskRow.svelte';
|
||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||
import BoardView from '$lib/components/BoardView.svelte';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
|
||||
let project = $state(null);
|
||||
let sections = $state([]);
|
||||
@@ -41,6 +43,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// The chosen view is kept locally as well as on the project, so switching is
|
||||
// instant and only persists once the request lands.
|
||||
let view = $state(null);
|
||||
let mode = $derived(view ?? project?.view_mode ?? 'list');
|
||||
|
||||
async function setView(next) {
|
||||
view = next;
|
||||
if (project?.role === 'owner') {
|
||||
try {
|
||||
await api.updateProject(project.id, { view_mode: next });
|
||||
} catch {
|
||||
// A view that did not persist is a small thing; it still switched.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
|
||||
let isOwner = $derived(project?.role === 'owner');
|
||||
|
||||
@@ -70,6 +88,16 @@
|
||||
{#if project}
|
||||
<header>
|
||||
<h1>{project.name}</h1>
|
||||
<div class="views" role="group" aria-label="Visning">
|
||||
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
|
||||
<button
|
||||
class:active={mode === value}
|
||||
onclick={() => setView(value)}
|
||||
aria-pressed={mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !project.is_inbox}
|
||||
<button class="share" onclick={() => (showShare = !showShare)}>
|
||||
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
|
||||
@@ -134,30 +162,36 @@
|
||||
<QuickAdd projectId={project.id} />
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
{#each unsectioned as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
{#if mode === 'board'}
|
||||
<BoardView {project} {sections} {canEdit} />
|
||||
{:else if mode === 'calendar'}
|
||||
<CalendarView />
|
||||
{:else}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
{#each tasks as task (task.id)}
|
||||
{#each unsectioned as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if !open.length}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ingenting her endnu.
|
||||
</p>
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if !open.length}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ingenting her endnu.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if project.role === 'viewer'}
|
||||
@@ -175,6 +209,12 @@
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
.view:has(.board),
|
||||
.view:has(.calendar) {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -189,6 +229,36 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.views button {
|
||||
padding: var(--s1) var(--s3);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--ground);
|
||||
color: var(--ink-muted);
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.views button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share {
|
||||
flex: none;
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -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