package engine import ( "fmt" "strconv" "strings" "time" ) // 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) { // 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 } // Relative dates 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 } lastPart := parts[len(parts)-1] // 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 } } } } 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, "tue": time.Tuesday, "tuesday": time.Tuesday, "wed": time.Wednesday, "wednesday": time.Wednesday, "thu": time.Thursday, "thursday": time.Thursday, "fri": time.Friday, "friday": time.Friday, "sat": time.Saturday, "saturday": time.Saturday, } if targetWeekday, ok := weekdays[s]; ok { return p.nextWeekday(targetWeekday), true } return time.Time{}, false } // parseMonthName handles month names (jan, january, feb, february, etc.) func (p *DateParser) parseMonthName(s string) (time.Time, bool) { 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 } // 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) { 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) { 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.) and named durations func (p *DateParser) parseDurationAsDate(s string) (time.Time, bool) { // 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.) 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 (p *DateParser) nextWeekday(target time.Weekday) time.Time { from := p.base // Calculate days until target daysUntil := int(target - from.Weekday()) if daysUntil <= 0 { daysUntil += 7 // Next week } 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 config settings func ParseDate(s string) (time.Time, error) { cfg, err := GetConfig() weekStart := time.Monday // default if err == nil { weekStart = cfg.GetWeekStart() } parser := NewDateParser(timeNow(), weekStart) return parser.ParseDate(s) }