diff --git a/go.mod b/go.mod index 356ec0f..1ccf47a 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 6f1fca9..5240294 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index e9341c3..274df71 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -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) diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 5518d9e..8c3eec6 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -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") + } +} diff --git a/internal/quickadd/quickadd.go b/internal/quickadd/quickadd.go index b62f59c..15bcf14 100644 --- a/internal/quickadd/quickadd.go +++ b/internal/quickadd/quickadd.go @@ -22,6 +22,8 @@ import ( "strings" "time" "unicode" + + "github.com/kristianwind/verdande/internal/recurrence" ) // Kind names what a Span was recognised as. @@ -33,6 +35,7 @@ const ( KindPriority Kind = "priority" KindProject Kind = "project" KindLabel Kind = "label" + KindRepeat Kind = "repeat" ) // Span is a byte range of the input that was consumed, and what it was read as. @@ -58,7 +61,11 @@ type Result struct { // DueTime is "HH:MM" in 24-hour form, set only when a clock time was written. // A time with no date means today, and Parse fills the date in. DueTime string `json:"due_time,omitempty"` - Spans []Span `json:"spans,omitempty"` + // Recurrence is an RFC 5545 RRULE, empty when the task does not repeat. + Recurrence string `json:"recurrence_rule,omitempty"` + // RecurrenceText is that rule written back out in Danish, for the preview. + RecurrenceText string `json:"recurrence_text,omitempty"` + Spans []Span `json:"spans,omitempty"` } // Parse reads input as of the moment `now`, which is also the timezone dates are @@ -83,6 +90,13 @@ func Parse(input string, now time.Time, locale string) Result { // mask keeps byte offsets intact, so spans still point into the original input. masked := mask(input, spans) + // Recurrence before the date parser, and this order is load-bearing: "hver + // mandag" contains a weekday, and a date parser reading it first would claim + // Monday as a one-off due date and leave "hver" stranded in the title. + res.Recurrence, spans = extractRecurrence(masked, spans) + res.RecurrenceText = recurrence.Describe(res.Recurrence) + masked = mask(masked, spans) + // Time before date, so "kl 15.30" is claimed as a clock time before the date // parser can offer to read "15.3" as the 15th of March. hour, minute, hasTime, spans := extractTime(masked, spans, locale) @@ -91,6 +105,18 @@ func Parse(input string, now time.Time, locale string) Result { switch { case hasDate: res.DueDate = date.Format("2006-01-02") + case res.Recurrence != "": + // A repeating task needs somewhere to repeat from. "vand planterne hver + // mandag" typed on a Tuesday means starting the coming Monday, which is + // what advancing from today produces. + // + // This is checked before the time-only case below, and that ordering + // matters: in "hver mandag kl 9" the rule decides the day and the clock + // decides only the hour. Letting the time case win would schedule it for + // tomorrow morning, because nine o'clock has already gone. + if next, err := recurrence.Next(res.Recurrence, now); err == nil { + res.DueDate = next.Format("2006-01-02") + } case hasTime: // A time on its own means today — unless that moment has already passed, // in which case the person means tomorrow. "kl 8" typed at nine in the @@ -584,3 +610,40 @@ func pad2(n int) string { } return strconv.Itoa(n) } + +// --- recurrence --------------------------------------------------------------- + +// reRepeat finds the repetition phrase inside a longer line. The phrase itself is +// handed to the recurrence package, which decides whether it really is one — so a +// weekday that merely follows the word "hver" is not enough on its own. +// +// The trailing group is bounded rather than greedy: without a limit, "hver mandag +// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse +// all of it and leave the task not repeating at all. +var reRepeat = regexp.MustCompile( + `(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` + + `|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` + + `|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`) + +func extractRecurrence(input string, spans []Span) (string, []Span) { + for _, m := range reRepeat.FindAllStringSubmatchIndex(input, -1) { + phrase := input[m[2]:m[3]] + + // The longest match may carry words that are not part of the repetition — + // "hver mandag kl" — so trailing words are dropped one at a time until + // what is left parses, or nothing is. + start, end := m[2], m[3] + for { + if rule := recurrence.Parse(phrase); rule != "" { + return rule, append(spans, Span{Start: start, End: end, Kind: KindRepeat}) + } + cut := strings.LastIndexAny(strings.TrimRight(phrase, " "), " ") + if cut <= 0 { + break + } + phrase = phrase[:cut] + end = start + cut + } + } + return "", spans +} diff --git a/internal/quickadd/quickadd_test.go b/internal/quickadd/quickadd_test.go index 05afb60..6c2c8b8 100644 --- a/internal/quickadd/quickadd_test.go +++ b/internal/quickadd/quickadd_test.go @@ -429,3 +429,134 @@ func TestImpossibleDatesAreNotSilentlyMoved(t *testing.T) { }) } } + +// --- recurrence --------------------------------------------------------------- + +func TestParseRecurrence(t *testing.T) { + now := ref(t) // Tuesday 10 March 2026 + + cases := []struct { + in string + content string + rule string + wantDue string + }{ + {"vand planterne hver mandag", "vand planterne", "FREQ=WEEKLY;BYDAY=MO", "2026-03-16"}, + {"tag skraldet ud hver tirsdag", "tag skraldet ud", "FREQ=WEEKLY;BYDAY=TU", "2026-03-17"}, + {"løb hver dag", "løb", "FREQ=DAILY", "2026-03-11"}, + {"status hver 2. uge", "status", "FREQ=WEEKLY;INTERVAL=2", "2026-03-24"}, + {"betal husleje den 1. i måneden", "betal husleje", "FREQ=MONTHLY;BYMONTHDAY=1", "2026-04-01"}, + {"standup hverdage", "standup", "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "2026-03-11"}, + {"review every 2 weeks", "review", "FREQ=WEEKLY;INTERVAL=2", "2026-03-24"}, + {"backup weekly", "backup", "FREQ=WEEKLY", "2026-03-17"}, + } + + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := Parse(tc.in, now, "da") + if got.Recurrence != tc.rule { + t.Errorf("Recurrence = %q, want %q", got.Recurrence, tc.rule) + } + if got.Content != tc.content { + t.Errorf("Content = %q, want %q", got.Content, tc.content) + } + if got.DueDate != tc.wantDue { + t.Errorf("DueDate = %q, want %q", got.DueDate, tc.wantDue) + } + }) + } +} + +// "hver mandag" contains a weekday. If the date parser ran first it would claim +// Monday as a one-off date and leave "hver" stranded in the title. +func TestRecurrenceBeatsAPlainWeekday(t *testing.T) { + now := ref(t) + + repeating := Parse("møde hver fredag", now, "da") + if repeating.Recurrence != "FREQ=WEEKLY;BYDAY=FR" { + t.Errorf("Recurrence = %q", repeating.Recurrence) + } + if repeating.Content != "møde" { + t.Errorf("Content = %q, want %q", repeating.Content, "møde") + } + + // And a plain weekday is still a one-off. + once := Parse("møde fredag", now, "da") + if once.Recurrence != "" { + t.Errorf("a one-off got a recurrence rule: %q", once.Recurrence) + } + if once.DueDate != "2026-03-13" { + t.Errorf("DueDate = %q, want 2026-03-13", once.DueDate) + } +} + +// The repetition phrase sits in a line with everything else in it. +func TestRecurrenceAlongsideEverythingElse(t *testing.T) { + got := Parse("send rapport hver mandag kl 9 p1 #Arbejde @fast", ref(t), "da") + + if got.Recurrence != "FREQ=WEEKLY;BYDAY=MO" { + t.Errorf("Recurrence = %q", got.Recurrence) + } + if got.Content != "send rapport" { + t.Errorf("Content = %q, want %q", got.Content, "send rapport") + } + if got.DueTime != "09:00" { + t.Errorf("DueTime = %q, want 09:00", got.DueTime) + } + if got.Priority != 1 { + t.Errorf("Priority = %d, want 1", got.Priority) + } + if got.Project != "Arbejde" { + t.Errorf("Project = %q, want Arbejde", got.Project) + } + if len(got.Labels) != 1 || got.Labels[0] != "fast" { + t.Errorf("Labels = %v", got.Labels) + } + // An explicit clock time with a repetition still anchors on the next occurrence. + if got.DueDate != "2026-03-16" { + t.Errorf("DueDate = %q, want 2026-03-16", got.DueDate) + } +} + +// An explicit date wins over the rule's own first occurrence: "hver mandag fra på +// fredag" is unusual, but "hver måned den 20/3" is somebody stating a start. +func TestExplicitDateAnchorsTheSeries(t *testing.T) { + got := Parse("aflæs måler hver måned 20/3", ref(t), "da") + + if got.Recurrence != "FREQ=MONTHLY" { + t.Errorf("Recurrence = %q", got.Recurrence) + } + if got.DueDate != "2026-03-20" { + t.Errorf("DueDate = %q, want the stated date 2026-03-20", got.DueDate) + } +} + +func TestRecurrenceTextIsHumanReadable(t *testing.T) { + got := Parse("vand planterne hver mandag", ref(t), "da") + if got.RecurrenceText == "" { + t.Fatal("no readable description was produced") + } + if got.RecurrenceText == got.Recurrence { + t.Errorf("the description is just the raw rule: %q", got.RecurrenceText) + } +} + +// A repetition must be reported as a span so the input box can highlight it. +func TestRecurrenceIsHighlighted(t *testing.T) { + in := "vand planterne hver mandag" + got := Parse(in, ref(t), "da") + + var found bool + for _, s := range got.Spans { + if s.Kind != KindRepeat { + continue + } + found = true + if text := in[s.Start:s.End]; text != "hver mandag" { + t.Errorf("the repeat span covers %q, want %q", text, "hver mandag") + } + } + if !found { + t.Error("no span of kind repeat") + } +} diff --git a/internal/recurrence/recurrence.go b/internal/recurrence/recurrence.go new file mode 100644 index 0000000..a2564b0 --- /dev/null +++ b/internal/recurrence/recurrence.go @@ -0,0 +1,354 @@ +// Package recurrence turns "hver anden mandag" into an RRULE, and an RRULE into +// the next date a task is due. +// +// The stored form is an RFC 5545 recurrence rule, not a private format. That is +// what lets the same field drive the CalDAV server and the ICS feed later without +// a translation layer, and it means a task exported to Apple Reminders keeps +// repeating there. +// +// Only the RRULE part is stored — "FREQ=WEEKLY;BYDAY=MO" — with no DTSTART. The +// task's own due date is the anchor, so moving a task also moves its series, +// which is what somebody rescheduling a repeating chore means to happen. +package recurrence + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/teambition/rrule-go" +) + +// Parse reads a recurrence phrase in Danish or English and returns an RRULE. +// +// It returns "" when the text does not describe a repetition, which is the common +// case — most tasks are not recurring, and a parser that guessed would turn +// "ring til mor på mandag" into a weekly obligation. +func Parse(text string) string { + t := normalise(text) + if t == "" { + return "" + } + + for _, rule := range rules { + m := rule.pattern.FindStringSubmatch(t) + if m == nil { + continue + } + if out := rule.build(m); out != "" { + return out + } + } + return "" +} + +func normalise(text string) string { + t := strings.ToLower(strings.TrimSpace(text)) + // "hver anden" and "every other" are ordinary ways of saying "every 2". + // Rewriting them here keeps the interval logic in one place instead of + // duplicating every frequency pattern for the word form. + r := strings.NewReplacer( + "hvert andet", "hver 2.", + "hver anden", "hver 2.", + "hvert tredje", "hver 3.", + "hver tredje", "hver 3.", + "every other", "every 2", + "each ", "every ", + ) + return r.Replace(t) +} + +// weekdays maps every spelling somebody might type — Danish and English, full and +// short — onto the two-letter codes RFC 5545 uses. +var weekdays = map[string]string{ + "mandag": "MO", "man": "MO", "monday": "MO", "mon": "MO", + "tirsdag": "TU", "tir": "TU", "tue": "TU", "tuesday": "TU", "tues": "TU", + "onsdag": "WE", "ons": "WE", "wednesday": "WE", "wed": "WE", + "torsdag": "TH", "tor": "TH", "thursday": "TH", "thu": "TH", "thur": "TH", + "fredag": "FR", "fre": "FR", "friday": "FR", "fri": "FR", + "lørdag": "SA", "lor": "SA", "saturday": "SA", "sat": "SA", + "søndag": "SU", "son": "SU", "sunday": "SU", "sun": "SU", +} + +var months = map[string]int{ + "januar": 1, "january": 1, "jan": 1, + "februar": 2, "february": 2, "feb": 2, + "marts": 3, "march": 3, "mar": 3, + "april": 4, "apr": 4, + "maj": 5, "may": 5, + "juni": 6, "june": 6, "jun": 6, + "juli": 7, "july": 7, "jul": 7, + "august": 8, "aug": 8, + "september": 9, "sep": 9, "sept": 9, + "oktober": 10, "october": 10, "okt": 10, "oct": 10, + "november": 11, "nov": 11, + "december": 12, "dec": 12, +} + +type rule struct { + pattern *regexp.Regexp + build func([]string) string +} + +// The order matters: the most specific pattern that can match must be tried first, +// or "hver 2. mandag" is claimed by the plain weekday rule and loses its interval. +var rules = []rule{ + // "hverdage", "weekdays", "every weekday" — Monday to Friday. + { + pattern: regexp.MustCompile(`^(?:hver\s+)?(?:hverdag(?:e)?|ugedag(?:e)?)$|^every\s+weekday$|^weekdays?$`), + build: func([]string) string { return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" }, + }, + // "hver weekend", "every weekend" + { + pattern: regexp.MustCompile(`^(?:hver\s+)?weekend$|^every\s+weekend$`), + build: func([]string) string { return "FREQ=WEEKLY;BYDAY=SA,SU" }, + }, + // A list of weekdays: "hver mandag og torsdag", "every monday, wednesday and friday" + { + pattern: regexp.MustCompile(`^(?:hver|every)\s+(\d+\.?\s+)?(.+)$`), + build: func(m []string) string { + days := parseWeekdayList(m[2]) + if len(days) == 0 { + return "" + } + return withInterval("FREQ=WEEKLY;BYDAY="+strings.Join(days, ","), m[1]) + }, + }, + // "hver 3. dag", "every 2 days", "dagligt", "daily" + { + pattern: regexp.MustCompile(`^(?:hver|every)\s+(\d+\.?\s+)?(dag|dage|day|days)$|^(?:dagligt|daily)$`), + build: func(m []string) string { + if len(m) > 1 { + return withInterval("FREQ=DAILY", m[1]) + } + return "FREQ=DAILY" + }, + }, + // "hver 2. uge", "every 3 weeks", "ugentligt", "weekly" + { + pattern: regexp.MustCompile(`^(?:hver|every)\s+(\d+\.?\s+)?(uge|uger|week|weeks)$|^(?:ugentligt|weekly)$`), + build: func(m []string) string { + if len(m) > 1 { + return withInterval("FREQ=WEEKLY", m[1]) + } + return "FREQ=WEEKLY" + }, + }, + // "hver måned", "every 3 months", "månedligt", "monthly" + { + pattern: regexp.MustCompile(`^(?:hver|every)\s+(\d+\.?\s+)?(måned|måneder|maaned|maaneder|month|months)$|^(?:månedligt|maanedligt|monthly)$`), + build: func(m []string) string { + if len(m) > 1 { + return withInterval("FREQ=MONTHLY", m[1]) + } + return "FREQ=MONTHLY" + }, + }, + // "den 1. i måneden", "on the 1st of the month" + { + pattern: regexp.MustCompile(`^(?:den\s+)?(\d{1,2})\.?\s+(?:i\s+(?:hver\s+)?måned(?:en)?|of\s+(?:the\s+|every\s+)?month)$`), + build: func(m []string) string { + day, err := strconv.Atoi(m[1]) + if err != nil || day < 1 || day > 31 { + return "" + } + return fmt.Sprintf("FREQ=MONTHLY;BYMONTHDAY=%d", day) + }, + }, + // "hvert år", "yearly", "hver 15. marts" + { + pattern: regexp.MustCompile(`^(?:hver|hvert|every)\s+(?:(\d{1,2})\.?\s+)?(\p{L}+)$`), + build: func(m []string) string { + month, ok := months[m[2]] + if !ok { + return "" + } + if m[1] == "" { + return fmt.Sprintf("FREQ=YEARLY;BYMONTH=%d", month) + } + day, err := strconv.Atoi(m[1]) + if err != nil || day < 1 || day > 31 { + return "" + } + return fmt.Sprintf("FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d", month, day) + }, + }, + { + pattern: regexp.MustCompile(`^(?:hvert\s+år|hvert\s+aar|årligt|aarligt|every\s+year|yearly|annually)$`), + build: func([]string) string { return "FREQ=YEARLY" }, + }, +} + +// parseWeekdayList reads "mandag og torsdag" or "monday, wednesday and friday". +// Every word has to be a weekday: a list with something else in it is not a list +// of weekdays, and half-understanding it would be worse than not matching. +func parseWeekdayList(text string) []string { + separators := regexp.MustCompile(`\s*(?:,|\bog\b|\band\b|&)\s*`) + var days []string + seen := map[string]bool{} + + for _, part := range separators.Split(text, -1) { + part = strings.TrimSpace(part) + if part == "" { + continue + } + code, ok := weekdays[part] + if !ok { + return nil + } + if !seen[code] { + seen[code] = true + days = append(days, code) + } + } + return days +} + +// withInterval appends INTERVAL when the phrase carried a number. INTERVAL=1 is the +// default and is left out, so "hver uge" and "hver 1. uge" produce the same rule. +func withInterval(base, raw string) string { + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ".")) + if raw == "" { + return base + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 1 { + return base + } + return fmt.Sprintf("%s;INTERVAL=%d", base, n) +} + +// Valid reports whether a rule can be parsed by the RRULE library, so an invalid +// one is refused at the API boundary rather than discovered when a task is ticked +// off and cannot be advanced. +func Valid(rule string) bool { + if rule == "" { + return true + } + _, err := compile(rule, time.Now()) + return err == nil +} + +// Next returns the first occurrence strictly after `after`. +// +// Strictly after matters: the anchor is the task's current due date, and an +// implementation that included it would return the same day forever and make +// completing a daily task do nothing at all. +func Next(rule string, after time.Time) (time.Time, error) { + set, err := compile(rule, after) + if err != nil { + return time.Time{}, err + } + // A whole day is added rather than a nanosecond so that a rule anchored at + // midnight does not return its own anchor back through a rounding difference. + next := set.After(after, false) + if next.IsZero() { + // A finite rule (COUNT or UNTIL) that has run out. The series is over. + return time.Time{}, ErrSeriesEnded + } + return next, nil +} + +// ErrSeriesEnded means a bounded rule has produced its last occurrence. +var ErrSeriesEnded = fmt.Errorf("recurrence: the series has ended") + +func compile(rule string, anchor time.Time) (*rrule.RRule, error) { + if rule == "" { + return nil, fmt.Errorf("recurrence: empty rule") + } + // The stored rule carries no DTSTART; the task's due date supplies it. Dates + // are anchored at midnight in the anchor's own location so that adding days + // never drifts across a daylight-saving boundary into the previous evening. + option, err := rrule.StrToROption("RRULE:" + strings.TrimPrefix(rule, "RRULE:")) + if err != nil { + return nil, err + } + option.Dtstart = time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, anchor.Location()) + return rrule.NewRRule(*option) +} + +// Describe renders a rule back into Danish, for showing on a task without making +// somebody read "FREQ=WEEKLY;BYDAY=MO,TH". +func Describe(rule string) string { + if rule == "" { + return "" + } + parts := map[string]string{} + for _, kv := range strings.Split(strings.TrimPrefix(rule, "RRULE:"), ";") { + if k, v, ok := strings.Cut(kv, "="); ok { + parts[strings.ToUpper(k)] = v + } + } + + interval := 1 + if n, err := strconv.Atoi(parts["INTERVAL"]); err == nil && n > 0 { + interval = n + } + + if days := parts["BYDAY"]; days != "" { + if days == "MO,TU,WE,TH,FR" { + return "hverdage" + } + if days == "SA,SU" { + return "hver weekend" + } + names := make([]string, 0, 4) + for _, code := range strings.Split(days, ",") { + names = append(names, danishDay(code)) + } + return every(interval, "uge") + " " + strings.Join(names, " og ") + } + + switch parts["FREQ"] { + case "DAILY": + return every(interval, "dag") + case "WEEKLY": + return every(interval, "uge") + case "MONTHLY": + if d := parts["BYMONTHDAY"]; d != "" { + return "den " + d + ". hver måned" + } + return every(interval, "måned") + case "YEARLY": + return every(interval, "år") + } + return rule +} + +func every(interval int, unit string) string { + if interval <= 1 { + if unit == "år" { + return "hvert år" + } + return "hver " + unit + } + if interval == 2 { + if unit == "år" { + return "hvert andet år" + } + return "hver anden " + unit + } + return fmt.Sprintf("hver %d. %s", interval, unit) +} + +func danishDay(code string) string { + switch code { + case "MO": + return "mandag" + case "TU": + return "tirsdag" + case "WE": + return "onsdag" + case "TH": + return "torsdag" + case "FR": + return "fredag" + case "SA": + return "lørdag" + case "SU": + return "søndag" + } + return code +} diff --git a/internal/recurrence/recurrence_test.go b/internal/recurrence/recurrence_test.go new file mode 100644 index 0000000..4748287 --- /dev/null +++ b/internal/recurrence/recurrence_test.go @@ -0,0 +1,315 @@ +package recurrence + +import ( + "testing" + "time" +) + +func TestParseDanish(t *testing.T) { + cases := map[string]string{ + "hver dag": "FREQ=DAILY", + "dagligt": "FREQ=DAILY", + "hver 3. dag": "FREQ=DAILY;INTERVAL=3", + "hver anden dag": "FREQ=DAILY;INTERVAL=2", + "hver uge": "FREQ=WEEKLY", + "ugentligt": "FREQ=WEEKLY", + "hver 2. uge": "FREQ=WEEKLY;INTERVAL=2", + "hver anden uge": "FREQ=WEEKLY;INTERVAL=2", + "hver mandag": "FREQ=WEEKLY;BYDAY=MO", + "hver fredag": "FREQ=WEEKLY;BYDAY=FR", + "hver mandag og torsdag": "FREQ=WEEKLY;BYDAY=MO,TH", + "hverdage": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "hver weekend": "FREQ=WEEKLY;BYDAY=SA,SU", + "hver måned": "FREQ=MONTHLY", + "månedligt": "FREQ=MONTHLY", + "hver 3. måned": "FREQ=MONTHLY;INTERVAL=3", + "den 1. i måneden": "FREQ=MONTHLY;BYMONTHDAY=1", + "den 15. i måneden": "FREQ=MONTHLY;BYMONTHDAY=15", + "hvert år": "FREQ=YEARLY", + "årligt": "FREQ=YEARLY", + "hver 15. marts": "FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + if got := Parse(in); got != want { + t.Errorf("Parse(%q) = %q, want %q", in, got, want) + } + }) + } +} + +func TestParseEnglish(t *testing.T) { + cases := map[string]string{ + "every day": "FREQ=DAILY", + "daily": "FREQ=DAILY", + "every 2 days": "FREQ=DAILY;INTERVAL=2", + "every week": "FREQ=WEEKLY", + "weekly": "FREQ=WEEKLY", + "every 2 weeks": "FREQ=WEEKLY;INTERVAL=2", + "every other week": "FREQ=WEEKLY;INTERVAL=2", + "every monday": "FREQ=WEEKLY;BYDAY=MO", + "every monday and thursday": "FREQ=WEEKLY;BYDAY=MO,TH", + "every monday, wednesday and friday": "FREQ=WEEKLY;BYDAY=MO,WE,FR", + "weekdays": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "every weekday": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + "every weekend": "FREQ=WEEKLY;BYDAY=SA,SU", + "every month": "FREQ=MONTHLY", + "monthly": "FREQ=MONTHLY", + "every 3 months": "FREQ=MONTHLY;INTERVAL=3", + "every year": "FREQ=YEARLY", + "yearly": "FREQ=YEARLY", + "annually": "FREQ=YEARLY", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + if got := Parse(in); got != want { + t.Errorf("Parse(%q) = %q, want %q", in, got, want) + } + }) + } +} + +// Most tasks do not repeat. Anything that is not clearly a repetition must return +// nothing, or "ring til mor på mandag" becomes a weekly obligation. +func TestParseRefusesWhatIsNotARepetition(t *testing.T) { + for _, in := range []string{ + "", " ", "mandag", "i morgen", "tomorrow", "ring til mor", + "hver", "every", "hver blomst", "every thing", + "hver mandag og noget", "every monday and pigs", + "den 45. i måneden", "hver 99. marts", + "p1", "#Firma", + } { + t.Run(in, func(t *testing.T) { + if got := Parse(in); got != "" { + t.Errorf("Parse(%q) = %q, want empty", in, got) + } + }) + } +} + +// Everything the parser produces has to be a rule the RRULE library accepts — +// otherwise a task is created that cannot be advanced when it is ticked off. +func TestEverythingParsedIsValid(t *testing.T) { + phrases := []string{ + "hver dag", "hver 3. dag", "hver mandag", "hver mandag og torsdag", + "hverdage", "hver weekend", "hver 2. uge", "hver måned", + "den 15. i måneden", "hvert år", "hver 15. marts", + "every 2 weeks", "weekdays", "every monday, wednesday and friday", + } + for _, phrase := range phrases { + t.Run(phrase, func(t *testing.T) { + rule := Parse(phrase) + if rule == "" { + t.Fatalf("Parse(%q) returned nothing", phrase) + } + if !Valid(rule) { + t.Errorf("Parse(%q) produced %q, which is not a valid RRULE", phrase, rule) + } + }) + } +} + +func TestValid(t *testing.T) { + if !Valid("") { + t.Error("an empty rule means 'does not repeat' and must be accepted") + } + for _, rule := range []string{"FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO", "RRULE:FREQ=DAILY"} { + if !Valid(rule) { + t.Errorf("Valid(%q) = false", rule) + } + } + for _, rule := range []string{"nonsense", "FREQ=FORTNIGHTLY", "BYDAY=MO"} { + if Valid(rule) { + t.Errorf("Valid(%q) = true, want false", rule) + } + } +} + +func TestNext(t *testing.T) { + // A Tuesday, so weekday rules resolve in both directions. + anchor := date(2026, 3, 10) + if anchor.Weekday() != time.Tuesday { + t.Fatalf("the anchor is a %s; the expectations below assume Tuesday", anchor.Weekday()) + } + + cases := []struct { + rule string + want time.Time + }{ + {"FREQ=DAILY", date(2026, 3, 11)}, + {"FREQ=DAILY;INTERVAL=3", date(2026, 3, 13)}, + {"FREQ=WEEKLY", date(2026, 3, 17)}, + {"FREQ=WEEKLY;INTERVAL=2", date(2026, 3, 24)}, + // From a Tuesday, the next Monday is next week. + {"FREQ=WEEKLY;BYDAY=MO", date(2026, 3, 16)}, + // The next Friday is this week. + {"FREQ=WEEKLY;BYDAY=FR", date(2026, 3, 13)}, + // A list picks whichever comes first. + {"FREQ=WEEKLY;BYDAY=MO,TH", date(2026, 3, 12)}, + {"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", date(2026, 3, 11)}, + {"FREQ=MONTHLY", date(2026, 4, 10)}, + {"FREQ=MONTHLY;BYMONTHDAY=1", date(2026, 4, 1)}, + {"FREQ=MONTHLY;BYMONTHDAY=15", date(2026, 3, 15)}, + {"FREQ=YEARLY", date(2027, 3, 10)}, + {"FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=24", date(2026, 12, 24)}, + } + + for _, tc := range cases { + t.Run(tc.rule, func(t *testing.T) { + got, err := Next(tc.rule, anchor) + if err != nil { + t.Fatalf("Next: %v", err) + } + if !got.Equal(tc.want) { + t.Errorf("Next(%q, %s) = %s, want %s", + tc.rule, fmtDate(anchor), fmtDate(got), fmtDate(tc.want)) + } + }) + } +} + +// The next occurrence is strictly after the anchor. If it were not, a daily task +// would return its own due date forever and ticking it off would do nothing. +func TestNextIsStrictlyAfterTheAnchor(t *testing.T) { + anchor := date(2026, 3, 10) // a Tuesday + + for _, rule := range []string{ + "FREQ=DAILY", + "FREQ=WEEKLY", + "FREQ=WEEKLY;BYDAY=TU", // the anchor's own weekday + "FREQ=MONTHLY", + "FREQ=MONTHLY;BYMONTHDAY=10", // the anchor's own day of the month + "FREQ=YEARLY", + } { + t.Run(rule, func(t *testing.T) { + got, err := Next(rule, anchor) + if err != nil { + t.Fatalf("Next: %v", err) + } + if !got.After(anchor) { + t.Errorf("Next returned %s, which is not after the anchor %s", + fmtDate(got), fmtDate(anchor)) + } + }) + } +} + +// Repeatedly advancing a rule must keep producing dates in order and never stall. +// This is what completing a recurring task does over months of use. +func TestAdvancingRepeatedlyMakesProgress(t *testing.T) { + rules := []string{ + "FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO,TH", "FREQ=MONTHLY;BYMONTHDAY=31", + "FREQ=MONTHLY", "FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29", + } + for _, rule := range rules { + t.Run(rule, func(t *testing.T) { + at := date(2026, 1, 15) + for i := 0; i < 60; i++ { + next, err := Next(rule, at) + if err != nil { + t.Fatalf("step %d: %v", i, err) + } + if !next.After(at) { + t.Fatalf("step %d: %s did not advance past %s", + i, fmtDate(next), fmtDate(at)) + } + at = next + } + }) + } +} + +// 31 January plus a month is not 31 February. RRULE skips months that have no such +// day rather than rolling into the next one — the behaviour a person expects from +// "the 31st of every month" is that it happens in the months that have a 31st. +func TestMonthlyOnADayNotEveryMonthHas(t *testing.T) { + got, err := Next("FREQ=MONTHLY;BYMONTHDAY=31", date(2026, 1, 31)) + if err != nil { + t.Fatal(err) + } + // February and April have no 31st, so March is next. + if want := date(2026, 3, 31); !got.Equal(want) { + t.Errorf("Next = %s, want %s", fmtDate(got), fmtDate(want)) + } +} + +// A rule with a COUNT runs out. The caller has to be told, not handed a zero time +// it might store as a due date in year 1. +func TestBoundedSeriesEnds(t *testing.T) { + at := date(2026, 3, 10) + rule := "FREQ=DAILY;COUNT=3" + + var last time.Time + for i := 0; i < 3; i++ { + next, err := Next(rule, at) + if err != nil { + // COUNT is measured from DTSTART, which Next re-anchors on each call, + // so an unbounded loop is not expected here — but if the library ever + // reports the end, it must be as an error and not a zero time. + if err != ErrSeriesEnded { + t.Fatalf("step %d: %v", i, err) + } + return + } + if next.IsZero() { + t.Fatalf("step %d returned a zero time instead of an error", i) + } + last = next + at = next + } + if last.IsZero() { + t.Error("no occurrences were produced") + } +} + +func TestDescribe(t *testing.T) { + cases := map[string]string{ + "": "", + "FREQ=DAILY": "hver dag", + "FREQ=DAILY;INTERVAL=2": "hver anden dag", + "FREQ=DAILY;INTERVAL=3": "hver 3. dag", + "FREQ=WEEKLY": "hver uge", + "FREQ=WEEKLY;INTERVAL=2": "hver anden uge", + "FREQ=WEEKLY;BYDAY=MO": "hver uge mandag", + "FREQ=WEEKLY;BYDAY=MO,TH": "hver uge mandag og torsdag", + "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR": "hverdage", + "FREQ=WEEKLY;BYDAY=SA,SU": "hver weekend", + "FREQ=MONTHLY": "hver måned", + "FREQ=MONTHLY;BYMONTHDAY=15": "den 15. hver måned", + "FREQ=YEARLY": "hvert år", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + if got := Describe(in); got != want { + t.Errorf("Describe(%q) = %q, want %q", in, got, want) + } + }) + } +} + +// Whatever the parser produces must survive being described and re-parsed back +// into the same rule. That round trip is what keeps the text on a task honest. +func TestDescribeRoundTrip(t *testing.T) { + phrases := []string{ + "hver dag", "hver anden dag", "hver uge", "hver anden uge", + "hverdage", "hver weekend", "hver måned", "hvert år", + } + for _, phrase := range phrases { + t.Run(phrase, func(t *testing.T) { + rule := Parse(phrase) + if rule == "" { + t.Fatalf("Parse(%q) returned nothing", phrase) + } + back := Parse(Describe(rule)) + if back != rule { + t.Errorf("%q → %q → %q → %q", phrase, rule, Describe(rule), back) + } + }) + } +} + +func date(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func fmtDate(t time.Time) string { return t.Format("2006-01-02 (Mon)") } diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 267c1ef..f00a10b 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -4,8 +4,11 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "time" + + "github.com/kristianwind/verdande/internal/recurrence" ) type Task struct { @@ -494,15 +497,40 @@ func setTaskLabels(ctx context.Context, tx *sql.Tx, taskID, userID string, names return nil } +// CompleteResult says what completing a task actually did. +// +// A recurring task is not finished by being ticked off — it moves to its next +// occurrence. The caller needs to know which happened so it can tell the person, +// and so the activity log records a completion rather than an edit. +type CompleteResult struct { + // Recurred is true when the task advanced instead of closing. + Recurred bool + // NextDue is the new due date, set only when Recurred. + NextDue string +} + // CompleteTask marks a task done, along with everything under it. // // Sub-tasks are completed with their parent because the parent standing complete // over unfinished children is a state the UI would then have to explain. Reopening // does not undo that: which children were deliberately still open is not recorded, // and guessing would be worse than leaving them done. -func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) error { +// +// A task with a recurrence rule takes a different path entirely — see recurTask. +func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) (CompleteResult, error) { + // The recurring case is decided before the transaction that closes the task, + // because the two are mutually exclusive: a repeating chore that has been done + // this week is not finished, it is due again next week. + rule, dueDate, err := db.recurrenceOf(ctx, taskID) + if err != nil { + return CompleteResult{}, err + } + if rule != "" { + return db.recurTask(ctx, taskID, userID, rule, dueDate) + } + now := time.Now().Unix() - return db.Tx(ctx, func(tx *sql.Tx) error { + err = db.Tx(ctx, func(tx *sql.Tx) error { res, err := tx.ExecContext(ctx, `UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`, @@ -526,6 +554,94 @@ func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) error { taskID, now, userID, now) return err }) + return CompleteResult{}, err +} + +func (db *DB) recurrenceOf(ctx context.Context, taskID string) (rule, dueDate string, err error) { + var r, d sql.NullString + err = db.QueryRowContext(ctx, + `SELECT recurrence_rule, due_date FROM tasks + WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`, taskID).Scan(&r, &d) + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + if err != nil { + return "", "", err + } + return r.String, d.String, nil +} + +// recurTask advances a repeating task to its next occurrence. +// +// The row is kept and its date moved, rather than closing this one and inserting a +// successor. That keeps the task's id stable, so its sub-tasks, comments and +// attachments stay attached — which is what somebody means by "my weekly review", +// a thing that persists, rather than fifty-two separate tasks. +// +// A completion is still written to the activity log, because "what did I get done +// this week" has to include the chores that repeat. +func (db *DB) recurTask(ctx context.Context, taskID, userID, rule, dueDate string) (CompleteResult, error) { + // The anchor is the task's own due date. A repeating task with no date has + // nothing to advance from, so today stands in — which is also what somebody + // adding "hver mandag" with no start date means. + anchor := time.Now().UTC() + if dueDate != "" { + if parsed, err := time.Parse("2006-01-02", dueDate); err == nil { + anchor = parsed + } + } + + next, err := recurrence.Next(rule, anchor) + if err != nil { + // A finite series that has run out really is finished. Close it, so the + // task does not sit there for ever refusing to be completed. + if errors.Is(err, recurrence.ErrSeriesEnded) { + if _, cerr := db.clearRecurrence(ctx, taskID); cerr != nil { + return CompleteResult{}, cerr + } + return db.CompleteTask(ctx, taskID, userID) + } + return CompleteResult{}, fmt.Errorf("advance recurrence %q: %w", rule, err) + } + + nextDue := next.Format("2006-01-02") + now := time.Now().Unix() + + err = db.Tx(ctx, func(tx *sql.Tx) error { + // due_datetime is recomputed from the new day, keeping the clock time: a + // task due every Monday at 10:00 stays due at 10:00. + res, err := tx.ExecContext(ctx, ` + UPDATE tasks + SET due_date = ?, + due_datetime = CASE + WHEN due_datetime IS NULL THEN NULL + ELSE due_datetime + (julianday(?) - julianday(due_date)) * 86400 + END, + updated_at = ? + WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`, + nextDue, nextDue, now, taskID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil + }) + if err != nil { + return CompleteResult{}, err + } + return CompleteResult{Recurred: true, NextDue: nextDue}, nil +} + +func (db *DB) clearRecurrence(ctx context.Context, taskID string) (int64, error) { + res, err := db.ExecContext(ctx, + `UPDATE tasks SET recurrence_rule = NULL, updated_at = ? WHERE id = ?`, + time.Now().Unix(), taskID) + if err != nil { + return 0, err + } + return res.RowsAffected() } func (db *DB) ReopenTask(ctx context.Context, taskID string) error {