feat(recurrence): repeating tasks as RRULE, in Danish and English
"vand planterne hver mandag" becomes FREQ=WEEKLY;BYDAY=MO. The stored form is RFC 5545, not a private format, so the same field will drive the CalDAV server and the ICS feed without a translation layer. Ticking off a repeating task advances it instead of closing it, keeping the same row — so its sub-tasks and comments stay attached. A weekly review is one thing that recurs, not fifty-two things. The clock time survives the move, and the completion is still written to the activity log, because "what did I get done this week" has to include the chores that repeat. Recurrence is parsed before dates, and that ordering is load-bearing: "hver mandag" contains a weekday, so a date parser reading it first would claim Monday as a one-off and strand "hver" in the title. It is also resolved before the time-only case, or "hver mandag kl 9" typed after nine would land tomorrow morning instead of on Monday. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3efc3754af
commit
a96aae57ec
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user