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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3efc3754af
commit
a96aae57ec
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)") }
|
||||
+118
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user