summaryrefslogtreecommitdiff
path: root/internal/reports
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-06 16:56:46 -0600
committerT <t@tjp.lol>2025-08-07 08:33:49 -0600
commit4843deb9cfa6d91282c5124ec025c636137e9e94 (patch)
tree180dc226e7dcdbbba8ea6ecabba821f4bdb64949 /internal/reports
parentd75bd93385bf3b54ada84c3d45011d7f8efc1f80 (diff)
Enhanced date range parsing with more flexible input formats
- Added support for 'this week' and 'this month' date ranges - Added support for month names (e.g., 'february', 'july') with automatic year detection - Added support for 'month year' format (e.g., 'july 2023', 'feb 2022') - Enhanced help text and examples for all report commands - Added comprehensive test coverage for date range parsing - Fixed timezone handling in TUI history display - Minor code style improvements
Diffstat (limited to 'internal/reports')
-rw-r--r--internal/reports/daterange.go162
-rw-r--r--internal/reports/daterange_test.go246
2 files changed, 398 insertions, 10 deletions
diff --git a/internal/reports/daterange.go b/internal/reports/daterange.go
index 3478615..adeb48b 100644
--- a/internal/reports/daterange.go
+++ b/internal/reports/daterange.go
@@ -2,6 +2,8 @@ package reports
import (
"fmt"
+ "regexp"
+ "strconv"
"strings"
"time"
)
@@ -11,6 +13,33 @@ type DateRange struct {
End time.Time
}
+var monthNames = map[string]time.Month{
+ "january": time.January,
+ "february": time.February,
+ "march": time.March,
+ "april": time.April,
+ "may": time.May,
+ "june": time.June,
+ "july": time.July,
+ "august": time.August,
+ "september": time.September,
+ "october": time.October,
+ "november": time.November,
+ "december": time.December,
+ "jan": time.January,
+ "feb": time.February,
+ "mar": time.March,
+ "apr": time.April,
+ "jun": time.June,
+ "jul": time.July,
+ "aug": time.August,
+ "sep": time.September,
+ "sept": time.September,
+ "oct": time.October,
+ "nov": time.November,
+ "dec": time.December,
+}
+
func ParseDateRange(dateStr string) (DateRange, error) {
dateStr = strings.TrimSpace(dateStr)
now := time.Now().UTC()
@@ -20,8 +49,22 @@ func ParseDateRange(dateStr string) (DateRange, error) {
switch lowerDateStr {
case "last week":
return getLastWeek(now), nil
+ case "this week":
+ return getThisWeek(now), nil
case "last month":
return getLastMonth(now), nil
+ case "this month":
+ return getThisMonth(now), nil
+ }
+
+ // Check for month name patterns (case-insensitive)
+ if dateRange, err := parseMonthName(dateStr, now); err == nil {
+ return dateRange, nil
+ }
+
+ // Check for "month year" format
+ if dateRange, err := parseMonthYear(dateStr); err == nil {
+ return dateRange, nil
}
// Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD"
@@ -29,7 +72,7 @@ func ParseDateRange(dateStr string) (DateRange, error) {
return parseCustomDateRange(dateStr)
}
- return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'last week', 'last month', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr)
+ return DateRange{}, fmt.Errorf("unsupported date range: %s (supported: 'this week', 'this month', 'last week', 'last month', month names like 'february', 'month year' like 'july 2023', or 'YYYY-MM-DD to YYYY-MM-DD')", dateStr)
}
func parseCustomDateRange(dateStr string) (DateRange, error) {
@@ -61,7 +104,7 @@ func parseCustomDateRange(dateStr string) (DateRange, error) {
// Convert to UTC and set times appropriately
// Start date: beginning of day (00:00:00)
startUTC := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.UTC)
-
+
// End date: end of day (23:59:59.999999999)
endUTC := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, time.UTC)
@@ -77,14 +120,14 @@ func getLastWeek(now time.Time) DateRange {
if weekday == 0 { // Sunday
weekday = 7
}
-
+
// Start of current week
- currentWeekStart := now.AddDate(0, 0, -(weekday-1)).Truncate(24 * time.Hour)
-
+ currentWeekStart := now.AddDate(0, 0, -(weekday - 1)).Truncate(24 * time.Hour)
+
// Last week is the week before current week
lastWeekStart := currentWeekStart.AddDate(0, 0, -7)
lastWeekEnd := currentWeekStart.Add(-time.Nanosecond)
-
+
return DateRange{
Start: lastWeekStart,
End: lastWeekEnd,
@@ -94,15 +137,114 @@ func getLastWeek(now time.Time) DateRange {
func getLastMonth(now time.Time) DateRange {
// Start of current month
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
-
+
// Last month start
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
-
+
// Last month end (last nanosecond of last month)
lastMonthEnd := currentMonthStart.Add(-time.Nanosecond)
-
+
return DateRange{
Start: lastMonthStart,
End: lastMonthEnd,
}
-} \ No newline at end of file
+}
+
+func getThisWeek(now time.Time) DateRange {
+ // Find the start of current week (Monday)
+ weekday := int(now.Weekday())
+ if weekday == 0 { // Sunday
+ weekday = 7
+ }
+
+ // Start of current week
+ weekStart := now.AddDate(0, 0, -(weekday - 1)).Truncate(24 * time.Hour)
+
+ // End of current week (Sunday 23:59:59.999999999)
+ weekEnd := weekStart.AddDate(0, 0, 7).Add(-time.Nanosecond)
+
+ return DateRange{
+ Start: weekStart,
+ End: weekEnd,
+ }
+}
+
+func getThisMonth(now time.Time) DateRange {
+ // Start of current month
+ monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+
+ // End of current month (last nanosecond)
+ nextMonthStart := monthStart.AddDate(0, 1, 0)
+ monthEnd := nextMonthStart.Add(-time.Nanosecond)
+
+ return DateRange{
+ Start: monthStart,
+ End: monthEnd,
+ }
+}
+
+func parseMonthName(dateStr string, now time.Time) (DateRange, error) {
+ lowerDateStr := strings.ToLower(dateStr)
+ month, exists := monthNames[lowerDateStr]
+ if !exists {
+ return DateRange{}, fmt.Errorf("not a valid month name")
+ }
+
+ // Find the most recent occurrence of this month
+ var targetYear int
+ currentMonth := now.Month()
+
+ if month == currentMonth {
+ // If it's the same month as now, use this year
+ targetYear = now.Year()
+ } else if month < currentMonth {
+ // If the month has already passed this year, use this year
+ targetYear = now.Year()
+ } else {
+ // If the month hasn't come yet this year, use last year
+ targetYear = now.Year() - 1
+ }
+
+ return getMonthRange(targetYear, month), nil
+}
+
+func parseMonthYear(dateStr string) (DateRange, error) {
+ // Match patterns like "july 2023", "feb 2022", etc.
+ re := regexp.MustCompile(`^(\w+)\s+(\d{4})$`)
+ matches := re.FindStringSubmatch(strings.ToLower(dateStr))
+ if len(matches) != 3 {
+ return DateRange{}, fmt.Errorf("invalid month year format")
+ }
+
+ monthStr := matches[1]
+ yearStr := matches[2]
+
+ // Parse year
+ year, err := strconv.Atoi(yearStr)
+ if err != nil {
+ return DateRange{}, fmt.Errorf("invalid year: %s", yearStr)
+ }
+
+ // Parse month
+ month, exists := monthNames[monthStr]
+ if !exists {
+ return DateRange{}, fmt.Errorf("invalid month name: %s", monthStr)
+ }
+
+ return getMonthRange(year, month), nil
+}
+
+func getMonthRange(year int, month time.Month) DateRange {
+ // Start of the specified month
+ monthStart := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
+
+ // End of the specified month (last nanosecond)
+ nextMonthStart := monthStart.AddDate(0, 1, 0)
+ monthEnd := nextMonthStart.Add(-time.Nanosecond)
+
+ return DateRange{
+ Start: monthStart,
+ End: monthEnd,
+ }
+}
+
diff --git a/internal/reports/daterange_test.go b/internal/reports/daterange_test.go
new file mode 100644
index 0000000..69b0e4a
--- /dev/null
+++ b/internal/reports/daterange_test.go
@@ -0,0 +1,246 @@
+package reports
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestParseDateRange(t *testing.T) {
+ // Use a fixed time for testing
+ testTime := time.Date(2024, time.August, 15, 10, 30, 0, 0, time.UTC) // Thursday, August 15, 2024
+
+ tests := []struct {
+ name string
+ input string
+ wantErr bool
+ validate func(t *testing.T, dr DateRange)
+ }{
+ {
+ name: "this week",
+ input: "this week",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be Monday Aug 12 to Sunday Aug 18
+ expectedStart := time.Date(2024, time.August, 12, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.August, 18, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "this month",
+ input: "this month",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be Aug 1 to Aug 31
+ expectedStart := time.Date(2024, time.August, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.August, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "month name - current month",
+ input: "august",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be August 2024 (current year since we're in August)
+ expectedStart := time.Date(2024, time.August, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.August, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "month name - past month this year",
+ input: "july",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be July 2024 (current year since July already passed)
+ expectedStart := time.Date(2024, time.July, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.July, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "month name - future month last year",
+ input: "december",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be December 2023 (last year since December hasn't come yet)
+ expectedStart := time.Date(2023, time.December, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2023, time.December, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "case insensitive month name",
+ input: "FEBRUARY",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be February 2024 (current year since February already passed)
+ expectedStart := time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.February, 29, 23, 59, 59, 999999999, time.UTC) // 2024 is a leap year
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "short month name",
+ input: "feb",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be February 2024
+ expectedStart := time.Date(2024, time.February, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2024, time.February, 29, 23, 59, 59, 999999999, time.UTC) // 2024 is a leap year
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "month year format",
+ input: "july 2023",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be July 2023
+ expectedStart := time.Date(2023, time.July, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2023, time.July, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "short month year format",
+ input: "feb 2022",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be February 2022
+ expectedStart := time.Date(2022, time.February, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2022, time.February, 28, 23, 59, 59, 999999999, time.UTC) // 2022 is not a leap year
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "case insensitive month year",
+ input: "MARCH 2023",
+ wantErr: false,
+ validate: func(t *testing.T, dr DateRange) {
+ // Should be March 2023
+ expectedStart := time.Date(2023, time.March, 1, 0, 0, 0, 0, time.UTC)
+ expectedEnd := time.Date(2023, time.March, 31, 23, 59, 59, 999999999, time.UTC)
+ if !dr.Start.Equal(expectedStart) {
+ t.Errorf("Start = %v, want %v", dr.Start, expectedStart)
+ }
+ if !dr.End.Equal(expectedEnd) {
+ t.Errorf("End = %v, want %v", dr.End, expectedEnd)
+ }
+ },
+ },
+ {
+ name: "invalid month name",
+ input: "invalid",
+ wantErr: true,
+ },
+ {
+ name: "invalid month year format",
+ input: "july 23",
+ wantErr: true,
+ },
+ {
+ name: "invalid year",
+ input: "july abcd",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create a custom ParseDateRange that uses our test time
+ testParseFunc := func(dateStr string) (DateRange, error) {
+ dateStr = strings.TrimSpace(dateStr)
+
+ // Check for predefined ranges (case-insensitive)
+ lowerDateStr := strings.ToLower(dateStr)
+ switch lowerDateStr {
+ case "last week":
+ return getLastWeek(testTime), nil
+ case "this week":
+ return getThisWeek(testTime), nil
+ case "last month":
+ return getLastMonth(testTime), nil
+ case "this month":
+ return getThisMonth(testTime), nil
+ }
+
+ // Check for month name patterns (case-insensitive)
+ if dateRange, err := parseMonthName(dateStr, testTime); err == nil {
+ return dateRange, nil
+ }
+
+ // Check for "month year" format
+ if dateRange, err := parseMonthYear(dateStr); err == nil {
+ return dateRange, nil
+ }
+
+ // Check for custom date range format: "YYYY-MM-DD to YYYY-MM-DD"
+ if strings.Contains(dateStr, " to ") {
+ return parseCustomDateRange(dateStr)
+ }
+
+ return DateRange{}, fmt.Errorf("unsupported date range: %s", dateStr)
+ }
+
+ got, err := testParseFunc(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParseDateRange() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if !tt.wantErr && tt.validate != nil {
+ tt.validate(t, got)
+ }
+ })
+ }
+}
+