ai cooking
0
fork

Configure Feed

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

Merge pull request #141 from paulgmiller/copilot/finalize-shopping-list-feature

Add finalize feature to save recipes without AI regeneration

authored by

Paul Miller and committed by
GitHub
c1104cc9 f325863f

+89 -35
+13
internal/recipes/buttons_test.go
··· 75 75 t.Error("HTML should contain Regenerate button") 76 76 } 77 77 78 + // Check that "Finalize" button exists 79 + if !strings.Contains(html, `Finalize`) { 80 + t.Error("HTML should contain Finalize button") 81 + } 82 + 83 + // Check for finalize submit button (not a POST form anymore) 84 + if !strings.Contains(html, `name="finalize"`) { 85 + t.Error("HTML should have finalize submit button") 86 + } 87 + if !strings.Contains(html, `value="true"`) { 88 + t.Error("HTML should have finalize value set to true") 89 + } 90 + 78 91 // Check that recipes are present with their titles 79 92 if !strings.Contains(html, "Recipe One") { 80 93 t.Error("HTML should contain Recipe One title")
-1
internal/recipes/generator.go
··· 101 101 instructions += " Enjoyed and saved :" 102 102 }*/ 103 103 for _, saved := range p.Saved { 104 - saved.Saved = true 105 104 // This ended up giving me a "Preference update + replacements requested" recipe 106 105 // instructions += saved.Title + "; " //is this enough or do we keep the exact one? 107 106 shoppingList.Recipes = append(shoppingList.Recipes, saved)
+2
internal/recipes/params.go
··· 127 127 savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean) 128 128 dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean) 129 129 // Load saved recipes from cache by their hashes 130 + // TODO is it overkill to pull full recip in param instead of just hash? 130 131 for _, hash := range savedHashes { 131 132 recipe, err := s.SingleFromCache(ctx, hash) 132 133 if err != nil { 133 134 slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err) 134 135 continue 135 136 } 137 + recipe.Saved = true 136 138 slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash) 137 139 p.Saved = append(p.Saved, *recipe) 138 140 }
+53 -32
internal/recipes/server.go
··· 113 113 } 114 114 115 115 if hashParam := r.URL.Query().Get("h"); hashParam != "" { 116 + //TODO check if generating and spin. 116 117 slist, err := s.FromCache(ctx, hashParam) 117 118 if err != nil { 118 119 slog.ErrorContext(ctx, "failed to load recipe list for hash", "hash", hashParam, "error", err) ··· 127 128 Name: "Unknown Location", 128 129 }, time.Now()) 129 130 } 131 + if r.URL.Query().Get("mail") == "true" { 132 + FormatMail(p, *slist, w) 133 + return 134 + } 130 135 FormatChatHTML(p, *slist, w) 131 - // backfill 132 - go func() { 133 - cutoff := lo.Must(time.Parse(time.DateOnly, "2025-12-22")) 134 - if p.Date.After(cutoff) { 135 - return 136 - } 137 - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 138 - defer cancel() 139 - // nothing we can do on failure anyways. Aleaady logged 140 - _ = s.SaveRecipes(ctx, slist.Recipes, p.Hash()) 141 - }() 142 136 return 143 137 } 144 138 ··· 155 149 return 156 150 } 157 151 158 - for _, last := range currentUser.LastRecipes { 159 - if last.CreatedAt.Before(time.Now().AddDate(0, 0, -14)) { 160 - break 152 + // Handle finalize - save recipes to user profile and display filtered list 153 + if r.URL.Query().Get("finalize") == "true" { 154 + // Check if user is authenticated 155 + if currentUser.ID == "" { 156 + http.Error(w, "must be logged in to finalize recipes", http.StatusUnauthorized) 157 + return 158 + } 159 + 160 + // If no recipes are saved, just return to home 161 + if len(p.Saved) == 0 { 162 + http.Error(w, "no recipes selected to save", http.StatusBadRequest) 163 + return 164 + } 165 + 166 + // Save recipes to user profile 167 + if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil { 168 + slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err) 169 + http.Error(w, "failed to save recipes", http.StatusInternalServerError) 170 + return 171 + } 172 + slog.InfoContext(ctx, "finalized recipes", "user_id", currentUser.ID, "count", len(p.Saved)) 173 + 174 + // Display the saved recipes 175 + shoppingList := &ai.ShoppingList{ 176 + Recipes: p.Saved, 177 + ConversationID: p.ConversationID, 178 + } 179 + 180 + // should finlize go into params to get a different hash that previous one with unsaved? 181 + // or should we shove a guid or iteration in params along with conversation id. Response id? 182 + if err := s.SaveShoppingList(ctx, shoppingList, p); err != nil { 183 + slog.ErrorContext(ctx, "save error", "error", err) 161 184 } 162 - p.LastRecipes = append(p.LastRecipes, last.Title) 185 + http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther) 186 + return 163 187 } 164 188 165 189 hash := p.Hash() 166 - if list, err := s.FromCache(ctx, hash); err == nil { 190 + if _, err := s.FromCache(ctx, hash); err == nil { 167 191 // TODO check not found error explicitly 168 - if r.URL.Query().Get("mail") == "true" { 169 - FormatMail(p, *list, w) 170 - return 192 + http.Redirect(w, r, "/recipes?h="+p.Hash(), http.StatusSeeOther) 193 + return 194 + } 195 + 196 + for _, last := range currentUser.LastRecipes { 197 + if last.CreatedAt.Before(time.Now().AddDate(0, 0, -14)) { 198 + break 171 199 } 172 - FormatChatHTML(p, *list, w) 173 - // backfill 174 - go func() { 175 - cutoff := lo.Must(time.Parse(time.DateOnly, "2025-12-22")) 176 - if p.Date.After(cutoff) { 177 - return 178 - } 179 - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) 180 - defer cancel() 181 - // nothing we can do on failure anyways. Aleaady logged 182 - _ = s.SaveRecipes(ctx, list.Recipes, p.Hash()) 183 - }() 184 - return 200 + p.LastRecipes = append(p.LastRecipes, last.Title) 185 201 } 186 202 187 203 s.wg.Add(1) ··· 213 229 } 214 230 }() 215 231 // TODO should we just redirect to cache page here? 232 + // need to save params first and do spin in hash loop above. 233 + s.Spin(w, r) 234 + } 235 + func (s *server) Spin(w http.ResponseWriter, r *http.Request) { 216 236 w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 237 + ctx := r.Context() 217 238 spinnerData := struct { 218 239 ClarityScript template.HTML 219 240 Style seasons.Style
+21 -2
internal/templates/chat.html
··· 69 69 <p class="mt-1 text-sm text-gray-500">{{.Description}}</p> 70 70 </div> 71 71 <div class="flex flex-wrap items-center gap-3"> 72 - <input type="radio" {{if .Saved}}checked{{end}} name="recipe-{{.ComputeHash}}" value="save" id="save-{{.ComputeHash}}" class="peer/save hidden" onchange="document.getElementById('saved-{{.ComputeHash}}').value = '{{.ComputeHash}}'; document.getElementById('dismissed-{{.ComputeHash}}').value = ''; setRecipeDetailsVisible('{{.ComputeHash}}', false);" /> 72 + <input type="radio" {{if .Saved}}checked{{end}} name="recipe-{{.ComputeHash}}" value="save" id="save-{{.ComputeHash}}" class="peer/save hidden" onchange="document.getElementById('saved-{{.ComputeHash}}').value = '{{.ComputeHash}}'; document.getElementById('dismissed-{{.ComputeHash}}').value = ''; setRecipeDetailsVisible('{{.ComputeHash}}', false); updateFinalizeButton();" /> 73 73 <label for="save-{{.ComputeHash}}" class="inline-flex items-center justify-center rounded-lg border-2 border-emerald-500 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 cursor-pointer transition hover:bg-emerald-100 peer-checked/save:bg-emerald-600 peer-checked/save:text-white peer-checked/save:border-emerald-700"> 74 74 Save 75 75 </label> 76 - <input type="radio" name="recipe-{{.ComputeHash}}" value="dismiss" id="dismiss-{{.ComputeHash}}" class="peer/dismiss hidden" onchange="document.getElementById('dismissed-{{.ComputeHash}}').value = '{{.ComputeHash}}'; document.getElementById('saved-{{.ComputeHash}}').value = ''; setRecipeDetailsVisible('{{.ComputeHash}}', false);" /> 76 + <input type="radio" name="recipe-{{.ComputeHash}}" value="dismiss" id="dismiss-{{.ComputeHash}}" class="peer/dismiss hidden" onchange="document.getElementById('dismissed-{{.ComputeHash}}').value = '{{.ComputeHash}}'; document.getElementById('saved-{{.ComputeHash}}').value = ''; setRecipeDetailsVisible('{{.ComputeHash}}', false); updateFinalizeButton();" /> 77 77 <label for="dismiss-{{.ComputeHash}}" class="inline-flex items-center justify-center rounded-lg border-2 border-red-500 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 cursor-pointer transition hover:bg-red-100 peer-checked/dismiss:bg-red-600 peer-checked/dismiss:text-white peer-checked/dismiss:border-red-700"> 78 78 Dismiss 79 79 </label> ··· 150 150 </form> 151 151 152 152 <div class="mt-10 flex flex-wrap items-center gap-4"> 153 + <button type="submit" form="regenerateForm" name="finalize" value="true" id="finalizeButton" 154 + class="inline-flex items-center justify-center rounded-lg bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2"> 155 + Finalize 156 + </button> 153 157 <button type="button" 154 158 onclick="shareRecipes()" 155 159 class="inline-flex items-center justify-center rounded-lg bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-300 focus:ring-offset-2"> ··· 165 169 </main> 166 170 167 171 <script> 172 + function updateFinalizeButton() { 173 + const form = document.getElementById('regenerateForm'); 174 + const savedInputs = form.querySelectorAll('input[name="saved"]'); 175 + const finalizeButton = document.getElementById('finalizeButton'); 176 + 177 + // Check if any saved inputs have non-empty values 178 + const hasSavedRecipes = Array.from(savedInputs).some(input => input.value.trim() !== ''); 179 + 180 + // Enable/disable the button based on whether recipes are saved 181 + finalizeButton.disabled = !hasSavedRecipes; 182 + } 183 + 184 + // Initialize button state on page load 185 + document.addEventListener('DOMContentLoaded', updateFinalizeButton); 186 + 168 187 function shareRecipes() { 169 188 const hash = "{{.Hash}}"; 170 189 const shareUrl = window.location.origin + "/recipes?h=" + encodeURIComponent(hash);