ai cooking
0
fork

Configure Feed

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

htmx for save/dismiss/regenerate and finalize (#293)

* htmx save

* try and get rid of finalize and regenerate javaascript

* okay goodbye javascipt

* working but ui off

* seems to be working

* forgot selction.go

* no more parsing from query args for saved and dismissed

* trim down selection

* try and simplify down

* comment

* fix up tests

* kitchen and also generator changes

* not sure I made it better or worse still need to think about how selection is merged

* maybe works

* I think maybe we have it

* slight logging change

* this is great

---------

Co-authored-by: paul miller <paul.miller>

authored by

Paul Miller
paul miller
and committed by
GitHub
eedeed57 0286a150

+1205 -332
+51 -19
cmd/careme/web_e2e_test.go
··· 41 41 _, recipesBody := followUntilRecipes(t, client, initialRecipesURL, true /*expectSpinner*/) 42 42 43 43 // Step 3: select one recipe to save and two to dismiss. 44 - conversationID := extractHiddenValue(t, recipesBody, "conversation_id") 45 - date := extractHiddenValue(t, recipesBody, "date") 46 - location := extractHiddenValue(t, recipesBody, "location") 44 + recipesHash := extractRecipesHash(t, recipesBody) 47 45 recipeHashes := extractRecipeHashes(t, recipesBody) 48 46 if len(recipeHashes) < 3 { 49 47 t.Fatalf("expected at least 3 recipes, got %d", len(recipeHashes)) ··· 54 52 _ = mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)) 55 53 dismissedHashes := recipeHashes[1:3] 56 54 57 - //step 4 todo regenrate again with commentary then save two more 55 + // Step 4: persist selection immediately via HTMX. 56 + saveURL := srv.URL + "/recipe/" + url.PathEscape(savedHash) + "/save" 57 + _ = mustPostFormBodyHTMX(t, client, saveURL, url.Values{"h": {recipesHash}}) 58 + for _, dismissed := range dismissedHashes { 59 + dismissURL := srv.URL + "/recipe/" + url.PathEscape(dismissed) + "/dismiss" 60 + _ = mustPostFormBodyHTMX(t, client, dismissURL, url.Values{"h": {recipesHash}}) 61 + } 58 62 59 - // Step 5: finalize with the saved/dismissed selections. 60 - finalizeURL := buildRecipesURL(srv.URL, location, date, conversationID, savedHash, dismissedHashes, true) 61 - _, finalizedBody := followUntilRecipes(t, client, finalizeURL, false /*expectSpinner*/) 63 + // Step 5: finalize using server-side selection. 64 + finalizeURL := srv.URL + "/recipes/" + url.PathEscape(recipesHash) + "/finalize" 65 + finalizeRedirect := mustPostFormRedirectHTMX(t, client, finalizeURL, url.Values{}) 66 + if !strings.HasPrefix(finalizeRedirect, "/recipes?") { 67 + t.Fatalf("expected finalize redirect to /recipes, got %q", finalizeRedirect) 68 + } 69 + _, finalizedBody := followUntilRecipes(t, client, srv.URL+finalizeRedirect, false /*expectSpinner*/) 62 70 recipeHashes = extractRecipeHashes(t, finalizedBody) 63 71 if len(recipeHashes) != 1 { 64 72 t.Fatalf("expected finalized page to show 1 recipe, got %d", len(recipeHashes)) ··· 75 83 76 84 // Step 6: ask a question on the finalized single recipe page. 77 85 question := "Can I use skirt steak instead?" 86 + conversationID := extractHiddenValue(t, mustGetBody(t, client, srv.URL+"/recipe/"+url.PathEscape(savedHash)), "conversation_id") 78 87 questionURL := srv.URL + "/recipe/" + url.PathEscape(savedHash) + "/question" 79 88 questionBody := mustPostFormBodyHTMX(t, client, questionURL, url.Values{ 80 89 "conversation_id": {conversationID}, ··· 242 251 return readAll(t, resp.Body) 243 252 } 244 253 254 + func mustPostFormRedirectHTMX(t *testing.T, client *http.Client, targetURL string, data url.Values) string { 255 + t.Helper() 256 + req, err := http.NewRequest(http.MethodPost, targetURL, strings.NewReader(data.Encode())) 257 + if err != nil { 258 + t.Fatalf("POST %s failed to build request: %v", targetURL, err) 259 + } 260 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 261 + req.Header.Set("HX-Request", "true") 262 + resp, err := client.Do(req) 263 + if err != nil { 264 + t.Fatalf("POST %s failed: %v", targetURL, err) 265 + } 266 + defer func() { 267 + if err := resp.Body.Close(); err != nil { 268 + t.Fatalf("failed to close response body: %v", err) 269 + } 270 + }() 271 + if resp.StatusCode != http.StatusOK { 272 + body := readAll(t, resp.Body) 273 + t.Fatalf("POST %s expected 200, got %d: %s", targetURL, resp.StatusCode, body) 274 + } 275 + redirect := resp.Header.Get("HX-Redirect") 276 + if redirect == "" { 277 + t.Fatalf("POST %s expected HX-Redirect header", targetURL) 278 + } 279 + return redirect 280 + } 281 + 245 282 func followUntilRecipes(t *testing.T, client *http.Client, startURL string, expectSpinner bool) (string, string) { 246 283 t.Helper() 247 284 deadline := time.Now().Add(10 * time.Second) ··· 333 370 return strings.TrimSpace(match[1]) 334 371 } 335 372 336 - func buildRecipesURL(base, location, date, conversationID, savedHash string, dismissedHashes []string, finalize bool) string { 337 - params := url.Values{} 338 - params.Set("location", location) 339 - params.Set("date", date) 340 - params.Set("conversation_id", conversationID) 341 - params.Add("saved", savedHash) 342 - for _, hash := range dismissedHashes { 343 - params.Add("dismissed", hash) 373 + func extractRecipesHash(t *testing.T, body string) string { 374 + t.Helper() 375 + re := regexp.MustCompile(`/recipes/([^"/?]+)/regenerate`) 376 + match := re.FindStringSubmatch(body) 377 + if len(match) < 2 { 378 + t.Fatalf("expected recipes page to include regenerate form action hash") 344 379 } 345 - if finalize { 346 - params.Set("finalize", "true") 347 - } 348 - return base + "/recipes?" + params.Encode() 380 + return match[1] 349 381 } 350 382 351 383 func readAll(t *testing.T, r io.Reader) string {
+1
docs/cache-layout.md
··· 14 14 | `ingredients/` | JSON `[]kroger.Ingredient` keyed by location hash | `internal/recipes/io.go` (`SaveIngredients`) via `internal/recipes/generator.go` (`GetStaples`) | `internal/recipes/io.go` (`IngredientsFromCache`) via `internal/recipes/generator.go` (`GetStaples`) | 15 15 | `params/` | JSON `generatorParams` keyed by shopping hash | `internal/recipes/io.go` (`SaveParams`) | `internal/recipes/io.go` (`ParamsFromCache`) | 16 16 | `recipe/` | JSON `ai.Recipe` (one recipe per hash) | `internal/recipes/io.go` (`SaveRecipes`) | `internal/recipes/io.go` (`SingleFromCache`) | 17 + | `recipe_selection/` | JSON `recipeSelection` (`saved_hashes`, `dismissed_hashes`, `updated_at`) keyed by `<user_id>/<origin_hash>` | `internal/recipes/selection.go` (`saveRecipeSelection`) via `internal/recipes/server.go` (`handleSaveRecipe`, `handleDismissRecipe`) | `internal/recipes/selection.go` (`loadRecipeSelection`) via `internal/recipes/server.go` (`handleRegenerate`, `handleFinalize`, `handleRecipes`) | 17 18 | `recipe_thread/` | JSON `[]RecipeThreadEntry` (Q/A thread for a recipe hash) | `internal/recipes/thread.go` (`SaveThread`) | `internal/recipes/thread.go` (`ThreadFromCache`) | 18 19 | `recipe_feedback/` | JSON `RecipeFeedback` (`cooked`, `stars`, `comment`, `updated_at`) per recipe hash | `internal/recipes/feedback.go` (`SaveFeedback`) via `internal/recipes/server.go` (`handleFeedback`) | `internal/recipes/feedback.go` (`FeedbackFromCache`) and `internal/recipes/server.go` (`handleSingle`, `handleFeedback`) | 19 20 | `users/` | JSON `users/types.User` by user ID | `internal/users/storage.go` (`Update`) | `internal/users/storage.go` (`GetByID`, `List`) |
+17 -13
internal/recipes/buttons_test.go
··· 46 46 // Verify HTML is valid 47 47 isValidHTML(t, html) 48 48 49 - // Check for Save and Dismiss radio buttons and labels 50 - if !strings.Contains(html, `name="saved"`) { 51 - t.Error("HTML should contain saved hidden inputs") 52 - } 53 - if !strings.Contains(html, `name="dismissed"`) { 54 - t.Error("HTML should contain dismissed hidden inputs") 55 - } 56 - 57 49 // Check for radio buttons 58 50 if !strings.Contains(html, `type="radio"`) { 59 51 t.Error("HTML should contain radio button inputs") 52 + } 53 + if !strings.Contains(html, `hx-post="/recipe/`) || !strings.Contains(html, `/save"`) { 54 + t.Error("HTML should contain HTMX save action") 55 + } 56 + if !strings.Contains(html, `hx-post="/recipe/`) || !strings.Contains(html, `/dismiss"`) { 57 + t.Error("HTML should contain HTMX dismiss action") 58 + } 59 + if !strings.Contains(html, `hx-trigger="click"`) { 60 + t.Error("HTML should trigger HTMX requests on click") 60 61 } 61 62 62 63 // Check for Save and Dismiss labels (without span tags) ··· 80 81 t.Error("HTML should contain Finalize button") 81 82 } 82 83 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") 84 + // Check for finalize HTMX button 85 + if !strings.Contains(html, `hx-post="/recipes/`) || !strings.Contains(html, `/finalize"`) { 86 + t.Error("HTML should submit finalize with HTMX POST") 87 + } 88 + if !strings.Contains(html, `/recipes/`) || !strings.Contains(html, `/regenerate"`) { 89 + t.Error("HTML should submit regenerate with POST endpoint") 86 90 } 87 - if !strings.Contains(html, `value="true"`) { 88 - t.Error("HTML should have finalize value set to true") 91 + if !strings.Contains(html, `hx-params="instructions"`) { 92 + t.Error("HTML regenerate form should submit only instructions") 89 93 } 90 94 91 95 // Check that recipes are present with their titles
+3 -13
internal/recipes/generator.go
··· 14 14 "net/http" 15 15 "slices" 16 16 "strconv" 17 - "strings" 18 17 "sync" 19 18 "time" 20 19 ··· 61 60 slog.InfoContext(ctx, "Regenerating recipes for location", "location", p.String(), "conversation_id", p.ConversationID) 62 61 // these should both always be true. Warn if not because its a caching bug? 63 62 instructions := []string{p.Instructions} 64 - var dismissedTitles []string 65 63 for _, dismissed := range p.Dismissed { 66 - dismissedTitles = append(dismissedTitles, dismissed.Title) 67 - } 68 - if len(p.Dismissed) > 0 { 69 - instructions = append(instructions, "Did not like "+strings.Join(dismissedTitles, "; ")) 64 + instructions = append(instructions, "Passed on "+dismissed.Title) 70 65 } 71 - // TODO pipe through dismissed and saved so we dont mess with instructions. Also format dismissed titles with toon? 66 + 72 67 shoppingList, err := g.aiClient.Regenerate(ctx, instructions, p.ConversationID) 73 68 if err != nil { 74 69 return nil, fmt.Errorf("failed to regenerate recipes with AI: %w", err) 75 70 } 71 + // want to add saved to insructions but only once. TODO 76 72 // Include saved recipes in the shopping list 77 - 78 - /*if len(p.Saved) > 0 { 79 - instructions += " Enjoyed and saved :" 80 - }*/ 81 - // This ended up giving me a "Preference update + replacements requested" recipe 82 - // instructions += saved.Title + "; " //is this enough or do we keep the exact one? 83 73 shoppingList.Recipes = append(shoppingList.Recipes, p.Saved...) 84 74 85 75 slog.InfoContext(ctx, "regenerated chat", "location", p.String(), "duration", time.Since(start), "hash", hash)
+12 -1
internal/recipes/html.go
··· 14 14 15 15 // FormatShoppingListHTML renders the multi-recipe shopping list view. 16 16 func FormatShoppingListHTML(p *generatorParams, l ai.ShoppingList, signedIn bool, writer http.ResponseWriter) { 17 + FormatShoppingListHTMLForHash(p, l, signedIn, p.Hash(), writer) 18 + } 19 + 20 + // FormatShoppingListHTMLForHash renders the multi-recipe shopping list view for a specific hash. 21 + func FormatShoppingListHTMLForHash(p *generatorParams, l ai.ShoppingList, signedIn bool, hash string, writer http.ResponseWriter) { 17 22 // TODO just put params into shopping list and pass that up? 23 + dismissedHashes := make(map[string]bool, len(p.Dismissed)) 24 + for _, recipe := range p.Dismissed { 25 + dismissedHashes[recipe.ComputeHash()] = true 26 + } 18 27 data := struct { 19 28 Location locations.Location 20 29 Date string ··· 25 34 Recipes []ai.Recipe 26 35 ShoppingList []ai.Ingredient 27 36 ConversationID string 37 + DismissedHashes map[string]bool 28 38 Style seasons.Style 29 39 ServerSignedIn bool 30 40 }{ ··· 33 43 ClarityScript: templates.ClarityScript(), 34 44 GoogleTagScript: templates.GoogleTagScript(), 35 45 Instructions: p.Instructions, 36 - Hash: p.Hash(), 46 + Hash: hash, 37 47 Recipes: l.Recipes, 38 48 ShoppingList: shoppingListForDisplay(l.Recipes), 39 49 ConversationID: l.ConversationID, 50 + DismissedHashes: dismissedHashes, 40 51 Style: seasons.GetCurrentStyle(), 41 52 ServerSignedIn: signedIn, 42 53 }
+3
internal/recipes/html_test.go
··· 70 70 if !strings.Contains(html, "Estimated cost:") { 71 71 t.Error("shopping list HTML should contain estimated cost") 72 72 } 73 + if !strings.Contains(html, `/static/htmx@2.0.8.js`) { 74 + t.Error("shopping list HTML should include htmx script") 75 + } 73 76 } 74 77 75 78 func TestFormatMail_ValidHTML(t *testing.T) {
+2 -2
internal/recipes/io.go
··· 119 119 120 120 // exported for backfilling 121 121 func (rio recipeio) SaveRecipes(ctx context.Context, recipes []ai.Recipe, originHash string) error { 122 - // Save each recipe separately by its hash 122 + // Save each recipe separately by its hash (could skip ones that are saved?) 123 123 var errs []error 124 124 for i := range recipes { 125 125 recipe := &recipes[i] 126 126 recipe.OriginHash = originHash 127 127 hash := recipe.ComputeHash() 128 128 129 - slog.InfoContext(ctx, "storing recipe", "title", recipe.Title, "hash", hash) 130 129 recipeJSON := lo.Must(json.Marshal(recipe)) 131 130 if err := rio.Cache.Put(ctx, recipeCachePrefix+hash, string(recipeJSON), cache.IfNoneMatch()); err != nil { 132 131 if errors.Is(err, cache.ErrAlreadyExists) { ··· 135 134 slog.ErrorContext(ctx, "failed to cache individual recipe", "recipe", recipe.Title, "error", err) 136 135 errs = append(errs, fmt.Errorf("error saving %s, %w", hash, err)) 137 136 } 137 + slog.InfoContext(ctx, "stored recipe", "title", recipe.Title, "hash", hash) 138 138 } 139 139 return errors.Join(errs...) 140 140 }
+4 -37
internal/recipes/params.go
··· 37 37 Directive string `json:"directive,omitempty"` // this is the new one that will be used. Can remove GenerationPrompt after a while. 38 38 LastRecipes []string `json:"last_recipes,omitempty"` 39 39 // UserID string `json:"user_id,omitempty"` 40 - ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? 41 - Saved []ai.Recipe `json:"saved_recipes,omitempty"` 42 - Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` 40 + ConversationID string `json:"conversation_id,omitempty"` // Can remove if we pass it in separately to generate recipes? 41 + //TODO Both should just be title and hash insread of full ai.Recipe 42 + Saved []ai.Recipe `json:"saved_recipes,omitempty"` 43 + Dismissed []ai.Recipe `json:"dismissed_recipes,omitempty"` 43 44 } 44 45 45 46 func DefaultParams(l *locations.Location, date time.Time) *generatorParams { ··· 125 126 126 127 p := DefaultParams(l, date) 127 128 p.Instructions = r.URL.Query().Get("instructions") 128 - 129 - // Handle saved and dismissed recipe hashes from checkboxes 130 - // Query().Get returns first value, Query() returns all values 131 - // will be empty values for every recipe and two for ones with no action 132 - // TODO look at way not to duplicate so many query arguments and pass down just a saved list or a query arg for each saved item. 133 - clean := func(s string, _ int) (string, bool) { 134 - ts := strings.TrimSpace(s) 135 - return ts, ts != "" 136 - } 137 - savedHashes := lo.FilterMap(r.URL.Query()["saved"], clean) 138 - dismissedHashes := lo.FilterMap(r.URL.Query()["dismissed"], clean) 139 - // Load saved recipes from cache by their hashes 140 - // TODO is it overkill to pull full recip in param instead of just hash? 141 - for _, hash := range savedHashes { 142 - recipe, err := s.SingleFromCache(ctx, hash) 143 - if err != nil { 144 - slog.ErrorContext(ctx, "failed to load saved recipe by hash", "hash", hash, "error", err) 145 - continue 146 - } 147 - recipe.Saved = true 148 - slog.InfoContext(ctx, "adding saved recipe to params", "title", recipe.Title, "hash", hash) 149 - p.Saved = append(p.Saved, *recipe) 150 - } 151 - 152 - // Add dismissed recipe titles to instructions so AI knows what to avoid 153 - for _, hash := range dismissedHashes { 154 - recipe, err := s.SingleFromCache(ctx, hash) 155 - if err != nil { 156 - slog.ErrorContext(ctx, "failed to load dismissed recipe by hash", "hash", hash, "error", err) 157 - continue 158 - } 159 - slog.InfoContext(ctx, "adding dismissed recipe to params", "title", recipe.Title, "hash", hash) 160 - p.Dismissed = append(p.Dismissed, *recipe) 161 - } 162 129 // should this be in hash? 163 130 p.ConversationID = strings.TrimSpace(r.URL.Query().Get("conversation_id")) 164 131
+152
internal/recipes/selection.go
··· 1 + package recipes 2 + 3 + import ( 4 + "careme/internal/ai" 5 + "careme/internal/cache" 6 + "context" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "strings" 11 + "time" 12 + 13 + "github.com/samber/lo" 14 + ) 15 + 16 + const recipeSelectionCachePrefix = "recipe_selection/" 17 + 18 + // recipeSelection tracks which recipes have been saved and dismisse by a user between regeneration/finalization. 19 + // After that they are merged back into params. 20 + type recipeSelection struct { 21 + SavedHashes []string `json:"saved_hashes,omitempty"` 22 + DismissedHashes []string `json:"dismissed_hashes,omitempty"` 23 + UpdatedAt time.Time `json:"updated_at,omitempty"` 24 + } 25 + 26 + func (s *recipeSelection) markSaved(recipeHash string) { 27 + hash := strings.TrimSpace(recipeHash) 28 + if hash == "" { 29 + return 30 + } 31 + s.SavedHashes = lo.Uniq(append(s.SavedHashes, hash)) 32 + s.DismissedHashes = lo.Filter(s.DismissedHashes, func(v string, _ int) bool { return v != hash }) 33 + } 34 + 35 + func (s *recipeSelection) Empty() bool { 36 + return len(s.SavedHashes) == 0 && len(s.DismissedHashes) == 0 37 + } 38 + 39 + func (s *recipeSelection) markDismissed(recipeHash string) { 40 + hash := strings.TrimSpace(recipeHash) 41 + if hash == "" { 42 + return 43 + } 44 + s.DismissedHashes = lo.Uniq(append(s.DismissedHashes, hash)) 45 + s.SavedHashes = lo.Filter(s.SavedHashes, func(v string, _ int) bool { return v != hash }) 46 + } 47 + 48 + func recipeSelectionKey(userID, originHash string) string { 49 + return fmt.Sprintf("%s%s/%s", recipeSelectionCachePrefix, strings.TrimSpace(userID), strings.TrimSpace(originHash)) 50 + } 51 + 52 + // this should die off eventually. 53 + func recipeSelectionFromParams(p *generatorParams) recipeSelection { 54 + if p == nil { 55 + return recipeSelection{} 56 + } 57 + selection := recipeSelection{ 58 + SavedHashes: make([]string, 0, len(p.Saved)), 59 + DismissedHashes: make([]string, 0, len(p.Dismissed)), 60 + } 61 + for _, r := range p.Saved { 62 + selection.SavedHashes = append(selection.SavedHashes, r.ComputeHash()) 63 + } 64 + for _, r := range p.Dismissed { 65 + selection.DismissedHashes = append(selection.DismissedHashes, r.ComputeHash()) 66 + } 67 + return selection 68 + } 69 + 70 + func (s *server) loadRecipeSelection(ctx context.Context, userID, originHash string) (recipeSelection, error) { 71 + reader, err := s.Cache.Get(ctx, recipeSelectionKey(userID, originHash)) 72 + if err != nil { 73 + if errors.Is(err, cache.ErrNotFound) { 74 + return recipeSelection{}, nil 75 + } 76 + return recipeSelection{}, err 77 + } 78 + defer func() { 79 + _ = reader.Close() 80 + }() 81 + 82 + var selection recipeSelection 83 + if err := json.NewDecoder(reader).Decode(&selection); err != nil { 84 + return recipeSelection{}, fmt.Errorf("failed to decode recipe selection: %w", err) 85 + } 86 + return selection, nil 87 + } 88 + 89 + func (s *server) saveRecipeSelection(ctx context.Context, userID, originHash string, selection recipeSelection) error { 90 + selection.UpdatedAt = time.Now() 91 + body, err := json.Marshal(selection) 92 + if err != nil { 93 + return fmt.Errorf("failed to marshal recipe selection: %w", err) 94 + } 95 + //good place for etags :) 96 + if err := s.Cache.Put(ctx, recipeSelectionKey(userID, originHash), string(body), cache.Unconditional()); err != nil { 97 + return fmt.Errorf("failed to save recipe selection: %w", err) 98 + } 99 + return nil 100 + } 101 + 102 + func (s *server) selectionRecipes(ctx context.Context, hashes []string, current []ai.Recipe) []ai.Recipe { 103 + if len(hashes) == 0 { 104 + return nil 105 + } 106 + currentByHash := make(map[string]ai.Recipe, len(current)) 107 + for _, recipe := range current { 108 + currentByHash[recipe.ComputeHash()] = recipe 109 + } 110 + 111 + recipes := make([]ai.Recipe, 0, len(hashes)) 112 + for _, hash := range hashes { 113 + if recipe, ok := currentByHash[hash]; ok { 114 + recipes = append(recipes, recipe) 115 + continue 116 + } 117 + recipe, err := s.SingleFromCache(ctx, hash) 118 + if err != nil { 119 + continue 120 + } 121 + recipes = append(recipes, *recipe) 122 + } 123 + return recipes 124 + } 125 + 126 + func (s *server) mergeParamsWithSelection(ctx context.Context, p *generatorParams, selection recipeSelection, current []ai.Recipe) { 127 + if p == nil { 128 + return 129 + } 130 + 131 + merged := recipeSelectionFromParams(p) 132 + for _, hash := range selection.SavedHashes { 133 + merged.markSaved(hash) 134 + } 135 + for _, hash := range selection.DismissedHashes { 136 + merged.markDismissed(hash) 137 + } 138 + 139 + p.Saved = s.selectionRecipes(ctx, merged.SavedHashes, current) 140 + p.Dismissed = s.selectionRecipes(ctx, merged.DismissedHashes, current) 141 + } 142 + 143 + func applySavedToRecipes(recipes []ai.Recipe, p *generatorParams) { 144 + saved := make(map[string]struct{}, len(p.Saved)) 145 + for _, r := range p.Saved { 146 + saved[r.ComputeHash()] = struct{}{} 147 + } 148 + for i := range recipes { 149 + hash := recipes[i].ComputeHash() 150 + _, recipes[i].Saved = saved[hash] 151 + } 152 + }
+322 -86
internal/recipes/server.go
··· 27 27 "github.com/samber/lo" 28 28 ) 29 29 30 + func setTextContent(w http.ResponseWriter) { 31 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 32 + } 33 + 30 34 type locServer interface { 31 35 GetLocationByID(ctx context.Context, locationID string) (*locations.Location, error) 32 36 } ··· 64 68 65 69 func (s *server) Register(mux *http.ServeMux) { 66 70 mux.HandleFunc("GET /recipes", s.handleRecipes) 71 + mux.HandleFunc("POST /recipes/{hash}/regenerate", s.handleRegenerate) 72 + mux.HandleFunc("POST /recipes/{hash}/finalize", s.handleFinalize) 67 73 mux.HandleFunc("GET /recipe/{hash}", s.handleSingle) 68 74 mux.HandleFunc("POST /recipe/{hash}/question", s.handleQuestion) 69 75 mux.HandleFunc("POST /recipe/{hash}/feedback", s.handleFeedback) 76 + mux.HandleFunc("POST /recipe/{hash}/save", s.handleSaveRecipe) 77 + mux.HandleFunc("POST /recipe/{hash}/dismiss", s.handleDismissRecipe) 70 78 //maybe this should be under locations server? 71 79 mux.HandleFunc("GET /ingredients/{location}", s.ingredients) 72 80 ··· 281 289 return 282 290 } 283 291 284 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 292 + setTextContent(w) 285 293 _, err = fmt.Fprint(w, `<span class="inline-flex items-center gap-1 text-sm font-medium text-green-700"><span aria-hidden="true">✓</span>Saved</span>`) 286 294 if err != nil { 287 295 slog.ErrorContext(ctx, "failed to write feedback response", "hash", hash, "error", err) ··· 289 297 } 290 298 } 291 299 300 + func (s *server) handleSaveRecipe(w http.ResponseWriter, r *http.Request) { 301 + ctx := r.Context() 302 + if !isHTMXRequest(r) { 303 + http.Error(w, "htmx request required", http.StatusBadRequest) 304 + return 305 + } 306 + recipeHash := strings.TrimSpace(r.PathValue("hash")) 307 + if recipeHash == "" { 308 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 309 + return 310 + } 311 + 312 + currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 313 + if err != nil { 314 + if errors.Is(err, auth.ErrNoSession) { 315 + w.Header().Set("HX-Redirect", "/sign-in") 316 + http.Error(w, "must be logged in to save recipes", http.StatusUnauthorized) 317 + return 318 + } 319 + slog.ErrorContext(ctx, "failed to load user for recipe save", "error", err) 320 + http.Error(w, "unable to load account", http.StatusInternalServerError) 321 + return 322 + } 323 + if err := r.ParseForm(); err != nil { 324 + http.Error(w, "invalid form", http.StatusBadRequest) 325 + return 326 + } 327 + 328 + selectionHash := strings.TrimSpace(r.FormValue(queryArgHash)) 329 + if selectionHash == "" { 330 + http.Error(w, "recipe list hash not found", http.StatusBadRequest) 331 + return 332 + } 333 + selection, err := s.loadRecipeSelection(ctx, currentUser.ID, selectionHash) 334 + if err != nil { 335 + slog.ErrorContext(ctx, "failed to load recipe selection for save", "user_id", currentUser.ID, "selection_hash", selectionHash, "error", err) 336 + http.Error(w, "failed to save recipe", http.StatusInternalServerError) 337 + return 338 + } 339 + selection.markSaved(recipeHash) 340 + if err := s.saveRecipeSelection(ctx, currentUser.ID, selectionHash, selection); err != nil { 341 + slog.ErrorContext(ctx, "failed to save recipe selection", "user_id", currentUser.ID, "selection_hash", selectionHash, "error", err) 342 + http.Error(w, "failed to save recipe", http.StatusInternalServerError) 343 + return 344 + } 345 + 346 + //could pass this in with htmx instead of loading title 347 + recipe, err := s.SingleFromCache(ctx, recipeHash) 348 + if err != nil { 349 + if errors.Is(err, cache.ErrNotFound) { 350 + http.Error(w, "recipe not found", http.StatusNotFound) 351 + return 352 + } 353 + slog.ErrorContext(ctx, "failed to load recipe for profile save", "hash", recipeHash, "error", err) 354 + http.Error(w, "failed to load recipe", http.StatusInternalServerError) 355 + return 356 + } 357 + 358 + if err := s.saveRecipesToUserProfile(ctx, currentUser, *recipe); err != nil { 359 + slog.ErrorContext(ctx, "failed to save recipe to user profile", "user_id", currentUser.ID, "hash", recipeHash, "error", err) 360 + http.Error(w, "failed to save recipe", http.StatusInternalServerError) 361 + return 362 + } 363 + 364 + setTextContent(w) 365 + _, err = fmt.Fprint(w, `<span class="text-xs font-medium text-action-green-700">Saved to kitchen</span>`) 366 + if err != nil { 367 + slog.ErrorContext(ctx, "failed to write save response", "hash", recipeHash, "error", err) 368 + http.Error(w, "failed to write response", http.StatusInternalServerError) 369 + } 370 + } 371 + 372 + func (s *server) handleDismissRecipe(w http.ResponseWriter, r *http.Request) { 373 + ctx := r.Context() 374 + if !isHTMXRequest(r) { 375 + http.Error(w, "htmx request required", http.StatusBadRequest) 376 + return 377 + } 378 + recipeHash := strings.TrimSpace(r.PathValue("hash")) 379 + if recipeHash == "" { 380 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 381 + return 382 + } 383 + 384 + currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 385 + if err != nil { 386 + if errors.Is(err, auth.ErrNoSession) { 387 + w.Header().Set("HX-Redirect", "/sign-in") 388 + http.Error(w, "must be logged in to dismiss recipes", http.StatusUnauthorized) 389 + return 390 + } 391 + slog.ErrorContext(ctx, "failed to load user for recipe dismiss", "error", err) 392 + http.Error(w, "unable to load account", http.StatusInternalServerError) 393 + return 394 + } 395 + if err := r.ParseForm(); err != nil { 396 + http.Error(w, "invalid form", http.StatusBadRequest) 397 + return 398 + } 399 + 400 + selectionHash := strings.TrimSpace(r.FormValue(queryArgHash)) 401 + if selectionHash == "" { 402 + http.Error(w, "recipe list hash not found", http.StatusBadRequest) 403 + return 404 + } 405 + selection, err := s.loadRecipeSelection(ctx, currentUser.ID, selectionHash) 406 + if err != nil { 407 + slog.ErrorContext(ctx, "failed to load recipe selection for dismiss", "user_id", currentUser.ID, "selection_hash", selectionHash, "error", err) 408 + http.Error(w, "failed to dismiss recipe", http.StatusInternalServerError) 409 + return 410 + } 411 + selection.markDismissed(recipeHash) 412 + if err := s.saveRecipeSelection(ctx, currentUser.ID, selectionHash, selection); err != nil { 413 + slog.ErrorContext(ctx, "failed to save recipe selection for dismiss", "user_id", currentUser.ID, "selection_hash", selectionHash, "error", err) 414 + http.Error(w, "failed to dismiss recipe", http.StatusInternalServerError) 415 + return 416 + } 417 + 418 + if err := s.removeRecipeFromUserProfile(ctx, *currentUser, recipeHash); err != nil { 419 + slog.ErrorContext(ctx, "failed to remove recipe from user profile", "user_id", currentUser.ID, "hash", recipeHash, "error", err) 420 + http.Error(w, "failed to dismiss recipe", http.StatusInternalServerError) 421 + return 422 + } 423 + 424 + setTextContent(w) 425 + _, err = fmt.Fprint(w, `<span class="text-xs font-medium text-action-red-700">Removed from kitchen</span>`) 426 + if err != nil { 427 + slog.ErrorContext(ctx, "failed to write dismiss response", "hash", recipeHash, "error", err) 428 + http.Error(w, "failed to write response", http.StatusInternalServerError) 429 + } 430 + } 431 + 432 + func (s *server) handleRegenerate(w http.ResponseWriter, r *http.Request) { 433 + ctx := r.Context() 434 + hash := strings.TrimSpace(r.PathValue("hash")) 435 + if hash == "" { 436 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 437 + return 438 + } 439 + 440 + currentUser, err := s.storage.FromRequest(ctx, r, s.clerk) 441 + if err != nil { 442 + if errors.Is(err, auth.ErrNoSession) { 443 + if isHTMXRequest(r) { 444 + w.Header().Set("HX-Redirect", "/sign-in") 445 + } 446 + http.Error(w, "must be logged in to regenerate recipes", http.StatusUnauthorized) 447 + return 448 + } 449 + http.Error(w, "unable to load account", http.StatusInternalServerError) 450 + return 451 + } 452 + if err := r.ParseForm(); err != nil { 453 + http.Error(w, "invalid form", http.StatusBadRequest) 454 + return 455 + } 456 + 457 + p, err := s.paramsForAction(ctx, hash, currentUser.ID, strings.TrimSpace(r.FormValue("instructions"))) 458 + if err != nil { 459 + http.Error(w, err.Error(), http.StatusBadRequest) 460 + return 461 + } 462 + newHash := p.Hash() 463 + 464 + if err := s.SaveParams(ctx, p); err != nil && !errors.Is(err, ErrAlreadyExists) { 465 + slog.ErrorContext(ctx, "failed to save params for regenerate", "hash", newHash, "error", err) 466 + http.Error(w, "failed to prepare regeneration", http.StatusInternalServerError) 467 + return 468 + } 469 + //so we have a choice we could save slection here matching params 470 + // or backfill it on first load after regeneration Backfilling is a little more resilient 471 + //selection := recipeSelectionFromParams(p) 472 + //if err := s.saveRecipeSelection(ctx, currentUser.ID, newHash, selection); 473 + s.kickgeneration(ctx, p, currentUser) 474 + 475 + redirectToHash(w, r, newHash, true /*useStart*/) 476 + } 477 + 478 + func (s *server) handleFinalize(w http.ResponseWriter, r *http.Request) { 479 + ctx := r.Context() 480 + hash := strings.TrimSpace(r.PathValue("hash")) 481 + if hash == "" { 482 + http.Error(w, "missing recipe hash", http.StatusBadRequest) 483 + return 484 + } 485 + 486 + userid, err := s.clerk.GetUserIDFromRequest(r) 487 + if err != nil { 488 + if errors.Is(err, auth.ErrNoSession) { 489 + if isHTMXRequest(r) { 490 + w.Header().Set("HX-Redirect", "/sign-in") 491 + } 492 + http.Error(w, "must be logged in to finalize recipes", http.StatusUnauthorized) 493 + return 494 + } 495 + http.Error(w, "unable to load account", http.StatusInternalServerError) 496 + return 497 + } 498 + 499 + p, err := s.paramsForAction(ctx, hash, userid, "") 500 + if err != nil { 501 + http.Error(w, err.Error(), http.StatusBadRequest) 502 + return 503 + } 504 + if len(p.Saved) == 0 { 505 + //ui should ideally not allow us to get here 506 + http.Error(w, "no recipes selected to save", http.StatusBadRequest) 507 + return 508 + } 509 + 510 + newHash := p.Hash() 511 + if err := s.SaveParams(ctx, p); err != nil && !errors.Is(err, ErrAlreadyExists) { 512 + slog.ErrorContext(ctx, "failed to save params for finalize", "hash", newHash, "error", err) 513 + http.Error(w, "failed to finalize recipes", http.StatusInternalServerError) 514 + return 515 + } 516 + 517 + shoppingList := &ai.ShoppingList{ 518 + Recipes: p.Saved, 519 + ConversationID: p.ConversationID, 520 + } 521 + if err := s.SaveShoppingList(ctx, shoppingList, newHash); err != nil { 522 + slog.ErrorContext(ctx, "failed to save finalized shopping list", "hash", newHash, "error", err) 523 + http.Error(w, "failed to finalize recipes", http.StatusInternalServerError) 524 + return 525 + } 526 + 527 + redirectToHash(w, r, newHash, false /*useStart*/) 528 + } 529 + 530 + // paramsForAction merges selction, old params, and selection(saved/dismissed) into a new params 531 + func (s *server) paramsForAction(ctx context.Context, hash, userID, instructions string) (*generatorParams, error) { 532 + baseParams, err := s.ParamsFromCache(ctx, hash) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to load recipe parameters") 535 + } 536 + currentList, err := s.FromCache(ctx, hash) 537 + if err != nil { 538 + return nil, fmt.Errorf("failed to load recipe list") 539 + } 540 + 541 + selection, err := s.loadRecipeSelection(ctx, userID, hash) 542 + if err != nil { 543 + //should we just fall back to params? selection saving 544 + return nil, fmt.Errorf("failed to load recipe selection") 545 + } 546 + 547 + params := *baseParams 548 + params.Instructions = instructions 549 + s.mergeParamsWithSelection(ctx, &params, selection, currentList.Recipes) 550 + if params.ConversationID == "" { 551 + params.ConversationID = currentList.ConversationID 552 + } 553 + return &params, nil 554 + } 555 + 292 556 const ( 293 557 queryArgHash = "h" 294 558 queryArgStart = "start" ··· 332 596 333 597 func (s *server) handleRecipes(w http.ResponseWriter, r *http.Request) { 334 598 ctx := r.Context() 599 + // TODO(pm): Revisit route shape for hash-based recipe lists. `h` is a derived key from 600 + // query params, so `/recipes?h=...` is defensible; decide later if we also want a 601 + // canonical path form like `/recipes/{h}` or just a redirect alias. 335 602 if hashParam := r.URL.Query().Get(queryArgHash); hashParam != "" { 336 603 if normalizedHash, ok := legacyHashToCurrent(hashParam, legacyRecipeHashSeed); ok { 337 604 slog.InfoContext(ctx, "redirecting legacy hash to canonical hash", "legacy_hash", hashParam, "hash", normalizedHash) ··· 368 635 } 369 636 styles := wineStyles(slist.Recipes) 370 637 slog.InfoContext(ctx, "wines!", "hash", hashParam, "wine_styles", styles) 371 - _, err = s.clerk.GetUserIDFromRequest(r) 638 + userID, err := s.clerk.GetUserIDFromRequest(r) 372 639 signedIn := !errors.Is(err, auth.ErrNoSession) 373 - FormatShoppingListHTML(p, *slist, signedIn, w) 640 + if signedIn { 641 + fromStore, selErr := s.loadRecipeSelection(ctx, userID, hashParam) 642 + if selErr != nil { 643 + slog.ErrorContext(ctx, "failed to load recipe selection for render", "user_id", userID, "hash", hashParam, "error", selErr) 644 + http.Error(w, "failed to load recipe selection", http.StatusInternalServerError) 645 + return 646 + } 647 + s.mergeParamsWithSelection(ctx, p, fromStore, slist.Recipes) 648 + } 649 + applySavedToRecipes(slist.Recipes, p) 650 + FormatShoppingListHTMLForHash(p, *slist, signedIn, hashParam, w) 374 651 return 375 652 } 376 653 ··· 409 686 410 687 hash := p.Hash() 411 688 412 - // Handle finalize - save recipes to user profile and display filtered list 413 - if r.URL.Query().Get("finalize") == "true" { 414 - // Check if user is authenticated 415 - if currentUser.ID == "" { 416 - http.Error(w, "must be logged in to finalize recipes", http.StatusUnauthorized) 417 - return 418 - } 419 - 420 - // If no recipes are saved, just return to home 421 - if len(p.Saved) == 0 { 422 - http.Error(w, "no recipes selected to save", http.StatusBadRequest) 423 - return 424 - } 425 - 426 - // Save recipes to user profile 427 - if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil { 428 - slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err) 429 - http.Error(w, "failed to save recipes", http.StatusInternalServerError) 430 - return 431 - } 432 - //styles := wineStyles(p.Saved) 433 - // todo regeenrate after grabbing list of wine styles from store. 434 - slog.InfoContext(ctx, "finalized recipes", "user_id", currentUser.ID, "count", len(p.Saved)) 435 - 436 - // Display the saved recipes 437 - shoppingList := &ai.ShoppingList{ 438 - Recipes: p.Saved, 439 - ConversationID: p.ConversationID, 440 - } 441 - 442 - // should finalize go into params to get a different hash that previous one with unsaved? 443 - // or should we shove a guid or iteration in params along with conversation id. Response id? 444 - if err := s.SaveShoppingList(ctx, shoppingList, hash); err != nil { 445 - slog.ErrorContext(ctx, "save error", "error", err) 446 - http.Error(w, "failed to save finalized recipes", http.StatusInternalServerError) 447 - return 448 - } 449 - redirectToHash(w, r, hash, false /*useStart*/) 450 - return 451 - } 452 - 453 689 s.kickgeneration(ctx, p, currentUser) 454 690 455 691 redirectToHash(w, r, hash, true /*useStart*/) ··· 490 726 slog.ErrorContext(ctx, "save error", "error", err) 491 727 return 492 728 } 493 - // saveRecipesToUserProfile saves recipes to the user profile if they were marked as saved. 494 - 495 - // Use the current user ID when saving recipes to the user profile 496 - // needs user to be logged in. Only do on finalize? 497 - if currentUser.ID != "" { 498 - if err := s.saveRecipesToUserProfile(ctx, currentUser.ID, p.Saved); err != nil { 499 - slog.ErrorContext(ctx, "failed to save recipes to user profile", "user_id", currentUser.ID, "error", err) 500 - } 501 - } 502 729 }() 503 730 } 504 731 ··· 531 758 args.Set(queryArgStart, time.Now().Format(time.RFC3339Nano)) 532 759 } 533 760 u.RawQuery = args.Encode() 761 + if isHTMXRequest(r) { 762 + w.Header().Set("HX-Redirect", u.String()) 763 + w.WriteHeader(http.StatusOK) 764 + return 765 + } 534 766 http.Redirect(w, r, u.String(), http.StatusSeeOther) 535 767 } 536 768 ··· 554 786 } 555 787 556 788 // saveRecipesToUserProfile adds saved recipes to the user's profile 557 - func (s *server) saveRecipesToUserProfile(ctx context.Context, userID string, savedRecipes []ai.Recipe) error { 558 - if userID == "" { 789 + func (s *server) saveRecipesToUserProfile(ctx context.Context, currentUser *utypes.User, recipe ai.Recipe) error { 790 + if currentUser == nil { 559 791 return fmt.Errorf("invalid user") 560 792 } 561 793 562 - if len(savedRecipes) == 0 { 794 + // Check if reciProfilepe already exists in user's last recipes 795 + hash := recipe.ComputeHash() 796 + 797 + _, exists := lo.Find(currentUser.LastRecipes, func(r utypes.Recipe) bool { 798 + return r.Hash == hash 799 + }) 800 + if exists { 563 801 return nil 564 802 } 803 + newRecipe := utypes.Recipe{ 804 + Title: recipe.Title, 805 + Hash: hash, 806 + CreatedAt: time.Now(), 807 + } 808 + currentUser.LastRecipes = append(currentUser.LastRecipes, newRecipe) 565 809 566 - // Reload the user to get the latest state 567 - currentUser, err := s.storage.GetByID(userID) 568 - if err != nil { 569 - return fmt.Errorf("failed to reload user: %w", err) 810 + // etag mismatch fun! 811 + if err := s.storage.Update(currentUser); err != nil { 812 + return fmt.Errorf("failed to update user with saved recipes: %w", err) 570 813 } 814 + slog.InfoContext(ctx, "added saved recipe to user profile", "user_id", currentUser.ID, "title", recipe.Title) 571 815 572 - // Track if any new recipes were added 573 - added := 0 574 - addTime := time.Now() 575 - for _, recipe := range savedRecipes { 576 - // Check if recipe already exists in user's last recipes 577 - hash := recipe.ComputeHash() 816 + return nil 817 + } 578 818 579 - _, exists := lo.Find(currentUser.LastRecipes, func(r utypes.Recipe) bool { 580 - return r.Hash == hash 581 - }) 582 - if exists { 583 - continue 584 - } 585 - newRecipe := utypes.Recipe{ 586 - Title: recipe.Title, 587 - Hash: hash, 588 - CreatedAt: addTime, 589 - } 590 - currentUser.LastRecipes = append(currentUser.LastRecipes, newRecipe) 591 - added++ 592 - slog.InfoContext(ctx, "added saved recipe to user profile", "user_id", userID, "title", recipe.Title) 819 + func (s *server) removeRecipeFromUserProfile(ctx context.Context, currentUser utypes.User, recipeHash string) error { 820 + 821 + recipeHash = strings.TrimSpace(recipeHash) 822 + if recipeHash == "" { 823 + return fmt.Errorf("invalid recipe hash") 593 824 } 594 825 595 - if added > 0 { 596 - // etag mismatch fun! 597 - if err := s.storage.Update(currentUser); err != nil { 598 - return fmt.Errorf("failed to update user with saved recipes: %w", err) 599 - } 600 - slog.InfoContext(ctx, "saved recipes to user profile", "user_id", userID, "count", added) 826 + before := len(currentUser.LastRecipes) 827 + currentUser.LastRecipes = lo.Filter(currentUser.LastRecipes, func(r utypes.Recipe, _ int) bool { 828 + return r.Hash != recipeHash 829 + }) 830 + 831 + if len(currentUser.LastRecipes) == before { 832 + return nil 601 833 } 602 834 835 + if err := s.storage.Update(&currentUser); err != nil { 836 + return fmt.Errorf("failed to update user when dismissing recipe: %w", err) 837 + } 838 + slog.InfoContext(ctx, "removed recipe from user profile", "user_id", currentUser.ID, "hash", recipeHash) 603 839 return nil 604 840 } 605 841
+511
internal/recipes/server_test.go
··· 7 7 "careme/internal/cache" 8 8 "careme/internal/locations" 9 9 "careme/internal/users" 10 + utypes "careme/internal/users/types" 10 11 "context" 11 12 "encoding/base64" 12 13 "fmt" ··· 397 398 } 398 399 if got, want := g.lastQuestion, "Regarding BBQ Pulled Pork: Can I swap the protein?"; got != want { 399 400 t.Fatalf("expected generator question %q, got %q", want, got) 401 + } 402 + } 403 + 404 + func TestHandleSaveRecipe_SavesRecipeToUserProfile(t *testing.T) { 405 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 406 + storage := users.NewStorage(cacheStore) 407 + s := &server{ 408 + recipeio: recipeio{Cache: cacheStore}, 409 + storage: storage, 410 + clerk: auth.DefaultMock(), 411 + } 412 + 413 + recipe := ai.Recipe{ 414 + Title: "Save Me", 415 + Description: "Recipe to save", 416 + } 417 + recipeHash := recipe.ComputeHash() 418 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "origin-hash"); err != nil { 419 + t.Fatalf("failed to save recipe in cache: %v", err) 420 + } 421 + 422 + form := url.Values{"h": {"origin-hash"}} 423 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/save", strings.NewReader(form.Encode())) 424 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 425 + req.Header.Set("HX-Request", "true") 426 + req.SetPathValue("hash", recipeHash) 427 + rr := httptest.NewRecorder() 428 + 429 + s.handleSaveRecipe(rr, req) 430 + 431 + if rr.Code != http.StatusOK { 432 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 433 + } 434 + if !strings.Contains(rr.Body.String(), "Saved to kitchen") { 435 + t.Fatalf("expected success response, got body: %s", rr.Body.String()) 436 + } 437 + 438 + user, err := storage.GetByID("mock-clerk-user-id") 439 + if err != nil { 440 + t.Fatalf("failed to load user: %v", err) 441 + } 442 + if len(user.LastRecipes) != 1 { 443 + t.Fatalf("expected one saved recipe, got %d", len(user.LastRecipes)) 444 + } 445 + if user.LastRecipes[0].Hash != recipeHash { 446 + t.Fatalf("expected saved hash %q, got %q", recipeHash, user.LastRecipes[0].Hash) 447 + } 448 + selection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "origin-hash") 449 + if err != nil { 450 + t.Fatalf("failed to load selection: %v", err) 451 + } 452 + if len(selection.SavedHashes) != 1 || selection.SavedHashes[0] != recipeHash { 453 + t.Fatalf("expected saved selection with hash %q, got %#v", recipeHash, selection.SavedHashes) 454 + } 455 + } 456 + 457 + func TestHandleSaveRecipe_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 458 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 459 + s := &server{ 460 + recipeio: recipeio{Cache: cacheStore}, 461 + storage: users.NewStorage(cacheStore), 462 + clerk: noSessionAuth{}, 463 + } 464 + 465 + req := httptest.NewRequest(http.MethodPost, "/recipe/hash/save", nil) 466 + req.Header.Set("HX-Request", "true") 467 + req.SetPathValue("hash", "hash") 468 + rr := httptest.NewRecorder() 469 + 470 + s.handleSaveRecipe(rr, req) 471 + 472 + if rr.Code != http.StatusUnauthorized { 473 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 474 + } 475 + if got := rr.Header().Get("HX-Redirect"); got != "/sign-in" { 476 + t.Fatalf("expected HX-Redirect to /sign-in, got %q", got) 477 + } 478 + } 479 + 480 + func TestHandleSaveRecipe_UsesRequestHashForSelectionKey(t *testing.T) { 481 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 482 + storage := users.NewStorage(cacheStore) 483 + s := &server{ 484 + recipeio: recipeio{Cache: cacheStore}, 485 + storage: storage, 486 + clerk: auth.DefaultMock(), 487 + } 488 + 489 + recipe := ai.Recipe{ 490 + Title: "Save Me", 491 + Description: "Recipe to save", 492 + } 493 + recipeHash := recipe.ComputeHash() 494 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "stale-origin-hash"); err != nil { 495 + t.Fatalf("failed to save recipe in cache: %v", err) 496 + } 497 + 498 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/save?h=current-list-hash", nil) 499 + req.Header.Set("HX-Request", "true") 500 + req.SetPathValue("hash", recipeHash) 501 + rr := httptest.NewRecorder() 502 + 503 + s.handleSaveRecipe(rr, req) 504 + 505 + if rr.Code != http.StatusOK { 506 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 507 + } 508 + currentSelection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "current-list-hash") 509 + if err != nil { 510 + t.Fatalf("failed to load current hash selection: %v", err) 511 + } 512 + if len(currentSelection.SavedHashes) != 1 || currentSelection.SavedHashes[0] != recipeHash { 513 + t.Fatalf("expected saved selection under current hash, got %#v", currentSelection.SavedHashes) 514 + } 515 + staleSelection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "stale-origin-hash") 516 + if err != nil { 517 + t.Fatalf("failed to load stale hash selection: %v", err) 518 + } 519 + if !staleSelection.Empty() { 520 + t.Fatalf("expected no selection under stale origin hash, got %#v", staleSelection) 521 + } 522 + } 523 + 524 + func TestHandleDismissRecipe_RemovesRecipeFromUserProfile(t *testing.T) { 525 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 526 + storage := users.NewStorage(cacheStore) 527 + s := &server{ 528 + recipeio: recipeio{Cache: cacheStore}, 529 + storage: storage, 530 + clerk: auth.DefaultMock(), 531 + } 532 + 533 + recipe := ai.Recipe{ 534 + Title: "Dismiss Recipe", 535 + Description: "Recipe to dismiss", 536 + } 537 + recipeHash := recipe.ComputeHash() 538 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "origin-hash"); err != nil { 539 + t.Fatalf("failed to save recipe in cache: %v", err) 540 + } 541 + 542 + user := &utypes.User{ 543 + ID: "mock-clerk-user-id", 544 + Email: []string{"you@careme.cooking"}, 545 + CreatedAt: time.Now(), 546 + ShoppingDay: "Saturday", 547 + LastRecipes: []utypes.Recipe{ 548 + { 549 + Title: "Keep Recipe", 550 + Hash: "keep-hash", 551 + CreatedAt: time.Now().Add(-1 * time.Hour), 552 + }, 553 + { 554 + Title: "Dismiss Recipe", 555 + Hash: recipeHash, 556 + CreatedAt: time.Now(), 557 + }, 558 + }, 559 + } 560 + if err := storage.Update(user); err != nil { 561 + t.Fatalf("failed to create user: %v", err) 562 + } 563 + 564 + form := url.Values{"h": {"origin-hash"}} 565 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/dismiss", strings.NewReader(form.Encode())) 566 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 567 + req.Header.Set("HX-Request", "true") 568 + req.SetPathValue("hash", recipeHash) 569 + rr := httptest.NewRecorder() 570 + 571 + s.handleDismissRecipe(rr, req) 572 + 573 + if rr.Code != http.StatusOK { 574 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 575 + } 576 + if !strings.Contains(rr.Body.String(), "Removed from kitchen") { 577 + t.Fatalf("expected dismiss response, got body: %s", rr.Body.String()) 578 + } 579 + 580 + updated, err := storage.GetByID("mock-clerk-user-id") 581 + if err != nil { 582 + t.Fatalf("failed to load user: %v", err) 583 + } 584 + if len(updated.LastRecipes) != 1 { 585 + t.Fatalf("expected one recipe after dismiss, got %d", len(updated.LastRecipes)) 586 + } 587 + if updated.LastRecipes[0].Hash != "keep-hash" { 588 + t.Fatalf("expected remaining hash keep-hash, got %q", updated.LastRecipes[0].Hash) 589 + } 590 + selection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "origin-hash") 591 + if err != nil { 592 + t.Fatalf("failed to load selection: %v", err) 593 + } 594 + if len(selection.DismissedHashes) != 1 || selection.DismissedHashes[0] != recipeHash { 595 + t.Fatalf("expected dismissed selection with hash %q, got %#v", recipeHash, selection.DismissedHashes) 596 + } 597 + } 598 + 599 + func TestHandleDismissRecipe_NoSessionHTMXSetsRedirectHeader(t *testing.T) { 600 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 601 + s := &server{ 602 + recipeio: recipeio{Cache: cacheStore}, 603 + storage: users.NewStorage(cacheStore), 604 + clerk: noSessionAuth{}, 605 + } 606 + 607 + req := httptest.NewRequest(http.MethodPost, "/recipe/hash/dismiss", nil) 608 + req.Header.Set("HX-Request", "true") 609 + req.SetPathValue("hash", "hash") 610 + rr := httptest.NewRecorder() 611 + 612 + s.handleDismissRecipe(rr, req) 613 + 614 + if rr.Code != http.StatusUnauthorized { 615 + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rr.Code) 616 + } 617 + if got := rr.Header().Get("HX-Redirect"); got != "/sign-in" { 618 + t.Fatalf("expected HX-Redirect to /sign-in, got %q", got) 619 + } 620 + } 621 + 622 + func TestHandleDismissRecipe_UsesRequestHashForSelectionKey(t *testing.T) { 623 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 624 + storage := users.NewStorage(cacheStore) 625 + s := &server{ 626 + recipeio: recipeio{Cache: cacheStore}, 627 + storage: storage, 628 + clerk: auth.DefaultMock(), 629 + } 630 + 631 + recipe := ai.Recipe{ 632 + Title: "Dismiss Recipe", 633 + Description: "Recipe to dismiss", 634 + } 635 + recipeHash := recipe.ComputeHash() 636 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{recipe}, "stale-origin-hash"); err != nil { 637 + t.Fatalf("failed to save recipe in cache: %v", err) 638 + } 639 + 640 + user := &utypes.User{ 641 + ID: "mock-clerk-user-id", 642 + Email: []string{"you@careme.cooking"}, 643 + CreatedAt: time.Now(), 644 + ShoppingDay: "Saturday", 645 + LastRecipes: []utypes.Recipe{ 646 + { 647 + Title: "Dismiss Recipe", 648 + Hash: recipeHash, 649 + CreatedAt: time.Now(), 650 + }, 651 + }, 652 + } 653 + if err := storage.Update(user); err != nil { 654 + t.Fatalf("failed to create user: %v", err) 655 + } 656 + 657 + req := httptest.NewRequest(http.MethodPost, "/recipe/"+recipeHash+"/dismiss?h=current-list-hash", nil) 658 + req.Header.Set("HX-Request", "true") 659 + req.SetPathValue("hash", recipeHash) 660 + rr := httptest.NewRecorder() 661 + 662 + s.handleDismissRecipe(rr, req) 663 + 664 + if rr.Code != http.StatusOK { 665 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 666 + } 667 + currentSelection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "current-list-hash") 668 + if err != nil { 669 + t.Fatalf("failed to load current hash selection: %v", err) 670 + } 671 + if len(currentSelection.DismissedHashes) != 1 || currentSelection.DismissedHashes[0] != recipeHash { 672 + t.Fatalf("expected dismissed selection under current hash, got %#v", currentSelection.DismissedHashes) 673 + } 674 + staleSelection, err := s.loadRecipeSelection(t.Context(), "mock-clerk-user-id", "stale-origin-hash") 675 + if err != nil { 676 + t.Fatalf("failed to load stale hash selection: %v", err) 677 + } 678 + if !staleSelection.Empty() { 679 + t.Fatalf("expected no selection under stale origin hash, got %#v", staleSelection) 680 + } 681 + } 682 + 683 + func TestHandleRegenerate_UsesServerSideSelectionAndRedirects(t *testing.T) { 684 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 685 + storage := users.NewStorage(cacheStore) 686 + s := &server{ 687 + recipeio: recipeio{Cache: cacheStore}, 688 + storage: storage, 689 + clerk: auth.DefaultMock(), 690 + generator: mock{}, 691 + } 692 + t.Cleanup(s.Wait) 693 + 694 + p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 695 + p.ConversationID = "conv-123" 696 + originHash := p.Hash() 697 + if err := s.SaveParams(t.Context(), p); err != nil { 698 + t.Fatalf("failed to save params: %v", err) 699 + } 700 + 701 + savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 702 + dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 703 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{savedRecipe, dismissedRecipe}, originHash); err != nil { 704 + t.Fatalf("failed to save recipes: %v", err) 705 + } 706 + shoppingList := &ai.ShoppingList{ 707 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 708 + ConversationID: "conv-123", 709 + } 710 + if err := s.SaveShoppingList(t.Context(), shoppingList, originHash); err != nil { 711 + t.Fatalf("failed to save shopping list: %v", err) 712 + } 713 + 714 + selection := recipeSelection{ 715 + SavedHashes: []string{savedRecipe.ComputeHash()}, 716 + DismissedHashes: []string{dismissedRecipe.ComputeHash()}, 717 + } 718 + if err := s.saveRecipeSelection(t.Context(), "mock-clerk-user-id", originHash, selection); err != nil { 719 + t.Fatalf("failed to save selection: %v", err) 720 + } 721 + 722 + form := url.Values{"instructions": {"make it vegetarian"}} 723 + req := httptest.NewRequest(http.MethodPost, "/recipes/"+originHash+"/regenerate", strings.NewReader(form.Encode())) 724 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 725 + req.Header.Set("HX-Request", "true") 726 + req.SetPathValue("hash", originHash) 727 + rr := httptest.NewRecorder() 728 + 729 + s.handleRegenerate(rr, req) 730 + 731 + if rr.Code != http.StatusOK { 732 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 733 + } 734 + location := rr.Header().Get("HX-Redirect") 735 + if location == "" { 736 + t.Fatal("expected HX-Redirect header") 737 + } 738 + u, err := url.Parse(location) 739 + if err != nil { 740 + t.Fatalf("failed to parse HX-Redirect: %v", err) 741 + } 742 + newHash := u.Query().Get("h") 743 + if newHash == "" { 744 + t.Fatalf("expected redirect hash in %q", location) 745 + } 746 + if newHash == originHash { 747 + t.Fatal("expected a new hash after regenerate") 748 + } 749 + 750 + updatedParams, err := s.ParamsFromCache(t.Context(), newHash) 751 + if err != nil { 752 + t.Fatalf("failed to load new params: %v", err) 753 + } 754 + if updatedParams.Instructions != "make it vegetarian" { 755 + t.Fatalf("expected instructions to persist, got %q", updatedParams.Instructions) 756 + } 757 + if len(updatedParams.Saved) != 1 || updatedParams.Saved[0].ComputeHash() != savedRecipe.ComputeHash() { 758 + t.Fatalf("expected saved recipe selection to persist in params, got %#v", updatedParams.Saved) 759 + } 760 + if len(updatedParams.Dismissed) != 1 || updatedParams.Dismissed[0].ComputeHash() != dismissedRecipe.ComputeHash() { 761 + t.Fatalf("expected dismissed recipe selection to persist in params, got %#v", updatedParams.Dismissed) 762 + } 763 + } 764 + 765 + func TestHandleFinalize_UsesServerSideSelection(t *testing.T) { 766 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 767 + storage := users.NewStorage(cacheStore) 768 + s := &server{ 769 + recipeio: recipeio{Cache: cacheStore}, 770 + storage: storage, 771 + clerk: auth.DefaultMock(), 772 + } 773 + 774 + p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 775 + p.ConversationID = "conv-123" 776 + originHash := p.Hash() 777 + if err := s.SaveParams(t.Context(), p); err != nil { 778 + t.Fatalf("failed to save params: %v", err) 779 + } 780 + 781 + savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 782 + dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 783 + if err := s.SaveRecipes(t.Context(), []ai.Recipe{savedRecipe, dismissedRecipe}, originHash); err != nil { 784 + t.Fatalf("failed to save recipes: %v", err) 785 + } 786 + shoppingList := &ai.ShoppingList{ 787 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 788 + ConversationID: "conv-123", 789 + } 790 + if err := s.SaveShoppingList(t.Context(), shoppingList, originHash); err != nil { 791 + t.Fatalf("failed to save shopping list: %v", err) 792 + } 793 + 794 + selection := recipeSelection{ 795 + SavedHashes: []string{savedRecipe.ComputeHash()}, 796 + DismissedHashes: []string{dismissedRecipe.ComputeHash()}, 797 + } 798 + if err := s.saveRecipeSelection(t.Context(), "mock-clerk-user-id", originHash, selection); err != nil { 799 + t.Fatalf("failed to save selection: %v", err) 800 + } 801 + 802 + req := httptest.NewRequest(http.MethodPost, "/recipes/"+originHash+"/finalize", nil) 803 + req.Header.Set("HX-Request", "true") 804 + req.SetPathValue("hash", originHash) 805 + rr := httptest.NewRecorder() 806 + 807 + s.handleFinalize(rr, req) 808 + 809 + if rr.Code != http.StatusOK { 810 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 811 + } 812 + location := rr.Header().Get("HX-Redirect") 813 + if location == "" { 814 + t.Fatal("expected HX-Redirect header") 815 + } 816 + u, err := url.Parse(location) 817 + if err != nil { 818 + t.Fatalf("failed to parse HX-Redirect: %v", err) 819 + } 820 + finalHash := u.Query().Get("h") 821 + if finalHash == "" { 822 + t.Fatalf("expected redirect hash in %q", location) 823 + } 824 + 825 + finalList, err := s.FromCache(t.Context(), finalHash) 826 + if err != nil { 827 + t.Fatalf("failed to load finalized list: %v", err) 828 + } 829 + if len(finalList.Recipes) != 1 || finalList.Recipes[0].ComputeHash() != savedRecipe.ComputeHash() { 830 + t.Fatalf("expected only saved recipe in finalized list, got %#v", finalList.Recipes) 831 + } 832 + } 833 + 834 + func TestParamsForAction_PreservesBaseSelectionWhenSelectionCacheEmpty(t *testing.T) { 835 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 836 + s := &server{ 837 + recipeio: recipeio{Cache: cacheStore}, 838 + } 839 + 840 + savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 841 + dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 842 + p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 843 + p.Saved = []ai.Recipe{savedRecipe} 844 + p.Dismissed = []ai.Recipe{dismissedRecipe} 845 + originHash := p.Hash() 846 + if err := s.SaveParams(t.Context(), p); err != nil { 847 + t.Fatalf("failed to save params: %v", err) 848 + } 849 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 850 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 851 + ConversationID: "conv-1", 852 + }, originHash); err != nil { 853 + t.Fatalf("failed to save shopping list: %v", err) 854 + } 855 + 856 + updated, err := s.paramsForAction(t.Context(), originHash, "user-1", "make it vegetarian") 857 + if err != nil { 858 + t.Fatalf("paramsForAction failed: %v", err) 859 + } 860 + 861 + if updated.Instructions != "make it vegetarian" { 862 + t.Fatalf("expected instructions to update, got %q", updated.Instructions) 863 + } 864 + if len(updated.Saved) != 1 || updated.Saved[0].ComputeHash() != savedRecipe.ComputeHash() { 865 + t.Fatalf("expected saved recipes from params to persist, got %#v", updated.Saved) 866 + } 867 + if len(updated.Dismissed) != 1 || updated.Dismissed[0].ComputeHash() != dismissedRecipe.ComputeHash() { 868 + t.Fatalf("expected dismissed recipes from params to persist, got %#v", updated.Dismissed) 869 + } 870 + } 871 + 872 + func TestParamsForAction_MergesSelectionAndRemovesOppositeRecipes(t *testing.T) { 873 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 874 + s := &server{ 875 + recipeio: recipeio{Cache: cacheStore}, 876 + } 877 + 878 + savedRecipe := ai.Recipe{Title: "Saved Recipe", Description: "Saved"} 879 + dismissedRecipe := ai.Recipe{Title: "Dismissed Recipe", Description: "Dismissed"} 880 + p := DefaultParams(&locations.Location{ID: "loc-1", Name: "Store"}, time.Now()) 881 + p.Saved = []ai.Recipe{savedRecipe} 882 + p.Dismissed = []ai.Recipe{dismissedRecipe} 883 + originHash := p.Hash() 884 + if err := s.SaveParams(t.Context(), p); err != nil { 885 + t.Fatalf("failed to save params: %v", err) 886 + } 887 + if err := s.SaveShoppingList(t.Context(), &ai.ShoppingList{ 888 + Recipes: []ai.Recipe{savedRecipe, dismissedRecipe}, 889 + ConversationID: "conv-1", 890 + }, originHash); err != nil { 891 + t.Fatalf("failed to save shopping list: %v", err) 892 + } 893 + 894 + if err := s.saveRecipeSelection(t.Context(), "user-1", originHash, recipeSelection{ 895 + SavedHashes: []string{dismissedRecipe.ComputeHash()}, 896 + DismissedHashes: []string{savedRecipe.ComputeHash()}, 897 + }); err != nil { 898 + t.Fatalf("failed to save selection: %v", err) 899 + } 900 + 901 + updated, err := s.paramsForAction(t.Context(), originHash, "user-1", "") 902 + if err != nil { 903 + t.Fatalf("paramsForAction failed: %v", err) 904 + } 905 + 906 + if len(updated.Saved) != 1 || updated.Saved[0].ComputeHash() != dismissedRecipe.ComputeHash() { 907 + t.Fatalf("expected selection to move dismissed recipe into saved, got %#v", updated.Saved) 908 + } 909 + if len(updated.Dismissed) != 1 || updated.Dismissed[0].ComputeHash() != savedRecipe.ComputeHash() { 910 + t.Fatalf("expected selection to move saved recipe into dismissed, got %#v", updated.Dismissed) 400 911 } 401 912 } 402 913
+53 -45
internal/recipes/user_profile_test.go
··· 45 45 } 46 46 47 47 // Create test recipes 48 - savedRecipes := []ai.Recipe{ 49 - { 50 - Title: "Test Recipe 1", 51 - Description: "A test recipe", 52 - }, 53 - { 54 - Title: "Test Recipe 2", 55 - Description: "Another test recipe", 56 - }, 48 + savedRecipe := ai.Recipe{ 49 + Title: "Test Recipe 1", 50 + Description: "A test recipe", 57 51 } 58 52 59 53 // Save recipes to user profile 60 54 ctx := context.Background() 61 - if err := srv.saveRecipesToUserProfile(ctx, testUser.ID, savedRecipes); err != nil { 55 + if err := srv.saveRecipesToUserProfile(ctx, testUser, savedRecipe); err != nil { 62 56 t.Fatalf("failed to save recipes to user profile: %v", err) 63 57 } 64 58 ··· 68 62 t.Fatalf("failed to retrieve updated user: %v", err) 69 63 } 70 64 71 - if len(updatedUser.LastRecipes) != 2 { 72 - t.Fatalf("expected 2 recipes in user profile, got %d", len(updatedUser.LastRecipes)) 65 + if len(updatedUser.LastRecipes) != 1 { 66 + t.Fatalf("expected 1 recipe in user profile, got %d", len(updatedUser.LastRecipes)) 73 67 } 74 68 75 - // Verify recipe titles match 76 - for i, recipe := range savedRecipes { 77 - if updatedUser.LastRecipes[i].Title != recipe.Title { 78 - t.Errorf("recipe %d title mismatch: expected %q, got %q", i, recipe.Title, updatedUser.LastRecipes[i].Title) 79 - } 80 - if updatedUser.LastRecipes[i].Hash != recipe.ComputeHash() { 81 - t.Errorf("recipe %d hash mismatch", i) 82 - } 69 + if updatedUser.LastRecipes[0].Title != savedRecipe.Title { 70 + t.Errorf("recipe title mismatch: expected %q, got %q", savedRecipe.Title, updatedUser.LastRecipes[0].Title) 71 + } 72 + if updatedUser.LastRecipes[0].Hash != savedRecipe.ComputeHash() { 73 + t.Errorf("recipe hash mismatch: expected %q, got %q", savedRecipe.ComputeHash(), updatedUser.LastRecipes[0].Hash) 83 74 } 75 + 84 76 } 85 77 86 78 func TestSaveRecipesToUserProfile_NoDuplicates(t *testing.T) { ··· 99 91 storage := users.NewStorage(tmpCache) 100 92 101 93 // Try to save the same recipe again (case-insensitive) 102 - savedRecipes := []ai.Recipe{ 103 - { 104 - Title: "test recipe 1", // lowercase version 105 - Description: "A test recipe", 106 - }, 107 - { 108 - Title: "Test Recipe 2", 109 - Description: "Another test recipe", 110 - }, 94 + savedRecipe := ai.Recipe{ 95 + Title: "test recipe 1", // lowercase version 96 + Description: "A test recipe", 111 97 } 112 - 113 98 // Create a test user with an existing recipe 114 99 existingRecipe := utypes.Recipe{ 115 - Title: savedRecipes[0].Title, 116 - Hash: savedRecipes[0].ComputeHash(), 100 + Title: savedRecipe.Title, 101 + Hash: savedRecipe.ComputeHash(), 117 102 CreatedAt: time.Now().Add(-24 * time.Hour), 118 103 } 119 104 testUser := &utypes.User{ ··· 134 119 135 120 // Save recipes to user profile 136 121 ctx := context.Background() 137 - if err := srv.saveRecipesToUserProfile(ctx, testUser.ID, savedRecipes); err != nil { 122 + if err := srv.saveRecipesToUserProfile(ctx, testUser, savedRecipe); err != nil { 138 123 t.Fatalf("failed to save recipes to user profile: %v", err) 139 124 } 140 125 ··· 144 129 t.Fatalf("failed to retrieve updated user: %v", err) 145 130 } 146 131 147 - if len(updatedUser.LastRecipes) != 2 { 148 - t.Fatalf("expected 2 recipes in user profile (1 existing + 1 new), got %d", len(updatedUser.LastRecipes)) 132 + if len(updatedUser.LastRecipes) != 1 { 133 + t.Fatalf("expected 1 recipe in user profile (no duplicates), got %d", len(updatedUser.LastRecipes)) 149 134 } 150 135 151 136 // Verify the existing recipe wasn't duplicated ··· 160 145 } 161 146 } 162 147 163 - func TestSaveRecipesToUserProfile_InvalidUser(t *testing.T) { 148 + func TestRemoveRecipeFromUserProfile(t *testing.T) { 164 149 tmpDir, err := os.MkdirTemp("", "careme-test-user-*") 165 150 if err != nil { 166 151 t.Fatalf("failed to create temp dir: %v", err) ··· 174 159 tmpCache := cache.NewFileCache(tmpDir) 175 160 storage := users.NewStorage(tmpCache) 176 161 162 + keep := utypes.Recipe{ 163 + Title: "Keep Recipe", 164 + Hash: "keep-hash", 165 + CreatedAt: time.Now().Add(-24 * time.Hour), 166 + } 167 + remove := utypes.Recipe{ 168 + Title: "Remove Recipe", 169 + Hash: "remove-hash", 170 + CreatedAt: time.Now(), 171 + } 172 + testUser := &utypes.User{ 173 + ID: "test-user-id", 174 + Email: []string{"test@example.com"}, 175 + CreatedAt: time.Now(), 176 + ShoppingDay: "Saturday", 177 + LastRecipes: []utypes.Recipe{keep, remove}, 178 + } 179 + if err := storage.Update(testUser); err != nil { 180 + t.Fatalf("failed to create test user: %v", err) 181 + } 182 + 177 183 srv := &server{ 178 184 storage: storage, 179 185 } 180 186 181 - savedRecipes := []ai.Recipe{ 182 - { 183 - Title: "Test Recipe", 184 - Description: "A test recipe", 185 - }, 187 + if err := srv.removeRecipeFromUserProfile(context.Background(), *testUser, remove.Hash); err != nil { 188 + t.Fatalf("failed to remove recipe from user profile: %v", err) 186 189 } 187 190 188 - ctx := context.Background() 189 - 190 - if err := srv.saveRecipesToUserProfile(ctx, "", savedRecipes); err == nil { 191 - t.Error("expected error with empty user ID, got nil") 191 + updatedUser, err := storage.GetByID(testUser.ID) 192 + if err != nil { 193 + t.Fatalf("failed to load updated user: %v", err) 194 + } 195 + if len(updatedUser.LastRecipes) != 1 { 196 + t.Fatalf("expected 1 recipe after removal, got %d", len(updatedUser.LastRecipes)) 197 + } 198 + if updatedUser.LastRecipes[0].Hash != keep.Hash { 199 + t.Fatalf("expected remaining recipe hash %q, got %q", keep.Hash, updatedUser.LastRecipes[0].Hash) 192 200 } 193 201 }
+74 -116
internal/templates/shoppinglist.html
··· 40 40 </div> 41 41 42 42 <div class="p-8"> 43 - <form id="regenerateForm" method="GET"> 44 - <input type="hidden" name="location" value="{{.Location.ID}}" /> 45 - <input type="hidden" name="date" value="{{.Date}}" /> 46 - <input type="hidden" name="conversation_id" value="{{.ConversationID}}" /> 43 + <form id="regenerateForm" 44 + method="POST" 45 + action="/recipes/{{.Hash}}/regenerate" 46 + hx-post="/recipes/{{.Hash}}/regenerate" 47 + hx-params="instructions" 48 + hx-swap="none"> 47 49 48 50 <div class="sticky top-3 z-20 -mx-2 px-2 pb-4"> 49 51 <div class="friendly-card-soft flex flex-col gap-3 border border-brand-100 bg-brand-50/80 p-4 shadow-sm backdrop-blur-[2px] sm:flex-row sm:items-center"> ··· 60 62 </button> 61 63 </div> 62 64 </div> 65 + </form> 63 66 64 - <div class="mt-8 space-y-8"> 65 - {{range .Recipes}} 66 - <article class="space-y-5 rounded-2xl border border-brand-100 bg-white/95 p-6 shadow-md"> 67 - <header class="space-y-4"> 68 - <div> 69 - <a href="/recipe/{{.ComputeHash}}" class="text-2xl font-semibold text-brand-700 hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 70 - {{.Title}} 71 - </a> 72 - <p class="mt-1 text-sm text-ink-500">{{.Description}}</p> 73 - </div> 74 - <div class="flex flex-wrap items-center gap-3"> 75 - <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();" /> 76 - <label for="save-{{.ComputeHash}}" class="inline-flex cursor-pointer items-center justify-center rounded-lg border-2 border-action-green-500 bg-action-green-50 px-4 py-2 text-sm font-medium text-action-green-700 transition hover:bg-action-green-100 peer-checked/save:border-action-green-700 peer-checked/save:bg-action-green-600 peer-checked/save:text-white"> 77 - Save 78 - </label> 79 - <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();" /> 80 - <label for="dismiss-{{.ComputeHash}}" class="inline-flex cursor-pointer items-center justify-center rounded-lg border-2 border-action-red-500 bg-action-red-50 px-4 py-2 text-sm font-medium text-action-red-700 transition hover:bg-action-red-100 peer-checked/dismiss:border-action-red-700 peer-checked/dismiss:bg-action-red-600 peer-checked/dismiss:text-white"> 81 - Dismiss 82 - </label> 83 - <button type="button" 84 - id="details-{{.ComputeHash}}-button" 85 - aria-controls="details-{{.ComputeHash}}" 86 - aria-expanded="{{if .Saved}}false{{else}}true{{end}}" 87 - onclick="toggleRecipeDetails('{{.ComputeHash}}')" 88 - class="inline-flex items-center justify-center rounded-lg border-2 border-brand-400 bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 transition hover:bg-brand-100 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 89 - {{if .Saved}}Show details{{else}}Hide details{{end}} 90 - </button> 91 - <input type="hidden" name="saved" id="saved-{{.ComputeHash}}" value="{{if .Saved}}{{.ComputeHash}}{{end}}" /> 92 - <input type="hidden" name="dismissed" id="dismissed-{{.ComputeHash}}" value="" /> 93 - </div> 94 - </header> 67 + <div class="mt-8 space-y-8"> 68 + {{range .Recipes}} 69 + <article class="space-y-5 rounded-2xl border border-brand-100 bg-white/95 p-6 shadow-md"> 70 + <header class="space-y-4"> 71 + <div> 72 + <a href="/recipe/{{.ComputeHash}}" class="text-2xl font-semibold text-brand-700 hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 73 + {{.Title}} 74 + </a> 75 + <p class="mt-1 text-sm text-ink-500">{{.Description}}</p> 76 + </div> 77 + <div class="flex flex-wrap items-center gap-3"> 78 + <input type="radio" 79 + {{if .Saved}}checked{{end}} 80 + name="recipe-{{.ComputeHash}}" 81 + value="save" 82 + id="save-{{.ComputeHash}}" 83 + class="peer/save hidden" /> 84 + <label for="save-{{.ComputeHash}}" 85 + hx-post="/recipe/{{.ComputeHash}}/save" 86 + hx-vals='{"h":"{{$.Hash}}"}' 87 + hx-trigger="click" 88 + hx-target="next .profile-status" 89 + hx-swap="innerHTML" 90 + onclick="var d=this.closest('article').querySelector('details'); if (d) d.open=false;" 91 + class="inline-flex cursor-pointer items-center justify-center rounded-lg border-2 border-action-green-500 bg-action-green-50 px-4 py-2 text-sm font-medium text-action-green-700 transition hover:bg-action-green-100 peer-checked/save:border-action-green-700 peer-checked/save:bg-action-green-600 peer-checked/save:text-white"> 92 + Save 93 + </label> 94 + <input type="radio" 95 + {{if index $.DismissedHashes (.ComputeHash)}}checked{{end}} 96 + name="recipe-{{.ComputeHash}}" 97 + value="dismiss" 98 + id="dismiss-{{.ComputeHash}}" 99 + class="peer/dismiss hidden" /> 100 + <label for="dismiss-{{.ComputeHash}}" 101 + hx-post="/recipe/{{.ComputeHash}}/dismiss" 102 + hx-vals='{"h":"{{$.Hash}}"}' 103 + hx-trigger="click" 104 + hx-target="next .profile-status" 105 + hx-swap="innerHTML" 106 + onclick="var d=this.closest('article').querySelector('details'); if (d) d.open=false;" 107 + class="inline-flex cursor-pointer items-center justify-center rounded-lg border-2 border-action-red-500 bg-action-red-50 px-4 py-2 text-sm font-medium text-action-red-700 transition hover:bg-action-red-100 peer-checked/dismiss:border-action-red-700 peer-checked/dismiss:bg-action-red-600 peer-checked/dismiss:text-white"> 108 + Dismiss 109 + </label> 110 + <button type="button" 111 + onclick="var d=this.closest('article').querySelector('details'); if (d) d.open=!d.open;" 112 + class="inline-flex items-center justify-center rounded-lg border-2 border-brand-400 bg-brand-50 px-4 py-2 text-sm font-medium text-brand-700 transition hover:bg-brand-100 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2"> 113 + Details 114 + </button> 115 + <span class="profile-status text-xs font-medium text-ink-600" aria-live="polite"></span> 116 + </div> 117 + </header> 95 118 96 - <section id="details-{{.ComputeHash}}" class="space-y-6 {{if .Saved}}hidden{{end}}"> 119 + <details {{if not (or .Saved (index $.DismissedHashes (.ComputeHash)))}}open{{end}} class="space-y-4"> 120 + <summary class="hidden">Details</summary> 121 + <section class="space-y-6"> 97 122 <div class="grid gap-6 md:grid-cols-2"> 98 123 <div> 99 124 <h3 class="text-sm font-semibold uppercase tracking-wide text-ink-500">Ingredients</h3> ··· 128 153 <p><span class="font-semibold text-brand-700">Drink pairing:</span> {{.DrinkPairing}}</p> 129 154 </div> 130 155 </section> 156 + </details> 131 157 </article> 132 158 {{end}} 133 159 134 160 {{if .ShoppingList}} 135 161 <section class="rounded-2xl border border-brand-100 bg-white/95 p-6 shadow-md"> 136 - <div class="flex flex-wrap items-center justify-between gap-3"> 137 - <h2 class="text-lg font-semibold text-brand-700">Shopping list</h2> 138 - <button type="button" 139 - id="shoppingListToggle" 140 - aria-controls="shoppingListPanel" 141 - aria-expanded="false" 142 - 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"> 143 - Show 144 - </button> 145 - </div> 146 - <div id="shoppingListPanel" class="mt-4 hidden"> 162 + <details> 163 + <summary class="inline-flex cursor-pointer 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"> 164 + Shopping list 165 + </summary> 166 + <div class="mt-4"> 147 167 <ul class="space-y-2 text-ink-700"> 148 168 {{range .ShoppingList}} 149 169 <li class="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-brand-50 px-3 py-2 text-sm"> ··· 152 172 </li> 153 173 {{end}} 154 174 </ul> 155 - </div> 175 + </div> 176 + </details> 156 177 </section> 157 178 {{end}} 158 179 </div> 159 - </form> 160 180 161 181 <div class="mt-10 flex flex-wrap items-center gap-4"> 162 - <button type="submit" form="regenerateForm" name="finalize" value="true" id="finalizeButton" 163 - class="inline-flex items-center justify-center rounded-lg bg-action-green-500 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition hover:bg-action-green-600 focus:outline-none focus:ring-2 focus:ring-action-green-300 focus:ring-offset-2"> 164 - Finalize 165 - </button> 166 182 <button type="button" 167 - onclick="shareRecipes()" 183 + id="finalizeButton" 184 + hx-post="/recipes/{{.Hash}}/finalize" 185 + hx-swap="none" 168 186 class="inline-flex items-center justify-center rounded-lg bg-action-green-500 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition hover:bg-action-green-600 focus:outline-none focus:ring-2 focus:ring-action-green-300 focus:ring-offset-2"> 169 - Share Recipes 187 + Finalize 170 188 </button> 171 189 </div> 172 190 </div> ··· 175 193 <p class="text-center text-sm text-ink-500">Generated by Careme.</p> 176 194 </section> 177 195 </main> 178 - 179 - <script> 180 - function updateFinalizeButton() { 181 - const form = document.getElementById('regenerateForm'); 182 - const savedInputs = form.querySelectorAll('input[name="saved"]'); 183 - const finalizeButton = document.getElementById('finalizeButton'); 184 - 185 - // Check if any saved inputs have non-empty values 186 - const hasSavedRecipes = Array.from(savedInputs).some(input => input.value.trim() !== ''); 187 - 188 - // Enable/disable the button based on whether recipes are saved 189 - finalizeButton.disabled = !hasSavedRecipes; 190 - } 191 - 192 - // Initialize button state on page load 193 - document.addEventListener('DOMContentLoaded', updateFinalizeButton); 194 - 195 - async function shareRecipes() { 196 - try { 197 - await navigator.share({title: document.title, url: window.location.href}); 198 - } catch (err) { 199 - if (err && err.name === 'AbortError') { 200 - return; 201 - } 202 - window.prompt('Copy this link:', window.location.href); 203 - } 204 - } 205 - 206 - function toggleRecipeDetails(hash) { 207 - const panel = document.getElementById('details-' + hash); 208 - if (!panel) { 209 - return; 210 - } 211 - const isOpen = panel.classList.contains('hidden'); 212 - setRecipeDetailsVisible(hash, isOpen); 213 - } 214 - 215 - function setRecipeDetailsVisible(hash, isVisible) { 216 - const panel = document.getElementById('details-' + hash); 217 - if (!panel) { 218 - return; 219 - } 220 - panel.classList.toggle('hidden', !isVisible); 221 - const button = document.getElementById('details-' + hash + '-button'); 222 - if (button) { 223 - button.setAttribute('aria-expanded', isVisible ? 'true' : 'false'); 224 - button.textContent = isVisible ? 'Hide details' : 'Show details'; 225 - } 226 - } 227 - 228 - const shoppingListToggle = document.getElementById('shoppingListToggle'); 229 - const shoppingListPanel = document.getElementById('shoppingListPanel'); 230 - if (shoppingListToggle && shoppingListPanel) { 231 - shoppingListToggle.addEventListener('click', function () { 232 - shoppingListPanel.classList.toggle('hidden'); 233 - const isOpen = !shoppingListPanel.classList.contains('hidden'); 234 - shoppingListToggle.textContent = isOpen ? 'Hide' : 'Show'; 235 - shoppingListToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 236 - }); 237 - } 238 - </script> 196 + <script src="/static/htmx@2.0.8.js"></script> 239 197 {{template "clerk_refresh.html" .}} 240 198 </body> 241 199 </html>