ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #123 from paulgmiller/shoppinglist

okay have a collapsible shopping list

authored by

Paul Miller and committed by
GitHub
e14990e1 58d8606a

+138
+44
internal/recipes/html.go
··· 8 8 "html/template" 9 9 "io" 10 10 "net/http" 11 + "strings" 11 12 ) 12 13 13 14 // FormatChatHTML renders the raw AI chat (JSON or free-form text) for a location. ··· 20 21 Instructions string 21 22 Hash string 22 23 Recipes []ai.Recipe 24 + ShoppingList []ai.Ingredient 23 25 ConversationID string 24 26 Style seasons.Style 25 27 }{ ··· 29 31 Instructions: p.Instructions, 30 32 Hash: p.Hash(), 31 33 Recipes: l.Recipes, 34 + ShoppingList: shoppingListForDisplay(l.Recipes), 32 35 ConversationID: l.ConversationID, 33 36 Style: seasons.GetCurrentStyle(), 34 37 } ··· 58 61 59 62 return templates.Mail.Execute(writer, data) 60 63 } 64 + 65 + func shoppingListForDisplay(recipes []ai.Recipe) []ai.Ingredient { 66 + if len(recipes) <= 1 { 67 + return nil 68 + } 69 + items := make(map[string]*ai.Ingredient) 70 + order := make([]string, 0) 71 + 72 + for _, recipe := range recipes { 73 + for _, ingredient := range recipe.Ingredients { 74 + name := strings.ToLower(strings.TrimSpace(ingredient.Name)) 75 + if name == "" { 76 + continue 77 + } 78 + existing, ok := items[name] 79 + if !ok { 80 + items[name] = &ai.Ingredient{ 81 + Name: ingredient.Name, 82 + Quantity: strings.TrimSpace(ingredient.Quantity), 83 + } 84 + order = append(order, name) 85 + continue 86 + } 87 + qty := strings.TrimSpace(ingredient.Quantity) 88 + if qty == "" { 89 + continue 90 + } 91 + if existing.Quantity == "" { 92 + existing.Quantity = qty 93 + continue 94 + } 95 + existing.Quantity = existing.Quantity + ", " + qty 96 + } 97 + } 98 + 99 + combined := make([]ai.Ingredient, 0, len(order)) 100 + for _, name := range order { 101 + combined = append(combined, *items[name]) 102 + } 103 + return combined 104 + }
+58
internal/recipes/shopping_list_test.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/ai" 5 + "reflect" 6 + "testing" 7 + ) 8 + 9 + func TestShoppingListForDisplay(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + recipes []ai.Recipe 13 + want []ai.Ingredient 14 + }{ 15 + { 16 + name: "single recipe returns nil", 17 + recipes: []ai.Recipe{ 18 + {Title: "Solo"}, 19 + }, 20 + want: nil, 21 + }, 22 + { 23 + name: "combines quantities and preserves first-seen order", 24 + recipes: []ai.Recipe{ 25 + { 26 + Title: "First", 27 + Ingredients: []ai.Ingredient{ 28 + {Name: "Onion", Quantity: "1"}, 29 + {Name: "Garlic", Quantity: ""}, 30 + }, 31 + }, 32 + { 33 + Title: "Second", 34 + Ingredients: []ai.Ingredient{ 35 + {Name: "onion", Quantity: "2"}, 36 + {Name: "garlic", Quantity: "3 cloves"}, 37 + {Name: "Basil", Quantity: " "}, 38 + {Name: " ", Quantity: "1"}, 39 + }, 40 + }, 41 + }, 42 + want: []ai.Ingredient{ 43 + {Name: "Onion", Quantity: "1, 2"}, 44 + {Name: "Garlic", Quantity: "3 cloves"}, 45 + {Name: "Basil", Quantity: ""}, 46 + }, 47 + }, 48 + } 49 + 50 + for _, tc := range tests { 51 + t.Run(tc.name, func(t *testing.T) { 52 + got := shoppingListForDisplay(tc.recipes) 53 + if !reflect.DeepEqual(got, tc.want) { 54 + t.Fatalf("shoppingListForDisplay() = %#v, want %#v", got, tc.want) 55 + } 56 + }) 57 + } 58 + }
+36
internal/templates/chat.html
··· 111 111 </section> 112 112 </article> 113 113 {{end}} 114 + 115 + {{if .ShoppingList}} 116 + <section class="rounded-2xl border border-brand-100 bg-white/95 p-6 shadow-md"> 117 + <div class="flex flex-wrap items-center justify-between gap-3"> 118 + <h2 class="text-lg font-semibold text-brand-700">Shopping list</h2> 119 + <button type="button" 120 + id="shoppingListToggle" 121 + aria-controls="shoppingListPanel" 122 + aria-expanded="false" 123 + class="inline-flex items-center justify-center rounded-lg border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700 shadow-sm transition hover:bg-brand-100 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 124 + Show 125 + </button> 126 + </div> 127 + <div id="shoppingListPanel" class="mt-4 hidden"> 128 + <ul class="space-y-2 text-gray-700"> 129 + {{range .ShoppingList}} 130 + <li class="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-brand-50 px-3 py-2 text-sm"> 131 + <span class="font-medium text-brand-700">{{.Name}}</span> 132 + <span class="text-gray-600">{{.Quantity}}</span> 133 + </li> 134 + {{end}} 135 + </ul> 136 + </div> 137 + </section> 138 + {{end}} 114 139 </div> 115 140 </form> 116 141 ··· 142 167 }, 2000); 143 168 }).catch(function (err) { 144 169 alert('Failed to copy link: ' + err); 170 + }); 171 + } 172 + 173 + const shoppingListToggle = document.getElementById('shoppingListToggle'); 174 + const shoppingListPanel = document.getElementById('shoppingListPanel'); 175 + if (shoppingListToggle && shoppingListPanel) { 176 + shoppingListToggle.addEventListener('click', function () { 177 + shoppingListPanel.classList.toggle('hidden'); 178 + const isOpen = !shoppingListPanel.classList.contains('hidden'); 179 + shoppingListToggle.textContent = isOpen ? 'Hide' : 'Show'; 180 + shoppingListToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 145 181 }); 146 182 } 147 183 </script>