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:
Kristian Wind
2026-08-17 06:53:00 +02:00
co-authored by Claude Opus 5
parent 3efc3754af
commit a96aae57ec
9 changed files with 1211 additions and 8 deletions
+188
View File
@@ -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")
}
}