feat(recurrence): repeating tasks as RRULE, in Danish and English
"vand planterne hver mandag" becomes FREQ=WEEKLY;BYDAY=MO. The stored form is RFC 5545, not a private format, so the same field will drive the CalDAV server and the ICS feed without a translation layer. Ticking off a repeating task advances it instead of closing it, keeping the same row — so its sub-tasks and comments stay attached. A weekly review is one thing that recurs, not fifty-two things. The clock time survives the move, and the completion is still written to the activity log, because "what did I get done this week" has to include the chores that repeat. Recurrence is parsed before dates, and that ordering is load-bearing: "hver mandag" contains a weekday, so a date parser reading it first would claim Monday as a one-off and strand "hver" in the title. It is also resolved before the time-only case, or "hver mandag kl 9" typed after nine would land tomorrow morning instead of on Monday. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3efc3754af
commit
a96aae57ec
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/quickadd"
|
||||
"github.com/kristianwind/verdande/internal/recurrence"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
@@ -24,6 +25,7 @@ type taskJSON struct {
|
||||
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"`
|
||||
@@ -39,6 +41,7 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
ID: t.ID, ProjectID: t.ProjectID, SectionID: t.SectionID, ParentID: t.ParentID,
|
||||
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
||||
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
||||
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
||||
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
||||
SortOrder: t.SortOrder,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
@@ -185,6 +188,13 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
t.AssigneeID = *req.AssigneeID
|
||||
}
|
||||
if req.Recurrence != nil {
|
||||
// Refused here rather than discovered when somebody ticks the task off and
|
||||
// it cannot be advanced — at which point the failure is a mystery and the
|
||||
// task is stuck.
|
||||
if !recurrence.Valid(*req.Recurrence) {
|
||||
writeFieldErrors(w, map[string]string{"recurrence_rule": "is not a valid RRULE"})
|
||||
return
|
||||
}
|
||||
t.RecurrenceRule = *req.Recurrence
|
||||
}
|
||||
if req.DurationMin != nil {
|
||||
@@ -257,6 +267,10 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeFieldErrors(w, map[string]string{"priority": "must be 1, 2, 3 or 4"})
|
||||
return
|
||||
}
|
||||
if req.Recurrence != nil && !recurrence.Valid(*req.Recurrence) {
|
||||
writeFieldErrors(w, map[string]string{"recurrence_rule": "is not a valid RRULE"})
|
||||
return
|
||||
}
|
||||
// Sending due_date at all means the due date is being set — including to
|
||||
// nothing, which is how a date is cleared.
|
||||
if req.DueDate != nil {
|
||||
@@ -299,7 +313,8 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err := s.db.CompleteTask(r.Context(), taskID, user.ID); err != nil {
|
||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "complete task", err)
|
||||
return
|
||||
}
|
||||
@@ -309,9 +324,27 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.completed", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.completed", toTaskJSON(*t))
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
|
||||
// A repeating task that moved forward is still recorded as a completion: "what
|
||||
// did I get done this week" has to include the chores that repeat.
|
||||
s.activity(r, t.ProjectID, t.ID, "task.completed", map[string]any{
|
||||
"content": t.Content,
|
||||
"recurred": result.Recurred,
|
||||
"next_due": result.NextDue,
|
||||
})
|
||||
|
||||
payload := taskWithRecurrence{taskJSON: toTaskJSON(*t), Recurred: result.Recurred, NextDue: result.NextDue}
|
||||
s.publish(t.ProjectID, "task.completed", payload)
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// taskWithRecurrence is the completion response. The two extra fields are what let
|
||||
// the interface say "flyttet til mandag" instead of animating a row away that is
|
||||
// about to come straight back.
|
||||
type taskWithRecurrence struct {
|
||||
taskJSON
|
||||
Recurred bool `json:"recurred"`
|
||||
NextDue string `json:"next_due,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -461,7 +494,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
|
||||
@@ -651,3 +651,191 @@ func TestActivityRecordsWhatHappened(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- recurring tasks ------------------------------------------------------------
|
||||
|
||||
// Ticking off a repeating task does not finish it — it moves to the next occurrence.
|
||||
// That is the whole behaviour, and it is the one that would be most annoying to get
|
||||
// wrong: a weekly chore that disappears is a weekly chore you stop doing.
|
||||
func TestCompletingARecurringTaskAdvancesIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "vand planterne hver mandag",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: %d %v", resp.StatusCode, task)
|
||||
}
|
||||
id := task["id"].(string)
|
||||
firstDue, _ := task["due_date"].(string)
|
||||
|
||||
if task["recurrence_rule"] != "FREQ=WEEKLY;BYDAY=MO" {
|
||||
t.Fatalf("recurrence_rule = %v", task["recurrence_rule"])
|
||||
}
|
||||
if firstDue == "" {
|
||||
t.Fatal("a repeating task was created with no date to repeat from")
|
||||
}
|
||||
|
||||
resp, done := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("complete: %d %v", resp.StatusCode, done)
|
||||
}
|
||||
|
||||
if done["recurred"] != true {
|
||||
t.Errorf("recurred = %v, want true", done["recurred"])
|
||||
}
|
||||
if done["completed"] != false {
|
||||
t.Error("a repeating task was closed instead of advanced")
|
||||
}
|
||||
|
||||
nextDue, _ := done["due_date"].(string)
|
||||
if nextDue <= firstDue {
|
||||
t.Errorf("due_date went from %q to %q — it did not move forward", firstDue, nextDue)
|
||||
}
|
||||
// Weekly, so exactly seven days on.
|
||||
first, _ := time.Parse("2006-01-02", firstDue)
|
||||
next, _ := time.Parse("2006-01-02", nextDue)
|
||||
if days := int(next.Sub(first).Hours() / 24); days != 7 {
|
||||
t.Errorf("the task moved %d days, want 7", days)
|
||||
}
|
||||
|
||||
// And it is still in the open list, not the completed one.
|
||||
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||
tasks, _ := list["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("the open list has %d tasks, want the repeating one", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// The task keeps its identity across occurrences, so its sub-tasks and comments
|
||||
// stay attached — a "weekly review" is one thing that recurs, not fifty-two things.
|
||||
func TestARecurringTaskKeepsItsIdentity(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "ugentlig gennemgang hver fredag",
|
||||
})
|
||||
id := task["id"].(string)
|
||||
|
||||
_, child := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "læs noter", "parent_id": id,
|
||||
})
|
||||
childID := child["id"].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil)
|
||||
|
||||
resp, after := ts.do(t, "GET", "/api/v1/tasks/"+id, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the task disappeared: %d", resp.StatusCode)
|
||||
}
|
||||
if after["id"] != id {
|
||||
t.Error("the id changed across an occurrence")
|
||||
}
|
||||
resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+childID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Error("the sub-task was lost when its parent recurred")
|
||||
}
|
||||
}
|
||||
|
||||
// A task due at a particular hour keeps that hour when it moves.
|
||||
func TestRecurrenceKeepsTheClockTime(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "standup hverdage kl 9",
|
||||
})
|
||||
id := task["id"].(string)
|
||||
before, _ := task["due_datetime"].(string)
|
||||
if before == "" {
|
||||
t.Fatal("no due_datetime was set")
|
||||
}
|
||||
|
||||
_, done := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil)
|
||||
after, _ := done["due_datetime"].(string)
|
||||
if after == "" {
|
||||
t.Fatal("the clock time was dropped when the task recurred")
|
||||
}
|
||||
|
||||
parseHM := func(s string) string {
|
||||
v, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", s, err)
|
||||
}
|
||||
return v.Format("15:04")
|
||||
}
|
||||
if parseHM(before) != parseHM(after) {
|
||||
t.Errorf("the time changed from %s to %s", parseHM(before), parseHM(after))
|
||||
}
|
||||
}
|
||||
|
||||
// A completion is still recorded, even though nothing was closed. "What did I get
|
||||
// done this week" has to include the chores that repeat.
|
||||
func TestRecurringCompletionIsRecorded(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "tøm opvaskemaskinen", "project_id": projectID,
|
||||
"due_date": userDate(t, 0), "recurrence_rule": "FREQ=DAILY",
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil)
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/activity", nil)
|
||||
entries, _ := body["activity"].([]any)
|
||||
|
||||
var found bool
|
||||
for _, e := range entries {
|
||||
entry := e.(map[string]any)
|
||||
if entry["event"] != "task.completed" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
payload, _ := entry["payload"].(map[string]any)
|
||||
if payload["recurred"] != true {
|
||||
t.Errorf("the completion was not marked as a recurrence: %v", payload)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no completion was recorded for a repeating task")
|
||||
}
|
||||
}
|
||||
|
||||
// A rule the RRULE library cannot read must be refused when the task is created,
|
||||
// not discovered when somebody ticks it off and it will not move.
|
||||
func TestInvalidRecurrenceIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "noget", "recurrence_rule": "FREQ=FORTNIGHTLY",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["recurrence_rule"] == nil {
|
||||
t.Errorf("the error does not name the field: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-repeating task must still complete normally.
|
||||
func TestCompletingAPlainTaskStillCloses(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "engangsopgave"})
|
||||
_, done := ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil)
|
||||
|
||||
if done["completed"] != true {
|
||||
t.Error("a plain task was not completed")
|
||||
}
|
||||
if done["recurred"] == true {
|
||||
t.Error("a plain task reported as recurring")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user