summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/interfaces.go2
-rw-r--r--internal/api/plantoeat.go6
-rw-r--r--internal/api/plantoeat_test.go126
-rw-r--r--internal/api/todoist.go8
-rw-r--r--internal/api/trello.go6
-rw-r--r--internal/config/timezone.go56
-rw-r--r--internal/handlers/handlers.go15
-rw-r--r--internal/handlers/timeline.go10
-rw-r--r--internal/handlers/timeline_logic.go2
-rw-r--r--internal/models/atom.go12
-rw-r--r--internal/models/timeline.go17
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