summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/handlers.go58
-rw-r--r--internal/handlers/timeline_logic.go49
-rw-r--r--internal/store/sqlite.go34
3 files changed, 104 insertions, 37 deletions
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 1e376b5..a1a12e7 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -1179,7 +1179,8 @@ func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) {
source := r.FormValue("source")
checked := r.FormValue("checked") == "true"
- if source == "user" {
+ switch source {
+ case "user":
var userID int64
if _, err := fmt.Sscanf(id, "user-%d", &userID); err != nil {
JSONError(w, http.StatusBadRequest, "Invalid user item ID", err)
@@ -1189,35 +1190,30 @@ func (h *Handler) HandleShoppingToggle(w http.ResponseWriter, r *http.Request) {
JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err)
return
}
+ case "trello", "plantoeat":
+ // Store checked state locally for external sources
+ if err := h.store.SetShoppingItemChecked(source, id, checked); err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to toggle item", err)
+ return
+ }
+ default:
+ JSONError(w, http.StatusBadRequest, "Unknown source", nil)
+ return
}
- // Note: Trello and PlanToEat toggle would need their own API calls
-
- // Return updated item HTML
- checkedClass := ""
- checkedAttr := ""
- textClass := "text-white"
- if checked {
- checkedClass = "opacity-50"
- checkedAttr = "checked"
- textClass = "line-through text-white/40"
- }
- html := fmt.Sprintf(`<li class="flex items-center gap-3 p-3 bg-white/5 hover:bg-white/10 transition-colors rounded-lg border border-white/5 %s">
- <input type="checkbox" %s
- hx-post="/shopping/toggle"
- hx-vals='{"id":"%s","source":"%s","checked":%t}'
- hx-target="closest li"
- hx-swap="outerHTML"
- class="h-5 w-5 rounded bg-black/40 border-white/30 text-green-500 focus:ring-white/30 cursor-pointer flex-shrink-0">
- <span class="flex-1 %s">%s</span>
- <span class="text-xs px-2 py-0.5 rounded bg-purple-900/50 text-purple-300">user</span>
- </li>`, checkedClass, checkedAttr, template.HTMLEscapeString(id), template.HTMLEscapeString(source), !checked, textClass, template.HTMLEscapeString(r.FormValue("name")))
- HTMLString(w, html)
+
+ // Return refreshed shopping tab
+ stores := h.aggregateShoppingLists(r.Context())
+ HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
}
// aggregateShoppingLists combines Trello, PlanToEat, and user shopping items by store
func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore {
storeMap := make(map[string]map[string][]models.UnifiedShoppingItem)
+ // Fetch locally stored checked states for external sources
+ trelloChecks, _ := h.store.GetShoppingItemChecks("trello")
+ plantoeatChecks, _ := h.store.GetShoppingItemChecks("plantoeat")
+
// 1. Fetch Trello "Shopping" board cards
boards, err := h.store.GetBoards()
if err != nil {
@@ -1237,10 +1233,11 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
}
item := models.UnifiedShoppingItem{
- ID: card.ID,
- Name: card.Name,
- Store: storeName,
- Source: "trello",
+ ID: card.ID,
+ Name: card.Name,
+ Store: storeName,
+ Source: "trello",
+ Checked: trelloChecks[card.ID],
}
storeMap[storeName][""] = append(storeMap[storeName][""], item)
}
@@ -1265,6 +1262,11 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
}
+ // Use locally stored check state if available, otherwise use scraped state
+ checked := item.Checked
+ if localChecked, ok := plantoeatChecks[item.ID]; ok {
+ checked = localChecked
+ }
unified := models.UnifiedShoppingItem{
ID: item.ID,
Name: item.Name,
@@ -1272,7 +1274,7 @@ func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingS
Category: item.Category,
Store: storeName,
Source: "plantoeat",
- Checked: item.Checked,
+ Checked: checked,
}
storeMap[storeName][item.Category] = append(storeMap[storeName][item.Category], unified)
}
diff --git a/internal/handlers/timeline_logic.go b/internal/handlers/timeline_logic.go
index bead98f..553593d 100644
--- a/internal/handlers/timeline_logic.go
+++ b/internal/handlers/timeline_logic.go
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"sort"
+ "strings"
"time"
"task-dashboard/internal/api"
@@ -40,15 +41,38 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
items = append(items, item)
}
- // 2. Fetch Meals
+ // 2. Fetch Meals - combine multiple items for same date+mealType
meals, err := s.GetMealsByDateRange(start, end)
if err != nil {
return nil, err
}
+
+ // Group meals by date+mealType key
+ type mealKey struct {
+ date string
+ mealType string
+ }
+ mealGroups := make(map[mealKey][]models.Meal)
for _, meal := range meals {
- mealTime := meal.Date
- // Apply Meal Defaults
- switch meal.MealType {
+ key := mealKey{
+ date: meal.Date.Format("2006-01-02"),
+ mealType: meal.MealType,
+ }
+ mealGroups[key] = append(mealGroups[key], meal)
+ }
+
+ // Create combined timeline items for each group
+ for _, group := range mealGroups {
+ if len(group) == 0 {
+ continue
+ }
+
+ // Use first meal as base
+ firstMeal := group[0]
+ mealTime := firstMeal.Date
+
+ // Apply Meal time defaults
+ switch firstMeal.MealType {
case "breakfast":
mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.BreakfastHour, 0, 0, 0, mealTime.Location())
case "lunch":
@@ -59,14 +83,21 @@ func BuildTimeline(ctx context.Context, s *store.Store, calendarClient api.Googl
mealTime = time.Date(mealTime.Year(), mealTime.Month(), mealTime.Day(), config.LunchHour, 0, 0, 0, mealTime.Location())
}
+ // Combine recipe names with " + "
+ var names []string
+ for _, m := range group {
+ names = append(names, m.RecipeName)
+ }
+ combinedTitle := strings.Join(names, " + ")
+
item := models.TimelineItem{
- ID: meal.ID,
+ ID: firstMeal.ID,
Type: models.TimelineItemTypeMeal,
- Title: meal.RecipeName,
+ Title: combinedTitle,
Time: mealTime,
- URL: meal.RecipeURL,
- OriginalItem: meal,
- IsCompleted: false, // Meals don't have completion status
+ URL: firstMeal.RecipeURL, // Use first meal's URL
+ OriginalItem: group, // Store all meals
+ IsCompleted: false,
Source: "plantoeat",
}
item.ComputeDaySection(now)
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index c2f6e98..396ac54 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -701,6 +701,40 @@ func (s *Store) DeleteUserShoppingItem(id int64) error {
return err
}
+// SetShoppingItemChecked sets the checked state for an external shopping item
+func (s *Store) SetShoppingItemChecked(source, itemID string, checked bool) error {
+ checkedInt := 0
+ if checked {
+ checkedInt = 1
+ }
+ _, err := s.db.Exec(`
+ INSERT INTO shopping_item_checks (source, item_id, checked, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(source, item_id) DO UPDATE SET checked = ?, updated_at = CURRENT_TIMESTAMP
+ `, source, itemID, checkedInt, checkedInt)
+ return err
+}
+
+// GetShoppingItemChecks returns a map of item_id -> checked for a given source
+func (s *Store) GetShoppingItemChecks(source string) (map[string]bool, error) {
+ rows, err := s.db.Query(`SELECT item_id, checked FROM shopping_item_checks WHERE source = ?`, source)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ checks := make(map[string]bool)
+ for rows.Next() {
+ var itemID string
+ var checked int
+ if err := rows.Scan(&itemID, &checked); err != nil {
+ return nil, err
+ }
+ checks[itemID] = checked == 1
+ }
+ return checks, rows.Err()
+}
+
// GetTasksByDateRange retrieves tasks due within a specific date range
func (s *Store) GetTasksByDateRange(start, end time.Time) ([]models.Task, error) {
rows, err := s.db.Query(`