ai cooking
0
fork

Configure Feed

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

Knife and fork for cooked dishes (#388)

* refactor cookedhashes

* got something

* silly frumpt

* can't tell what a fork is

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
c3d4b8f3 03d440b9

+159 -66
+10 -14
internal/mail/mail.go
··· 19 19 "careme/internal/locations" 20 20 "careme/internal/recipes" 21 21 "careme/internal/users" 22 + 22 23 utypes "careme/internal/users/types" 23 24 24 25 "github.com/samber/lo" 25 - lop "github.com/samber/lo/parallel" 26 26 "github.com/sendgrid/rest" 27 27 "github.com/sendgrid/sendgrid-go" 28 28 "github.com/sendgrid/sendgrid-go/helpers/mail" ··· 169 169 recent := lo.Filter(user.LastRecipes, func(r utypes.Recipe, _ int) bool { 170 170 return r.CreatedAt.After(time.Now().AddDate(0, 0, -14)) // magic number. Should it be loner and shoul we use star rating? 171 171 }) 172 - 173 - keep := lop.Map(recent, func(r utypes.Recipe, _ int) bool { 174 - feedback, err := rio.FeedbackFromCache(ctx, r.Hash) 175 - if err != nil { 176 - if !errors.Is(err, cache.ErrNotFound) { 177 - slog.WarnContext(ctx, "failed to load recipe feedback while building avoid list", "recipe_hash", r.Hash, "error", err) 178 - } 179 - return false 180 - } 181 - return feedback.Cooked 172 + hashes := make([]string, 0, len(recent)) 173 + for _, recipe := range recent { 174 + hashes = append(hashes, recipe.Hash) 175 + } 176 + cooked := rio.CookedHashes(ctx, hashes) 177 + p.LastRecipes = lo.FilterMap(recent, func(r utypes.Recipe, _ int) (string, bool) { 178 + return r.Title, cooked[r.Hash] 182 179 }) 183 - 184 - for i, last := range recent { 185 - if !keep[i] { 180 + for _, last := range recent { 181 + if cooked[last.Hash] { 186 182 continue 187 183 } 188 184 p.LastRecipes = append(p.LastRecipes, last.Title)
+28
internal/recipes/feedback/model.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "log/slog" 7 8 "time" 8 9 9 10 "careme/internal/cache" 11 + 12 + "github.com/samber/lo" 13 + lop "github.com/samber/lo/parallel" 10 14 ) 11 15 12 16 type Feedback struct { ··· 58 62 } 59 63 return nil 60 64 } 65 + 66 + func (fio FeedbackIO) CookedHashes(ctx context.Context, hashes []string) map[string]bool { 67 + cooked := lop.Map(hashes, func(hash string, _ int) string { 68 + if hash == "" { 69 + return "" 70 + } 71 + 72 + state, err := fio.FeedbackFromCache(ctx, hash) 73 + if err != nil { 74 + if !errors.Is(err, cache.ErrNotFound) { 75 + slog.WarnContext(ctx, "failed to load recipe feedback", "hash", hash, "error", err) 76 + } 77 + return "" 78 + } 79 + if !state.Cooked { 80 + return "" 81 + } 82 + return hash 83 + }) 84 + 85 + return lo.SliceToMap(lo.Compact(cooked), func(hash string) (string, bool) { 86 + return hash, true 87 + }) 88 + }
+20
internal/recipes/feedback/model_test.go
··· 32 32 t.Fatalf("round trip mismatch: got %#v want %#v", *decoded, original) 33 33 } 34 34 } 35 + 36 + func TestCookedHashes(t *testing.T) { 37 + cache := cache.NewInMemoryCache() 38 + io := NewIO(cache) 39 + 40 + if err := io.SaveFeedback(t.Context(), "cooked", Feedback{Cooked: true, UpdatedAt: time.Now()}); err != nil { 41 + t.Fatalf("failed to save cooked feedback: %v", err) 42 + } 43 + if err := io.SaveFeedback(t.Context(), "saved", Feedback{Cooked: false, UpdatedAt: time.Now()}); err != nil { 44 + t.Fatalf("failed to save uncooked feedback: %v", err) 45 + } 46 + 47 + got := io.CookedHashes(t.Context(), []string{"cooked", "saved", "missing", "", "cooked"}) 48 + if len(got) != 1 { 49 + t.Fatalf("expected exactly one cooked hash, got %v", got) 50 + } 51 + if _, ok := got["cooked"]; !ok { 52 + t.Fatalf("expected cooked hash in result, got %v", got) 53 + } 54 + }
+7 -17
internal/recipes/server.go
··· 28 28 utypes "careme/internal/users/types" 29 29 30 30 "github.com/samber/lo" 31 - lop "github.com/samber/lo/parallel" 32 31 ) 33 32 34 33 func setTextContent(w http.ResponseWriter) { ··· 866 865 recent := lo.Filter(currentUser.LastRecipes, func(r utypes.Recipe, _ int) bool { 867 866 return r.CreatedAt.After(time.Now().AddDate(0, 0, -14)) // magic number. Should it be loner and shoul we use star rating? 868 867 }) 868 + hashes := make([]string, 0, len(recent)) 869 + for _, recipe := range recent { 870 + hashes = append(hashes, recipe.Hash) 871 + } 872 + cooked := s.CookedHashes(ctx, hashes) 869 873 870 - keep := lop.Map(recent, func(r utypes.Recipe, _ int) bool { 871 - feedback, err := s.FeedbackFromCache(ctx, r.Hash) 872 - if err != nil { 873 - if !errors.Is(err, cache.ErrNotFound) { 874 - slog.WarnContext(ctx, "failed to load recipe feedback while building avoid list", "recipe_hash", r.Hash, "error", err) 875 - } 876 - return false 877 - } 878 - return feedback.Cooked 874 + p.LastRecipes = lo.FilterMap(recent, func(r utypes.Recipe, _ int) (string, bool) { 875 + return r.Title, cooked[r.Hash] 879 876 }) 880 - 881 - for i, last := range recent { 882 - if !keep[i] { 883 - continue 884 - } 885 - p.LastRecipes = append(p.LastRecipes, last.Title) 886 - } 887 877 888 878 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash) 889 879 shoppingList, err := s.generator.GenerateRecipes(ctx, p)
+4 -4
internal/templates/user.html
··· 144 144 </button> 145 145 </form> 146 146 147 - {{if .User.LastRecipes}} 147 + {{if .PastRecipes}} 148 148 <span class="text-xs uppercase tracking-wide text-gray-400">Recent history</span> 149 149 <ul class="divide-y divide-brand-100 rounded-xl border border-brand-100 bg-white/60"> 150 - {{range .User.LastRecipes}} 150 + {{range .PastRecipes}} 151 151 <li class="px-5 py-4 text-sm text-gray-700"> 152 152 <div class="font-semibold text-brand-700"> 153 153 {{if .Hash}} 154 - <a href="/recipe/{{.Hash}}" class="hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2">{{.Title}}</a> 154 + <a href="/recipe/{{.Hash}}" class="hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:ring-offset-2">{{.Title}}</a>{{if .Cooked}} <span aria-label="Cooked" title="Cooked">⭐</span>{{end}} 155 155 {{else}} 156 - {{.Title}} 156 + {{.Title}}{{if .Cooked}} <span aria-label="Cooked" title="Cooked">⭐</span>{{end}} 157 157 {{end}} 158 158 </div> 159 159 <p class="mt-1 text-xs text-gray-500">Added: {{.CreatedAt.Format "January 2, 2006"}}</p>
+6 -31
internal/users/admin_page.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "html/template" 7 6 "log/slog" 8 7 "net/http" 9 8 "sort" 10 9 "strings" 11 - "sync" 12 - "sync/atomic" 13 10 "time" 14 11 15 12 "careme/internal/cache" 13 + "careme/internal/recipes/feedback" 16 14 17 15 utypes "careme/internal/users/types" 18 16 ) ··· 21 19 ID string 22 20 Emails []string 23 21 SavedRecipeCount int 24 - CookedRecipeCount int32 22 + CookedRecipeCount int 25 23 } 26 24 27 25 var adminUsersPageTmpl = template.Must(template.New("admin-users").Parse(`<!doctype html> ··· 143 141 return filtered 144 142 } 145 143 146 - type adminRecipeFeedback struct { 147 - Cooked bool `json:"cooked"` 148 - } 149 - 150 - func cookedRecipeCount(ctx context.Context, c cache.Cache, user utypes.User) int32 { 151 - var count atomic.Int32 152 - var wg sync.WaitGroup 144 + func cookedRecipeCount(ctx context.Context, c cache.Cache, user utypes.User) int { 145 + hashes := make([]string, 0, len(user.LastRecipes)) 153 146 for _, recipe := range user.LastRecipes { 154 147 if time.Since(recipe.CreatedAt) > 30*time.Hour*24 { 155 148 continue 156 149 } 157 - 158 - wg.Go(func() { 159 - feedbackReader, err := c.Get(ctx, "recipe_feedback/"+recipe.Hash) 160 - if err != nil { 161 - return 162 - } 163 - 164 - var feedback adminRecipeFeedback 165 - decodeErr := json.NewDecoder(feedbackReader).Decode(&feedback) 166 - closeErr := feedbackReader.Close() 167 - if decodeErr != nil || closeErr != nil { 168 - return 169 - } 170 - 171 - if feedback.Cooked { 172 - count.Add(1) 173 - } 174 - }) 150 + hashes = append(hashes, recipe.Hash) 175 151 } 176 - wg.Wait() 177 - return count.Load() 152 + return len(feedback.NewIO(c).CookedHashes(ctx, hashes)) 178 153 } 179 154 180 155 func renderAdminEmailsText(w http.ResponseWriter, users []utypes.User) {
+28
internal/users/server.go
··· 10 10 "time" 11 11 12 12 "careme/internal/auth" 13 + "careme/internal/cache" 13 14 "careme/internal/locations" 15 + "careme/internal/recipes/feedback" 14 16 "careme/internal/routing" 15 17 "careme/internal/seasons" 16 18 "careme/internal/templates" 19 + 17 20 utypes "careme/internal/users/types" 21 + 22 + "github.com/samber/lo" 18 23 ) 19 24 20 25 type locationGetter interface { ··· 26 31 userTmpl *template.Template // just remove or is this useful? 27 32 locGetter locationGetter 28 33 clerk auth.AuthClient // make an interface 34 + } 35 + 36 + type pastRecipeView struct { 37 + utypes.Recipe 38 + Cooked bool 29 39 } 30 40 31 41 // NewHandler returns an http.Handler that serves the user related routes under /user. ··· 177 187 Success bool 178 188 FavoriteStoreName string 179 189 ActiveTab string 190 + PastRecipes []pastRecipeView 180 191 Style seasons.Style 181 192 ServerSignedIn bool 182 193 }{ ··· 186 197 Success: success, 187 198 FavoriteStoreName: favoriteStoreName, 188 199 ActiveTab: activeTab, 200 + PastRecipes: pastRecipeViews(ctx, s.storage.cache, userForTemplate.LastRecipes), 189 201 Style: seasons.GetCurrentStyle(), 190 202 ServerSignedIn: true, 191 203 } ··· 193 205 slog.ErrorContext(ctx, "user template execute error", "error", err) 194 206 http.Error(w, "template error", http.StatusInternalServerError) 195 207 } 208 + } 209 + 210 + func pastRecipeViews(ctx context.Context, c cache.Cache, recipes []utypes.Recipe) []pastRecipeView { 211 + feedbackIO := feedback.NewIO(c) 212 + hashes := make([]string, 0, len(recipes)) 213 + for _, recipe := range recipes { 214 + hashes = append(hashes, recipe.Hash) 215 + } 216 + cooked := feedbackIO.CookedHashes(ctx, hashes) 217 + 218 + return lo.Map(recipes, func(recipe utypes.Recipe, _ int) pastRecipeView { 219 + return pastRecipeView{ 220 + Recipe: recipe, 221 + Cooked: cooked[recipe.Hash], 222 + } 223 + }) 196 224 } 197 225 198 226 func (s *server) handleFavorite(w http.ResponseWriter, r *http.Request) {
+56
internal/users/server_test.go
··· 14 14 15 15 "careme/internal/cache" 16 16 "careme/internal/locations" 17 + "careme/internal/recipes/feedback" 17 18 "careme/internal/routing" 19 + "careme/internal/templates" 18 20 utypes "careme/internal/users/types" 19 21 ) 20 22 ··· 160 162 t.Fatalf("expected persisted favorite store to stay unchanged, got %q", user.FavoriteStore) 161 163 } 162 164 } 165 + 166 + func TestHandleUser_PastRecipesShowCookedIndicator(t *testing.T) { 167 + t.Parallel() 168 + cacheStore := cache.NewFileCache(filepath.Join(t.TempDir(), "cache")) 169 + storage := NewStorage(cacheStore) 170 + s := &server{ 171 + storage: storage, 172 + userTmpl: templates.User, 173 + clerk: testAuthClient{}, 174 + } 175 + 176 + existing := &utypes.User{ 177 + ID: "user-1", 178 + Email: []string{"user@example.com"}, 179 + CreatedAt: time.Now(), 180 + ShoppingDay: "Saturday", 181 + LastRecipes: []utypes.Recipe{ 182 + {Title: "Cooked Pasta", Hash: "hash-cooked", CreatedAt: time.Now().Add(-2 * time.Hour)}, 183 + {Title: "Saved Soup", Hash: "hash-saved", CreatedAt: time.Now().Add(-1 * time.Hour)}, 184 + {Title: "Manual Entry", CreatedAt: time.Now()}, 185 + }, 186 + } 187 + if err := storage.Update(existing); err != nil { 188 + t.Fatalf("failed to seed user: %v", err) 189 + } 190 + 191 + feedbackIO := feedback.NewIO(cacheStore) 192 + if err := feedbackIO.SaveFeedback(t.Context(), "hash-cooked", feedback.Feedback{Cooked: true, UpdatedAt: time.Now()}); err != nil { 193 + t.Fatalf("failed to seed cooked feedback: %v", err) 194 + } 195 + if err := feedbackIO.SaveFeedback(t.Context(), "hash-saved", feedback.Feedback{Cooked: false, UpdatedAt: time.Now()}); err != nil { 196 + t.Fatalf("failed to seed uncooked feedback: %v", err) 197 + } 198 + 199 + req := httptest.NewRequest(http.MethodGet, "/user?tab=past", nil) 200 + rr := httptest.NewRecorder() 201 + 202 + s.handleUser(rr, req) 203 + 204 + if rr.Code != http.StatusOK { 205 + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 206 + } 207 + 208 + body := rr.Body.String() 209 + if !strings.Contains(body, `Cooked Pasta</a> <span aria-label="Cooked" title="Cooked">⭐</span>`) { 210 + t.Fatalf("expected cooked recipe to render emoji, got body: %s", body) 211 + } 212 + if strings.Contains(body, `Saved Soup</a> <span aria-label="Cooked" title="Cooked">⭐</span>`) { 213 + t.Fatalf("expected uncooked saved recipe not to render emoji, got body: %s", body) 214 + } 215 + if strings.Contains(body, `Manual Entry <span aria-label="Cooked" title="Cooked">⭐</span>`) { 216 + t.Fatalf("expected manual recipe without hash not to render emoji, got body: %s", body) 217 + } 218 + }