Files
verdande/web/src/lib/components/TaskList.svelte
T
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:59:34 +02:00

199 lines
6.4 KiB
Svelte

<script>
/**
* A list of tasks that can be reordered by dragging.
*
* The board view drags cards between columns; this drags rows within one. Both
* use the native HTML drag-and-drop API for the same reason — it works with a
* trackpad, a mouse and the browser's own affordances without reimplementing
* any of them.
*
* Where the board shows the target *column*, a list has to show the target
* *gap*: the whole decision here is "above this one or below it", so a line is
* drawn where the row would land.
*/
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { api } from '$lib/api.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskRow from './TaskRow.svelte';
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
let draggingId = $state(null);
/** The row the line is drawn at, and which side of it. */
let overId = $state(null);
let overBelow = $state(false);
function onDragStart(event, task) {
if (!canEdit) return;
draggingId = task.id;
// Typed, because the same task can now be dropped somewhere else entirely —
// a project in the sidebar, a day in Kommende — and those targets have to be
// able to tell what is coming while the drag is still in the air.
startDrag(event, TASK, task.id);
}
function onDragOver(event, task) {
// `carries` rather than `draggingId`: a task dragged from another list — the
// unsectioned rows above, or another section — never called this component's
// own dragstart, so its `draggingId` is null. Refusing on that made every
// list reject everything that came from outside it, which is the whole point
// of dragging between them.
if (!canEdit || !carries(event, TASK) || draggingId === task.id) return;
accept(event);
// Which half of the row the pointer is in decides the gap. Comparing against
// the row's own midpoint rather than counting rows is what keeps the line
// where the eye expects it when rows have different heights.
const box = event.currentTarget.getBoundingClientRect();
overId = task.id;
overBelow = event.clientY > box.top + box.height / 2;
}
function clearOver() {
overId = null;
}
async function onDrop(event, target) {
event.preventDefault();
// The section around this list also takes drops, for the coarse "into here"
// gesture and for sections with no rows to aim at. A drop that landed on a
// row has already said exactly where it goes, so it stops here.
event.stopPropagation();
// From the drag itself, not from `draggingId`, which is only set when the
// drag *started* in this list. A task dragged in from the unsectioned rows
// or another section left it null, so this returned early — and it had
// already called stopPropagation, so the section around it never got the
// drop either. The result: a task could only be dropped into a section with
// no rows in it to aim at, which made every section look like it could hold
// exactly one task.
const id = dragged(event, TASK) || draggingId;
const below = overBelow;
draggingId = null;
overId = null;
if (!id || !canEdit || id === target.id) return;
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
// The dragged task may not be in this list at all — that is the cross-list
// case — so `without` is "this list minus it" either way.
const without = ordered.filter((t) => t.id !== id);
const at = without.findIndex((t) => t.id === target.id);
if (at < 0) return;
// The gap the row lands in, named by where they end up on screen — the API's
// after_id is the task above, which is the opposite of what "after" reads as
// in a vertical list.
const index = below ? at + 1 : at;
const above = without[index - 1];
const beneath = without[index];
const task = app.get(id);
if (!task) return;
const previous = { ...task };
// Optimistic: the row is in its new place before the request goes out. The
// midpoint is a local guess — the server owns the real value and sends it
// back, and if the gap has run out of precision it respaces the section and
// tries again.
const order =
above && beneath
? (above.sort_order + beneath.sort_order) / 2
: above
? above.sort_order + 1024
: (beneath?.sort_order ?? 1024) - 1024;
app.replace(id, { ...task, sort_order: order });
try {
app.replace(
id,
await api.moveTask(id, {
project_id: projectId,
section_id: sectionId,
after_id: above?.id ?? '',
before_id: beneath?.id ?? ''
})
);
} catch {
app.replace(id, previous);
app.toast(t('task.moveFailed'));
}
}
// A snoozed task sinks below the rest, then rises back when its time passes —
// the same order the server sorts a list in, kept here so a drag-sorted list
// agrees rather than jumping when it reloads.
const snoozedNow = (t) => t.snoozed_until && new Date(t.snoozed_until) > new Date();
let ordered = $derived(
[...tasks].sort((a, b) => {
const sa = snoozedNow(a) ? 1 : 0;
const sb = snoozedNow(b) ? 1 : 0;
if (sa !== sb) return sa - sb;
return a.sort_order - b.sort_order;
})
);
</script>
{#each ordered as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sortable"
class:dragging={draggingId === task.id}
class:drop-above={overId === task.id && !overBelow}
class:drop-below={overId === task.id && overBelow}
draggable={canEdit}
ondragstart={(e) => onDragStart(e, task)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, task)}
ondragleave={clearOver}
ondrop={(e) => onDrop(e, task)}
>
<TaskRow {task} />
</div>
{/each}
<style>
.sortable {
position: relative;
transition: opacity var(--fast) var(--ease);
}
/* Only the handle-less body drags; the checkbox stays clickable because the
drag needs a few pixels of movement before the browser calls it a drag. */
.sortable[draggable='true'] {
cursor: grab;
}
.sortable.dragging {
opacity: 0.4;
cursor: grabbing;
}
/* A line in the gap rather than a highlighted row: the row is not the target,
the space between two rows is. */
.sortable::before {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
.sortable.drop-above::before {
top: -1px;
opacity: 1;
}
.sortable.drop-below::before {
bottom: -1px;
opacity: 1;
}
</style>