gogcli/internal/cmd/time_helpers.go
2026-04-20 14:16:50 +01:00

324 lines
9.6 KiB
Go

package cmd
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/timeparse"
)
// TimeRangeFlags provides common time range options for calendar commands.
// Embed this struct in commands that need time range support.
type TimeRangeFlags struct {
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
Today bool `name:"today" help:"Today only"`
Tomorrow bool `name:"tomorrow" help:"Tomorrow only"`
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
Days int `name:"days" help:"Next N days" default:"0"`
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
}
// TimeRange represents a resolved time range with timezone.
type TimeRange struct {
From time.Time
To time.Time
Location *time.Location
}
// getCalendarLocation fetches a calendar's timezone and returns it as a location.
// Uses Calendars.Get (not CalendarList.Get) so it works for service accounts
// whose "primary" calendar may not appear in their CalendarList.
func getCalendarLocation(ctx context.Context, svc *calendar.Service, calendarID string) (string, *time.Location, error) {
calendarID = strings.TrimSpace(calendarID)
if calendarID == "" {
return "", nil, fmt.Errorf("calendarId required")
}
cal, err := svc.Calendars.Get(calendarID).Context(ctx).Do()
if err != nil {
return "", nil, fmt.Errorf("failed to get calendar %q: %w", calendarID, err)
}
if cal.TimeZone == "" {
return "", nil, fmt.Errorf("calendar %q has no timezone set", calendarID)
}
loc, err := loadTimezoneLocation(cal.TimeZone)
if err != nil {
return "", nil, fmt.Errorf("invalid calendar timezone %q: %w", cal.TimeZone, err)
}
return cal.TimeZone, loc, nil
}
// getUserTimezone fetches the user's timezone.
// It first tries calendarList/primary, then Calendars.Get("primary") for
// service-account compatibility, then falls back to calendar-list entries
// because some Workspace accounts return 404 for the primary alias.
func getUserTimezone(ctx context.Context, svc *calendar.Service) (*time.Location, error) {
calEntry, listAliasErr := svc.CalendarList.Get(primaryCalendarID).Context(ctx).Do()
if listAliasErr == nil {
return loadTimezoneOrUTC(calEntry.TimeZone)
}
if !isGoogleNotFound(listAliasErr) {
return nil, fmt.Errorf("failed to get primary calendar: %w", listAliasErr)
}
cal, directErr := svc.Calendars.Get(primaryCalendarID).Context(ctx).Do()
if directErr == nil {
return loadTimezoneOrUTC(cal.TimeZone)
}
if !isGoogleNotFound(directErr) {
return nil, fmt.Errorf("failed to get primary calendar via calendar list and direct lookup: %w", errors.Join(listAliasErr, directErr))
}
calendars, listErr := listCalendarList(ctx, svc)
if listErr != nil {
return nil, fmt.Errorf("failed to get primary calendar via alias, direct lookup, and fallback calendar list lookup: %w", errors.Join(listAliasErr, directErr, listErr))
}
return timezoneFromCalendarList(calendars)
}
func timezoneFromCalendarList(calendars []*calendar.CalendarListEntry) (*time.Location, error) {
var fallback *time.Location
var firstInvalid error
for _, cal := range calendars {
if cal == nil {
continue
}
tz := strings.TrimSpace(cal.TimeZone)
if tz == "" {
continue
}
loc, err := loadTimezoneLocation(tz)
if err != nil {
if firstInvalid == nil {
firstInvalid = fmt.Errorf("invalid calendar timezone %q on calendar %q: %w", tz, cal.Id, err)
}
continue
}
if cal.Primary {
return loc, nil
}
if fallback == nil {
fallback = loc
}
}
if fallback != nil {
return fallback, nil
}
if firstInvalid != nil {
return nil, firstInvalid
}
return time.UTC, nil
}
func loadTimezoneOrUTC(timezone string) (*time.Location, error) {
if strings.TrimSpace(timezone) == "" {
return time.UTC, nil
}
loc, err := loadTimezoneLocation(timezone)
if err != nil {
return nil, fmt.Errorf("invalid calendar timezone %q: %w", timezone, err)
}
return loc, nil
}
func isGoogleNotFound(err error) bool {
var gerr *googleapi.Error
return errors.As(err, &gerr) && gerr.Code == http.StatusNotFound
}
// ResolveTimeRange resolves the time range flags into absolute times.
// If no flags are provided, defaults to "next 7 days" from now.
func ResolveTimeRange(ctx context.Context, svc *calendar.Service, flags TimeRangeFlags) (*TimeRange, error) {
defaults := TimeRangeDefaults{
FromOffset: 0,
ToOffset: 7 * 24 * time.Hour,
ToFromOffset: 7 * 24 * time.Hour,
}
return ResolveTimeRangeWithDefaults(ctx, svc, flags, defaults)
}
// TimeRangeDefaults controls the default window when flags are missing.
type TimeRangeDefaults struct {
FromOffset time.Duration
ToOffset time.Duration
ToFromOffset time.Duration
}
// ResolveTimeRangeWithDefaults resolves the time range flags into absolute times,
// using provided defaults when --from/--to are not set.
func ResolveTimeRangeWithDefaults(ctx context.Context, svc *calendar.Service, flags TimeRangeFlags, defaults TimeRangeDefaults) (*TimeRange, error) {
loc, err := getUserTimezone(ctx, svc)
if err != nil {
return nil, err
}
now := time.Now().In(loc)
var from, to time.Time
weekStart, err := resolveWeekStart(flags.WeekStart)
if err != nil {
return nil, err
}
// Handle convenience flags first
switch {
case flags.Today:
from = startOfDay(now)
to = endOfDay(now)
case flags.Tomorrow:
tomorrow := now.AddDate(0, 0, 1)
from = startOfDay(tomorrow)
to = endOfDay(tomorrow)
case flags.Week:
from = startOfWeek(now, weekStart)
to = endOfWeek(now, weekStart)
case flags.Days > 0:
from = startOfDay(now)
to = endOfDay(now.AddDate(0, 0, flags.Days-1))
default:
// Parse --from and --to, or use defaults
if flags.From != "" {
from, err = parseTimeExpr(flags.From, now, loc)
if err != nil {
return nil, fmt.Errorf("invalid --from: %w", err)
}
} else {
from = now.Add(defaults.FromOffset)
}
switch {
case flags.To != "":
to, err = parseTimeExprEndOfDay(flags.To, now, loc)
if err != nil {
return nil, fmt.Errorf("invalid --to: %w", err)
}
case flags.From != "" && defaults.ToFromOffset != 0:
to = from.Add(defaults.ToFromOffset)
default:
to = now.Add(defaults.ToOffset)
}
}
return &TimeRange{
From: from,
To: to,
Location: loc,
}, nil
}
func parseTimeExprEndOfDay(expr string, now time.Time, loc *time.Location) (time.Time, error) {
t, err := parseTimeExpr(expr, now, loc)
if err != nil {
return t, err
}
if isDayExpr(expr, now, loc) {
return endOfDay(t), nil
}
return t, nil
}
func isDayExpr(expr string, now time.Time, loc *time.Location) bool {
expr = strings.TrimSpace(expr)
if expr == "" {
return false
}
exprLower := strings.ToLower(expr)
switch exprLower {
case "today", "tomorrow", "yesterday":
return true
case "now":
return false
}
if _, ok := parseWeekday(exprLower, now); ok {
return true
}
if _, err := time.ParseInLocation("2006-01-02", expr, loc); err == nil {
return true
}
return false
}
// parseTimeExpr parses a time expression which can be:
// - RFC3339: 2026-01-05T14:00:00-08:00
// - ISO 8601 with numeric timezone: 2026-01-05T14:00:00-0800 (no colon)
// - Date only: 2026-01-05 (interpreted as start of day in user's timezone)
// - Relative: today, tomorrow, monday, next tuesday
func parseTimeExpr(expr string, now time.Time, loc *time.Location) (time.Time, error) {
return timeparse.ParseRangeExpr(expr, now, loc)
}
// parseWeekday parses weekday expressions like "monday", "next tuesday"
func parseWeekday(expr string, now time.Time) (time.Time, bool) {
return timeparse.ParseWeekdayExpr(expr, now)
}
// startOfDay returns the start of the day (00:00:00) in the given time's location.
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// endOfDay returns the start of the next day so that when formatted as
// RFC3339 (second precision) and used as an exclusive timeMax, the entire
// target day is included. Previously, 23:59:59.999999999 was truncated to
// 23:59:59 by time.RFC3339, which excluded events starting at that second.
func endOfDay(t time.Time) time.Time {
return startOfDay(t.AddDate(0, 0, 1))
}
// startOfWeek returns the start of the week for the given time.
func startOfWeek(t time.Time, weekStart time.Weekday) time.Time {
weekday := int(t.Weekday())
start := int(weekStart)
daysBack := weekday - start
if daysBack < 0 {
daysBack += 7
}
return startOfDay(t.AddDate(0, 0, -daysBack))
}
// endOfWeek returns the end of the week for the given time.
func endOfWeek(t time.Time, weekStart time.Weekday) time.Time {
start := startOfWeek(t, weekStart)
return endOfDay(start.AddDate(0, 0, 6))
}
// FormatRFC3339 formats a time as RFC3339 for API calls.
func (tr *TimeRange) FormatRFC3339() (from, to string) {
return tr.From.Format(time.RFC3339), tr.To.Format(time.RFC3339)
}
// FormatHuman returns a human-readable description of the time range.
func (tr *TimeRange) FormatHuman() string {
fromDate := tr.From.Format("Mon Jan 2")
toDate := tr.To.Format("Mon Jan 2")
if fromDate == toDate {
return fromDate
}
return fmt.Sprintf("%s to %s", fromDate, toDate)
}
func resolveWeekStart(value string) (time.Weekday, error) {
if value == "" {
return time.Monday, nil
}
if wd, ok := parseWeekStart(value); ok {
return wd, nil
}
return time.Monday, fmt.Errorf("invalid --week-start %q (use sun, mon, ...)", value)
}
func parseWeekStart(value string) (time.Weekday, bool) {
return timeparse.ParseWeekdayName(value)
}