feat(recurrence): repeating tasks as RRULE, in Danish and English

"vand planterne hver mandag" becomes FREQ=WEEKLY;BYDAY=MO. The stored
form is RFC 5545, not a private format, so the same field will drive the
CalDAV server and the ICS feed without a translation layer.

Ticking off a repeating task advances it instead of closing it, keeping
the same row — so its sub-tasks and comments stay attached. A weekly
review is one thing that recurs, not fifty-two things. The clock time
survives the move, and the completion is still written to the activity
log, because "what did I get done this week" has to include the chores
that repeat.

Recurrence is parsed before dates, and that ordering is load-bearing:
"hver mandag" contains a weekday, so a date parser reading it first
would claim Monday as a one-off and strand "hver" in the title. It is
also resolved before the time-only case, or "hver mandag kl 9" typed
after nine would land tomorrow morning instead of on Monday.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 06:53:00 +02:00
co-authored by Claude Opus 5
parent 3efc3754af
commit a96aae57ec
9 changed files with 1211 additions and 8 deletions
+354
View File
@@ -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
}
+315
View File
@@ -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)") }