Compare commits

...
Author SHA1 Message Date
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 <[email protected]>
2026-08-25 10:59:34 +02:00
Kristian Wind fa816b2e41 Merge pull request #27 from kristianwind/ics-alldays-for-dated-tasks
CI / OpenAPI is valid (push) Successful in 10s
CI / End-to-end (push) Successful in 7m52s
CI / Docker image builds (push) Successful in 1m19s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 13m49s
Fix: date-only tasks now appear in a subscribed calendar (ICS)
2026-08-25 08:48:47 +02:00
14 changed files with 401 additions and 26 deletions
+22
View File
@@ -805,6 +805,28 @@ paths:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/tasks/{taskID}/snooze:
parameters:
- { name: taskID, in: path, required: true, schema: { type: string } }
post:
tags: [tasks]
summary: Snooze a task until a time, or wake it.
description: |
Parks the task at the bottom of its list, greyed, until `until` passes —
nothing else about it moves. An empty `until` wakes it now.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
until: { type: string, format: date-time, description: "RFC3339; empty to wake." }
responses:
"200": { $ref: "#/components/responses/Task" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/tasks/quick-add:
post:
tags: [tasks]
+1
View File
@@ -551,6 +551,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/complete", s.handleCompleteTask)
r.Post("/reopen", s.handleReopenTask)
r.Post("/move", s.handleMoveTask)
r.Post("/snooze", s.handleSnoozeTask)
})
})
})
+70 -17
View File
@@ -16,23 +16,24 @@ import (
)
type taskJSON struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
SectionID string `json:"section_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Content string `json:"content"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
DueDate string `json:"due_date,omitempty"`
DueDatetime string `json:"due_datetime,omitempty"`
DurationMin *int `json:"duration_min,omitempty"`
Recurrence string `json:"recurrence_rule,omitempty"`
RepeatText string `json:"recurrence_text,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
Labels []string `json:"labels"`
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
CreatedBy string `json:"created_by"`
ID string `json:"id"`
ProjectID string `json:"project_id"`
SectionID string `json:"section_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Content string `json:"content"`
Description string `json:"description,omitempty"`
Priority int `json:"priority"`
DueDate string `json:"due_date,omitempty"`
DueDatetime string `json:"due_datetime,omitempty"`
DurationMin *int `json:"duration_min,omitempty"`
Recurrence string `json:"recurrence_rule,omitempty"`
RepeatText string `json:"recurrence_text,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
Labels []string `json:"labels"`
Completed bool `json:"completed"`
CompletedAt string `json:"completed_at,omitempty"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
CreatedBy string `json:"created_by"`
// What the row can say without being opened. Sent with every task rather than
// fetched when one is opened, because the point is that you see it in the list.
SubtaskCount int `json:"subtask_count,omitempty"`
@@ -67,6 +68,9 @@ func toTaskJSON(t store.Task) taskJSON {
if t.CompletedAt != nil {
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
}
if t.SnoozedUntil != nil {
j.SnoozedUntil = t.SnoozedUntil.Format(time.RFC3339)
}
return j
}
@@ -698,3 +702,52 @@ func parseLimit(raw string, def, max int) int {
}
return n
}
type snoozeRequest struct {
// Until is an RFC3339 time to snooze to, or empty to wake the task now.
Until string `json:"until"`
}
// handleSnoozeTask parks a task until a time, or wakes it. Snooze changes nothing
// but when the task wants to be seen — its due date and everything else stay put.
func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
var req snoozeRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
taskID := chi.URLParam(r, "taskID")
user := userFrom(r.Context())
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
var until *time.Time
if strings.TrimSpace(req.Until) != "" {
parsed, perr := time.Parse(time.RFC3339, req.Until)
if perr != nil {
writeFieldErrors(w, map[string]string{"until": "must be an RFC3339 time"})
return
}
until = &parsed
}
if err := s.db.SnoozeTask(r.Context(), taskID, until); err != nil {
s.storeError(w, r, "snooze task", err)
return
}
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
verb := "task.snoozed"
if until == nil {
verb = "task.unsnoozed"
}
s.activity(r, t.ProjectID, t.ID, verb, nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
+47
View File
@@ -1817,3 +1817,50 @@ func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
}
}
}
// A snoozed task sinks to the bottom of the list and greys until its time; waking
// it puts it back. The list order is what the row's dimming rides on.
func TestSnoozeSinksAndWakes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
aID, _ := a["id"].(string)
// Snooze Alfa two days out.
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
if resp.StatusCode != http.StatusOK {
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
}
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
t.Error("the response does not carry snoozed_until")
}
// Alfa now sorts after Bravo, though it was created first.
order := func() []string {
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
var out []string
for _, raw := range list["tasks"].([]any) {
out = append(out, raw.(map[string]any)["content"].(string))
}
return out
}
got := order()
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
t.Errorf("snoozed task did not sink: order = %v", got)
}
// Wake it, and it comes back above Bravo.
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("wake: %d %v", resp.StatusCode, body)
}
if s, _ := body["snoozed_until"].(string); s != "" {
t.Errorf("waking left snoozed_until set: %q", s)
}
if got := order(); got[0] != "Alfa" {
t.Errorf("woken task did not return: order = %v", got)
}
}
@@ -0,0 +1,7 @@
-- Snooze: put a task out of the way without finishing or filing it. A snoozed task
-- sinks to the bottom of its list and greys out until the time it is snoozed to,
-- then comes back on its own — no job wakes it, because "snoozed" is simply
-- snoozed_until being still in the future, read against the clock each time.
--
-- Null means not snoozed, which is every task until somebody snoozes one.
ALTER TABLE tasks ADD COLUMN snoozed_until INTEGER;
+37 -5
View File
@@ -29,6 +29,9 @@ type Task struct {
AssigneeID string
CompletedAt *time.Time
CompletedBy string
// SnoozedUntil, when set and still in the future, parks the task at the bottom
// of its list, greyed, until it passes. Nil means the task is not snoozed.
SnoozedUntil *time.Time
// CreatedBy is empty when the author's account has been deleted: `created_by`
// is ON DELETE SET NULL so the task survives the person who wrote it.
CreatedBy string
@@ -219,7 +222,7 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,` + taskCounts + `
FROM tasks t
JOIN projects p ON p.id = t.project_id
WHERE ` + strings.Join(where, " AND ") + `
@@ -313,7 +316,7 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
t.created_by, t.sort_order, t.created_at, t.updated_at, t.snoozed_until,`+taskCounts+`
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
if err != nil {
return nil, err
@@ -390,7 +393,32 @@ func order(f TaskFilter) string {
if f.CompletedOnly {
return "t.completed_at DESC, t.id DESC"
}
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
// A snoozed task sinks below everything else — the CASE is 1 while it is still
// snoozed and 0 the moment its time passes, so it rejoins the list in its proper
// place by the clock, with nothing to run. Compared as an integer, or the TEXT
// strftime returns would sort under every number.
snoozed := "(CASE WHEN t.snoozed_until > CAST(strftime('%s','now') AS INTEGER) THEN 1 ELSE 0 END)"
return snoozed + ", t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
}
// SnoozeTask parks a task until a moment, or wakes it when until is nil. Only the
// snooze changes; the due date, the time, everything else is left alone — a snooze
// is about when you want to see it, not when it is due.
func (db *DB) SnoozeTask(ctx context.Context, id string, until *time.Time) error {
var v any
if until != nil {
v = until.Unix()
}
res, err := db.ExecContext(ctx,
`UPDATE tasks SET snoozed_until = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
v, time.Now().Unix(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func scanTask(rows *sql.Rows) (Task, error) {
@@ -400,13 +428,13 @@ func scanTask(rows *sql.Rows) (Task, error) {
// stays; it loses its author.
var createdBy sql.NullString
var dueDatetime, completedAt sql.NullInt64
var duration sql.NullInt64
var duration, snoozedUntil sql.NullInt64
var created, updated int64
err := rows.Scan(&t.ID, &t.ProjectID, &sectionID, &parentID, &t.Content, &t.Description,
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
&recurrence, &assignee, &completedAt, &completedBy,
&createdBy, &t.SortOrder, &created, &updated,
&createdBy, &t.SortOrder, &created, &updated, &snoozedUntil,
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
if err != nil {
return t, err
@@ -432,6 +460,10 @@ func scanTask(rows *sql.Rows) (Task, error) {
v := int(duration.Int64)
t.DurationMin = &v
}
if snoozedUntil.Valid {
v := time.Unix(snoozedUntil.Int64, 0).UTC()
t.SnoozedUntil = &v
}
t.CreatedAt = time.Unix(created, 0).UTC()
t.UpdatedAt = time.Unix(updated, 0).UTC()
return t, nil
+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;