diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3a740bc..c424feb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9cc6726..4ff6121 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) }) }) }) diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index c009a11..815aa4f 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -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)) +} diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 81cdd5a..c532186 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0031_task_snooze.sql b/internal/store/migrations/0031_task_snooze.sql new file mode 100644 index 0000000..7c52e33 --- /dev/null +++ b/internal/store/migrations/0031_task_snooze.sql @@ -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; diff --git a/internal/store/tasks.go b/internal/store/tasks.go index fc05ad4..a6ddc15 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -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, §ionID, &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 diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index f41c99d..a91fb81 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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('/'); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 7a5a349..c2ef5f2 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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 }), diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index 136afc6..b227e41 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -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 @@ + +
+ {t('detail.snooze')} + {#if snoozed} +
+ {t('task.snoozedUntil', { when: snoozedText })} + +
+ {:else} +
+ + + + snoozeCustom && snoozeTo(new Date(snoozeCustom).toISOString())} + /> +
+ {/if} +
+
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; + }) + ); {#each ordered as task (task.id)} diff --git a/web/src/lib/components/TaskRow.svelte b/web/src/lib/components/TaskRow.svelte index b4470dd..ceca249 100644 --- a/web/src/lib/components/TaskRow.svelte +++ b/web/src/lib/components/TaskRow.svelte @@ -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; + }); -
+