ai cooking
0
fork

Configure Feed

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

show stars for everything you cooked (#391)

* show stars for everything you cooked

* more stars

* fix tests

* fumpt

* cooked vs stars

* FUMPT

---------

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

authored by

Paul Miller
paul miller
and committed by
GitHub
04fdf04a d2cc2547

+86 -44
+2 -2
AGENTS.md
··· 16 16 - `export GOCACHE=/tmp/go-build` 17 17 - `export GOMODCACHE=/tmp/go-modcache` 18 18 - Alternative persistent path inside repo: `export GOCACHE=$PWD/.cache/go-build && export GOMODCACHE=$PWD/.cache/go-modcache` 19 - - `go fmt ./...` then `go vet ./...`: Baseline formatting and static checks. 19 + - `gofumpt -l -w .` then `go vet ./...`: Baseline formatting and static checks. 20 20 - From the repo root, run `golangci-lint run ./...`: Expanded Go linters. 21 21 - `export ENABLE_MOCKS=1`: to test without kroger, openai credentials 22 22 - `go test ./...`: Run unit tests across all packages; add `-cover` when changing core logic. ··· 26 26 - `tailwind\generate.sh`: run when ever you change css or html 27 27 28 28 ## Coding Style & Naming Conventions 29 - - Go 1.24; keep code `gofmt`-clean before review. Favor small, focused functions and table-driven tests. 29 + - Go 1.24; keep code `gofumpt`-clean before review. Favor small, focused functions and table-driven tests. 30 30 - Exported identifiers in `CamelCase`; package-private helpers in `lowerCamel`. Template names mirror file names in `internal/templates`. 31 31 - Prefer standard library first; add dependencies sparingly and record rationale in PR description if new. 32 32 - Prefer simple html to javascript frameworks
+2 -3
internal/mail/mail.go
··· 173 173 for _, recipe := range recent { 174 174 hashes = append(hashes, recipe.Hash) 175 175 } 176 - cooked := rio.CookedHashes(ctx, hashes) 176 + cooked := rio.FeedbackByHash(ctx, hashes) 177 177 p.LastRecipes = lo.FilterMap(recent, func(r utypes.Recipe, _ int) (string, bool) { 178 - return r.Title, cooked[r.Hash] 178 + return r.Title, cooked[r.Hash].Cooked 179 179 }) 180 - 181 180 // can orphan recipes here with crash or shutdown. Params should have a start time 182 181 183 182 shoppingList, err = m.generator.GenerateRecipes(ctx, p)
+14 -13
internal/recipes/feedback/model.go
··· 24 24 c cache.Cache 25 25 } 26 26 27 + type feedbackResult struct { 28 + Hash string 29 + Feedback Feedback 30 + } 31 + 27 32 func NewIO(c cache.Cache) FeedbackIO { 28 33 if c == nil { 29 34 panic("cache cannot be nil") ··· 63 68 return nil 64 69 } 65 70 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 - 71 + func (fio FeedbackIO) FeedbackByHash(ctx context.Context, hashes []string) map[string]Feedback { 72 + results := lop.Map(hashes, func(hash string, _ int) feedbackResult { 72 73 state, err := fio.FeedbackFromCache(ctx, hash) 73 74 if err != nil { 74 75 if !errors.Is(err, cache.ErrNotFound) { 75 76 slog.WarnContext(ctx, "failed to load recipe feedback", "hash", hash, "error", err) 76 77 } 77 - return "" 78 + return feedbackResult{} 78 79 } 79 - if !state.Cooked { 80 - return "" 80 + return feedbackResult{ 81 + Hash: hash, 82 + Feedback: *state, 81 83 } 82 - return hash 83 84 }) 84 - 85 - return lo.SliceToMap(lo.Compact(cooked), func(hash string) (string, bool) { 86 - return hash, true 85 + results = lo.Compact(results) 86 + return lo.SliceToMap(results, func(result feedbackResult) (string, Feedback) { 87 + return result.Hash, result.Feedback 87 88 }) 88 89 }
+9 -10
internal/recipes/feedback/model_test.go
··· 33 33 } 34 34 } 35 35 36 - func TestCookedHashes(t *testing.T) { 36 + func TestFeedbackByHash(t *testing.T) { 37 37 cache := cache.NewInMemoryCache() 38 38 io := NewIO(cache) 39 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) 40 + state := Feedback{Cooked: true, Stars: 4, UpdatedAt: time.Now()} 41 + if err := io.SaveFeedback(t.Context(), "rated", state); err != nil { 42 + t.Fatalf("failed to save feedback: %v", err) 45 43 } 46 44 47 - got := io.CookedHashes(t.Context(), []string{"cooked", "saved", "missing", "", "cooked"}) 45 + got := io.FeedbackByHash(t.Context(), []string{"rated", "missing", "", "rated"}) 48 46 if len(got) != 1 { 49 - t.Fatalf("expected exactly one cooked hash, got %v", got) 47 + t.Fatalf("expected one feedback entry, got %v", got) 50 48 } 51 - if _, ok := got["cooked"]; !ok { 52 - t.Fatalf("expected cooked hash in result, got %v", got) 49 + rated := got["rated"] 50 + if rated.Cooked != state.Cooked || rated.Stars != state.Stars || rated.Comment != state.Comment || !rated.UpdatedAt.Equal(state.UpdatedAt) { 51 + t.Fatalf("unexpected feedback map contents: got %#v want %#v", rated, state) 53 52 } 54 53 }
+2 -2
internal/recipes/server.go
··· 869 869 for _, recipe := range recent { 870 870 hashes = append(hashes, recipe.Hash) 871 871 } 872 - cooked := s.CookedHashes(ctx, hashes) 872 + cooked := s.FeedbackByHash(ctx, hashes) 873 873 874 874 p.LastRecipes = lo.FilterMap(recent, func(r utypes.Recipe, _ int) (string, bool) { 875 - return r.Title, cooked[r.Hash] 875 + return r.Title, cooked[r.Hash].Cooked 876 876 }) 877 877 878 878 slog.InfoContext(ctx, "generating cached recipes", "params", p.String(), "hash", hash)
+2 -2
internal/templates/user.html
··· 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>{{if .Cooked}} <span aria-label="Cooked" title="Cooked">⭐</span>{{end}} 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="{{.CookedStarsLabel}}" title="{{.CookedStarsLabel}}">{{.CookedStars}}</span>{{end}} 155 155 {{else}} 156 - {{.Title}}{{if .Cooked}} <span aria-label="Cooked" title="Cooked">⭐</span>{{end}} 156 + {{.Title}}{{if .Cooked}} <span aria-label="{{.CookedStarsLabel}}" title="{{.CookedStarsLabel}}">{{.CookedStars}}</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>
+1 -1
internal/users/admin_page.go
··· 149 149 } 150 150 hashes = append(hashes, recipe.Hash) 151 151 } 152 - return len(feedback.NewIO(c).CookedHashes(ctx, hashes)) 152 + return len(feedback.NewIO(c).FeedbackByHash(ctx, hashes)) 153 153 } 154 154 155 155 func renderAdminEmailsText(w http.ResponseWriter, users []utypes.User) {
+36 -4
internal/users/server.go
··· 6 6 "html/template" 7 7 "log/slog" 8 8 "net/http" 9 + "strconv" 9 10 "strings" 10 11 "time" 11 12 ··· 35 36 36 37 type pastRecipeView struct { 37 38 utypes.Recipe 38 - Cooked bool 39 + Cooked bool 40 + CookedStars string 41 + CookedStarsLabel string 39 42 } 40 43 41 44 // NewHandler returns an http.Handler that serves the user related routes under /user. ··· 105 108 w.WriteHeader(http.StatusMethodNotAllowed) 106 109 return 107 110 } 111 + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") 108 112 ctx := r.Context() 109 113 activeTab := "customize" 110 114 if r.URL.Query().Get("tab") == "past" { ··· 213 217 for _, recipe := range recipes { 214 218 hashes = append(hashes, recipe.Hash) 215 219 } 216 - cooked := feedbackIO.CookedHashes(ctx, hashes) 220 + feedbackByHash := feedbackIO.FeedbackByHash(ctx, hashes) 217 221 218 222 return lo.Map(recipes, func(recipe utypes.Recipe, _ int) pastRecipeView { 223 + state, ok := feedbackByHash[recipe.Hash] 219 224 return pastRecipeView{ 220 - Recipe: recipe, 221 - Cooked: cooked[recipe.Hash], 225 + Recipe: recipe, 226 + Cooked: ok && state.Cooked, 227 + CookedStars: cookedStars(ok, state), 228 + CookedStarsLabel: cookedStarsLabel(ok, state), 222 229 } 223 230 }) 231 + } 232 + 233 + func cookedStars(ok bool, state feedback.Feedback) string { 234 + if !ok || !state.Cooked { 235 + return "" 236 + } 237 + stars := state.Stars 238 + if stars < 1 { 239 + return "🔪" 240 + } 241 + return strings.Repeat("⭐", stars) 242 + } 243 + 244 + func cookedStarsLabel(ok bool, state feedback.Feedback) string { 245 + if !ok || !state.Cooked { 246 + return "" 247 + } 248 + stars := state.Stars 249 + if stars < 1 { 250 + return "Cooked" 251 + } 252 + if stars == 1 { 253 + return "Rated 1 star" 254 + } 255 + return "Rated " + strconv.Itoa(stars) + " stars" 224 256 } 225 257 226 258 func (s *server) handleFavorite(w http.ResponseWriter, r *http.Request) {
+18 -7
internal/users/server_test.go
··· 17 17 "careme/internal/recipes/feedback" 18 18 "careme/internal/routing" 19 19 "careme/internal/templates" 20 + 20 21 utypes "careme/internal/users/types" 21 22 ) 22 23 ··· 63 64 64 65 if rr.Code != http.StatusOK { 65 66 t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) 67 + } 68 + if got := rr.Header().Get("Cache-Control"); got != "no-store, no-cache, must-revalidate" { 69 + t.Fatalf("expected no-store cache header, got %q", got) 66 70 } 67 71 68 72 user, err := storage.GetByID("user-1") ··· 180 184 ShoppingDay: "Saturday", 181 185 LastRecipes: []utypes.Recipe{ 182 186 {Title: "Cooked Pasta", Hash: "hash-cooked", CreatedAt: time.Now().Add(-2 * time.Hour)}, 187 + {Title: "Cooked No Rating", Hash: "hash-cooked-unrated", CreatedAt: time.Now().Add(-90 * time.Minute)}, 183 188 {Title: "Saved Soup", Hash: "hash-saved", CreatedAt: time.Now().Add(-1 * time.Hour)}, 184 189 {Title: "Manual Entry", CreatedAt: time.Now()}, 185 190 }, ··· 189 194 } 190 195 191 196 feedbackIO := feedback.NewIO(cacheStore) 192 - if err := feedbackIO.SaveFeedback(t.Context(), "hash-cooked", feedback.Feedback{Cooked: true, UpdatedAt: time.Now()}); err != nil { 197 + if err := feedbackIO.SaveFeedback(t.Context(), "hash-cooked", feedback.Feedback{Cooked: true, Stars: 4, UpdatedAt: time.Now()}); err != nil { 193 198 t.Fatalf("failed to seed cooked feedback: %v", err) 199 + } 200 + if err := feedbackIO.SaveFeedback(t.Context(), "hash-cooked-unrated", feedback.Feedback{Cooked: true, UpdatedAt: time.Now()}); err != nil { 201 + t.Fatalf("failed to seed unrated cooked feedback: %v", err) 194 202 } 195 203 if err := feedbackIO.SaveFeedback(t.Context(), "hash-saved", feedback.Feedback{Cooked: false, UpdatedAt: time.Now()}); err != nil { 196 204 t.Fatalf("failed to seed uncooked feedback: %v", err) ··· 206 214 } 207 215 208 216 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) 217 + if !strings.Contains(body, `Cooked Pasta</a> <span aria-label="Rated 4 stars" title="Rated 4 stars">⭐⭐⭐⭐</span>`) { 218 + t.Fatalf("expected cooked recipe to render 4 stars, got body: %s", body) 219 + } 220 + if !strings.Contains(body, `Cooked No Rating</a> <span aria-label="Cooked" title="Cooked">🔪</span>`) { 221 + t.Fatalf("expected unrated cooked recipe to render 1 star, got body: %s", body) 211 222 } 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) 223 + if strings.Contains(body, `Saved Soup</a> <span aria-label="Rated`) { 224 + t.Fatalf("expected uncooked saved recipe not to render stars, got body: %s", body) 214 225 } 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) 226 + if strings.Contains(body, `Manual Entry <span aria-label="Rated`) { 227 + t.Fatalf("expected manual recipe without hash not to render stars, got body: %s", body) 217 228 } 218 229 }