summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/dashboard/main.go9
-rw-r--r--internal/api/plantoeat.go285
-rw-r--r--internal/handlers/handlers.go123
-rw-r--r--internal/models/types.go33
-rw-r--r--web/templates/index.html16
-rw-r--r--web/templates/partials/shopping-tab.html42
6 files changed, 479 insertions, 29 deletions
diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go
index 8716db9..ce447ff 100644
--- a/cmd/dashboard/main.go
+++ b/cmd/dashboard/main.go
@@ -84,7 +84,11 @@ func main() {
var planToEatClient api.PlanToEatAPI
if cfg.HasPlanToEat() {
- planToEatClient = api.NewPlanToEatClient(cfg.PlanToEatAPIKey)
+ pteClient := api.NewPlanToEatClient(cfg.PlanToEatAPIKey)
+ if cfg.PlanToEatSession != "" {
+ pteClient.SetSessionCookie(cfg.PlanToEatSession)
+ }
+ planToEatClient = pteClient
}
var googleCalendarClient api.GoogleCalendarAPI
@@ -130,11 +134,14 @@ func main() {
r.Get("/api/tasks", h.HandleGetTasks)
r.Get("/api/meals", h.HandleGetMeals)
r.Get("/api/boards", h.HandleGetBoards)
+ r.Get("/api/shopping", h.HandleGetShoppingList)
// Tab routes for HTMX
r.Get("/tabs/tasks", h.HandleTabTasks)
r.Get("/tabs/planning", h.HandleTabPlanning)
r.Get("/tabs/meals", h.HandleTabMeals)
+ r.Get("/tabs/timeline", h.HandleTimeline)
+ r.Get("/tabs/shopping", h.HandleTabShopping)
r.Post("/tabs/refresh", h.HandleRefreshTab)
// Trello card operations
diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go
index eb29c63..a7fdf58 100644
--- a/internal/api/plantoeat.go
+++ b/internal/api/plantoeat.go
@@ -3,17 +3,27 @@ package api
import (
"context"
"fmt"
+ "io"
+ "log"
+ "net/http"
+ "strings"
"time"
+ "github.com/PuerkitoBio/goquery"
+
"task-dashboard/internal/models"
)
-const planToEatBaseURL = "https://www.plantoeat.com/api/v2"
+const (
+ planToEatBaseURL = "https://www.plantoeat.com/api/v2"
+ planToEatWebURL = "https://app.plantoeat.com"
+)
// PlanToEatClient handles interactions with the PlanToEat API
type PlanToEatClient struct {
BaseClient
- apiKey string
+ apiKey string
+ sessionCookie string // For web scraping endpoints
}
// NewPlanToEatClient creates a new PlanToEat API client
@@ -24,6 +34,11 @@ func NewPlanToEatClient(apiKey string) *PlanToEatClient {
}
}
+// SetSessionCookie sets the session cookie for web scraping endpoints
+func (c *PlanToEatClient) SetSessionCookie(cookie string) {
+ c.sessionCookie = cookie
+}
+
func (c *PlanToEatClient) authHeaders() map[string]string {
return map[string]string{"Authorization": "Bearer " + c.apiKey}
}
@@ -45,53 +60,146 @@ type planToEatResponse struct {
Items []planToEatPlannerItem `json:"items"`
}
-// GetUpcomingMeals fetches meals for the next N days
+// GetUpcomingMeals fetches meals by scraping the planner web interface
+// Requires a valid session cookie set via SetSessionCookie
func (c *PlanToEatClient) GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) {
+ if c.sessionCookie == "" {
+ return nil, fmt.Errorf("session cookie required for meals - use SetSessionCookie")
+ }
+
if days <= 0 {
days = 7
}
- startDate := time.Now()
- endDate := startDate.AddDate(0, 0, days)
+ req, err := http.NewRequestWithContext(ctx, "GET", planToEatWebURL+"/planner", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Cookie", c.sessionCookie)
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
- path := fmt.Sprintf("/planner_items?start_date=%s&end_date=%s",
- startDate.Format("2006-01-02"),
- endDate.Format("2006-01-02"))
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ log.Printf("DEBUG [PlanToEat/Meals]: Response status %d", resp.StatusCode)
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status %d (session may be expired)", resp.StatusCode)
+ }
+
+ return parsePlannerHTML(resp.Body, days)
+}
+
+// parsePlannerHTML extracts meals from the planner page HTML
+func parsePlannerHTML(body io.Reader, days int) ([]models.Meal, error) {
+ doc, err := goquery.NewDocumentFromReader(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse HTML: %w", err)
+ }
- var apiResponse planToEatResponse
- if err := c.Get(ctx, path, c.authHeaders(), &apiResponse); err != nil {
- return nil, fmt.Errorf("failed to fetch meals: %w", err)
+ // Check for login page
+ title := doc.Find("title").First().Text()
+ titleLower := strings.ToLower(title)
+ log.Printf("DEBUG [PlanToEat/Meals]: Page title: %q", title)
+ if strings.Contains(titleLower, "login") || strings.Contains(titleLower, "log in") || strings.Contains(titleLower, "sign in") {
+ return nil, fmt.Errorf("session expired - got login page (title: %s)", title)
}
- meals := make([]models.Meal, 0, len(apiResponse.Items))
- for _, item := range apiResponse.Items {
- mealDate, err := time.Parse("2006-01-02", item.Date)
+ var meals []models.Meal
+ now := time.Now()
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ endDate := today.AddDate(0, 0, days)
+
+ // PlanToEat structure:
+ // - Day cells have data-date="YYYY-MM-DD"
+ // - Within each day, sections: <div class="time" data-section="breakfast|lunch|dinner|xtra">
+ // - Meal items inside sections: <div data-id="..." data-recipe-id="...">
+ // - Recipe name: <a class="title recipe">Name</a>
+ // - Non-recipe: <span class="title item">Name</span>
+
+ // Find all day cells with data-date
+ doc.Find("[data-date]").Each(func(_ int, dayEl *goquery.Selection) {
+ dateStr, exists := dayEl.Attr("data-date")
+ if !exists {
+ return
+ }
+
+ mealDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
- continue
+ return
}
- meals = append(meals, models.Meal{
- ID: fmt.Sprintf("%d", item.ID),
- RecipeName: item.Recipe.Title,
- Date: mealDate,
- MealType: normalizeMealType(item.MealType),
- RecipeURL: item.Recipe.URL,
+ // Skip dates outside our range
+ if mealDate.Before(today) || mealDate.After(endDate) {
+ return
+ }
+
+ // Find meal sections within this day
+ dayEl.Find("div.time[data-section]").Each(func(_ int, sectionEl *goquery.Selection) {
+ section, _ := sectionEl.Attr("data-section")
+ mealType := normalizeMealType(section)
+
+ // Find meal items within this section
+ sectionEl.Find("[data-id]").Each(func(_ int, itemEl *goquery.Selection) {
+ meal := models.Meal{
+ Date: mealDate,
+ MealType: mealType,
+ }
+
+ // Get ID
+ if id, exists := itemEl.Attr("data-id"); exists {
+ meal.ID = id
+ }
+
+ // Try to get recipe name from a.title.recipe or span.title.item
+ if recipeLink := itemEl.Find("a.title.recipe, a.title").First(); recipeLink.Length() > 0 {
+ meal.RecipeName = strings.TrimSpace(recipeLink.Text())
+ if href, exists := recipeLink.Attr("href"); exists {
+ // Convert /recipes/{id}/{event_id} to /recipes/{id}/cook/{event_id}
+ if strings.HasPrefix(href, "/recipes/") {
+ parts := strings.Split(href, "/")
+ if len(parts) == 4 { // ["", "recipes", "id", "event_id"]
+ href = fmt.Sprintf("/recipes/%s/cook/%s", parts[2], parts[3])
+ }
+ }
+ if !strings.HasPrefix(href, "http") {
+ meal.RecipeURL = planToEatWebURL + href
+ } else {
+ meal.RecipeURL = href
+ }
+ }
+ } else if titleSpan := itemEl.Find("span.title.item, span.title").First(); titleSpan.Length() > 0 {
+ meal.RecipeName = strings.TrimSpace(titleSpan.Text())
+ }
+
+ if meal.RecipeName != "" {
+ meals = append(meals, meal)
+ }
+ })
})
- }
+ })
+
+ log.Printf("DEBUG [PlanToEat/Meals]: Found %d meals", len(meals))
return meals, nil
}
-// normalizeMealType ensures meal type matches our expected values
+// normalizeMealType ensures meal type matches DB constraint (breakfast, lunch, dinner, snack)
func normalizeMealType(mealType string) string {
- switch mealType {
- case "breakfast", "Breakfast":
+ lower := strings.ToLower(strings.TrimSpace(mealType))
+ switch lower {
+ case "breakfast":
return "breakfast"
- case "lunch", "Lunch":
+ case "lunch":
return "lunch"
- case "dinner", "Dinner":
+ case "dinner":
return "dinner"
- case "snack", "Snack":
+ case "snack", "xtra", "snacks":
return "snack"
default:
return "dinner"
@@ -107,3 +215,124 @@ func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error {
return fmt.Errorf("not implemented yet")
}
+
+// GetShoppingList fetches the shopping list by scraping the web interface
+// Requires a valid session cookie set via SetSessionCookie
+func (c *PlanToEatClient) GetShoppingList(ctx context.Context) ([]models.ShoppingItem, error) {
+ if c.sessionCookie == "" {
+ return nil, fmt.Errorf("session cookie required for shopping list - use SetSessionCookie")
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", planToEatWebURL+"/shopping_lists", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Cookie", c.sessionCookie)
+ req.Header.Set("Accept", "text/html")
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
+
+ log.Printf("DEBUG [PlanToEat/Shopping]: Fetching %s", planToEatWebURL+"/shopping_lists")
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ log.Printf("DEBUG [PlanToEat/Shopping]: Response status %d", resp.StatusCode)
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("unexpected status %d (session may be expired)", resp.StatusCode)
+ }
+
+ return parseShoppingListHTML(resp.Body)
+}
+
+// parseShoppingListHTML extracts shopping items from the HTML response
+func parseShoppingListHTML(body io.Reader) ([]models.ShoppingItem, error) {
+ doc, err := goquery.NewDocumentFromReader(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse HTML: %w", err)
+ }
+
+ var items []models.ShoppingItem
+ currentStore := ""
+ currentCategory := ""
+
+ // Debug: log page title and structure hints
+ title := doc.Find("title").First().Text()
+ log.Printf("DEBUG [PlanToEat/Shopping]: Page title: %q", title)
+
+ // Check if we got a login page
+ titleLower := strings.ToLower(title)
+ if strings.Contains(titleLower, "login") || strings.Contains(titleLower, "log in") || strings.Contains(titleLower, "sign in") {
+ return nil, fmt.Errorf("session expired - got login page (title: %s)", title)
+ }
+
+ // Debug: count elements to understand structure
+ storeCount := doc.Find("div.store").Length()
+ log.Printf("DEBUG [PlanToEat/Shopping]: Found %d div.store elements", storeCount)
+
+ // Iterate through stores
+ doc.Find("div.store").Each(func(_ int, store *goquery.Selection) {
+ currentStore = strings.TrimSpace(store.Find("h4.store_name").First().Text())
+ // Clean up store name (remove count and icons)
+ if idx := strings.Index(currentStore, "("); idx > 0 {
+ currentStore = strings.TrimSpace(currentStore[:idx])
+ }
+
+ log.Printf("DEBUG [PlanToEat/Shopping]: Processing store: %q", currentStore)
+
+ // Iterate through categories within store
+ store.Find("div.category-box").Each(func(_ int, catBox *goquery.Selection) {
+ currentCategory = strings.TrimSpace(catBox.Find("p.category-title span").First().Text())
+
+ // Iterate through items in category
+ catBox.Find("li.sli").Each(func(_ int, li *goquery.Selection) {
+ item := models.ShoppingItem{
+ Store: currentStore,
+ Category: currentCategory,
+ }
+
+ // Extract ID from class (e.g., "sli i493745889")
+ if class, exists := li.Attr("class"); exists {
+ for _, c := range strings.Fields(class) {
+ if strings.HasPrefix(c, "i") && len(c) > 1 {
+ item.ID = c[1:] // Remove 'i' prefix
+ break
+ }
+ }
+ }
+
+ // Extract name
+ item.Name = strings.TrimSpace(li.Find("strong").First().Text())
+
+ // Extract quantity
+ item.Quantity = strings.TrimSpace(li.Find("span.quan").First().Text())
+ // Clean up HTML entities in quantity
+ item.Quantity = cleanQuantity(item.Quantity)
+
+ // Check if item is checked (has specific class or attribute)
+ if li.HasClass("checked") || li.HasClass("crossed") {
+ item.Checked = true
+ }
+
+ if item.Name != "" {
+ items = append(items, item)
+ }
+ })
+ })
+ })
+
+ log.Printf("DEBUG [PlanToEat/Shopping]: Parsed %d items total", len(items))
+
+ return items, nil
+}
+
+// cleanQuantity removes HTML entities and extra whitespace from quantity strings
+func cleanQuantity(q string) string {
+ q = strings.ReplaceAll(q, "\u00a0", " ") // non-breaking space
+ q = strings.ReplaceAll(q, " ", " ")
+ return strings.TrimSpace(q)
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index a2d1f0b..4d38f72 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -145,6 +145,21 @@ func (h *Handler) HandleGetBoards(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, boards)
}
+// HandleGetShoppingList returns PlanToEat shopping list as JSON
+func (h *Handler) HandleGetShoppingList(w http.ResponseWriter, r *http.Request) {
+ if h.planToEatClient == nil {
+ JSONError(w, http.StatusServiceUnavailable, "PlanToEat not configured", nil)
+ return
+ }
+
+ items, err := h.planToEatClient.GetShoppingList(r.Context())
+ if err != nil {
+ JSONError(w, http.StatusInternalServerError, "Failed to get shopping list", err)
+ return
+ }
+ JSONResponse(w, items)
+}
+
// HandleTasksTab renders the tasks tab content (Trello + Todoist + PlanToEat)
func (h *Handler) HandleTasksTab(w http.ResponseWriter, r *http.Request) {
data, err := h.aggregateData(r.Context(), false)
@@ -186,6 +201,7 @@ func (h *Handler) aggregateData(ctx context.Context, forceRefresh bool) (*models
default:
}
if err := fn(); err != nil {
+ log.Printf("ERROR [%s]: %v", name, err)
mu.Lock()
data.Errors = append(data.Errors, name+": "+err.Error())
mu.Unlock()
@@ -1151,6 +1167,113 @@ func (h *Handler) HandleTabMeals(w http.ResponseWriter, r *http.Request) {
HTMLResponse(w, h.templates, "meals-tab", struct{ Meals []models.Meal }{meals})
}
+// HandleTabShopping renders the Shopping tab (Trello Shopping board + PlanToEat)
+func (h *Handler) HandleTabShopping(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ stores := h.aggregateShoppingLists(ctx)
+ HTMLResponse(w, h.templates, "shopping-tab", struct{ Stores []models.ShoppingStore }{stores})
+}
+
+// aggregateShoppingLists combines Trello and PlanToEat shopping items by store
+func (h *Handler) aggregateShoppingLists(ctx context.Context) []models.ShoppingStore {
+ storeMap := make(map[string]map[string][]models.UnifiedShoppingItem)
+
+ // 1. Fetch Trello "Shopping" board cards
+ boards, err := h.store.GetBoards()
+ if err != nil {
+ log.Printf("ERROR [Shopping/Trello]: %v", err)
+ } else {
+ for _, board := range boards {
+ if strings.EqualFold(board.Name, "Shopping") {
+ log.Printf("DEBUG [Shopping]: Found Shopping board with %d cards", len(board.Cards))
+ for _, card := range board.Cards {
+ storeName := card.ListName
+ if storeName == "" {
+ storeName = "Uncategorized"
+ }
+
+ if storeMap[storeName] == nil {
+ storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
+ }
+
+ item := models.UnifiedShoppingItem{
+ ID: card.ID,
+ Name: card.Name,
+ Store: storeName,
+ Source: "trello",
+ }
+ storeMap[storeName][""] = append(storeMap[storeName][""], item)
+ }
+ }
+ }
+ }
+
+ // 2. Fetch PlanToEat shopping list
+ if h.planToEatClient != nil {
+ items, err := h.planToEatClient.GetShoppingList(ctx)
+ if err != nil {
+ log.Printf("ERROR [Shopping/PlanToEat]: %v", err)
+ } else {
+ log.Printf("DEBUG [Shopping/PlanToEat]: Found %d items", len(items))
+ for _, item := range items {
+ storeName := item.Store
+ if storeName == "" {
+ storeName = "PlanToEat"
+ }
+
+ if storeMap[storeName] == nil {
+ storeMap[storeName] = make(map[string][]models.UnifiedShoppingItem)
+ }
+
+ unified := models.UnifiedShoppingItem{
+ ID: item.ID,
+ Name: item.Name,
+ Quantity: item.Quantity,
+ Category: item.Category,
+ Store: storeName,
+ Source: "plantoeat",
+ Checked: item.Checked,
+ }
+ storeMap[storeName][item.Category] = append(storeMap[storeName][item.Category], unified)
+ }
+ }
+ } else {
+ log.Printf("DEBUG [Shopping/PlanToEat]: Client not configured")
+ }
+
+ // 3. Convert map to sorted slice
+ var stores []models.ShoppingStore
+ for storeName, categories := range storeMap {
+ store := models.ShoppingStore{Name: storeName}
+
+ // Get sorted category names
+ var catNames []string
+ for catName := range categories {
+ catNames = append(catNames, catName)
+ }
+ sort.Strings(catNames)
+
+ for _, catName := range catNames {
+ items := categories[catName]
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Name < items[j].Name
+ })
+ store.Categories = append(store.Categories, models.ShoppingCategory{
+ Name: catName,
+ Items: items,
+ })
+ }
+ stores = append(stores, store)
+ }
+
+ // Sort stores alphabetically
+ sort.Slice(stores, func(i, j int) bool {
+ return stores[i].Name < stores[j].Name
+ })
+
+ return stores
+}
+
// isActionableList returns true if the list name indicates an actionable list
func isActionableList(name string) bool {
lower := strings.ToLower(name)
diff --git a/internal/models/types.go b/internal/models/types.go
index a604b28..f45e346 100644
--- a/internal/models/types.go
+++ b/internal/models/types.go
@@ -26,6 +26,39 @@ type Meal struct {
RecipeURL string `json:"recipe_url"`
}
+// ShoppingItem represents an item on the PlanToEat shopping list
+type ShoppingItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Quantity string `json:"quantity"`
+ Category string `json:"category"`
+ Store string `json:"store"`
+ Checked bool `json:"checked"`
+}
+
+// UnifiedShoppingItem combines Trello cards and PlanToEat items
+type UnifiedShoppingItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Quantity string `json:"quantity,omitempty"`
+ Category string `json:"category,omitempty"`
+ Store string `json:"store"`
+ Source string `json:"source"` // "trello" or "plantoeat"
+ Checked bool `json:"checked"`
+}
+
+// ShoppingStore groups items by store
+type ShoppingStore struct {
+ Name string
+ Categories []ShoppingCategory
+}
+
+// ShoppingCategory groups items within a store
+type ShoppingCategory struct {
+ Name string
+ Items []UnifiedShoppingItem
+}
+
// List represents a Trello list
type List struct {
ID string `json:"id"`
diff --git a/web/templates/index.html b/web/templates/index.html
index 050f0bf..a5a7f38 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -56,6 +56,22 @@
onclick="setActiveTab(this)">
🍽️ Meals
</button>
+ <button
+ class="tab-button {{if eq .ActiveTab "timeline"}}tab-button-active{{end}}"
+ hx-get="/tabs/timeline"
+ hx-target="#tab-content"
+ hx-push-url="?tab=timeline"
+ onclick="setActiveTab(this)">
+ 📅 Timeline
+ </button>
+ <button
+ class="tab-button {{if eq .ActiveTab "shopping"}}tab-button-active{{end}}"
+ hx-get="/tabs/shopping"
+ hx-target="#tab-content"
+ hx-push-url="?tab=shopping"
+ onclick="setActiveTab(this)">
+ 🛒 Shopping
+ </button>
</nav>
</div>
diff --git a/web/templates/partials/shopping-tab.html b/web/templates/partials/shopping-tab.html
new file mode 100644
index 0000000..2362eef
--- /dev/null
+++ b/web/templates/partials/shopping-tab.html
@@ -0,0 +1,42 @@
+{{define "shopping-tab"}}
+<div class="space-y-6 text-shadow-sm"
+ hx-get="/tabs/shopping"
+ hx-trigger="refresh-tasks from:body"
+ hx-target="#tab-content"
+ hx-swap="innerHTML">
+ {{if .Stores}}
+ {{range .Stores}}
+ <section class="bg-black/40 backdrop-blur-sm rounded-xl p-4 sm:p-5">
+ <h2 class="text-xl font-medium mb-4 text-white">{{.Name}}</h2>
+ {{range .Categories}}
+ <div class="mb-4 last:mb-0">
+ {{if .Name}}<h3 class="text-sm text-white/60 mb-2 uppercase tracking-wide">{{.Name}}</h3>{{end}}
+ <ul class="space-y-2">
+ {{range .Items}}
+ <li class="flex items-center gap-3 p-3 bg-black/30 rounded-lg {{if .Checked}}opacity-50{{end}}">
+ <input type="checkbox" {{if .Checked}}checked{{end}}
+ hx-post="/shopping/toggle"
+ hx-vals='{"id":"{{.ID}}","source":"{{.Source}}","checked":{{if .Checked}}false{{else}}true{{end}}}'
+ 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 {{if .Checked}}line-through text-white/40{{else}}text-white{{end}}">{{.Name}}</span>
+ {{if .Quantity}}<span class="text-white/50 text-sm">{{.Quantity}}</span>{{end}}
+ <span class="text-xs px-2 py-0.5 rounded {{if eq .Source "trello"}}bg-blue-900/50 text-blue-300{{else}}bg-green-900/50 text-green-300{{end}}">
+ {{.Source}}
+ </span>
+ </li>
+ {{end}}
+ </ul>
+ </div>
+ {{end}}
+ </section>
+ {{end}}
+ {{else}}
+ <div class="text-center py-16 text-white/50">
+ <p class="text-lg mb-2">No shopping items</p>
+ <p class="text-sm">Add items from PlanToEat or Trello's Shopping board</p>
+ </div>
+ {{end}}
+</div>
+{{end}}