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>
This commit is contained in:
kristianwind
2026-08-25 10:59:34 +02:00
co-authored by Claude Opus 5
parent fa816b2e41
commit 9e3f20b050
14 changed files with 401 additions and 26 deletions
+26
View File
@@ -127,6 +127,32 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
expect(trouble).toEqual([]);
});
test('en opgave kan slumres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
const box = page.getByLabel('Ny opgave');
await box.fill('Alfa i dag');
await box.press('Enter');
// Snooze it to tomorrow from its drawer.
await page.getByText('Alfa', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByRole('button', { name: 'I morgen' }).click();
// The row greys — it carries the snoozed class and a "slumret til" mark.
const alfa = page.locator('.row', { hasText: 'Alfa' });
await expect(alfa).toHaveClass(/snoozed/);
await expect(alfa.locator('.snooze-mark')).toBeVisible();
// The drawer now offers to wake it, and waking takes the grey off.
await drawer.getByRole('button', { name: 'Væk nu' }).click();
await expect(alfa).not.toHaveClass(/snoozed/);
expect(trouble).toEqual([]);
});
test('en opgave kan åbnes og redigeres', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
+1
View File
@@ -310,6 +310,7 @@ export const api = {
completeTask: (id) => post(`/tasks/${id}/complete`),
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
snoozeTask: (id, until) => post(`/tasks/${id}/snooze`, { until: until ?? '' }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
+101 -2
View File
@@ -13,7 +13,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props();
@@ -26,6 +26,46 @@
// Length in minutes, as a string for the field. Only meaningful with a time —
// see the field below — so it is seeded and shown alongside the clock.
let duration = $state('');
let snoozeCustom = $state('');
const snoozed = $derived(!!task?.snoozed_until && new Date(task.snoozed_until) > new Date());
const snoozedText = $derived(
snoozed
? new Date(task.snoozed_until).toLocaleString(tag(), {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
: ''
);
async function snoozeTo(iso) {
snoozeCustom = '';
try {
await app.snooze(task.id, iso);
} catch (e) {
app.toast(humanMessage(e));
}
}
// Quick picks, in local time, as ISO for the API. This evening is six; the
// morning ones are nine; next week is the coming Monday.
const atLocal = (d, h) => {
d.setHours(h, 0, 0, 0);
return d.toISOString();
};
const thisEvening = () => atLocal(new Date(), 18);
const tomorrowMorning = () => {
const d = new Date();
d.setDate(d.getDate() + 1);
return atLocal(d, 9);
};
const nextWeek = () => {
const d = new Date();
d.setDate(d.getDate() + (((8 - d.getDay()) % 7) || 7));
return atLocal(d, 9);
};
let labels = $state('');
let projectId = $state('');
let recurrence = $state('');
@@ -459,6 +499,31 @@
</div>
</div>
<!-- Snooze: park it out of the way until a moment, without touching when it is
due. While snoozed the row greys and sinks to the bottom of its list. -->
<div class="field">
<span class="field-label">{t('detail.snooze')}</span>
{#if snoozed}
<div class="snoozed-now">
<span>{t('task.snoozedUntil', { when: snoozedText })}</span>
<button class="snooze-btn" onclick={() => snoozeTo('')}>{t('detail.wake')}</button>
</div>
{:else}
<div class="snooze-row">
<button class="snooze-btn" onclick={() => snoozeTo(thisEvening())}>{t('detail.thisEvening')}</button>
<button class="snooze-btn" onclick={() => snoozeTo(tomorrowMorning())}>{t('detail.tomorrow')}</button>
<button class="snooze-btn" onclick={() => snoozeTo(nextWeek())}>{t('detail.nextWeek')}</button>
<input
type="datetime-local"
class="snooze-custom"
aria-label={t('detail.snoozeCustom')}
bind:value={snoozeCustom}
onchange={() => snoozeCustom && snoozeTo(new Date(snoozeCustom).toISOString())}
/>
</div>
{/if}
</div>
<div class="field">
<label for="labels">{t('detail.labels')}</label>
<input
@@ -765,13 +830,47 @@
gap: var(--s2);
}
label {
label,
.field-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
.snooze-row {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
align-items: center;
}
.snooze-btn {
width: auto;
padding: var(--s1) var(--s2);
font-size: var(--text-xs);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
cursor: pointer;
}
.snooze-btn:hover {
background: var(--surface-raised);
}
.snooze-custom {
width: auto;
flex: 1;
min-width: 9rem;
}
.snoozed-now {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s2);
font-size: var(--text-sm);
color: var(--ink-muted);
}
input,
textarea,
select {
+12 -1
View File
@@ -120,7 +120,18 @@
}
}
let ordered = $derived([...tasks].sort((a, b) => a.sort_order - b.sort_order));
// 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)}
+50 -1
View File
@@ -63,9 +63,30 @@
})
: null
);
// Snoozed while its time is still ahead. Greys the row and adds a "slumret til"
// mark, so a parked task reads as set-aside rather than forgotten.
let snoozed = $derived(task.snoozed_until && new Date(task.snoozed_until) > new Date());
let snoozedLabel = $derived.by(() => {
if (!snoozed) return null;
const at = new Date(task.snoozed_until);
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const days = Math.round((new Date(at.toDateString()) - midnight) / 86400000);
const clock = at.toLocaleTimeString(tag(), { hour: '2-digit', minute: '2-digit' });
if (days <= 0) return clock;
if (days === 1) return t('task.tomorrow') + ' ' + clock;
return at.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }) + ' ' + clock;
});
</script>
<div class="row" class:leaving class:completed={task.completed} data-priority={task.priority}>
<div
class="row"
class:leaving
class:completed={task.completed}
class:snoozed
data-priority={task.priority}
>
<button
class="check"
class:checked={task.completed || leaving}
@@ -100,6 +121,14 @@
{assignee.name}
</span>
{/if}
{#if snoozed}
<span class="snooze-mark" title={t('task.snoozedUntil', { when: snoozedLabel })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h8l-8 8h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
{snoozedLabel}
</span>
{/if}
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
@@ -183,6 +212,26 @@
text-decoration-color: var(--ink-faint);
}
/* Parked, not gone: dimmed so it reads as set aside, but still there and still
readable. The checkbox and the snooze mark keep their weight, so it can be
woken or ticked off without peering at it. */
.row.snoozed .content,
.row.snoozed .description,
.row.snoozed .meta {
opacity: 0.5;
}
.snooze-mark {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--ink-muted);
opacity: 1;
}
.snooze-mark svg {
width: 12px;
height: 12px;
}
.check {
flex: none;
width: 20px;
+7
View File
@@ -600,6 +600,13 @@ export const da = {
'detail.due': 'Forfalder',
'detail.dueTime': 'Klokkeslæt',
'detail.duration': 'Længde i minutter',
'detail.snooze': 'Slumre',
'detail.wake': 'Væk nu',
'detail.thisEvening': 'I aften',
'detail.tomorrow': 'I morgen',
'detail.nextWeek': 'Næste uge',
'detail.snoozeCustom': 'Vælg tidspunkt',
'task.snoozedUntil': 'Slumret til {when}',
'detail.durationShort': 'min',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
+7
View File
@@ -596,6 +596,13 @@ export const en = {
'detail.due': 'Due',
'detail.dueTime': 'Time',
'detail.duration': 'Length in minutes',
'detail.snooze': 'Snooze',
'detail.wake': 'Wake now',
'detail.thisEvening': 'This evening',
'detail.tomorrow': 'Tomorrow',
'detail.nextWeek': 'Next week',
'detail.snoozeCustom': 'Pick a time',
'task.snoozedUntil': 'Snoozed until {when}',
'detail.durationShort': 'min',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
+13
View File
@@ -408,6 +408,19 @@ class AppState {
* and time are left alone, so making a task longer on the calendar does not
* also reschedule it.
*/
/**
* Snoozes a task until an ISO time, or wakes it when `until` is empty. Nothing
* else about the task moves — a snooze is about when you want to see it, not when
* it is due. Optimistic, so it greys and sinks at once.
*/
async snooze(id, until) {
await this.#optimistic(
id,
{ snoozed_until: until || undefined },
() => api.snoozeTask(id, until || '')
);
}
async resize(id, minutes) {
const previous = this.get(id);
if (!previous) return;