Files
gems/opal-task/internal/engine/dateparse.go
T
joakim 04fa9222d8 test: add comprehensive tests for new UX features and fix ISO date timezone bug
Add 33 new test functions covering annotations, undo system, history
formatting, relative date display, and weekday parsing pipeline. Fix ISO
date parsing to use ParseInLocation instead of Parse to respect the
parser's timezone context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:42:49 +01:00

493 lines
14 KiB
Go

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.ParseInLocation("2006-01-02", s, p.base.Location()); 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)
}