flake-update-20260201
  1// Package timerange provides natural language time range parsing.
  2package timerange
  3
  4import (
  5	"fmt"
  6	"regexp"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"github.com/vdemeester/home/tools/review-tool/internal/activity"
 12)
 13
 14var (
 15	pastRe      = regexp.MustCompile(`^past\s+(\d+)\s+(day|days|week|weeks|month|months)$`)
 16	lastNRe     = regexp.MustCompile(`^last\s+(\d+)\s+(day|days|week|weeks|month|months)$`)
 17	dateRangeRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$`)
 18)
 19
 20// Parse converts natural language to a time range.
 21func Parse(input string) (*activity.TimeRange, error) {
 22	input = strings.ToLower(strings.TrimSpace(input))
 23	now := time.Now()
 24
 25	switch input {
 26	case "today":
 27		start := startOfDay(now)
 28		return &activity.TimeRange{Start: start, End: now, Description: "today"}, nil
 29
 30	case "yesterday":
 31		yesterday := now.AddDate(0, 0, -1)
 32		return &activity.TimeRange{
 33			Start:       startOfDay(yesterday),
 34			End:         endOfDay(yesterday),
 35			Description: "yesterday",
 36		}, nil
 37
 38	case "this week":
 39		start := startOfWeek(now)
 40		return &activity.TimeRange{Start: start, End: now, Description: "this week"}, nil
 41
 42	case "last week":
 43		thisWeekStart := startOfWeek(now)
 44		lastWeekStart := thisWeekStart.AddDate(0, 0, -7)
 45		return &activity.TimeRange{
 46			Start:       lastWeekStart,
 47			End:         thisWeekStart.Add(-time.Second),
 48			Description: "last week",
 49		}, nil
 50
 51	case "this month":
 52		start := startOfMonth(now)
 53		return &activity.TimeRange{Start: start, End: now, Description: "this month"}, nil
 54
 55	case "last month":
 56		thisMonthStart := startOfMonth(now)
 57		lastMonthStart := thisMonthStart.AddDate(0, -1, 0)
 58		return &activity.TimeRange{
 59			Start:       lastMonthStart,
 60			End:         thisMonthStart.Add(-time.Second),
 61			Description: "last month",
 62		}, nil
 63	}
 64
 65	// Try "past N days/weeks/months"
 66	if matches := pastRe.FindStringSubmatch(input); len(matches) == 3 {
 67		return parseDuration(matches[1], matches[2], now, input)
 68	}
 69
 70	// Try "last N days/weeks/months"
 71	if matches := lastNRe.FindStringSubmatch(input); len(matches) == 3 {
 72		return parseDuration(matches[1], matches[2], now, input)
 73	}
 74
 75	// Try date range "2026-01-01..2026-01-15"
 76	if matches := dateRangeRe.FindStringSubmatch(input); len(matches) == 3 {
 77		return parseDateRange(matches[1], matches[2])
 78	}
 79
 80	return nil, fmt.Errorf("unrecognized time range: %q", input)
 81}
 82
 83func parseDuration(nStr, unit string, now time.Time, desc string) (*activity.TimeRange, error) {
 84	n, err := strconv.Atoi(nStr)
 85	if err != nil {
 86		return nil, fmt.Errorf("invalid number: %s", nStr)
 87	}
 88
 89	var start time.Time
 90	switch {
 91	case strings.HasPrefix(unit, "day"):
 92		start = now.AddDate(0, 0, -n)
 93	case strings.HasPrefix(unit, "week"):
 94		start = now.AddDate(0, 0, -7*n)
 95	case strings.HasPrefix(unit, "month"):
 96		start = now.AddDate(0, -n, 0)
 97	default:
 98		return nil, fmt.Errorf("unknown unit: %s", unit)
 99	}
100
101	return &activity.TimeRange{
102		Start:       startOfDay(start),
103		End:         now,
104		Description: desc,
105	}, nil
106}
107
108func parseDateRange(startStr, endStr string) (*activity.TimeRange, error) {
109	start, err := time.Parse("2006-01-02", startStr)
110	if err != nil {
111		return nil, fmt.Errorf("invalid start date: %w", err)
112	}
113
114	end, err := time.Parse("2006-01-02", endStr)
115	if err != nil {
116		return nil, fmt.Errorf("invalid end date: %w", err)
117	}
118
119	return &activity.TimeRange{
120		Start:       startOfDay(start),
121		End:         endOfDay(end),
122		Description: fmt.Sprintf("%s to %s", startStr, endStr),
123	}, nil
124}
125
126func startOfDay(t time.Time) time.Time {
127	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
128}
129
130func endOfDay(t time.Time) time.Time {
131	return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, t.Location())
132}
133
134func startOfWeek(t time.Time) time.Time {
135	weekday := int(t.Weekday())
136	if weekday == 0 {
137		weekday = 7 // Sunday is 7, not 0
138	}
139	return startOfDay(t.AddDate(0, 0, -weekday+1)) // Monday
140}
141
142func startOfMonth(t time.Time) time.Time {
143	return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
144}