From 42a4e32daca13b518e64e5821080ff3d6adf0e39 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 26 Jan 2026 16:49:44 -1000 Subject: Use configured timezone throughout codebase - Add config/timezone.go with timezone utilities: - SetDisplayTimezone(), GetDisplayTimezone() - Now(), Today() - current time/date in display TZ - ParseDateInDisplayTZ(), ToDisplayTZ() - parsing helpers - Initialize timezone at startup in main.go - Update all datetime logic to use configured timezone: - handlers/handlers.go - all time.Now() calls - handlers/timeline.go - date parsing - handlers/timeline_logic.go - now calculation - models/atom.go - ComputeUIFields() - models/timeline.go - ComputeDaySection() - api/plantoeat.go - meal date parsing - api/todoist.go - due date parsing - api/trello.go - due date parsing This ensures all dates/times display correctly regardless of server timezone setting. Co-Authored-By: Claude Opus 4.5 --- internal/api/interfaces.go | 2 + internal/api/plantoeat.go | 6 +- internal/api/plantoeat_test.go | 126 +++++++++++++++++++++++++++++++++++++++++ internal/api/todoist.go | 8 ++- internal/api/trello.go | 6 +- 5 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 internal/api/plantoeat_test.go (limited to 'internal/api') diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index 842814c..70bba1f 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -31,6 +31,7 @@ type TrelloAPI interface { // PlanToEatAPI defines the interface for PlanToEat operations type PlanToEatAPI interface { GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) + GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error) GetRecipes(ctx context.Context) error AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error } @@ -38,6 +39,7 @@ type PlanToEatAPI interface { // GoogleCalendarAPI defines the interface for Google Calendar operations type GoogleCalendarAPI interface { GetUpcomingEvents(ctx context.Context, maxResults int) ([]models.CalendarEvent, error) + GetEventsByDateRange(ctx context.Context, start, end time.Time) ([]models.CalendarEvent, error) } // Ensure concrete types implement interfaces diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go index 5c24cc1..ab5d2cd 100644 --- a/internal/api/plantoeat.go +++ b/internal/api/plantoeat.go @@ -11,6 +11,7 @@ import ( "github.com/PuerkitoBio/goquery" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -90,8 +91,7 @@ func parsePlannerHTML(body io.Reader, days int) ([]models.Meal, error) { } var meals []models.Meal - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + today := config.Today() endDate := today.AddDate(0, 0, days) // PlanToEat structure: @@ -108,7 +108,7 @@ func parsePlannerHTML(body io.Reader, days int) ([]models.Meal, error) { return } - mealDate, err := time.Parse("2006-01-02", dateStr) + mealDate, err := config.ParseDateInDisplayTZ(dateStr) if err != nil { return } diff --git a/internal/api/plantoeat_test.go b/internal/api/plantoeat_test.go new file mode 100644 index 0000000..50ad4ed --- /dev/null +++ b/internal/api/plantoeat_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "strings" + "testing" +) + +func TestParseShoppingListHTML(t *testing.T) { + // Sample HTML structure matching PlanToEat's shopping list + html := ` +
+
+
+

Costco (3)

+
+ + + + +
+
+

Baking

+
    +
  • + +
  • +
  • + +
  • +
+
+
+

Meat

+
    +
  • + +
  • +
+
+
+
+
` + + items, err := parseShoppingListHTML(strings.NewReader(html)) + if err != nil { + t.Fatalf("parseShoppingListHTML failed: %v", err) + } + + if len(items) != 3 { + t.Errorf("Expected 3 items, got %d", len(items)) + } + + // Check first item + if items[0].Name != "Olive oil" { + t.Errorf("Expected first item name 'Olive oil', got '%s'", items[0].Name) + } + if items[0].Quantity != "2 tablespoons" { + t.Errorf("Expected quantity '2 tablespoons', got '%s'", items[0].Quantity) + } + if items[0].Category != "Baking" { + t.Errorf("Expected category 'Baking', got '%s'", items[0].Category) + } + if items[0].Store != "Costco" { + t.Errorf("Expected store 'Costco', got '%s'", items[0].Store) + } + if items[0].ID != "493745871" { + t.Errorf("Expected ID '493745871', got '%s'", items[0].ID) + } + if items[0].Checked { + t.Error("Expected first item to not be checked") + } + + // Check checked item + if !items[1].Checked { + t.Error("Expected second item to be checked") + } + + // Check item from different category + if items[2].Name != "Ground beef" { + t.Errorf("Expected 'Ground beef', got '%s'", items[2].Name) + } + if items[2].Category != "Meat" { + t.Errorf("Expected category 'Meat', got '%s'", items[2].Category) + } +} + +func TestParseShoppingListHTML_EmptyList(t *testing.T) { + html := `
` + + items, err := parseShoppingListHTML(strings.NewReader(html)) + if err != nil { + t.Fatalf("parseShoppingListHTML failed: %v", err) + } + + if len(items) != 0 { + t.Errorf("Expected 0 items for empty list, got %d", len(items)) + } +} + +func TestCleanQuantity(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"1 cup", "1 cup"}, + {"2 tablespoons", "2 tablespoons"}, + {"1\u00a0pound", "1 pound"}, + {" 3 oz ", "3 oz"}, + } + + for _, tt := range tests { + result := cleanQuantity(tt.input) + if result != tt.expected { + t.Errorf("cleanQuantity(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/internal/api/todoist.go b/internal/api/todoist.go index 2c94e08..6068d2e 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -287,9 +288,14 @@ func parseDueDate(due *dueInfo) *time.Time { var dueDate time.Time var err error if due.Datetime != "" { + // RFC3339 includes timezone, then convert to display timezone dueDate, err = time.Parse(time.RFC3339, due.Datetime) + if err == nil { + dueDate = config.ToDisplayTZ(dueDate) + } } else if due.Date != "" { - dueDate, err = time.Parse("2006-01-02", due.Date) + // Date-only, parse in display timezone + dueDate, err = config.ParseDateInDisplayTZ(due.Date) } if err != nil { return nil diff --git a/internal/api/trello.go b/internal/api/trello.go index a276726..1a5642c 100644 --- a/internal/api/trello.go +++ b/internal/api/trello.go @@ -125,7 +125,8 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C if apiCard.Due != nil && *apiCard.Due != "" { if dueDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { - card.DueDate = &dueDate + dueInTZ := config.ToDisplayTZ(dueDate) + card.DueDate = &dueInTZ } } @@ -267,7 +268,8 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description if apiCard.Due != nil && *apiCard.Due != "" { if parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil { - card.DueDate = &parsedDate + dueInTZ := config.ToDisplayTZ(parsedDate) + card.DueDate = &dueInTZ } } -- cgit v1.2.3