All checks were successful
Deploy production / deploy (push) Successful in 2m33s
921 lines
29 KiB
Svelte
921 lines
29 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { Button } from '#lib/components/ui/button/index.js';
|
|
import { Input } from '#lib/components/ui/input/index.js';
|
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
|
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
|
import Plus from 'lucide-svelte/icons/plus';
|
|
import Pencil from 'lucide-svelte/icons/pencil';
|
|
import Lock from 'lucide-svelte/icons/lock';
|
|
import Flag from 'lucide-svelte/icons/flag';
|
|
import Bell from 'lucide-svelte/icons/bell';
|
|
import Repeat from 'lucide-svelte/icons/repeat';
|
|
import Target from 'lucide-svelte/icons/target';
|
|
import Circle from 'lucide-svelte/icons/circle';
|
|
import CircleDot from 'lucide-svelte/icons/circle-dot';
|
|
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
|
import CircleX from 'lucide-svelte/icons/circle-x';
|
|
import type { SubmitFunction } from '@sveltejs/kit';
|
|
import type { PageProps } from './$types';
|
|
|
|
let { data }: PageProps = $props();
|
|
|
|
type Task = (typeof data.tasks)[number];
|
|
type Goal = (typeof data.goals)[number];
|
|
type Status = Task['status'];
|
|
|
|
const STATUS_LABEL: Record<Status, string> = {
|
|
todo: 'To do',
|
|
in_progress: 'In progress',
|
|
done: 'Done',
|
|
dropped: 'Dropped'
|
|
};
|
|
const STATUS_ICON = {
|
|
todo: Circle,
|
|
in_progress: CircleDot,
|
|
done: CircleCheck,
|
|
dropped: CircleX
|
|
} as const;
|
|
/** Clicking the status icon: to do → in progress → done → to do. */
|
|
const NEXT: Record<Status, Status> = {
|
|
todo: 'in_progress',
|
|
in_progress: 'done',
|
|
done: 'todo',
|
|
dropped: 'todo'
|
|
};
|
|
const PRIORITY = ['None', 'Low', 'Medium', 'High'] as const;
|
|
const STATUS_FILTERS = [
|
|
['open', 'Open'],
|
|
['todo', 'To do'],
|
|
['in_progress', 'In progress'],
|
|
['done', 'Done'],
|
|
['dropped', 'Dropped'],
|
|
['all', 'All']
|
|
] as const;
|
|
|
|
// Filters
|
|
let statusFilter = $state<'open' | 'all' | Status>('open');
|
|
let areaFilter = $state('all');
|
|
let priorityFilter = $state<'any' | 'flagged' | 'high'>('any');
|
|
let search = $state('');
|
|
// Long areas (years of TickTick history) render the first PAGE rows until expanded.
|
|
const PAGE = 40;
|
|
let expanded = $state<Record<string, boolean>>({});
|
|
|
|
const isOpen = (t: Task) => t.status === 'todo' || t.status === 'in_progress';
|
|
const matches = (t: Task) =>
|
|
(statusFilter === 'all' || (statusFilter === 'open' ? isOpen(t) : t.status === statusFilter)) &&
|
|
(areaFilter === 'all' ||
|
|
(areaFilter === 'none' ? t.areaId === null : t.areaId === Number(areaFilter))) &&
|
|
(priorityFilter === 'any' ||
|
|
(priorityFilter === 'flagged' ? t.flagged : t.priority >= 2 || t.flagged)) &&
|
|
(!search.trim() ||
|
|
`${t.title} ${t.notes ?? ''} ${t.tags.join(' ')}`
|
|
.toLowerCase()
|
|
.includes(search.trim().toLowerCase()));
|
|
|
|
const visible = $derived(data.tasks.filter(matches));
|
|
const visibleIds = $derived(new Set(visible.map((t) => t.id)));
|
|
const childrenOf = $derived.by(() => {
|
|
const map: Record<number, Task[]> = {};
|
|
for (const t of visible)
|
|
if (t.parentId !== null && visibleIds.has(t.parentId)) (map[t.parentId] ??= []).push(t);
|
|
return map;
|
|
});
|
|
const rank = (t: Task) => (t.status === 'in_progress' ? 0 : t.status === 'todo' ? 1 : 2);
|
|
const byUrgency = (a: Task, b: Task) =>
|
|
rank(a) - rank(b) ||
|
|
Number(b.flagged) - Number(a.flagged) ||
|
|
(a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999') ||
|
|
b.priority - a.priority ||
|
|
a.sortOrder - b.sortOrder;
|
|
const groups = $derived.by(() => {
|
|
const roots = visible.filter((t) => t.parentId === null || !visibleIds.has(t.parentId));
|
|
const list = data.areas.map((a) => ({
|
|
key: String(a.id),
|
|
name: a.name,
|
|
description: a.description,
|
|
tasks: roots.filter((t) => t.areaId === a.id).sort(byUrgency),
|
|
total: data.tasks.filter((t) => t.areaId === a.id).length,
|
|
done: data.tasks.filter((t) => t.areaId === a.id && t.status === 'done').length
|
|
}));
|
|
const loose = roots.filter((t) => t.areaId === null).sort(byUrgency);
|
|
if (loose.length)
|
|
list.push({
|
|
key: 'none',
|
|
name: 'No area',
|
|
description: null,
|
|
tasks: loose,
|
|
total: loose.length,
|
|
done: 0
|
|
});
|
|
return list.filter((g) => g.tasks.length);
|
|
});
|
|
|
|
const counts = $derived.by(() => {
|
|
const c = { todo: 0, in_progress: 0, done: 0, dropped: 0, overdue: 0, week: 0, flagged: 0 };
|
|
for (const t of data.tasks) {
|
|
c[t.status]++;
|
|
if (!isOpen(t)) continue;
|
|
if (t.flagged) c.flagged++;
|
|
const d = dueState(t);
|
|
if (d === 'overdue') c.overdue++;
|
|
else if (d === 'today' || d === 'soon') c.week++;
|
|
}
|
|
return c;
|
|
});
|
|
const upcoming = $derived(
|
|
data.tasks
|
|
.filter((t) => isOpen(t) && t.dueDate && daysUntil(t.dueDate) <= 14)
|
|
.sort((a, b) =>
|
|
`${a.dueDate}${a.dueTime ?? ''}`.localeCompare(`${b.dueDate}${b.dueTime ?? ''}`)
|
|
)
|
|
);
|
|
|
|
// Dates
|
|
function daysUntil(day: string) {
|
|
return Math.round(
|
|
(Date.parse(`${day}T12:00:00Z`) - Date.parse(`${data.today}T12:00:00Z`)) / 86_400_000
|
|
);
|
|
}
|
|
function dueState(t: Task): 'overdue' | 'today' | 'soon' | 'later' | null {
|
|
if (!t.dueDate) return null;
|
|
const n = daysUntil(t.dueDate);
|
|
return n < 0 ? 'overdue' : n === 0 ? 'today' : n <= 7 ? 'soon' : 'later';
|
|
}
|
|
function dueLabel(t: Task) {
|
|
if (!t.dueDate) return '';
|
|
const n = daysUntil(t.dueDate);
|
|
const time = t.dueTime ? ` ${t.dueTime.slice(0, 5)}` : '';
|
|
if (n === 0) return `today${time}`;
|
|
if (n === 1) return `tomorrow${time}`;
|
|
if (n === -1) return `yesterday${time}`;
|
|
const date = new Date(`${t.dueDate}T12:00:00Z`).toLocaleDateString('en-GB', {
|
|
weekday: n > 0 && n < 7 ? 'short' : undefined,
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: t.dueDate.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric',
|
|
timeZone: 'UTC'
|
|
});
|
|
return `${date}${time}`;
|
|
}
|
|
const localMoment = new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: 'Europe/Amsterdam',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
const reminderLabel = (d: Date) =>
|
|
d.toLocaleString('en-GB', {
|
|
timeZone: 'Europe/Amsterdam',
|
|
day: 'numeric',
|
|
month: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
const areaName = (id: number | null) => data.areas.find((a) => a.id === id)?.name ?? '';
|
|
const goalTitle = (id: number | null) => data.goals.find((g) => g.id === id)?.title ?? '';
|
|
|
|
// Goals
|
|
const goalStats = (g: Goal) => {
|
|
const linked = data.tasks.filter((t) => t.goalId === g.id && t.status !== 'dropped');
|
|
return { total: linked.length, done: linked.filter((t) => t.status === 'done').length };
|
|
};
|
|
const metricPct = (g: Goal) =>
|
|
g.metricTarget && g.metricCurrent !== null
|
|
? Math.max(0, Math.min(100, (g.metricCurrent / g.metricTarget) * 100))
|
|
: null;
|
|
const money = (n: number, unit: string | null) =>
|
|
unit === 'EUR'
|
|
? new Intl.NumberFormat('nl-NL', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
maximumFractionDigits: 0
|
|
}).format(n)
|
|
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
|
|
function targetLabel(day: string) {
|
|
const n = daysUntil(day);
|
|
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
|
month: 'short',
|
|
year: 'numeric',
|
|
timeZone: 'UTC'
|
|
});
|
|
if (n < 0) return `${date} (passed)`;
|
|
if (n < 60) return `${date} · ${n} days`;
|
|
if (n < 730) return `${date} · ${Math.round(n / 30.4)} months`;
|
|
return `${date} · ${(n / 365.25).toFixed(1)} years`;
|
|
}
|
|
|
|
// Editors
|
|
let taskDialog = $state(false);
|
|
let goalDialog = $state(false);
|
|
let editing = $state<Task | null>(null);
|
|
let editingGoal = $state<Goal | null>(null);
|
|
let confirmDelete = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let quickError = $state<string | null>(null);
|
|
let quickArea = $state('');
|
|
|
|
function openTask(t: Task | null = null) {
|
|
editing = t;
|
|
confirmDelete = false;
|
|
error = null;
|
|
taskDialog = true;
|
|
}
|
|
function openGoal(g: Goal | null = null) {
|
|
editingGoal = g;
|
|
confirmDelete = false;
|
|
error = null;
|
|
goalDialog = true;
|
|
}
|
|
|
|
const submitDialog: SubmitFunction =
|
|
() =>
|
|
async ({ result, update }) => {
|
|
await update({ reset: false });
|
|
if (result.type === 'success') taskDialog = goalDialog = false;
|
|
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
|
|
};
|
|
const submitQuick: SubmitFunction =
|
|
() =>
|
|
async ({ result, update }) => {
|
|
await update();
|
|
quickError =
|
|
result.type === 'failure' ? String(result.data?.message ?? 'Could not add') : null;
|
|
};
|
|
const submitInline: SubmitFunction =
|
|
() =>
|
|
async ({ update }) => {
|
|
await update({ reset: false });
|
|
};
|
|
</script>
|
|
|
|
{#snippet statusButton(t: Task)}
|
|
{@const Icon = STATUS_ICON[t.status]}
|
|
<form method="POST" action="?/status" use:enhance={submitInline} class="contents">
|
|
<input type="hidden" name="id" value={t.id} />
|
|
<input type="hidden" name="status" value={NEXT[t.status]} />
|
|
<button
|
|
class="status status-{t.status}"
|
|
type="submit"
|
|
title={`${STATUS_LABEL[t.status]}: mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
|
|
aria-label={`${t.title}: ${STATUS_LABEL[t.status]}, mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
|
|
><Icon size={18} /></button
|
|
>
|
|
</form>
|
|
{/snippet}
|
|
|
|
{#snippet taskRow(t: Task, depth: number)}
|
|
{@const due = dueState(t)}
|
|
<li class="task" class:closed={!isOpen(t)} style:padding-left={`${depth * 1.5}rem`}>
|
|
{@render statusButton(t)}
|
|
<div class="min-w-0 flex-1">
|
|
<p class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<button type="button" class="title" onclick={() => openTask(t)}>{t.title}</button>
|
|
{#if t.flagged}<Flag size={13} class="text-red-500" aria-label="Flagged" />{/if}
|
|
{#if t.priority >= 2}<span class="chip prio-{t.priority}">{PRIORITY[t.priority]}</span>{/if}
|
|
{#if t.dueDate}<span class="chip due-{isOpen(t) ? due : 'later'}">{dueLabel(t)}</span>{/if}
|
|
{#if t.reminderAt && isOpen(t)}<span class="chip" title="Reminder"
|
|
><Bell size={11} /> {reminderLabel(t.reminderAt)}</span
|
|
>{/if}
|
|
{#if t.recurrence}<Repeat size={12} aria-label="Repeats" />{/if}
|
|
{#each t.tags as tag (tag)}<span class="tag">#{tag}</span>{/each}
|
|
{#if t.goalId}<span class="tag"><Target size={11} /> {goalTitle(t.goalId)}</span>{/if}
|
|
</p>
|
|
{#if t.notes}<p class="notes">{t.notes}</p>{/if}
|
|
</div>
|
|
<form method="POST" action="?/status" use:enhance={submitInline} class="hidden sm:block">
|
|
<input type="hidden" name="id" value={t.id} />
|
|
<select
|
|
name="status"
|
|
class="status-select"
|
|
aria-label={`Status of ${t.title}`}
|
|
value={t.status}
|
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
|
>
|
|
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
|
|
>{label}</option
|
|
>{/each}
|
|
</select>
|
|
</form>
|
|
<button class="icon" type="button" aria-label={`Edit ${t.title}`} onclick={() => openTask(t)}
|
|
><Pencil size={14} /></button
|
|
>
|
|
</li>
|
|
{#each childrenOf[t.id] ?? [] as child (child.id)}
|
|
{@render taskRow(child, depth + 1)}
|
|
{/each}
|
|
{/snippet}
|
|
|
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
|
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">tasks</h1>
|
|
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
|
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
|
|
</p>
|
|
</div>
|
|
<dl class="stats">
|
|
<div>
|
|
<dt>to do</dt>
|
|
<dd>{counts.todo}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>in progress</dt>
|
|
<dd>{counts.in_progress}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>done</dt>
|
|
<dd>{counts.done}</dd>
|
|
</div>
|
|
<div class:alert={counts.overdue > 0}>
|
|
<dt>overdue</dt>
|
|
<dd>{counts.overdue}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>this week</dt>
|
|
<dd>{counts.week}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>flagged</dt>
|
|
<dd>{counts.flagged}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<form
|
|
method="POST"
|
|
action="?/quick"
|
|
use:enhance={submitQuick}
|
|
class="panel mb-4 flex flex-wrap items-center gap-2"
|
|
>
|
|
<Input
|
|
name="title"
|
|
required
|
|
maxlength={500}
|
|
placeholder="Add a task… (end with ! to flag, !!! for high priority)"
|
|
class="min-w-[14rem] flex-1"
|
|
aria-label="New task"
|
|
/>
|
|
<NativeSelect name="areaId" bind:value={quickArea} aria-label="Area">
|
|
<option value="">No area</option>
|
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
|
</NativeSelect>
|
|
<Input name="due" type="date" class="w-auto" aria-label="Due date" />
|
|
<Button type="submit"><Plus size={16} /> Add</Button>
|
|
{#if quickError}<p class="w-full text-sm text-red-500" role="alert">{quickError}</p>{/if}
|
|
</form>
|
|
|
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
|
<div class="segmented" role="group" aria-label="Status filter">
|
|
{#each STATUS_FILTERS as [value, label] (value)}
|
|
<button
|
|
type="button"
|
|
aria-pressed={statusFilter === value}
|
|
onclick={() => (statusFilter = value)}>{label}</button
|
|
>
|
|
{/each}
|
|
</div>
|
|
<NativeSelect bind:value={areaFilter} aria-label="Area filter">
|
|
<option value="all">All areas</option>
|
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
|
<option value="none">No area</option>
|
|
</NativeSelect>
|
|
<NativeSelect bind:value={priorityFilter} aria-label="Priority filter">
|
|
<option value="any">Any priority</option>
|
|
<option value="flagged">Flagged (!)</option>
|
|
<option value="high">Flagged or medium/high</option>
|
|
</NativeSelect>
|
|
<Input
|
|
type="search"
|
|
bind:value={search}
|
|
placeholder="Search…"
|
|
class="w-auto min-w-[10rem]"
|
|
aria-label="Search tasks"
|
|
/>
|
|
<Button type="button" variant="outline" onclick={() => openTask()}
|
|
><Plus size={16} /> Detailed task</Button
|
|
>
|
|
</div>
|
|
|
|
{#if upcoming.length && statusFilter === 'open' && areaFilter === 'all' && !search}
|
|
<section class="panel mb-6">
|
|
<h2 class="mb-2 text-lg font-semibold tracking-tight">Next two weeks</h2>
|
|
<ul class="divide-y divide-[var(--site-border)]">
|
|
{#each upcoming as t (t.id)}
|
|
{@const due = dueState(t)}
|
|
<li class="flex items-center gap-3 py-1.5 text-sm">
|
|
{@render statusButton(t)}
|
|
<span class="chip due-{due} w-28 shrink-0 justify-center">{dueLabel(t)}</span>
|
|
<button type="button" class="title truncate" onclick={() => openTask(t)}
|
|
>{t.title}</button
|
|
>
|
|
<small class="ml-auto hidden shrink-0 sm:inline">{areaName(t.areaId)}</small>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
|
|
<div class="grid gap-4 lg:grid-cols-2">
|
|
{#each groups as g (g.key)}
|
|
<section class="panel">
|
|
<header class="mb-2 flex items-baseline justify-between gap-2">
|
|
<div>
|
|
<h2 class="text-lg font-semibold tracking-tight">{g.name}</h2>
|
|
{#if g.description}<small>{g.description}</small>{/if}
|
|
</div>
|
|
<small class="shrink-0 tabular-nums">{g.done}/{g.total} done</small>
|
|
</header>
|
|
{#if g.total}<div class="bar mb-2" aria-hidden="true">
|
|
<span style:width={`${(g.done / g.total) * 100}%`}></span>
|
|
</div>{/if}
|
|
<ul class="divide-y divide-[var(--site-border)]">
|
|
{#each expanded[g.key] ? g.tasks : g.tasks.slice(0, PAGE) as t (t.id)}{@render taskRow(
|
|
t,
|
|
0
|
|
)}{/each}
|
|
</ul>
|
|
{#if g.tasks.length > PAGE && !expanded[g.key]}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="mt-2"
|
|
onclick={() => (expanded[g.key] = true)}>Show all {g.tasks.length}</Button
|
|
>
|
|
{/if}
|
|
</section>
|
|
{:else}
|
|
<p class="text-[var(--site-muted)]">No tasks match these filters.</p>
|
|
{/each}
|
|
</div>
|
|
|
|
<section class="mt-12">
|
|
<header class="mb-4 flex items-center justify-between gap-2">
|
|
<h2 class="text-2xl font-semibold tracking-tight">goals</h2>
|
|
<Button type="button" variant="outline" onclick={() => openGoal()}
|
|
><Plus size={16} /> Goal</Button
|
|
>
|
|
</header>
|
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each data.goals as goal (goal.id)}
|
|
{@const stats = goalStats(goal)}
|
|
{@const pct = metricPct(goal)}
|
|
<button
|
|
type="button"
|
|
class="panel goal text-left"
|
|
class:muted={goal.status !== 'active'}
|
|
onclick={() => openGoal(goal)}
|
|
>
|
|
<p class="mb-1 flex items-start justify-between gap-2">
|
|
<strong class="leading-snug">{goal.title}</strong>
|
|
{#if goal.status !== 'active'}<span class="chip">{goal.status}</span
|
|
>{:else if goal.flagged}<Flag size={13} class="shrink-0 text-red-500" />{/if}
|
|
</p>
|
|
{#if goal.notes}<p class="notes mb-2">{goal.notes}</p>{/if}
|
|
{#if goal.targetDate}<small class="block">🎯 {targetLabel(goal.targetDate)}</small>{/if}
|
|
{#if goal.metricTarget !== null}
|
|
<small class="block"
|
|
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
|
|
? `${money(goal.metricCurrent, goal.metricUnit)} / `
|
|
: ''}{money(goal.metricTarget, goal.metricUnit)}</small
|
|
>
|
|
{#if pct !== null}<div class="bar mt-1" aria-hidden="true">
|
|
<span style:width={`${pct}%`}></span>
|
|
</div>{/if}
|
|
{/if}
|
|
{#if stats.total}
|
|
<small class="mt-1 block">{stats.done}/{stats.total} linked tasks done</small>
|
|
<div class="bar mt-1" aria-hidden="true">
|
|
<span style:width={`${(stats.done / stats.total) * 100}%`}></span>
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
{:else}
|
|
<p class="text-[var(--site-muted)]">No goals yet.</p>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<Dialog.Root bind:open={taskDialog}>
|
|
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
|
<Dialog.Header class="mb-4 pr-8">
|
|
<Dialog.Title
|
|
>{confirmDelete ? 'Delete task' : editing ? 'Edit task' : 'New task'}</Dialog.Title
|
|
>
|
|
<Dialog.Description class="sr-only">Manage a private task.</Dialog.Description>
|
|
</Dialog.Header>
|
|
{#if confirmDelete && editing}
|
|
<form method="POST" action="?/delete" use:enhance={submitDialog}>
|
|
<input type="hidden" name="id" value={editing.id} />
|
|
<p class="mb-4 text-sm">
|
|
Delete “{editing.title}”{childrenOf[editing.id]?.length ? ' and its subtasks' : ''}? This
|
|
cannot be undone. (Marking it dropped keeps the history.)
|
|
</p>
|
|
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
|
<div class="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
|
|
>Back</Button
|
|
>
|
|
<Button type="submit" variant="destructive">Delete</Button>
|
|
</div>
|
|
</form>
|
|
{:else}
|
|
{#key editing?.id ?? 'new'}
|
|
<form method="POST" action="?/save" use:enhance={submitDialog} class="grid gap-3">
|
|
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Title *</span>
|
|
<Input name="title" required maxlength={500} value={editing?.title ?? ''} /></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Notes</span>
|
|
<Textarea name="notes" rows={3} value={editing?.notes ?? ''} /></label
|
|
>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Status</span>
|
|
<NativeSelect name="status" value={editing?.status ?? 'todo'}>
|
|
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
|
|
>{label}</option
|
|
>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Priority</span>
|
|
<NativeSelect name="priority" value={String(editing?.priority ?? 0)}>
|
|
{#each PRIORITY as label, i (label)}<option value={String(i)}>{label}</option
|
|
>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Due date</span>
|
|
<Input name="due" type="date" value={editing?.dueDate ?? ''} /></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Due time</span>
|
|
<Input
|
|
name="dueTime"
|
|
type="time"
|
|
value={editing?.dueTime?.slice(0, 5) ?? ''}
|
|
/></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Area</span>
|
|
<NativeSelect
|
|
name="areaId"
|
|
value={editing ? String(editing.areaId ?? '') : quickArea}
|
|
>
|
|
<option value="">No area</option>
|
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Goal</span>
|
|
<NativeSelect name="goalId" value={String(editing?.goalId ?? '')}>
|
|
<option value="">None</option>
|
|
{#each data.goals as g (g.id)}<option value={String(g.id)}>{g.title}</option>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Reminder</span>
|
|
<Input
|
|
name="reminderAt"
|
|
type="datetime-local"
|
|
value={editing?.reminderAt
|
|
? localMoment.format(editing.reminderAt).replace(' ', 'T')
|
|
: ''}
|
|
/></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Subtask of</span>
|
|
<NativeSelect name="parentId" value={String(editing?.parentId ?? '')}>
|
|
<option value="">—</option>
|
|
{#each data.tasks.filter((t) => t.id !== editing?.id && t.parentId === null && isOpen(t)) as t (t.id)}<option
|
|
value={String(t.id)}>{t.title}</option
|
|
>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
</div>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Tags <small>(comma or space separated)</small></span>
|
|
<Input name="tags" value={editing?.tags.join(', ') ?? ''} /></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Repeat <small>(RRULE, e.g. FREQ=WEEKLY;BYDAY=MO; not automatic yet)</small></span
|
|
>
|
|
<Input name="recurrence" value={editing?.recurrence ?? ''} /></label
|
|
>
|
|
<label class="flex items-center gap-2 text-sm"
|
|
><input type="checkbox" name="flagged" checked={editing?.flagged ?? false} /> Flagged (!)</label
|
|
>
|
|
{#if editing}<small
|
|
>Created {editing.createdAt.toLocaleDateString('en-GB')}{editing.completedAt
|
|
? ` · closed ${editing.completedAt.toLocaleDateString('en-GB')}`
|
|
: ''}{editing.source ? ` · ${editing.source}` : ''}</small
|
|
>{/if}
|
|
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
|
<div class="mt-2 flex justify-between gap-2">
|
|
{#if editing}<Button
|
|
type="button"
|
|
variant="ghost"
|
|
class="text-red-500"
|
|
onclick={() => (confirmDelete = true)}>Delete</Button
|
|
>{:else}<span></span>{/if}
|
|
<div class="flex gap-2">
|
|
<Button type="button" variant="outline" onclick={() => (taskDialog = false)}
|
|
>Cancel</Button
|
|
>
|
|
<Button type="submit">Save</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{/key}
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
|
|
<Dialog.Root bind:open={goalDialog}>
|
|
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
|
<Dialog.Header class="mb-4 pr-8">
|
|
<Dialog.Title
|
|
>{confirmDelete ? 'Delete goal' : editingGoal ? 'Edit goal' : 'New goal'}</Dialog.Title
|
|
>
|
|
<Dialog.Description class="sr-only">Manage a private goal.</Dialog.Description>
|
|
</Dialog.Header>
|
|
{#if confirmDelete && editingGoal}
|
|
<form method="POST" action="?/deleteGoal" use:enhance={submitDialog}>
|
|
<input type="hidden" name="id" value={editingGoal.id} />
|
|
<p class="mb-4 text-sm">
|
|
Delete “{editingGoal.title}”? Linked tasks stay, without the goal.
|
|
</p>
|
|
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
|
<div class="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
|
|
>Back</Button
|
|
>
|
|
<Button type="submit" variant="destructive">Delete</Button>
|
|
</div>
|
|
</form>
|
|
{:else}
|
|
{#key editingGoal?.id ?? 'new'}
|
|
<form method="POST" action="?/saveGoal" use:enhance={submitDialog} class="grid gap-3">
|
|
{#if editingGoal}<input type="hidden" name="id" value={editingGoal.id} />{/if}
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Title *</span>
|
|
<Input name="title" required maxlength={300} value={editingGoal?.title ?? ''} /></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Notes</span>
|
|
<Textarea name="notes" rows={2} value={editingGoal?.notes ?? ''} /></label
|
|
>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Status</span>
|
|
<NativeSelect name="status" value={editingGoal?.status ?? 'active'}>
|
|
<option value="active">Active</option>
|
|
<option value="achieved">Achieved</option>
|
|
<option value="dropped">Dropped</option>
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Target date</span>
|
|
<Input name="targetDate" type="date" value={editingGoal?.targetDate ?? ''} /></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Area</span>
|
|
<NativeSelect name="areaId" value={String(editingGoal?.areaId ?? '')}>
|
|
<option value="">No area</option>
|
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
|
</NativeSelect></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Metric</span>
|
|
<Input
|
|
name="metric"
|
|
value={editingGoal?.metric ?? ''}
|
|
placeholder="e.g. Net income per month"
|
|
/></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Current</span>
|
|
<Input
|
|
name="metricCurrent"
|
|
inputmode="decimal"
|
|
value={editingGoal?.metricCurrent ?? ''}
|
|
/></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Target</span>
|
|
<Input
|
|
name="metricTarget"
|
|
inputmode="decimal"
|
|
value={editingGoal?.metricTarget ?? ''}
|
|
/></label
|
|
>
|
|
<label class="grid gap-1 text-sm"
|
|
><span>Unit</span>
|
|
<Input
|
|
name="metricUnit"
|
|
value={editingGoal?.metricUnit ?? ''}
|
|
placeholder="EUR, kg…"
|
|
/></label
|
|
>
|
|
<label class="flex items-center gap-2 self-end pb-2 text-sm"
|
|
><input type="checkbox" name="flagged" checked={editingGoal?.flagged ?? false} /> Flagged</label
|
|
>
|
|
</div>
|
|
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
|
<div class="mt-2 flex justify-between gap-2">
|
|
{#if editingGoal}<Button
|
|
type="button"
|
|
variant="ghost"
|
|
class="text-red-500"
|
|
onclick={() => (confirmDelete = true)}>Delete</Button
|
|
>{:else}<span></span>{/if}
|
|
<div class="flex gap-2">
|
|
<Button type="button" variant="outline" onclick={() => (goalDialog = false)}
|
|
>Cancel</Button
|
|
>
|
|
<Button type="submit">Save</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
{/key}
|
|
{/if}
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|
|
|
|
<style>
|
|
.panel {
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.75rem;
|
|
background: var(--site-surface);
|
|
padding: 1rem 1.25rem;
|
|
}
|
|
small,
|
|
.notes {
|
|
color: var(--site-muted);
|
|
}
|
|
.notes {
|
|
font-size: 0.8rem;
|
|
line-height: 1.35;
|
|
margin-top: 0.15rem;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1.25rem;
|
|
}
|
|
.stats dt {
|
|
font-size: 0.75rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.stats dd {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.stats .alert dd {
|
|
color: rgb(239 68 68);
|
|
}
|
|
.task {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.6rem;
|
|
padding-top: 0.5rem;
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
.task.closed .title {
|
|
text-decoration: line-through;
|
|
color: var(--site-muted);
|
|
}
|
|
.title {
|
|
text-align: left;
|
|
font-size: 0.925rem;
|
|
}
|
|
.title:hover {
|
|
text-decoration: underline;
|
|
text-underline-offset: 3px;
|
|
}
|
|
.status {
|
|
display: inline-flex;
|
|
flex-shrink: 0;
|
|
border-radius: 999px;
|
|
color: var(--site-muted);
|
|
margin-top: 0.05rem;
|
|
}
|
|
.status:hover {
|
|
color: var(--site-text);
|
|
}
|
|
.status-in_progress {
|
|
color: rgb(245 158 11);
|
|
}
|
|
.status-done {
|
|
color: rgb(34 197 94);
|
|
}
|
|
.status-select {
|
|
font-size: 0.75rem;
|
|
background: transparent;
|
|
color: var(--site-muted);
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.375rem;
|
|
padding: 0.1rem 0.25rem;
|
|
}
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
font-size: 0.7rem;
|
|
line-height: 1;
|
|
padding: 0.2rem 0.45rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--site-border);
|
|
color: var(--site-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.due-overdue {
|
|
background: rgb(239 68 68 / 0.15);
|
|
border-color: rgb(239 68 68 / 0.5);
|
|
color: rgb(239 68 68);
|
|
}
|
|
.due-today {
|
|
background: rgb(245 158 11 / 0.18);
|
|
border-color: rgb(245 158 11 / 0.55);
|
|
color: rgb(217 119 6);
|
|
}
|
|
.due-soon {
|
|
border-color: rgb(59 130 246 / 0.5);
|
|
color: rgb(59 130 246);
|
|
}
|
|
.prio-3 {
|
|
color: rgb(239 68 68);
|
|
}
|
|
.prio-2 {
|
|
color: rgb(245 158 11);
|
|
}
|
|
.tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
font-size: 0.7rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 0.375rem;
|
|
padding: 0.3rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.icon:hover {
|
|
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
|
color: var(--site-text);
|
|
}
|
|
.segmented {
|
|
display: inline-flex;
|
|
flex-wrap: wrap;
|
|
border: 1px solid var(--site-border);
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
.segmented button {
|
|
font-size: 0.8rem;
|
|
padding: 0.35rem 0.7rem;
|
|
color: var(--site-muted);
|
|
}
|
|
.segmented button[aria-pressed='true'] {
|
|
background: color-mix(in srgb, var(--site-text) 12%, transparent);
|
|
color: var(--site-text);
|
|
font-weight: 500;
|
|
}
|
|
.bar {
|
|
height: 4px;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, var(--site-muted) 20%, transparent);
|
|
overflow: hidden;
|
|
}
|
|
.bar span {
|
|
display: block;
|
|
height: 100%;
|
|
background: rgb(34 197 94);
|
|
border-radius: inherit;
|
|
}
|
|
.goal {
|
|
display: block;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.goal:hover {
|
|
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
|
}
|
|
.goal.muted {
|
|
opacity: 0.6;
|
|
}
|
|
</style>
|