diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/interfaces.go | 2 | ||||
| -rw-r--r-- | internal/api/plantoeat.go | 6 | ||||
| -rw-r--r-- | internal/api/plantoeat_test.go | 126 | ||||
| -rw-r--r-- | internal/api/todoist.go | 8 | ||||
| -rw-r--r-- | internal/api/trello.go | 6 | ||||
| -rw-r--r-- | internal/config/timezone.go | 56 | ||||
| -rw-r--r-- | internal/handlers/handlers.go | 15 | ||||
| -rw-r--r-- | internal/handlers/timeline.go | 10 | ||||
| -rw-r--r-- | internal/handlers/timeline_logic.go | 2 | ||||
| -rw-r--r-- | internal/models/atom.go | 12 | ||||
| -rw-r--r-- | internal/models/timeline.go | 17 |
11 files changed, 229 insertions, 31 deletions
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 := ` + <div id="shopping_items" data-total-items="3"> + <div id="store-0" class="store" data-store-id="0"> + <div class="store_heading"> + <h4 class="store_name">Costco (3)</h4> + </div> + <table> + <tr> + <td class="column"> + <div class="category-box" id="cat01_350"> + <p class="category-title"><span>Baking</span></p> + <ul class="ingredients"> + <li class="sli noselect i493745871" data-store-id="0"> + <label class="checkbox-label"> + <input type="checkbox" name="items[493745871]" value="493745871"> + <strong>Olive oil</strong> <span class="quan">2 tablespoons</span> + </label> + </li> + <li class="sli noselect i493745872 checked" data-store-id="0"> + <label class="checkbox-label"> + <input type="checkbox" name="items[493745872]" value="493745872" checked> + <strong>Flour</strong> <span class="quan">1 cup</span> + </label> + </li> + </ul> + </div> + <div class="category-box" id="cat01_347"> + <p class="category-title"><span>Meat</span></p> + <ul class="ingredients"> + <li class="sli noselect i493745889" data-store-id="0"> + <label class="checkbox-label"> + <input type="checkbox" name="items[493745889]" value="493745889"> + <strong>Ground beef</strong> <span class="quan">1 pound</span> + </label> + </li> + </ul> + </div> + </td> + </tr> + </table> + </div> + </div>` + + 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 := `<div id="shopping_items" data-total-items="0"></div>` + + 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 } } diff --git a/internal/config/timezone.go b/internal/config/timezone.go new file mode 100644 index 0000000..04f218e --- /dev/null +++ b/internal/config/timezone.go @@ -0,0 +1,56 @@ +package config + +import ( + "sync" + "time" +) + +var ( + displayTimezone *time.Location + tzOnce sync.Once +) + +// SetDisplayTimezone sets the global display timezone (call once at startup) +func SetDisplayTimezone(tz string) { + tzOnce.Do(func() { + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + } + displayTimezone = loc + }) +} + +// GetDisplayTimezone returns the configured display timezone +func GetDisplayTimezone() *time.Location { + if displayTimezone == nil { + return time.UTC + } + return displayTimezone +} + +// Now returns the current time in the configured display timezone +func Now() time.Time { + return time.Now().In(GetDisplayTimezone()) +} + +// Today returns today's date at midnight in the configured timezone +func Today() time.Time { + now := Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, GetDisplayTimezone()) +} + +// ParseDateInDisplayTZ parses a date string (2006-01-02) in the display timezone +func ParseDateInDisplayTZ(dateStr string) (time.Time, error) { + return time.ParseInLocation("2006-01-02", dateStr, GetDisplayTimezone()) +} + +// ParseDateTimeInDisplayTZ parses a datetime string in the display timezone +func ParseDateTimeInDisplayTZ(layout, value string) (time.Time, error) { + return time.ParseInLocation(layout, value, GetDisplayTimezone()) +} + +// ToDisplayTZ converts a time to the display timezone +func ToDisplayTZ(t time.Time) time.Time { + return t.In(GetDisplayTimezone()) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 595ab67..1e376b5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -124,7 +124,7 @@ func (h *Handler) HandleGetTasks(w http.ResponseWriter, r *http.Request) { // HandleGetMeals returns meals as JSON func (h *Handler) HandleGetMeals(w http.ResponseWriter, r *http.Request) { - startDate := time.Now() + startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) @@ -183,7 +183,7 @@ func (h *Handler) HandleRefreshTab(w http.ResponseWriter, r *http.Request) { // aggregateData fetches and caches data from all sources concurrently func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models.DashboardData, error) { data := &models.DashboardData{ - LastUpdated: time.Now(), + LastUpdated: config.Now(), Errors: make([]string, 0), } @@ -423,7 +423,7 @@ func (h *Handler) convertSyncItemToTask(item api.SyncItemResponse, projectMap ma // fetchMeals fetches meals from cache or API func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.Meal, error) { - startDate := time.Now() + startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) fetcher := &CacheFetcher[models.Meal]{ @@ -741,7 +741,7 @@ func (h *Handler) HandleUnifiedAdd(w http.ResponseWriter, r *http.Request) { var dueDate *time.Time if dueDateStr != "" { - if parsed, err := time.ParseInLocation("2006-01-02", dueDateStr, time.Local); err == nil { + if parsed, err := config.ParseDateInDisplayTZ(dueDateStr); err == nil { dueDate = &parsed } } @@ -978,7 +978,7 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { Atoms: currentAtoms, FutureAtoms: futureAtoms, Boards: boards, - Today: time.Now().Format("2006-01-02"), + Today: config.Now().Format("2006-01-02"), } HTMLResponse(w, h.templates, "tasks-tab", data) @@ -986,8 +986,7 @@ func (h *Handler) HandleTabTasks(w http.ResponseWriter, r *http.Request) { // HandleTabPlanning renders the Planning tab with structured sections func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + today := config.Today() tomorrow := today.AddDate(0, 0, 1) in3Days := today.AddDate(0, 0, 4) @@ -1121,7 +1120,7 @@ func (h *Handler) HandleTabPlanning(w http.ResponseWriter, r *http.Request) { // HandleTabMeals renders the Meals tab (PlanToEat) func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) { - startDate := time.Now() + startDate := config.Now() endDate := startDate.AddDate(0, 0, 7) meals, err := h.store.GetMeals(startDate, endDate) diff --git a/internal/handlers/timeline.go b/internal/handlers/timeline.go index ce0e831..9821452 100644 --- a/internal/handlers/timeline.go +++ b/internal/handlers/timeline.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" ) @@ -25,19 +26,16 @@ func (h *Handler) HandleTimeline(w http.ResponseWriter, r *http.Request) { var start time.Time if startStr != "" { - parsed, err := time.ParseInLocation("2006-01-02", startStr, time.Local) + parsed, err := config.ParseDateInDisplayTZ(startStr) if err == nil { start = parsed } else { - start = time.Now() + start = config.Today() } } else { - start = time.Now() + start = config.Today() } - // Normalize start to beginning of day - start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) - days := 3 // Default if daysStr != "" { if d, err := strconv.Atoi(daysStr); err == nil && d > 0 { diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go index 0d4595f..bead98f 100644 --- a/internal/handlers/timeline_logic.go +++ b/internal/handlers/timeline_logic.go @@ -14,7 +14,7 @@ import ( // BuildTimeline aggregates and normalizes data into a timeline structure func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.GoogleCalendarAPI, start, end time.Time) ([]models.TimelineItem, error) { var items []models.TimelineItem - now := time.Now() + now := config.Now() // 1. Fetch Tasks tasks, err := s.GetTasksByDateRange(start, end) diff --git a/internal/models/atom.go b/internal/models/atom.go index 3e08896..3804de4 100644 --- a/internal/models/atom.go +++ b/internal/models/atom.go @@ -3,6 +3,8 @@ package models import ( "fmt" "time" + + "task-dashboard/internal/config" ) type AtomSource string @@ -56,19 +58,21 @@ func (a *Atom) ComputeUIFields() { return } - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + tz := config.GetDisplayTimezone() + now := config.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, tz) tomorrow := today.AddDate(0, 0, 1) // Check if overdue (due date is before today) - dueDay := time.Date(a.DueDate.Year(), a.DueDate.Month(), a.DueDate.Day(), 0, 0, 0, 0, a.DueDate.Location()) + dueInTZ := a.DueDate.In(tz) + dueDay := time.Date(dueInTZ.Year(), dueInTZ.Month(), dueInTZ.Day(), 0, 0, 0, 0, tz) a.IsOverdue = dueDay.Before(today) // Check if future (due date is after today) a.IsFuture = !dueDay.Before(tomorrow) // Check if has set time (not midnight) - a.HasSetTime = a.DueDate.Hour() != 0 || a.DueDate.Minute() != 0 + a.HasSetTime = dueInTZ.Hour() != 0 || dueInTZ.Minute() != 0 } // TaskToAtom converts a Todoist Task to an Atom diff --git a/internal/models/timeline.go b/internal/models/timeline.go index 54f7f45..3475696 100644 --- a/internal/models/timeline.go +++ b/internal/models/timeline.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "time" + + "task-dashboard/internal/config" +) type TimelineItemType string @@ -37,15 +41,16 @@ type TimelineItem struct { // ComputeDaySection sets the DaySection based on the item's time func (item *TimelineItem) ComputeDaySection(now time.Time) { - // Ensure we're working in local timezone for consistent comparisons - localNow := now.Local() - localItemTime := item.Time.Local() + // Use configured display timezone for consistent comparisons + tz := config.GetDisplayTimezone() + localNow := now.In(tz) + localItemTime := item.Time.In(tz) - today := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, time.Local) + today := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, tz) tomorrow := today.AddDate(0, 0, 1) dayAfterTomorrow := today.AddDate(0, 0, 2) - itemDay := time.Date(localItemTime.Year(), localItemTime.Month(), localItemTime.Day(), 0, 0, 0, 0, time.Local) + itemDay := time.Date(localItemTime.Year(), localItemTime.Month(), localItemTime.Day(), 0, 0, 0, 0, tz) if itemDay.Before(tomorrow) { item.DaySection = DaySectionToday |
