diff --git a/opal-task/TIME.md b/opal-task/TIME.md new file mode 100644 index 0000000..511288f --- /dev/null +++ b/opal-task/TIME.md @@ -0,0 +1,28 @@ +# Time parsing + +## Time formats +- mon, monday - sun, sunday +- 21jan, 30dec etc +- 2015-12-21 +- jan, feb, etc. - Sets to first of month +- now, current date and time +- today, tomorrow, yesterday - at time 00:00 +- sod, sow, som, soy - start of X at time 00:00 +- eod, eow, eom, eoy - end of X at time 23:59 +- later, someday - 2150-01-01 at time 00:00 + +All time formats should also support time of day, mon:15:35 and mon:1535. 24-hour format. Mind the ':', must be seperated from the attribute:value syntax. + +## Duration formats +- 5sec, second, seconds +- 5min, minute, minutes +- 5hrs, hour, hours +- 3d[ays], 2w[eeks], 4m[onths], 1y[ear]. Singular if 1, plural >1 +- daily, weekly, monthly (30days), yearly + +There is indirect support for durations everywhere that a date value is expected. +No ordinal assumes 1 (hours = hrs = hour = 60min) + +## Relative formats +One attribute can be relative to another: +`opal add Buy milk due:mon wait:due-1d` diff --git a/opal-task/internal/engine/dateparse.go b/opal-task/internal/engine/dateparse.go index ed5a542..d91ee69 100644 --- a/opal-task/internal/engine/dateparse.go +++ b/opal-task/internal/engine/dateparse.go @@ -2,34 +2,199 @@ package engine import ( "fmt" + "strconv" "strings" "time" ) -// ParseDate parses date strings with smart interpretation -// Supports: ISO dates, relative (tomorrow, today), weekdays (sun, monday) -func ParseDate(s string) (time.Time, error) { - s = strings.ToLower(strings.TrimSpace(s)) - now := timeNow() +// DateParser handles all date/time/duration parsing with configurable options +type DateParser struct { + base time.Time + weekStart time.Weekday +} +// NewDateParser creates a new DateParser with the given base time and week start +func NewDateParser(base time.Time, weekStart time.Weekday) *DateParser { + return &DateParser{ + base: base, + weekStart: weekStart, + } +} + +// NewDefaultDateParser creates a DateParser with current time and Monday week start +func NewDefaultDateParser() *DateParser { + return &DateParser{ + base: timeNow(), + weekStart: time.Monday, + } +} + +// ParseDate is the main entry point for date parsing +// Handles: ISO dates, weekdays, month names, day+month, period boundaries, durations, time of day +func (p *DateParser) ParseDate(s string) (time.Time, error) { + s = strings.ToLower(strings.TrimSpace(s)) + + // Check for time of day component (e.g., "mon:15:35" or "21jan:0800") + if dateStr, timeStr, hasTime := p.splitDateTime(s); hasTime { + dateVal, err := p.parseDateOnly(dateStr) + if err != nil { + return time.Time{}, err + } + return p.parseTimeOfDay(dateVal, timeStr) + } + + return p.parseDateOnly(s) +} + +// parseDateOnly parses just the date component without time +func (p *DateParser) parseDateOnly(s string) (time.Time, error) { // Try ISO format first if t, err := time.Parse("2006-01-02", s); err == nil { return t, nil } // Relative dates - switch s { - case "today": - return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil - case "tomorrow": - tomorrow := now.AddDate(0, 0, 1) - return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), nil - case "yesterday": - yesterday := now.AddDate(0, 0, -1) - return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), nil + if t, ok := p.parseRelative(s); ok { + return t, nil } // Weekday names + if t, ok := p.parseWeekday(s); ok { + return t, nil + } + + // Month names (jan, january, feb, etc.) + if t, ok := p.parseMonthName(s); ok { + return t, nil + } + + // Day+month format (21jan, Jan21, etc.) + if t, ok := p.parseDayMonth(s); ok { + return t, nil + } + + // Period boundaries (sod, eod, sow, eow, etc.) + if t, ok := p.parsePeriodBoundary(s); ok { + return t, nil + } + + // Special keywords (later, someday) + if t, ok := p.parseSpecialKeyword(s); ok { + return t, nil + } + + // Duration as date offset (2d, 3w, etc.) + if t, ok := p.parseDurationAsDate(s); ok { + return t, nil + } + + return time.Time{}, fmt.Errorf("unable to parse date: %s", s) +} + +// splitDateTime checks if the string contains a time component +// Returns dateStr, timeStr, hasTime +func (p *DateParser) splitDateTime(s string) (string, string, bool) { + // Look for time patterns: HH:MM or HHMM at the end + // Examples: "mon:15:35", "21jan:0800", "15:35" (just time) + + parts := strings.Split(s, ":") + if len(parts) < 2 { + return s, "", false + } + + // Check last part for time pattern (2-4 digits) + lastPart := parts[len(parts)-1] + if len(lastPart) == 2 || len(lastPart) == 4 { + // Could be minutes (HH:MM) or HHMM format + if _, err := strconv.Atoi(lastPart); err == nil { + // Last part is numeric, could be time + if len(parts) == 2 { + // Format: "15:35" (just time, no date) + return "", s, true + } else if len(parts) >= 3 { + // Format: "mon:15:35" or similar + secondLast := parts[len(parts)-2] + if _, err := strconv.Atoi(secondLast); err == nil { + // HH:MM format + dateStr := strings.Join(parts[:len(parts)-2], ":") + timeStr := secondLast + ":" + lastPart + return dateStr, timeStr, true + } else if len(lastPart) == 4 { + // HHMM format + dateStr := strings.Join(parts[:len(parts)-1], ":") + return dateStr, lastPart, true + } + } + } + } + + return s, "", false +} + +// parseTimeOfDay applies time to a date +func (p *DateParser) parseTimeOfDay(date time.Time, timeStr string) (time.Time, error) { + var hour, minute int + var err error + + if strings.Contains(timeStr, ":") { + // HH:MM format + parts := strings.Split(timeStr, ":") + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid time format: %s", timeStr) + } + hour, err = strconv.Atoi(parts[0]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid hour: %s", parts[0]) + } + minute, err = strconv.Atoi(parts[1]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid minute: %s", parts[1]) + } + } else { + // HHMM format + if len(timeStr) != 4 { + return time.Time{}, fmt.Errorf("invalid time format: %s (expected HHMM)", timeStr) + } + hour, err = strconv.Atoi(timeStr[0:2]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid hour: %s", timeStr[0:2]) + } + minute, err = strconv.Atoi(timeStr[2:4]) + if err != nil { + return time.Time{}, fmt.Errorf("invalid minute: %s", timeStr[2:4]) + } + } + + // Validate ranges + if hour < 0 || hour > 23 { + return time.Time{}, fmt.Errorf("hour must be 0-23: %d", hour) + } + if minute < 0 || minute > 59 { + return time.Time{}, fmt.Errorf("minute must be 0-59: %d", minute) + } + + return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, date.Location()), nil +} + +// parseRelative handles relative date keywords +func (p *DateParser) parseRelative(s string) (time.Time, bool) { + switch s { + case "now": + return p.base, true + case "today": + return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), true + case "tomorrow": + tomorrow := p.base.AddDate(0, 0, 1) + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), true + case "yesterday": + yesterday := p.base.AddDate(0, 0, -1) + return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), true + } + return time.Time{}, false +} + +// parseWeekday handles weekday names (mon, monday, etc.) +func (p *DateParser) parseWeekday(s string) (time.Time, bool) { weekdays := map[string]time.Weekday{ "sun": time.Sunday, "sunday": time.Sunday, "mon": time.Monday, "monday": time.Monday, @@ -41,16 +206,52 @@ func ParseDate(s string) (time.Time, error) { } if targetWeekday, ok := weekdays[s]; ok { - return nextWeekday(now, targetWeekday), nil + return p.nextWeekday(targetWeekday), true } + return time.Time{}, false +} - return time.Time{}, fmt.Errorf("unable to parse date: %s", s) +// parseMonthName handles month names (jan, january, feb, february, etc.) +func (p *DateParser) parseMonthName(s string) (time.Time, bool) { + // Placeholder for Phase 2 + return time.Time{}, false +} + +// parseDayMonth handles day+month formats (21jan, Jan21, etc.) +func (p *DateParser) parseDayMonth(s string) (time.Time, bool) { + // Placeholder for Phase 2 + return time.Time{}, false +} + +// parsePeriodBoundary handles period boundary keywords (sod, eod, sow, eow, etc.) +func (p *DateParser) parsePeriodBoundary(s string) (time.Time, bool) { + // Placeholder for Phase 2 + return time.Time{}, false +} + +// parseSpecialKeyword handles special keywords (later, someday) +func (p *DateParser) parseSpecialKeyword(s string) (time.Time, bool) { + // Placeholder for Phase 2 + return time.Time{}, false +} + +// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) +func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) { + // Placeholder for Phase 2 + return time.Time{}, false +} + +// ParseDuration parses duration strings (1d, 2w, 5min, etc.) +func (p *DateParser) ParseDuration(s string) (time.Duration, error) { + // Use existing ParseRecurrencePattern for now (will expand in Phase 2) + return ParseRecurrencePattern(s) } // nextWeekday returns the next occurrence of the target weekday // Smart logic: if today is Thursday and target is Sunday, returns this Sunday // If today is Sunday and target is Sunday, returns next Sunday -func nextWeekday(from time.Time, target time.Weekday) time.Time { +func (p *DateParser) nextWeekday(target time.Weekday) time.Time { + from := p.base // Calculate days until target daysUntil := int(target - from.Weekday()) @@ -61,3 +262,9 @@ func nextWeekday(from time.Time, target time.Weekday) time.Time { next := from.AddDate(0, 0, daysUntil) return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) } + +// ParseDate is the public API that uses default settings +func ParseDate(s string) (time.Time, error) { + parser := NewDefaultDateParser() + return parser.ParseDate(s) +} diff --git a/opal-task/internal/engine/modifier_test.go b/opal-task/internal/engine/modifier_test.go index 19e12b8..cf9992d 100644 --- a/opal-task/internal/engine/modifier_test.go +++ b/opal-task/internal/engine/modifier_test.go @@ -199,7 +199,8 @@ func TestParseDateWeekday(t *testing.T) { func TestNextWeekday(t *testing.T) { // Test case: Thursday -> next Sunday should be this Sunday thursday := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Jan 1, 2026 is a Thursday - nextSun := nextWeekday(thursday, time.Sunday) + parser := NewDateParser(thursday, time.Monday) + nextSun := parser.nextWeekday(time.Sunday) if nextSun.Weekday() != time.Sunday { t.Error("Should return Sunday") @@ -214,7 +215,8 @@ func TestNextWeekday(t *testing.T) { // Test case: Sunday -> next Sunday should be 7 days later sunday := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) // Jan 4, 2026 is a Sunday - nextSun2 := nextWeekday(sunday, time.Sunday) + parser2 := NewDateParser(sunday, time.Monday) + nextSun2 := parser2.nextWeekday(time.Sunday) expectedDays = 7 actualDays = int(nextSun2.Sub(sunday).Hours() / 24)