Phase 3: Implement date format parsers and duration extensions

- Add month name parsing (jan, january, feb, etc.) with year logic
- Add day+month parsing (21jan, Jan21, 21January, etc.) in all case variations
- Add period boundaries (sod, eod, sow, eow, som, eom, soy, eoy)
- Add special keywords (later, someday -> 2150-01-01)
- Add duration-as-date offset (2d, 3w, etc. means X from now)
- Add named duration aliases (daily, weekly, monthly, yearly)
- Enhance ParseRecurrencePattern to support min, sec, hrs and word forms
- Implement time-of-day parsing (mon:15:35, tomorrow:0800, 15:35)
- Add comprehensive test suite (50+ tests total)
- All tests passing
This commit is contained in:
2026-01-05 09:59:46 +01:00
parent 43bbefbc00
commit cd476cfc99
3 changed files with 566 additions and 50 deletions
+248 -32
View File
@@ -48,6 +48,11 @@ func (p *DateParser) ParseDate(s string) (time.Time, error) {
// parseDateOnly parses just the date component without time
func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
// Empty string means "today" (used when parsing just time like "15:35")
if s == "" {
return time.Date(p.base.Year(), p.base.Month(), p.base.Day(), 0, 0, 0, 0, p.base.Location()), nil
}
// Try ISO format first
if t, err := time.Parse("2006-01-02", s); err == nil {
return t, nil
@@ -102,27 +107,47 @@ func (p *DateParser) splitDateTime(s string) (string, string, bool) {
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
// Format: "mon:15:35" or "tomorrow:15:35" (HH:MM with 3+ parts)
if len(parts) >= 3 {
// Check if last two parts form a valid time
secondLast := parts[len(parts)-2]
if h, err := strconv.Atoi(secondLast); err == nil && h >= 0 && h <= 23 {
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
// HH:MM format
dateStr := strings.Join(parts[:len(parts)-2], ":")
if dateStr == "" {
dateStr = "today" // Just time means today
}
timeStr := secondLast + ":" + lastPart
return dateStr, timeStr, true
}
}
}
// Format with 2 parts: could be "15:35" (just time) or "mon:0800" (date+HHMM)
if len(parts) == 2 {
// Check if lastPart is HHMM format (4 digits)
if len(lastPart) == 4 {
if _, err := strconv.Atoi(lastPart); err == nil {
h, _ := strconv.Atoi(lastPart[0:2])
m, _ := strconv.Atoi(lastPart[2:4])
if h >= 0 && h <= 23 && m >= 0 && m <= 59 {
// Valid HHMM time
// If first part is also numeric, it's a date part (like "21jan:0800" but already lowercase)
// Otherwise it's a keyword like "tomorrow:0800"
return parts[0], lastPart, true
}
}
}
// Check if it's "HH:MM" format (both parts are 2-digit numbers)
if len(parts[0]) <= 2 && len(lastPart) <= 2 {
if h, err := strconv.Atoi(parts[0]); err == nil && h >= 0 && h <= 23 {
if m, err := strconv.Atoi(lastPart); err == nil && m >= 0 && m <= 59 {
// Just time, no date
return "", parts[0] + ":" + lastPart, true
}
}
}
@@ -213,32 +238,223 @@ func (p *DateParser) parseWeekday(s string) (time.Time, bool) {
// parseMonthName handles month names (jan, january, feb, february, etc.)
func (p *DateParser) parseMonthName(s string) (time.Time, bool) {
// Placeholder for Phase 2
months := map[string]time.Month{
"jan": time.January, "january": time.January,
"feb": time.February, "february": time.February,
"mar": time.March, "march": time.March,
"apr": time.April, "april": time.April,
"may": time.May,
"jun": time.June, "june": time.June,
"jul": time.July, "july": time.July,
"aug": time.August, "august": time.August,
"sep": time.September, "september": time.September,
"oct": time.October, "october": time.October,
"nov": time.November, "november": time.November,
"dec": time.December, "december": time.December,
}
month, ok := months[s]
if !ok {
return time.Time{}, false
}
// Set to first of month
// If that date has passed this year, use next year
year := p.base.Year()
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
if firstOfMonth.Before(p.base) || firstOfMonth.Equal(p.base) {
// First of month has passed, use next year
year++
firstOfMonth = time.Date(year, month, 1, 0, 0, 0, 0, p.base.Location())
}
return firstOfMonth, true
}
// parseDayMonth handles day+month formats (21jan, Jan21, 21January, January21)
func (p *DateParser) parseDayMonth(s string) (time.Time, bool) {
// Try pattern: digits followed by month name (21jan, 21January)
for i := 0; i < len(s); i++ {
if s[i] >= '0' && s[i] <= '9' {
continue
}
// Found first non-digit
if i > 0 && i < len(s) {
dayStr := s[:i]
monthStr := strings.ToLower(s[i:])
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
return p.constructDate(day, month), true
}
}
break
}
// Try pattern: month name followed by digits (jan21, January21)
for i := 0; i < len(s); i++ {
if s[i] >= 'a' && s[i] <= 'z' || s[i] >= 'A' && s[i] <= 'Z' {
continue
}
// Found first non-letter
if i > 0 && i < len(s) {
monthStr := strings.ToLower(s[:i])
dayStr := s[i:]
if day, month, ok := p.parseDayAndMonth(dayStr, monthStr); ok {
return p.constructDate(day, month), true
}
}
break
}
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
// parseDayAndMonth extracts day and month from string parts
func (p *DateParser) parseDayAndMonth(dayStr, monthStr string) (int, time.Month, bool) {
day, err := strconv.Atoi(dayStr)
if err != nil || day < 1 || day > 31 {
return 0, 0, false
}
months := map[string]time.Month{
"jan": time.January, "january": time.January,
"feb": time.February, "february": time.February,
"mar": time.March, "march": time.March,
"apr": time.April, "april": time.April,
"may": time.May,
"jun": time.June, "june": time.June,
"jul": time.July, "july": time.July,
"aug": time.August, "august": time.August,
"sep": time.September, "september": time.September,
"oct": time.October, "october": time.October,
"nov": time.November, "november": time.November,
"dec": time.December, "december": time.December,
}
month, ok := months[monthStr]
if !ok {
return 0, 0, false
}
return day, month, true
}
// constructDate creates a date with year logic (current year if future, next year if past)
func (p *DateParser) constructDate(day int, month time.Month) time.Time {
year := p.base.Year()
date := time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
// Validate day is valid for month (handles Feb 30, etc.)
if date.Month() != month {
// Invalid day for month, return zero time (will be caught as parse error)
return time.Time{}
}
if date.Before(p.base) || date.Equal(p.base) {
// Date has passed, use next year
year++
date = time.Date(year, month, day, 0, 0, 0, 0, p.base.Location())
}
return date
}
// 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
now := p.base
loc := now.Location()
switch s {
case "sod":
// Start of day - today at 00:00:00
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc), true
case "eod":
// End of day - today at 23:59:59
return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, loc), true
case "sow":
// Start of week - next occurrence of week start day at 00:00:00
return p.nextWeekday(p.weekStart), true
case "eow":
// End of week - next occurrence of week end day at 23:59:59
weekEnd := p.weekStart - 1
if weekEnd < 0 {
weekEnd = 6
}
endDate := p.nextWeekday(weekEnd)
return time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 0, loc), true
case "som":
// Start of month - 1st of current month at 00:00:00
// If we're past the 1st, use next month
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
if firstOfMonth.Before(now) || firstOfMonth.Equal(now) {
// Already past the 1st, use next month
firstOfMonth = time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
}
return firstOfMonth, true
case "eom":
// End of month - last day of current month at 23:59:59
// Get first day of next month, then subtract one day
firstOfNextMonth := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, loc)
lastOfMonth := firstOfNextMonth.AddDate(0, 0, -1)
return time.Date(lastOfMonth.Year(), lastOfMonth.Month(), lastOfMonth.Day(), 23, 59, 59, 0, loc), true
case "soy":
// Start of year - Jan 1 of current year at 00:00:00
// If we're past Jan 1, use next year
firstOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
if firstOfYear.Before(now) || firstOfYear.Equal(now) {
firstOfYear = time.Date(now.Year()+1, time.January, 1, 0, 0, 0, 0, loc)
}
return firstOfYear, true
case "eoy":
// End of year - Dec 31 of current year at 23:59:59
return time.Date(now.Year(), time.December, 31, 23, 59, 59, 0, loc), true
default:
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
switch s {
case "later", "someday":
// Far future: 2150-01-01 at 00:00:00
return time.Date(2150, time.January, 1, 0, 0, 0, 0, p.base.Location()), true
default:
return time.Time{}, false
}
}
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.)
// parseDurationAsDate handles duration as date offset (2d, 3w, etc.) and named durations
func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) {
// Placeholder for Phase 2
return time.Time{}, false
// Named duration aliases
namedDurations := map[string]string{
"daily": "1d",
"weekly": "1w",
"monthly": "30d",
"yearly": "1y",
}
// Check for named duration
if pattern, ok := namedDurations[s]; ok {
s = pattern
}
// Try parsing as duration
duration, err := ParseRecurrencePattern(s)
if err != nil {
return time.Time{}, false
}
// Add duration to base time
return p.base.Add(duration), true
}
// ParseDuration parses duration strings (1d, 2w, 5min, etc.)