Files
ink-note-app/src/lib/components/TasksDialog.svelte
T
Your NameandClaude Sonnet 5.5 27fafa0bcd feat(ui): components, routes and styles
Sidebar (lazy tree, keyboard navigation, saved searches), page view with
typed properties, tasks, tags, calendar and clip dialogs, settings with
shortcut rebinding, canvas, database and board views.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:57:09 +02:00

140 lines
5.8 KiB
Svelte

<script>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let status = $state('open'); // 'open' | 'done' | 'all'
let when = $state('any'); // 'any' | 'overdue' | 'today' | 'week' | 'none'
let tag = $state('');
let q = $state('');
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const now = new Date();
const today = iso(now);
const weekEnd = iso(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7));
async function load() {
try {
await store.flush(); // what is on screen is on disk before it is read here
items = (await api.tasks()) || [];
} catch (err) {
items = [];
toast(errorText(err), 'x');
}
}
onMount(load);
const plain = (t) => t
.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, '$2')
.replace(/\[\[([^\]]*)\]\]/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/(?:📅|\bdue:)\s*\d{4}-\d{2}-\d{2}/g, '')
.replace(/\*\*|__|==|`|\*/g, '')
.replace(/\s+/g, ' ')
.trim();
let tags = $derived([...new Set((items || []).flatMap((t) => t.tags))].sort((a, b) => a.localeCompare(b)));
let shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
return (items || []).filter((t) => {
if (status === 'open' && t.done) return false;
if (status === 'done' && !t.done) return false;
if (tag && !t.tags.includes(tag)) return false;
if (when === 'none' && t.due) return false;
if (when === 'overdue' && !(t.due && t.due < today && !t.done)) return false;
if (when === 'today' && t.due !== today) return false;
if (when === 'week' && !(t.due && t.due >= today && t.due <= weekEnd)) return false;
if (needle && !(t.text.toLowerCase().includes(needle) || t.title.toLowerCase().includes(needle))) return false;
return true;
});
});
let groups = $derived.by(() => {
const map = new Map();
for (const t of shown) {
if (!map.has(t.id)) map.set(t.id, { id: t.id, title: t.title, icon: t.icon, tasks: [] });
map.get(t.id).tasks.push(t);
}
return [...map.values()].sort((a, b) => a.title.localeCompare(b.title));
});
let openCount = $derived((items || []).filter((t) => !t.done).length);
async function toggle(t) {
const done = !t.done;
t.done = done; // optimistic; undone below if the file refuses
try {
await api.setTask(t.id, t.line, t.text, done);
await store.refresh();
} catch (err) {
toast(errorText(err), 'x');
await load();
}
}
function openPage(id) {
close();
setTimeout(() => store.openPage(id), 120);
}
function close() {
closing = true;
setTimeout(() => { store.tasksOpen = false; closing = false; }, 150);
}
const dueText = (d) => (d === today ? 'Today' : new Date(d + 'T00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tasks">
<div class="trash-top">
<Icon name="check" />
<h2>Tasks{#if items}<span class="muted"> · {openCount} open</span>{/if}</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar">
<div class="seg3" role="radiogroup" aria-label="Status">
{#each [['open', 'Open'], ['done', 'Done'], ['all', 'All']] as [v, label] (v)}
<button class:on={status === v} role="radio" aria-checked={status === v} onclick={() => (status = v)}>{label}</button>
{/each}
</div>
<select class="tk-sel" aria-label="Due date" bind:value={when}>
<option value="any">Any date</option>
<option value="overdue">Overdue</option>
<option value="today">Today</option>
<option value="week">Next 7 days</option>
<option value="none">No date</option>
</select>
<select class="tk-sel" aria-label="Tag" bind:value={tag}>
<option value="">Any tag</option>
{#each tags as t (t)}<option value={t}>#{t}</option>{/each}
</select>
<input class="tk-q" placeholder="Filter by text or page" aria-label="Filter tasks" bind:value={q} />
</div>
<div class="trash-list tk-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !groups.length}
<div class="trash-empty"><Icon name="check" />{items.length ? 'No tasks match.' : 'No tasks yet. Write “- [ ] something” on any page.'}</div>
{:else}
{#each groups as g (g.id)}
<div class="tk-group">
<button class="tk-page" onclick={() => openPage(g.id)}><span class="row-ico"><Icon name={iconOf(g)} /></span>{g.title}</button>
{#each g.tasks as t (t.line)}
<label class="tk" class:done={t.done} style="padding-left:{10 + Math.min(t.depth, 4) * 18}px">
<input type="checkbox" checked={t.done} onchange={() => toggle(t)} />
<span class="tk-t">{plain(t.text)}</span>
{#if t.due}<span class="tk-due" class:over={!t.done && t.due < today}>{dueText(t.due)}</span>{/if}
{#each t.tags as x (x)}<span class="tk-tag">#{x}</span>{/each}
</label>
{/each}
</div>
{/each}
{/if}
</div>
<div class="trash-foot"><span>Add a due date with 📅 2026-03-01 or due:2026-03-01 in the task. Boards are left out.</span></div>
</div>
</div>