Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

feat: initial recipes work

authored by

Patrick Dewey and committed by tangled.org 69aad181 b448aeac

+2401 -17
+65
docs/recipes.norg
··· 1 + @document.meta 2 + title: Arabica Recipes 3 + authors: @pdewey.com 4 + created: 03/17/26 5 + categories: [spec, lexicon] 6 + @end 7 + 8 + * Recipe Records 9 + 10 + ** Motivation 11 + 12 + When inputting a new brew, it is tedious to type out all the fields when many 13 + of them are always the same. A "recipe" is supposed to solve parts of this 14 + tedium by auto-filling a number of the fields (most notably pours; count, 15 + water amount, and time). A user should be able to create/save a recipe from 16 + one of their brews (or maybe another users brews as well?). 17 + 18 + Recipes are also have potential as a social construct that can be easily 19 + shared around and used. Saving another users recipe is something I would like 20 + to build around in the future. Analytics would also be super cool for 21 + recipes. 22 + 23 + ** Lexicon Fields 24 + 25 + - `name` (string) 26 + - `brewerRef` (ref) 27 + - `brewerType` (string) 28 + - `coffeeAmount` (int -- should be * 10 but isn't) 29 + - `waterAmount` (int * 10) 30 + - `grindSize` (int * 10) 31 + - `pours` (array of `#pour`) (references {*** Pour Schema})` 32 + - `notes`: (string) reeform description field 33 + 34 + It would probably also make sense to ceraete a `brewRef` field that draws a 35 + link to the brew that the original version of the recipe was created from. 36 + 37 + *** Pour Schema 38 + 39 + Both required: 40 + - `waterAmount` (int - multiplied by 10) 41 + - `timeSeconds` (int) 42 + 43 + ** Implementation 44 + 45 + - Once selected, a recipe should autofill all non-null fields in the brew 46 + - A user should be able to save a recipe off of any brew (by them or by 47 + another user) 48 + - Users should be able to create new recipes from scratch (requires new modal 49 + and view page) 50 + 51 + ** Debugging and Changes 52 + 53 + *** Explore page 54 + 55 + The explore page filters are a bit weird, and values between certain ones 56 + seem a bit wonky (i.e. a 15g dose brew shows up in single, small, and large 57 + filters when it should probably only show up in the single cup one (small 58 + should probably be 12 or less? -- not sure about the exact values). 59 + 60 + Recipes should also show the profile picture and username of the creator of 61 + the recipe. 62 + 63 + Some recipe fields should be interpolated from other fields when missing 64 + (i.e. water amount and ratio, from pours and coffee amount. Ratio may be 65 + transitive)
+23
internal/atproto/cache.go
··· 18 18 Roasters []*models.Roaster 19 19 Grinders []*models.Grinder 20 20 Brewers []*models.Brewer 21 + Recipes []*models.Recipe 21 22 Brews []*models.Brew 22 23 Timestamp time.Time 23 24 } ··· 40 41 Roasters: c.Roasters, 41 42 Grinders: c.Grinders, 42 43 Brewers: c.Brewers, 44 + Recipes: c.Recipes, 43 45 Brews: c.Brews, 44 46 Timestamp: c.Timestamp, 45 47 } ··· 119 121 defer sc.mu.Unlock() 120 122 newCache := sc.caches[sessionID].clone() 121 123 newCache.Brewers = brewers 124 + newCache.Timestamp = time.Now() 125 + sc.caches[sessionID] = newCache 126 + } 127 + 128 + // SetRecipes updates just the recipes in the cache using copy-on-write 129 + func (sc *SessionCache) SetRecipes(sessionID string, recipes []*models.Recipe) { 130 + sc.mu.Lock() 131 + defer sc.mu.Unlock() 132 + newCache := sc.caches[sessionID].clone() 133 + newCache.Recipes = recipes 122 134 newCache.Timestamp = time.Now() 123 135 sc.caches[sessionID] = newCache 124 136 } ··· 175 187 if cache, ok := sc.caches[sessionID]; ok { 176 188 newCache := cache.clone() 177 189 newCache.Brewers = nil 190 + sc.caches[sessionID] = newCache 191 + } 192 + } 193 + 194 + // InvalidateRecipes marks that recipes need to be refreshed using copy-on-write 195 + func (sc *SessionCache) InvalidateRecipes(sessionID string) { 196 + sc.mu.Lock() 197 + defer sc.mu.Unlock() 198 + if cache, ok := sc.caches[sessionID]; ok { 199 + newCache := cache.clone() 200 + newCache.Recipes = nil 178 201 sc.caches[sessionID] = newCache 179 202 } 180 203 }
+1
internal/atproto/nsid.go
··· 19 19 NSIDComment = NSIDBase + ".comment" 20 20 NSIDGrinder = NSIDBase + ".grinder" 21 21 NSIDLike = NSIDBase + ".like" 22 + NSIDRecipe = NSIDBase + ".recipe" 22 23 NSIDRoaster = NSIDBase + ".roaster" 23 24 24 25 // MaxRKeyLength is the maximum allowed length for a record key
+1
internal/atproto/oauth.go
··· 26 26 "repo:" + NSIDComment, 27 27 "repo:" + NSIDGrinder, 28 28 "repo:" + NSIDLike, 29 + "repo:" + NSIDRecipe, 29 30 "repo:" + NSIDRoaster, 30 31 } 31 32
+114 -2
internal/atproto/records.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 ) 11 11 12 + // ========== Recipe Conversions ========== 13 + 14 + // RecipeToRecord converts a models.Recipe to an atproto record map 15 + func RecipeToRecord(recipe *models.Recipe, brewerURI string) (map[string]interface{}, error) { 16 + record := map[string]interface{}{ 17 + "$type": NSIDRecipe, 18 + "name": recipe.Name, 19 + "createdAt": recipe.CreatedAt.Format(time.RFC3339), 20 + } 21 + 22 + if brewerURI != "" { 23 + record["brewerRef"] = brewerURI 24 + } 25 + if recipe.BrewerType != "" { 26 + record["brewerType"] = recipe.BrewerType 27 + } 28 + if recipe.CoffeeAmount > 0 { 29 + record["coffeeAmount"] = int(recipe.CoffeeAmount * 10) 30 + } 31 + if recipe.WaterAmount > 0 { 32 + record["waterAmount"] = int(recipe.WaterAmount * 10) 33 + } 34 + if recipe.GrindSize != "" { 35 + record["grindSize"] = recipe.GrindSize 36 + } 37 + if recipe.Notes != "" { 38 + record["notes"] = recipe.Notes 39 + } 40 + 41 + if len(recipe.Pours) > 0 { 42 + pours := make([]map[string]interface{}, len(recipe.Pours)) 43 + for i, pour := range recipe.Pours { 44 + pours[i] = map[string]interface{}{ 45 + "waterAmount": pour.WaterAmount, 46 + "timeSeconds": pour.TimeSeconds, 47 + } 48 + } 49 + record["pours"] = pours 50 + } 51 + 52 + return record, nil 53 + } 54 + 55 + // RecordToRecipe converts an atproto record map to a models.Recipe 56 + func RecordToRecipe(record map[string]interface{}, atURI string) (*models.Recipe, error) { 57 + recipe := &models.Recipe{} 58 + 59 + if atURI != "" { 60 + parsedURI, err := syntax.ParseATURI(atURI) 61 + if err != nil { 62 + return nil, fmt.Errorf("invalid AT-URI: %w", err) 63 + } 64 + recipe.RKey = parsedURI.RecordKey().String() 65 + } 66 + 67 + name, ok := record["name"].(string) 68 + if !ok || name == "" { 69 + return nil, fmt.Errorf("name is required") 70 + } 71 + recipe.Name = name 72 + 73 + createdAtStr, ok := record["createdAt"].(string) 74 + if !ok { 75 + return nil, fmt.Errorf("createdAt is required") 76 + } 77 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 78 + if err != nil { 79 + return nil, fmt.Errorf("invalid createdAt format: %w", err) 80 + } 81 + recipe.CreatedAt = createdAt 82 + 83 + if brewerType, ok := record["brewerType"].(string); ok { 84 + recipe.BrewerType = brewerType 85 + } 86 + if coffeeAmount, ok := record["coffeeAmount"].(float64); ok { 87 + recipe.CoffeeAmount = coffeeAmount / 10.0 88 + } 89 + if waterAmount, ok := record["waterAmount"].(float64); ok { 90 + recipe.WaterAmount = waterAmount / 10.0 91 + } 92 + if grindSize, ok := record["grindSize"].(string); ok { 93 + recipe.GrindSize = grindSize 94 + } 95 + if notes, ok := record["notes"].(string); ok { 96 + recipe.Notes = notes 97 + } 98 + 99 + if poursRaw, ok := record["pours"].([]interface{}); ok { 100 + recipe.Pours = make([]*models.Pour, len(poursRaw)) 101 + for i, pourRaw := range poursRaw { 102 + pourMap, ok := pourRaw.(map[string]interface{}) 103 + if !ok { 104 + continue 105 + } 106 + pour := &models.Pour{} 107 + if waterAmount, ok := pourMap["waterAmount"].(float64); ok { 108 + pour.WaterAmount = int(waterAmount) 109 + } 110 + if timeSeconds, ok := pourMap["timeSeconds"].(float64); ok { 111 + pour.TimeSeconds = int(timeSeconds) 112 + } 113 + pour.PourNumber = i + 1 114 + recipe.Pours[i] = pour 115 + } 116 + } 117 + 118 + return recipe, nil 119 + } 120 + 12 121 // ========== Brew Conversions ========== 13 122 14 123 // BrewToRecord converts a models.Brew to an atproto record map 15 - // Note: References (beanRef, grinderRef, brewerRef) must be AT-URIs 16 - func BrewToRecord(brew *models.Brew, beanURI, grinderURI, brewerURI string) (map[string]interface{}, error) { 124 + // Note: References (beanRef, grinderRef, brewerRef, recipeRef) must be AT-URIs 125 + func BrewToRecord(brew *models.Brew, beanURI, grinderURI, brewerURI, recipeURI string) (map[string]interface{}, error) { 17 126 if beanURI == "" { 18 127 return nil, fmt.Errorf("beanRef (AT-URI) is required") 19 128 } ··· 49 158 } 50 159 if brewerURI != "" { 51 160 record["brewerRef"] = brewerURI 161 + } 162 + if recipeURI != "" { 163 + record["recipeRef"] = recipeURI 52 164 } 53 165 if brew.TastingNotes != "" { 54 166 record["tastingNotes"] = brew.TastingNotes
+4 -4
internal/atproto/records_test.go
··· 30 30 grinderURI := "at://did:plc:test/social.arabica.alpha.grinder/grinder123" 31 31 brewerURI := "at://did:plc:test/social.arabica.alpha.brewer/brewer123" 32 32 33 - record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI) 33 + record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI, "") 34 34 if err != nil { 35 35 t.Fatalf("BrewToRecord() error = %v", err) 36 36 } ··· 96 96 97 97 beanURI := "at://did:plc:test/social.arabica.alpha.bean/bean123" 98 98 99 - record, err := BrewToRecord(brew, beanURI, "", "") 99 + record, err := BrewToRecord(brew, beanURI, "", "", "") 100 100 if err != nil { 101 101 t.Fatalf("BrewToRecord() error = %v", err) 102 102 } ··· 124 124 CreatedAt: createdAt, 125 125 } 126 126 127 - _, err := BrewToRecord(brew, "", "", "") 127 + _, err := BrewToRecord(brew, "", "", "", "") 128 128 if err == nil { 129 129 t.Error("BrewToRecord() should error without beanURI") 130 130 } ··· 688 688 CreatedAt: createdAt, 689 689 } 690 690 691 - record, err := BrewToRecord(brew, "at://did:plc:test/social.arabica.alpha.bean/bean123", "", "") 691 + record, err := BrewToRecord(brew, "at://did:plc:test/social.arabica.alpha.bean/bean123", "", "", "") 692 692 if err != nil { 693 693 t.Fatalf("BrewToRecord() error = %v", err) 694 694 }
+6 -1
internal/atproto/resolver.go
··· 139 139 return resolveRef(ctx, client, atURI, sessionID, NSIDBrewer, RecordToBrewer) 140 140 } 141 141 142 + // ResolveRecipeRef fetches a recipe record from an AT-URI 143 + func ResolveRecipeRef(ctx context.Context, client *Client, atURI string, sessionID string) (*models.Recipe, error) { 144 + return resolveRef(ctx, client, atURI, sessionID, NSIDRecipe, RecordToRecipe) 145 + } 146 + 142 147 // ResolveBrewRefs resolves all references within a brew record 143 - // This is a convenience function that resolves bean, grinder, and brewer refs in one call 148 + // This is a convenience function that resolves bean, grinder, brewer, and recipe refs in one call 144 149 func ResolveBrewRefs(ctx context.Context, client *Client, brew *models.Brew, beanRef, grinderRef, brewerRef, sessionID string) error { 145 150 var err error 146 151
+267 -5
internal/atproto/store.go
··· 52 52 brew.BrewerRKey = c.RKey 53 53 } 54 54 } 55 + if recipeRef, _ := record["recipeRef"].(string); recipeRef != "" { 56 + if c, err := ResolveATURI(recipeRef); err == nil { 57 + brew.RecipeRKey = c.RKey 58 + } 59 + } 55 60 } 56 61 57 62 // brewModelFromRequest converts a CreateBrewRequest into a Brew model with the given creation time. 58 63 func brewModelFromRequest(req *models.CreateBrewRequest, createdAt time.Time) *models.Brew { 59 64 brew := &models.Brew{ 60 65 BeanRKey: req.BeanRKey, 66 + RecipeRKey: req.RecipeRKey, 61 67 GrinderRKey: req.GrinderRKey, 62 68 BrewerRKey: req.BrewerRKey, 63 69 Method: req.Method, ··· 92 98 93 99 beanURI := BuildATURI(s.did.String(), NSIDBean, brew.BeanRKey) 94 100 95 - var grinderURI, brewerURI string 101 + var grinderURI, brewerURI, recipeURI string 96 102 if brew.GrinderRKey != "" { 97 103 grinderURI = BuildATURI(s.did.String(), NSIDGrinder, brew.GrinderRKey) 98 104 } 99 105 if brew.BrewerRKey != "" { 100 106 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey) 107 + } 108 + if brew.RecipeRKey != "" { 109 + recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey) 101 110 } 102 111 103 112 // Convert to models.Brew for record conversion 104 113 brewModel := brewModelFromRequest(brew, time.Now().UTC()) 105 114 106 115 // Convert to atproto record 107 - record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI) 116 + record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI, recipeURI) 108 117 if err != nil { 109 118 return nil, fmt.Errorf("failed to convert brew to record: %w", err) 110 119 } ··· 171 180 if err != nil { 172 181 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") 173 182 } 183 + if recipeRef, _ := output.Value["recipeRef"].(string); recipeRef != "" { 184 + brew.RecipeObj, err = ResolveRecipeRef(ctx, s.client, recipeRef, s.sessionID) 185 + if err != nil { 186 + log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve recipe reference") 187 + } 188 + } 174 189 175 190 return brew, nil 176 191 } ··· 213 228 if err != nil { 214 229 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") 215 230 } 231 + if recipeRef, _ := output.Value["recipeRef"].(string); recipeRef != "" { 232 + brew.RecipeObj, err = ResolveRecipeRef(ctx, s.client, recipeRef, s.sessionID) 233 + if err != nil { 234 + log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve recipe reference") 235 + } 236 + } 216 237 217 238 return &BrewRecord{ 218 239 Brew: brew, ··· 255 276 } 256 277 257 278 // Resolve references using cached data instead of N+1 queries 258 - // This fetches beans/grinders/brewers once (from cache if available) 279 + // This fetches beans/grinders/brewers/recipes once (from cache if available) 259 280 // then links them to brews in memory 260 281 beans, _ := s.ListBeans(ctx) 261 282 grinders, _ := s.ListGrinders(ctx) 262 283 brewers, _ := s.ListBrewers(ctx) 263 284 roasters, _ := s.ListRoasters(ctx) 285 + recipes, _ := s.ListRecipes(ctx) 264 286 265 287 // Build lookup maps 266 288 beanMap := make(map[string]*models.Bean) ··· 278 300 roasterMap := make(map[string]*models.Roaster) 279 301 for _, r := range roasters { 280 302 roasterMap[r.RKey] = r 303 + } 304 + recipeMap := make(map[string]*models.Recipe) 305 + for _, r := range recipes { 306 + recipeMap[r.RKey] = r 281 307 } 282 308 283 309 // Link references ··· 295 321 if brew.BrewerRKey != "" { 296 322 brew.BrewerObj = brewerMap[brew.BrewerRKey] 297 323 } 324 + if brew.RecipeRKey != "" { 325 + brew.RecipeObj = recipeMap[brew.RecipeRKey] 326 + } 298 327 } 299 328 300 329 // Update cache ··· 311 340 312 341 beanURI := BuildATURI(s.did.String(), NSIDBean, brew.BeanRKey) 313 342 314 - var grinderURI, brewerURI string 343 + var grinderURI, brewerURI, recipeURI string 315 344 if brew.GrinderRKey != "" { 316 345 grinderURI = BuildATURI(s.did.String(), NSIDGrinder, brew.GrinderRKey) 317 346 } 318 347 if brew.BrewerRKey != "" { 319 348 brewerURI = BuildATURI(s.did.String(), NSIDBrewer, brew.BrewerRKey) 320 349 } 350 + if brew.RecipeRKey != "" { 351 + recipeURI = BuildATURI(s.did.String(), NSIDRecipe, brew.RecipeRKey) 352 + } 321 353 322 354 // Get the existing record to preserve createdAt 323 355 existing, err := s.GetBrewByRKey(ctx, rkey) ··· 329 361 brewModel := brewModelFromRequest(brew, existing.CreatedAt) 330 362 331 363 // Convert to atproto record 332 - record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI) 364 + record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI, recipeURI) 333 365 if err != nil { 334 366 return fmt.Errorf("failed to convert brew to record: %w", err) 335 367 } ··· 1143 1175 1144 1176 // Invalidate cache 1145 1177 s.cache.InvalidateBrewers(s.sessionID) 1178 + 1179 + return nil 1180 + } 1181 + 1182 + // ========== Recipe Operations ========== 1183 + 1184 + // RecipeRecord contains a recipe with its AT Protocol metadata 1185 + type RecipeRecord struct { 1186 + Recipe *models.Recipe 1187 + URI string 1188 + CID string 1189 + } 1190 + 1191 + func (s *AtprotoStore) CreateRecipe(ctx context.Context, req *models.CreateRecipeRequest) (*models.Recipe, error) { 1192 + var brewerURI string 1193 + if req.BrewerRKey != "" { 1194 + brewerURI = BuildATURI(s.did.String(), NSIDBrewer, req.BrewerRKey) 1195 + } 1196 + 1197 + recipeModel := &models.Recipe{ 1198 + Name: req.Name, 1199 + BrewerRKey: req.BrewerRKey, 1200 + BrewerType: req.BrewerType, 1201 + CoffeeAmount: req.CoffeeAmount, 1202 + WaterAmount: req.WaterAmount, 1203 + GrindSize: req.GrindSize, 1204 + Notes: req.Notes, 1205 + CreatedAt: time.Now(), 1206 + } 1207 + if len(req.Pours) > 0 { 1208 + recipeModel.Pours = make([]*models.Pour, len(req.Pours)) 1209 + for i, pour := range req.Pours { 1210 + recipeModel.Pours[i] = &models.Pour{ 1211 + WaterAmount: pour.WaterAmount, 1212 + TimeSeconds: pour.TimeSeconds, 1213 + } 1214 + } 1215 + } 1216 + 1217 + record, err := RecipeToRecord(recipeModel, brewerURI) 1218 + if err != nil { 1219 + return nil, fmt.Errorf("failed to convert recipe to record: %w", err) 1220 + } 1221 + 1222 + output, err := s.client.CreateRecord(ctx, s.did, s.sessionID, &CreateRecordInput{ 1223 + Collection: NSIDRecipe, 1224 + Record: record, 1225 + }) 1226 + if err != nil { 1227 + return nil, fmt.Errorf("failed to create recipe record: %w", err) 1228 + } 1229 + 1230 + atURI, err := syntax.ParseATURI(output.URI) 1231 + if err != nil { 1232 + return nil, fmt.Errorf("failed to parse returned AT-URI: %w", err) 1233 + } 1234 + 1235 + recipeModel.RKey = atURI.RecordKey().String() 1236 + 1237 + s.cache.InvalidateRecipes(s.sessionID) 1238 + 1239 + return recipeModel, nil 1240 + } 1241 + 1242 + func (s *AtprotoStore) GetRecipeByRKey(ctx context.Context, rkey string) (*models.Recipe, error) { 1243 + output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 1244 + Collection: NSIDRecipe, 1245 + RKey: rkey, 1246 + }) 1247 + if err != nil { 1248 + return nil, fmt.Errorf("failed to get recipe record: %w", err) 1249 + } 1250 + 1251 + atURI := BuildATURI(s.did.String(), NSIDRecipe, rkey) 1252 + recipe, err := RecordToRecipe(output.Value, atURI) 1253 + if err != nil { 1254 + return nil, fmt.Errorf("failed to convert recipe record: %w", err) 1255 + } 1256 + 1257 + recipe.RKey = rkey 1258 + 1259 + // Resolve brewer reference if present 1260 + if brewerRef, ok := output.Value["brewerRef"].(string); ok && brewerRef != "" { 1261 + if components, err := ResolveATURI(brewerRef); err == nil { 1262 + recipe.BrewerRKey = components.RKey 1263 + } 1264 + recipe.BrewerObj, err = ResolveBrewerRef(ctx, s.client, brewerRef, s.sessionID) 1265 + if err != nil { 1266 + log.Warn().Err(err).Str("recipe_rkey", rkey).Msg("Failed to resolve brewer reference") 1267 + } 1268 + } 1269 + 1270 + return recipe, nil 1271 + } 1272 + 1273 + // GetRecipeRecordByRKey fetches a recipe by rkey and returns it with its AT Protocol metadata 1274 + func (s *AtprotoStore) GetRecipeRecordByRKey(ctx context.Context, rkey string) (*RecipeRecord, error) { 1275 + output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 1276 + Collection: NSIDRecipe, 1277 + RKey: rkey, 1278 + }) 1279 + if err != nil { 1280 + return nil, fmt.Errorf("failed to get recipe record: %w", err) 1281 + } 1282 + 1283 + atURI := BuildATURI(s.did.String(), NSIDRecipe, rkey) 1284 + recipe, err := RecordToRecipe(output.Value, atURI) 1285 + if err != nil { 1286 + return nil, fmt.Errorf("failed to convert recipe record: %w", err) 1287 + } 1288 + 1289 + recipe.RKey = rkey 1290 + 1291 + if brewerRef, ok := output.Value["brewerRef"].(string); ok && brewerRef != "" { 1292 + if components, err := ResolveATURI(brewerRef); err == nil { 1293 + recipe.BrewerRKey = components.RKey 1294 + } 1295 + recipe.BrewerObj, err = ResolveBrewerRef(ctx, s.client, brewerRef, s.sessionID) 1296 + if err != nil { 1297 + log.Warn().Err(err).Str("recipe_rkey", rkey).Msg("Failed to resolve brewer reference") 1298 + } 1299 + } 1300 + 1301 + return &RecipeRecord{ 1302 + Recipe: recipe, 1303 + URI: output.URI, 1304 + CID: output.CID, 1305 + }, nil 1306 + } 1307 + 1308 + func (s *AtprotoStore) ListRecipes(ctx context.Context) ([]*models.Recipe, error) { 1309 + // Check cache first 1310 + userCache := s.cache.Get(s.sessionID) 1311 + if userCache != nil && userCache.Recipes != nil && userCache.IsValid() { 1312 + return userCache.Recipes, nil 1313 + } 1314 + 1315 + output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDRecipe) 1316 + if err != nil { 1317 + return nil, fmt.Errorf("failed to list recipe records: %w", err) 1318 + } 1319 + 1320 + recipes := make([]*models.Recipe, 0, len(output.Records)) 1321 + 1322 + for _, rec := range output.Records { 1323 + recipe, err := RecordToRecipe(rec.Value, rec.URI) 1324 + if err != nil { 1325 + log.Warn().Err(err).Str("uri", rec.URI).Msg("Failed to convert recipe record") 1326 + continue 1327 + } 1328 + 1329 + if components, err := ResolveATURI(rec.URI); err == nil { 1330 + recipe.RKey = components.RKey 1331 + } 1332 + 1333 + // Extract brewer rkey from reference 1334 + if brewerRef, ok := rec.Value["brewerRef"].(string); ok && brewerRef != "" { 1335 + if components, err := ResolveATURI(brewerRef); err == nil { 1336 + recipe.BrewerRKey = components.RKey 1337 + } 1338 + } 1339 + 1340 + recipes = append(recipes, recipe) 1341 + } 1342 + 1343 + s.cache.SetRecipes(s.sessionID, recipes) 1344 + 1345 + return recipes, nil 1346 + } 1347 + 1348 + func (s *AtprotoStore) UpdateRecipeByRKey(ctx context.Context, rkey string, req *models.UpdateRecipeRequest) error { 1349 + existing, err := s.GetRecipeByRKey(ctx, rkey) 1350 + if err != nil { 1351 + return fmt.Errorf("failed to get existing recipe: %w", err) 1352 + } 1353 + 1354 + var brewerURI string 1355 + if req.BrewerRKey != "" { 1356 + brewerURI = BuildATURI(s.did.String(), NSIDBrewer, req.BrewerRKey) 1357 + } 1358 + 1359 + recipeModel := &models.Recipe{ 1360 + Name: req.Name, 1361 + BrewerRKey: req.BrewerRKey, 1362 + BrewerType: req.BrewerType, 1363 + CoffeeAmount: req.CoffeeAmount, 1364 + WaterAmount: req.WaterAmount, 1365 + GrindSize: req.GrindSize, 1366 + Notes: req.Notes, 1367 + CreatedAt: existing.CreatedAt, 1368 + } 1369 + if len(req.Pours) > 0 { 1370 + recipeModel.Pours = make([]*models.Pour, len(req.Pours)) 1371 + for i, pour := range req.Pours { 1372 + recipeModel.Pours[i] = &models.Pour{ 1373 + WaterAmount: pour.WaterAmount, 1374 + TimeSeconds: pour.TimeSeconds, 1375 + } 1376 + } 1377 + } 1378 + 1379 + record, err := RecipeToRecord(recipeModel, brewerURI) 1380 + if err != nil { 1381 + return fmt.Errorf("failed to convert recipe to record: %w", err) 1382 + } 1383 + 1384 + err = s.client.PutRecord(ctx, s.did, s.sessionID, &PutRecordInput{ 1385 + Collection: NSIDRecipe, 1386 + RKey: rkey, 1387 + Record: record, 1388 + }) 1389 + if err != nil { 1390 + return fmt.Errorf("failed to update recipe record: %w", err) 1391 + } 1392 + 1393 + s.cache.InvalidateRecipes(s.sessionID) 1394 + 1395 + return nil 1396 + } 1397 + 1398 + func (s *AtprotoStore) DeleteRecipeByRKey(ctx context.Context, rkey string) error { 1399 + err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 1400 + Collection: NSIDRecipe, 1401 + RKey: rkey, 1402 + }) 1403 + if err != nil { 1404 + return fmt.Errorf("failed to delete recipe record: %w", err) 1405 + } 1406 + 1407 + s.cache.InvalidateRecipes(s.sessionID) 1146 1408 1147 1409 return nil 1148 1410 }
+7
internal/database/store.go
··· 48 48 UpdateBrewerByRKey(ctx context.Context, rkey string, brewer *models.UpdateBrewerRequest) error 49 49 DeleteBrewerByRKey(ctx context.Context, rkey string) error 50 50 51 + // Recipe operations 52 + CreateRecipe(ctx context.Context, recipe *models.CreateRecipeRequest) (*models.Recipe, error) 53 + GetRecipeByRKey(ctx context.Context, rkey string) (*models.Recipe, error) 54 + ListRecipes(ctx context.Context) ([]*models.Recipe, error) 55 + UpdateRecipeByRKey(ctx context.Context, rkey string, recipe *models.UpdateRecipeRequest) error 56 + DeleteRecipeByRKey(ctx context.Context, rkey string) error 57 + 51 58 // Like operations 52 59 CreateLike(ctx context.Context, req *models.CreateLikeRequest) (*models.Like, error) 53 60 DeleteLikeByRKey(ctx context.Context, rkey string) error
+4
internal/feed/service.go
··· 49 49 Roaster *models.Roaster 50 50 Grinder *models.Grinder 51 51 Brewer *models.Brewer 52 + Recipe *models.Recipe 52 53 53 54 Author *atproto.Profile 54 55 Timestamp time.Time ··· 128 129 Roaster *models.Roaster 129 130 Grinder *models.Grinder 130 131 Brewer *models.Brewer 132 + Recipe *models.Recipe 131 133 Author *atproto.Profile 132 134 Timestamp time.Time 133 135 TimeAgo string ··· 377 379 Roaster: fi.Roaster, 378 380 Grinder: fi.Grinder, 379 381 Brewer: fi.Brewer, 382 + Recipe: fi.Recipe, 380 383 Author: fi.Author, 381 384 Timestamp: fi.Timestamp, 382 385 TimeAgo: fi.TimeAgo, ··· 422 425 Roaster: fi.Roaster, 423 426 Grinder: fi.Grinder, 424 427 Brewer: fi.Brewer, 428 + Recipe: fi.Recipe, 425 429 Author: fi.Author, 426 430 Timestamp: fi.Timestamp, 427 431 TimeAgo: fi.TimeAgo,
+1
internal/firehose/adapter.go
··· 62 62 Roaster: item.Roaster, 63 63 Grinder: item.Grinder, 64 64 Brewer: item.Brewer, 65 + Recipe: item.Recipe, 65 66 Author: item.Author, 66 67 Timestamp: item.Timestamp, 67 68 TimeAgo: item.TimeAgo,
+1
internal/firehose/config.go
··· 21 21 atproto.NSIDRoaster, 22 22 atproto.NSIDGrinder, 23 23 atproto.NSIDBrewer, 24 + atproto.NSIDRecipe, 24 25 atproto.NSIDLike, 25 26 atproto.NSIDComment, 26 27 }
+38 -3
internal/firehose/index.go
··· 280 280 return idx.db 281 281 } 282 282 283 - 284 283 // Close closes the index database 285 284 func (idx *FeedIndex) Close() error { 286 285 if idx.db != nil { ··· 400 399 Roaster *models.Roaster 401 400 Grinder *models.Grinder 402 401 Brewer *models.Brewer 402 + Recipe *models.Recipe 403 403 404 404 Author *atproto.Profile 405 405 Timestamp time.Time ··· 426 426 lexicons.RecordTypeRoaster: atproto.NSIDRoaster, 427 427 lexicons.RecordTypeGrinder: atproto.NSIDGrinder, 428 428 lexicons.RecordTypeBrewer: atproto.NSIDBrewer, 429 + lexicons.RecordTypeRecipe: atproto.NSIDRecipe, 429 430 } 430 431 431 432 // feedableCollections is the set of collection NSIDs that appear in the feed ··· 446 447 q.Sort = FeedSortRecent 447 448 } 448 449 449 - var collectionFilter string 450 + collectionFilter := "" 450 451 if q.TypeFilter != "" { 451 452 nsid, ok := recordTypeToNSID[q.TypeFilter] 452 453 if !ok { ··· 465 466 if err != nil { 466 467 return nil, err 467 468 } 468 - 469 469 470 470 if q.Sort == FeedSortPopular { 471 471 sort.Slice(items, func(i, j int) bool { ··· 709 709 } 710 710 } 711 711 712 + // Resolve recipe reference 713 + if recipeRef, ok := recordData["recipeRef"].(string); ok && recipeRef != "" { 714 + if c, err := atproto.ResolveATURI(recipeRef); err == nil { 715 + brew.RecipeRKey = c.RKey 716 + } 717 + if recipeRecord, found := refMap[recipeRef]; found { 718 + var recipeData map[string]any 719 + if err := json.Unmarshal(recipeRecord.Record, &recipeData); err == nil { 720 + recipe, _ := atproto.RecordToRecipe(recipeData, recipeRef) 721 + brew.RecipeObj = recipe 722 + } 723 + } 724 + } 725 + 712 726 item.RecordType = lexicons.RecordTypeBrew 713 727 item.Action = "added a new brew" 714 728 item.Brew = brew ··· 760 774 item.RecordType = lexicons.RecordTypeBrewer 761 775 item.Action = "added a new brewer" 762 776 item.Brewer = brewer 777 + 778 + case atproto.NSIDRecipe: 779 + recipe, err := atproto.RecordToRecipe(recordData, record.URI) 780 + if err != nil { 781 + return nil, err 782 + } 783 + 784 + // Resolve brewer reference 785 + if brewerRef, ok := recordData["brewerRef"].(string); ok && brewerRef != "" { 786 + if brewerRecord, found := refMap[brewerRef]; found { 787 + var brewerData map[string]any 788 + if err := json.Unmarshal(brewerRecord.Record, &brewerData); err == nil { 789 + brewer, _ := atproto.RecordToBrewer(brewerData, brewerRef) 790 + recipe.BrewerObj = brewer 791 + } 792 + } 793 + } 794 + 795 + item.RecordType = lexicons.RecordTypeRecipe 796 + item.Action = "added a new recipe" 797 + item.Recipe = recipe 763 798 764 799 case atproto.NSIDLike: 765 800 return nil, fmt.Errorf("unexpected: likes should be filtered before conversion")
+14
internal/handlers/brew.go
··· 543 543 http.Error(w, errMsg, http.StatusBadRequest) 544 544 return 545 545 } 546 + recipeRKey := r.FormValue("recipe_rkey") 547 + if errMsg := validateOptionalRKey(recipeRKey, "Recipe selection"); errMsg != "" { 548 + log.Warn().Str("recipe_rkey", recipeRKey).Msg("Brew create: invalid recipe rkey") 549 + http.Error(w, errMsg, http.StatusBadRequest) 550 + return 551 + } 546 552 547 553 req := &models.CreateBrewRequest{ 548 554 BeanRKey: beanRKey, 555 + RecipeRKey: recipeRKey, 549 556 Method: r.FormValue("method"), 550 557 Temperature: temperature, 551 558 WaterAmount: waterAmount, ··· 631 638 http.Error(w, errMsg, http.StatusBadRequest) 632 639 return 633 640 } 641 + recipeRKey := r.FormValue("recipe_rkey") 642 + if errMsg := validateOptionalRKey(recipeRKey, "Recipe selection"); errMsg != "" { 643 + log.Warn().Str("rkey", rkey).Str("recipe_rkey", recipeRKey).Msg("Brew update: invalid recipe rkey") 644 + http.Error(w, errMsg, http.StatusBadRequest) 645 + return 646 + } 634 647 635 648 req := &models.CreateBrewRequest{ 636 649 BeanRKey: beanRKey, 650 + RecipeRKey: recipeRKey, 637 651 Method: r.FormValue("method"), 638 652 Temperature: temperature, 639 653 WaterAmount: waterAmount,
+25
internal/handlers/entities.go
··· 32 32 var roasters []*models.Roaster 33 33 var grinders []*models.Grinder 34 34 var brewers []*models.Brewer 35 + var recipes []*models.Recipe 35 36 36 37 g.Go(func() error { 37 38 var err error ··· 53 54 brewers, err = store.ListBrewers(ctx) 54 55 return err 55 56 }) 57 + g.Go(func() error { 58 + var err error 59 + recipes, err = store.ListRecipes(ctx) 60 + return err 61 + }) 56 62 57 63 if err := g.Wait(); err != nil { 58 64 log.Error().Err(err).Msg("Failed to fetch manage page data") ··· 63 69 // Link beans to their roasters 64 70 atproto.LinkBeansToRoasters(beans, roasters) 65 71 72 + // Link recipes to their brewers 73 + brewerMap := make(map[string]*models.Brewer, len(brewers)) 74 + for _, b := range brewers { 75 + brewerMap[b.RKey] = b 76 + } 77 + for _, recipe := range recipes { 78 + if recipe.BrewerRKey != "" { 79 + recipe.BrewerObj = brewerMap[recipe.BrewerRKey] 80 + } 81 + } 82 + 66 83 // Render manage partial 67 84 if err := components.ManagePartial(components.ManagePartialProps{ 68 85 Beans: beans, 69 86 Roasters: roasters, 70 87 Grinders: grinders, 71 88 Brewers: brewers, 89 + Recipes: recipes, 72 90 }).Render(r.Context(), w); err != nil { 73 91 http.Error(w, "Failed to render content", http.StatusInternalServerError) 74 92 log.Error().Err(err).Msg("Failed to render manage partial") ··· 96 114 var roasters []*models.Roaster 97 115 var grinders []*models.Grinder 98 116 var brewers []*models.Brewer 117 + var recipes []*models.Recipe 99 118 var brews []*models.Brew 100 119 101 120 g.Go(func() error { ··· 120 139 }) 121 140 g.Go(func() error { 122 141 var err error 142 + recipes, err = store.ListRecipes(ctx) 143 + return err 144 + }) 145 + g.Go(func() error { 146 + var err error 123 147 brews, err = store.ListBrews(ctx, 1) // User ID not used with atproto 124 148 return err 125 149 }) ··· 139 163 "roasters": roasters, 140 164 "grinders": grinders, 141 165 "brewers": brewers, 166 + "recipes": recipes, 142 167 "brews": brews, 143 168 } 144 169
+426
internal/handlers/recipe.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "arabica/internal/models" 9 + "arabica/internal/web/components" 10 + "arabica/internal/web/pages" 11 + 12 + "github.com/rs/zerolog/log" 13 + ) 14 + 15 + // HandleRecipeCreate creates a new recipe 16 + func (h *Handler) HandleRecipeCreate(w http.ResponseWriter, r *http.Request) { 17 + store, authenticated := h.getAtprotoStore(r) 18 + if !authenticated { 19 + http.Error(w, "Authentication required", http.StatusUnauthorized) 20 + return 21 + } 22 + 23 + var req models.CreateRecipeRequest 24 + 25 + if err := decodeRequest(r, &req, func() error { 26 + req = models.CreateRecipeRequest{ 27 + Name: r.FormValue("name"), 28 + BrewerRKey: r.FormValue("brewer_rkey"), 29 + BrewerType: r.FormValue("brewer_type"), 30 + GrindSize: r.FormValue("grind_size"), 31 + Notes: r.FormValue("notes"), 32 + } 33 + if v := r.FormValue("coffee_amount"); v != "" { 34 + if f, err := strconv.ParseFloat(v, 64); err == nil { 35 + req.CoffeeAmount = f 36 + } 37 + } 38 + if v := r.FormValue("water_amount"); v != "" { 39 + if f, err := strconv.ParseFloat(v, 64); err == nil { 40 + req.WaterAmount = f 41 + } 42 + } 43 + req.Pours = parsePours(r) 44 + return nil 45 + }); err != nil { 46 + log.Warn().Err(err).Msg("Failed to decode recipe create request") 47 + http.Error(w, "Invalid request body", http.StatusBadRequest) 48 + return 49 + } 50 + 51 + if err := req.Validate(); err != nil { 52 + log.Warn().Err(err).Str("name", req.Name).Msg("Recipe create validation failed") 53 + http.Error(w, err.Error(), http.StatusBadRequest) 54 + return 55 + } 56 + 57 + if errMsg := validateOptionalRKey(req.BrewerRKey, "Brewer selection"); errMsg != "" { 58 + http.Error(w, errMsg, http.StatusBadRequest) 59 + return 60 + } 61 + 62 + recipe, err := store.CreateRecipe(r.Context(), &req) 63 + if err != nil { 64 + http.Error(w, "Failed to create recipe", http.StatusInternalServerError) 65 + log.Error().Err(err).Msg("Failed to create recipe") 66 + return 67 + } 68 + 69 + writeJSON(w, recipe, "recipe") 70 + } 71 + 72 + // HandleRecipeUpdate updates an existing recipe 73 + func (h *Handler) HandleRecipeUpdate(w http.ResponseWriter, r *http.Request) { 74 + rkey := validateRKey(w, r.PathValue("id")) 75 + if rkey == "" { 76 + return 77 + } 78 + 79 + store, authenticated := h.getAtprotoStore(r) 80 + if !authenticated { 81 + http.Error(w, "Authentication required", http.StatusUnauthorized) 82 + return 83 + } 84 + 85 + var req models.UpdateRecipeRequest 86 + 87 + if err := decodeRequest(r, &req, func() error { 88 + req = models.UpdateRecipeRequest{ 89 + Name: r.FormValue("name"), 90 + BrewerRKey: r.FormValue("brewer_rkey"), 91 + BrewerType: r.FormValue("brewer_type"), 92 + GrindSize: r.FormValue("grind_size"), 93 + Notes: r.FormValue("notes"), 94 + } 95 + if v := r.FormValue("coffee_amount"); v != "" { 96 + if f, err := strconv.ParseFloat(v, 64); err == nil { 97 + req.CoffeeAmount = f 98 + } 99 + } 100 + if v := r.FormValue("water_amount"); v != "" { 101 + if f, err := strconv.ParseFloat(v, 64); err == nil { 102 + req.WaterAmount = f 103 + } 104 + } 105 + req.Pours = parsePours(r) 106 + return nil 107 + }); err != nil { 108 + log.Warn().Err(err).Msg("Failed to decode recipe update request") 109 + http.Error(w, "Invalid request body", http.StatusBadRequest) 110 + return 111 + } 112 + 113 + if err := req.Validate(); err != nil { 114 + log.Warn().Err(err).Str("name", req.Name).Msg("Recipe update validation failed") 115 + http.Error(w, err.Error(), http.StatusBadRequest) 116 + return 117 + } 118 + 119 + if errMsg := validateOptionalRKey(req.BrewerRKey, "Brewer selection"); errMsg != "" { 120 + http.Error(w, errMsg, http.StatusBadRequest) 121 + return 122 + } 123 + 124 + if err := store.UpdateRecipeByRKey(r.Context(), rkey, &req); err != nil { 125 + http.Error(w, "Failed to update recipe", http.StatusInternalServerError) 126 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update recipe") 127 + return 128 + } 129 + 130 + w.WriteHeader(http.StatusOK) 131 + } 132 + 133 + // HandleRecipeDelete deletes a recipe 134 + func (h *Handler) HandleRecipeDelete(w http.ResponseWriter, r *http.Request) { 135 + rkey := validateRKey(w, r.PathValue("id")) 136 + if rkey == "" { 137 + return 138 + } 139 + 140 + store, authenticated := h.getAtprotoStore(r) 141 + if !authenticated { 142 + http.Error(w, "Authentication required", http.StatusUnauthorized) 143 + return 144 + } 145 + 146 + if err := store.DeleteRecipeByRKey(r.Context(), rkey); err != nil { 147 + http.Error(w, "Failed to delete recipe", http.StatusInternalServerError) 148 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete recipe") 149 + return 150 + } 151 + 152 + w.WriteHeader(http.StatusOK) 153 + } 154 + 155 + // HandleRecipeGet returns a single recipe as JSON (for autofill) 156 + func (h *Handler) HandleRecipeGet(w http.ResponseWriter, r *http.Request) { 157 + rkey := validateRKey(w, r.PathValue("id")) 158 + if rkey == "" { 159 + return 160 + } 161 + 162 + store, authenticated := h.getAtprotoStore(r) 163 + if !authenticated { 164 + http.Error(w, "Authentication required", http.StatusUnauthorized) 165 + return 166 + } 167 + 168 + recipe, err := store.GetRecipeByRKey(r.Context(), rkey) 169 + if err != nil { 170 + http.Error(w, "Recipe not found", http.StatusNotFound) 171 + log.Warn().Err(err).Str("rkey", rkey).Msg("Failed to get recipe") 172 + return 173 + } 174 + 175 + writeJSON(w, recipe, "recipe") 176 + } 177 + 178 + // HandleRecipeCreateFromBrew creates a recipe pre-populated from an existing brew's parameters 179 + func (h *Handler) HandleRecipeCreateFromBrew(w http.ResponseWriter, r *http.Request) { 180 + brewRKey := validateRKey(w, r.PathValue("id")) 181 + if brewRKey == "" { 182 + return 183 + } 184 + 185 + store, authenticated := h.getAtprotoStore(r) 186 + if !authenticated { 187 + http.Error(w, "Authentication required", http.StatusUnauthorized) 188 + return 189 + } 190 + 191 + if err := r.ParseForm(); err != nil { 192 + http.Error(w, "Invalid form data", http.StatusBadRequest) 193 + return 194 + } 195 + 196 + // Get the brew to extract parameters 197 + brew, err := store.GetBrewByRKey(r.Context(), brewRKey) 198 + if err != nil { 199 + http.Error(w, "Brew not found", http.StatusNotFound) 200 + log.Warn().Err(err).Str("rkey", brewRKey).Msg("Failed to get brew for recipe creation") 201 + return 202 + } 203 + 204 + // Name is required from the form 205 + name := r.FormValue("name") 206 + if name == "" { 207 + http.Error(w, "Recipe name is required", http.StatusBadRequest) 208 + return 209 + } 210 + 211 + // Build recipe from brew parameters 212 + req := &models.CreateRecipeRequest{ 213 + Name: name, 214 + BrewerRKey: brew.BrewerRKey, 215 + CoffeeAmount: float64(brew.CoffeeAmount), 216 + WaterAmount: float64(brew.WaterAmount), 217 + GrindSize: brew.GrindSize, 218 + } 219 + 220 + // Copy pours 221 + if len(brew.Pours) > 0 { 222 + req.Pours = make([]models.CreatePourData, len(brew.Pours)) 223 + for i, pour := range brew.Pours { 224 + req.Pours[i] = models.CreatePourData{ 225 + WaterAmount: pour.WaterAmount, 226 + TimeSeconds: pour.TimeSeconds, 227 + } 228 + } 229 + } 230 + 231 + // If the brew has a brewer but no brewer type, get the brewer type 232 + if brew.BrewerObj != nil { 233 + req.BrewerType = brew.BrewerObj.BrewerType 234 + } 235 + 236 + if err := req.Validate(); err != nil { 237 + http.Error(w, err.Error(), http.StatusBadRequest) 238 + return 239 + } 240 + 241 + recipe, err := store.CreateRecipe(r.Context(), req) 242 + if err != nil { 243 + http.Error(w, "Failed to create recipe", http.StatusInternalServerError) 244 + log.Error().Err(err).Msg("Failed to create recipe from brew") 245 + return 246 + } 247 + 248 + writeJSON(w, recipe, "recipe") 249 + } 250 + 251 + // HandleRecipeSuggestions returns filtered recipes based on query parameters. 252 + // Query params: q (text search), brewer_type, min_coffee, max_coffee, min_water, max_water, category 253 + func (h *Handler) HandleRecipeSuggestions(w http.ResponseWriter, r *http.Request) { 254 + store, authenticated := h.getAtprotoStore(r) 255 + if !authenticated { 256 + http.Error(w, "Authentication required", http.StatusUnauthorized) 257 + return 258 + } 259 + 260 + filter := models.RecipeFilter{ 261 + Query: r.URL.Query().Get("q"), 262 + BrewerType: r.URL.Query().Get("brewer_type"), 263 + Category: r.URL.Query().Get("category"), 264 + } 265 + if v := r.URL.Query().Get("min_coffee"); v != "" { 266 + if f, err := strconv.ParseFloat(v, 64); err == nil { 267 + filter.MinCoffee = f 268 + } 269 + } 270 + if v := r.URL.Query().Get("max_coffee"); v != "" { 271 + if f, err := strconv.ParseFloat(v, 64); err == nil { 272 + filter.MaxCoffee = f 273 + } 274 + } 275 + if v := r.URL.Query().Get("min_water"); v != "" { 276 + if f, err := strconv.ParseFloat(v, 64); err == nil { 277 + filter.MinWater = f 278 + } 279 + } 280 + if v := r.URL.Query().Get("max_water"); v != "" { 281 + if f, err := strconv.ParseFloat(v, 64); err == nil { 282 + filter.MaxWater = f 283 + } 284 + } 285 + 286 + recipes, err := store.ListRecipes(r.Context()) 287 + if err != nil { 288 + http.Error(w, "Failed to list recipes", http.StatusInternalServerError) 289 + log.Error().Err(err).Msg("Failed to list recipes for suggestions") 290 + return 291 + } 292 + 293 + // Resolve brewer references for display 294 + brewers, _ := store.ListBrewers(r.Context()) 295 + brewerMap := make(map[string]*models.Brewer, len(brewers)) 296 + for _, b := range brewers { 297 + brewerMap[b.RKey] = b 298 + } 299 + for _, recipe := range recipes { 300 + if recipe.BrewerRKey != "" { 301 + recipe.BrewerObj = brewerMap[recipe.BrewerRKey] 302 + } 303 + // Populate BrewerType from BrewerObj if not set 304 + if recipe.BrewerType == "" && recipe.BrewerObj != nil { 305 + recipe.BrewerType = recipe.BrewerObj.BrewerType 306 + } 307 + } 308 + 309 + filtered := models.FilterRecipes(recipes, filter) 310 + 311 + w.Header().Set("Content-Type", "application/json") 312 + if err := json.NewEncoder(w).Encode(filtered); err != nil { 313 + log.Error().Err(err).Msg("Failed to encode recipe suggestions response") 314 + } 315 + } 316 + 317 + // HandleRecipeList returns all recipes as JSON 318 + func (h *Handler) HandleRecipeList(w http.ResponseWriter, r *http.Request) { 319 + store, authenticated := h.getAtprotoStore(r) 320 + if !authenticated { 321 + http.Error(w, "Authentication required", http.StatusUnauthorized) 322 + return 323 + } 324 + 325 + recipes, err := store.ListRecipes(r.Context()) 326 + if err != nil { 327 + http.Error(w, "Failed to list recipes", http.StatusInternalServerError) 328 + log.Error().Err(err).Msg("Failed to list recipes") 329 + return 330 + } 331 + 332 + // Resolve brewer references using cached data 333 + brewers, _ := store.ListBrewers(r.Context()) 334 + brewerMap := make(map[string]*models.Brewer, len(brewers)) 335 + for _, b := range brewers { 336 + brewerMap[b.RKey] = b 337 + } 338 + for _, recipe := range recipes { 339 + if recipe.BrewerRKey != "" { 340 + recipe.BrewerObj = brewerMap[recipe.BrewerRKey] 341 + } 342 + } 343 + 344 + w.Header().Set("Content-Type", "application/json") 345 + if err := json.NewEncoder(w).Encode(recipes); err != nil { 346 + log.Error().Err(err).Msg("Failed to encode recipes response") 347 + } 348 + } 349 + 350 + // HandleRecipeModalNew returns the recipe creation modal HTML 351 + func (h *Handler) HandleRecipeModalNew(w http.ResponseWriter, r *http.Request) { 352 + store, authenticated := h.getAtprotoStore(r) 353 + if !authenticated { 354 + http.Error(w, "Authentication required", http.StatusUnauthorized) 355 + return 356 + } 357 + 358 + brewers, err := store.ListBrewers(r.Context()) 359 + if err != nil { 360 + log.Warn().Err(err).Msg("Failed to fetch brewers for recipe modal") 361 + brewers = []*models.Brewer{} 362 + } 363 + 364 + brewersSlice := make([]models.Brewer, len(brewers)) 365 + for i, b := range brewers { 366 + brewersSlice[i] = *b 367 + } 368 + 369 + if err := components.RecipeDialogModal(nil, brewersSlice).Render(r.Context(), w); err != nil { 370 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 371 + log.Error().Err(err).Msg("Failed to render recipe modal") 372 + } 373 + } 374 + 375 + // HandleRecipeModalEdit returns the recipe edit modal HTML 376 + func (h *Handler) HandleRecipeModalEdit(w http.ResponseWriter, r *http.Request) { 377 + rkey := validateRKey(w, r.PathValue("id")) 378 + if rkey == "" { 379 + return 380 + } 381 + 382 + store, authenticated := h.getAtprotoStore(r) 383 + if !authenticated { 384 + http.Error(w, "Authentication required", http.StatusUnauthorized) 385 + return 386 + } 387 + 388 + recipe, err := store.GetRecipeByRKey(r.Context(), rkey) 389 + if err != nil { 390 + http.Error(w, "Recipe not found", http.StatusNotFound) 391 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get recipe for modal") 392 + return 393 + } 394 + 395 + brewers, err := store.ListBrewers(r.Context()) 396 + if err != nil { 397 + log.Warn().Err(err).Msg("Failed to fetch brewers for recipe modal") 398 + brewers = []*models.Brewer{} 399 + } 400 + 401 + brewersSlice := make([]models.Brewer, len(brewers)) 402 + for i, b := range brewers { 403 + brewersSlice[i] = *b 404 + } 405 + 406 + if err := components.RecipeDialogModal(recipe, brewersSlice).Render(r.Context(), w); err != nil { 407 + http.Error(w, "Failed to render modal", http.StatusInternalServerError) 408 + log.Error().Err(err).Msg("Failed to render recipe modal") 409 + } 410 + } 411 + 412 + // HandleRecipeExplore renders the recipe explore page 413 + func (h *Handler) HandleRecipeExplore(w http.ResponseWriter, r *http.Request) { 414 + _, authenticated := h.getAtprotoStore(r) 415 + if !authenticated { 416 + http.Redirect(w, r, "/login", http.StatusFound) 417 + return 418 + } 419 + 420 + layoutData, _, _ := h.layoutDataFromRequest(r, "Explore Recipes") 421 + 422 + if err := pages.RecipeExplorePage(layoutData, pages.RecipeExploreProps{}).Render(r.Context(), w); err != nil { 423 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 424 + log.Error().Err(err).Msg("Failed to render recipe explore page") 425 + } 426 + }
+4 -1
internal/lexicons/record_type.go
··· 11 11 RecordTypeBrewer RecordType = "brewer" 12 12 RecordTypeGrinder RecordType = "grinder" 13 13 RecordTypeLike RecordType = "like" 14 + RecordTypeRecipe RecordType = "recipe" 14 15 RecordTypeRoaster RecordType = "roaster" 15 16 ) 16 17 ··· 22 23 // ParseRecordType converts a string to a RecordType if valid, returns empty string if not. 23 24 func ParseRecordType(s string) RecordType { 24 25 switch RecordType(s) { 25 - case RecordTypeBean, RecordTypeBrew, RecordTypeBrewer, RecordTypeGrinder, RecordTypeRoaster: 26 + case RecordTypeBean, RecordTypeBrew, RecordTypeBrewer, RecordTypeGrinder, RecordTypeRecipe, RecordTypeRoaster: 26 27 return RecordType(s) 27 28 default: 28 29 return "" ··· 42 43 return "Grinder" 43 44 case RecordTypeLike: 44 45 return "Like" 46 + case RecordTypeRecipe: 47 + return "Recipe" 45 48 case RecordTypeRoaster: 46 49 return "Roaster" 47 50 default:
+81
internal/models/models.go
··· 93 93 CreatedAt time.Time `json:"created_at"` 94 94 } 95 95 96 + type Recipe struct { 97 + RKey string `json:"rkey"` 98 + Name string `json:"name"` 99 + BrewerRKey string `json:"brewer_rkey"` 100 + BrewerType string `json:"brewer_type"` 101 + CoffeeAmount float64 `json:"coffee_amount"` 102 + WaterAmount float64 `json:"water_amount"` 103 + GrindSize string `json:"grind_size"` 104 + Notes string `json:"notes"` 105 + CreatedAt time.Time `json:"created_at"` 106 + 107 + // Joined data for display 108 + BrewerObj *Brewer `json:"brewer_obj,omitempty"` 109 + Pours []*Pour `json:"pours,omitempty"` 110 + } 111 + 96 112 type Brew struct { 97 113 RKey string `json:"rkey"` // Record key 98 114 BeanRKey string `json:"bean_rkey"` 115 + RecipeRKey string `json:"recipe_rkey"` 99 116 Method string `json:"method,omitempty"` 100 117 Temperature float64 `json:"temperature"` 101 118 WaterAmount int `json:"water_amount"` ··· 110 127 111 128 // Joined data for display 112 129 Bean *Bean `json:"bean,omitempty"` 130 + RecipeObj *Recipe `json:"recipe_obj,omitempty"` 113 131 GrinderObj *Grinder `json:"grinder_obj,omitempty"` 114 132 BrewerObj *Brewer `json:"brewer_obj,omitempty"` 115 133 Pours []*Pour `json:"pours,omitempty"` ··· 117 135 118 136 type CreateBrewRequest struct { 119 137 BeanRKey string `json:"bean_rkey"` 138 + RecipeRKey string `json:"recipe_rkey"` 120 139 Method string `json:"method"` 121 140 Temperature float64 `json:"temperature"` 122 141 WaterAmount int `json:"water_amount"` ··· 167 186 BrewerType string `json:"brewer_type"` 168 187 Description string `json:"description"` 169 188 SourceRef string `json:"source_ref,omitempty"` 189 + } 190 + 191 + type CreateRecipeRequest struct { 192 + Name string `json:"name"` 193 + BrewerRKey string `json:"brewer_rkey"` 194 + BrewerType string `json:"brewer_type"` 195 + CoffeeAmount float64 `json:"coffee_amount"` 196 + WaterAmount float64 `json:"water_amount"` 197 + GrindSize string `json:"grind_size"` 198 + Notes string `json:"notes"` 199 + Pours []CreatePourData `json:"pours"` 200 + } 201 + 202 + type UpdateRecipeRequest struct { 203 + Name string `json:"name"` 204 + BrewerRKey string `json:"brewer_rkey"` 205 + BrewerType string `json:"brewer_type"` 206 + CoffeeAmount float64 `json:"coffee_amount"` 207 + WaterAmount float64 `json:"water_amount"` 208 + GrindSize string `json:"grind_size"` 209 + Notes string `json:"notes"` 210 + Pours []CreatePourData `json:"pours"` 170 211 } 171 212 172 213 type UpdateBeanRequest struct { ··· 379 420 } 380 421 if len(r.Description) > MaxDescriptionLength { 381 422 return ErrDescTooLong 423 + } 424 + return nil 425 + } 426 + 427 + // Validate checks that all fields are within acceptable limits 428 + func (r *CreateRecipeRequest) Validate() error { 429 + if r.Name == "" { 430 + return ErrNameRequired 431 + } 432 + if len(r.Name) > MaxNameLength { 433 + return ErrNameTooLong 434 + } 435 + if len(r.BrewerType) > MaxBrewerTypeLength { 436 + return ErrFieldTooLong 437 + } 438 + if len(r.GrindSize) > MaxGrindSizeLength { 439 + return ErrFieldTooLong 440 + } 441 + if len(r.Notes) > MaxNotesLength { 442 + return ErrNotesTooLong 443 + } 444 + return nil 445 + } 446 + 447 + // Validate checks that all fields are within acceptable limits 448 + func (r *UpdateRecipeRequest) Validate() error { 449 + if r.Name == "" { 450 + return ErrNameRequired 451 + } 452 + if len(r.Name) > MaxNameLength { 453 + return ErrNameTooLong 454 + } 455 + if len(r.BrewerType) > MaxBrewerTypeLength { 456 + return ErrFieldTooLong 457 + } 458 + if len(r.GrindSize) > MaxGrindSizeLength { 459 + return ErrFieldTooLong 460 + } 461 + if len(r.Notes) > MaxNotesLength { 462 + return ErrNotesTooLong 382 463 } 383 464 return nil 384 465 }
+82
internal/models/recipe_filter.go
··· 1 + package models 2 + 3 + import "strings" 4 + 5 + // RecipeFilter defines criteria for filtering recipes. 6 + type RecipeFilter struct { 7 + Query string // case-insensitive substring match on name 8 + BrewerType string // exact match on brewer_type 9 + MinCoffee float64 // minimum coffee amount in grams 10 + MaxCoffee float64 // maximum coffee amount in grams 11 + MinWater float64 // minimum water amount in grams 12 + MaxWater float64 // maximum water amount in grams 13 + Category string // predefined category key 14 + } 15 + 16 + // RecipeCategories maps category names to their filter criteria. 17 + var RecipeCategories = map[string]RecipeFilter{ 18 + "small": {MaxCoffee: 20}, 19 + "large": {MinCoffee: 30}, 20 + "single": {MaxCoffee: 20, MaxWater: 300}, 21 + "batch": {MinWater: 500}, 22 + } 23 + 24 + // MatchesFilter returns true if the recipe satisfies all non-zero filter criteria. 25 + // Criteria are combined with AND logic; zero-value fields are ignored. 26 + func MatchesFilter(recipe *Recipe, filter RecipeFilter) bool { 27 + // Apply category defaults first (explicit fields override) 28 + f := resolveCategory(filter) 29 + 30 + if f.Query != "" && !strings.Contains(strings.ToLower(recipe.Name), strings.ToLower(f.Query)) { 31 + return false 32 + } 33 + if f.BrewerType != "" && !strings.EqualFold(recipe.BrewerType, f.BrewerType) { 34 + return false 35 + } 36 + if f.MinCoffee > 0 && recipe.CoffeeAmount < f.MinCoffee { 37 + return false 38 + } 39 + if f.MaxCoffee > 0 && recipe.CoffeeAmount > f.MaxCoffee { 40 + return false 41 + } 42 + if f.MinWater > 0 && recipe.WaterAmount < f.MinWater { 43 + return false 44 + } 45 + if f.MaxWater > 0 && recipe.WaterAmount > f.MaxWater { 46 + return false 47 + } 48 + return true 49 + } 50 + 51 + // FilterRecipes returns the subset of recipes matching the filter. 52 + func FilterRecipes(recipes []*Recipe, filter RecipeFilter) []*Recipe { 53 + var result []*Recipe 54 + for _, r := range recipes { 55 + if MatchesFilter(r, filter) { 56 + result = append(result, r) 57 + } 58 + } 59 + return result 60 + } 61 + 62 + // resolveCategory merges category defaults with explicit filter fields. 63 + // Explicit fields take precedence over category defaults. 64 + func resolveCategory(f RecipeFilter) RecipeFilter { 65 + cat, ok := RecipeCategories[f.Category] 66 + if !ok { 67 + return f 68 + } 69 + if f.MinCoffee == 0 { 70 + f.MinCoffee = cat.MinCoffee 71 + } 72 + if f.MaxCoffee == 0 { 73 + f.MaxCoffee = cat.MaxCoffee 74 + } 75 + if f.MinWater == 0 { 76 + f.MinWater = cat.MinWater 77 + } 78 + if f.MaxWater == 0 { 79 + f.MaxWater = cat.MaxWater 80 + } 81 + return f 82 + }
+156
internal/models/recipe_filter_test.go
··· 1 + package models 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestMatchesFilter_EmptyFilter(t *testing.T) { 10 + recipe := &Recipe{Name: "V60 Morning", CoffeeAmount: 18, WaterAmount: 300} 11 + assert.True(t, MatchesFilter(recipe, RecipeFilter{})) 12 + } 13 + 14 + func TestMatchesFilter_QueryMatch(t *testing.T) { 15 + recipe := &Recipe{Name: "V60 Morning Brew"} 16 + 17 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Query: "morning"})) 18 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Query: "V60"})) 19 + assert.False(t, MatchesFilter(recipe, RecipeFilter{Query: "espresso"})) 20 + } 21 + 22 + func TestMatchesFilter_QueryCaseInsensitive(t *testing.T) { 23 + recipe := &Recipe{Name: "French Press Bold"} 24 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Query: "FRENCH"})) 25 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Query: "french press"})) 26 + } 27 + 28 + func TestMatchesFilter_BrewerType(t *testing.T) { 29 + recipe := &Recipe{Name: "Test", BrewerType: "Pour-Over"} 30 + 31 + assert.True(t, MatchesFilter(recipe, RecipeFilter{BrewerType: "Pour-Over"})) 32 + assert.True(t, MatchesFilter(recipe, RecipeFilter{BrewerType: "pour-over"})) 33 + assert.False(t, MatchesFilter(recipe, RecipeFilter{BrewerType: "French Press"})) 34 + } 35 + 36 + func TestMatchesFilter_CoffeeRange(t *testing.T) { 37 + recipe := &Recipe{Name: "Test", CoffeeAmount: 18} 38 + 39 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MinCoffee: 15})) 40 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MaxCoffee: 20})) 41 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MinCoffee: 15, MaxCoffee: 20})) 42 + assert.False(t, MatchesFilter(recipe, RecipeFilter{MinCoffee: 20})) 43 + assert.False(t, MatchesFilter(recipe, RecipeFilter{MaxCoffee: 15})) 44 + } 45 + 46 + func TestMatchesFilter_WaterRange(t *testing.T) { 47 + recipe := &Recipe{Name: "Test", WaterAmount: 300} 48 + 49 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MinWater: 200})) 50 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MaxWater: 400})) 51 + assert.False(t, MatchesFilter(recipe, RecipeFilter{MinWater: 400})) 52 + assert.False(t, MatchesFilter(recipe, RecipeFilter{MaxWater: 200})) 53 + } 54 + 55 + func TestMatchesFilter_MultipleFilters(t *testing.T) { 56 + recipe := &Recipe{ 57 + Name: "V60 Light", 58 + BrewerType: "Pour-Over", 59 + CoffeeAmount: 15, 60 + WaterAmount: 250, 61 + } 62 + 63 + // All match 64 + assert.True(t, MatchesFilter(recipe, RecipeFilter{ 65 + Query: "V60", 66 + BrewerType: "Pour-Over", 67 + MinCoffee: 10, 68 + MaxWater: 300, 69 + })) 70 + 71 + // One fails 72 + assert.False(t, MatchesFilter(recipe, RecipeFilter{ 73 + Query: "V60", 74 + BrewerType: "French Press", 75 + MinCoffee: 10, 76 + })) 77 + } 78 + 79 + func TestMatchesFilter_CategorySmall(t *testing.T) { 80 + small := &Recipe{Name: "Small Dose", CoffeeAmount: 12} 81 + borderline := &Recipe{Name: "Borderline", CoffeeAmount: 20} 82 + large := &Recipe{Name: "Big Batch", CoffeeAmount: 35} 83 + 84 + assert.True(t, MatchesFilter(small, RecipeFilter{Category: "small"})) 85 + assert.True(t, MatchesFilter(borderline, RecipeFilter{Category: "small"})) 86 + assert.False(t, MatchesFilter(large, RecipeFilter{Category: "small"})) 87 + } 88 + 89 + func TestMatchesFilter_CategoryLarge(t *testing.T) { 90 + small := &Recipe{Name: "Small Dose", CoffeeAmount: 12} 91 + large := &Recipe{Name: "Big Batch", CoffeeAmount: 35} 92 + 93 + assert.False(t, MatchesFilter(small, RecipeFilter{Category: "large"})) 94 + assert.True(t, MatchesFilter(large, RecipeFilter{Category: "large"})) 95 + } 96 + 97 + func TestMatchesFilter_CategorySingle(t *testing.T) { 98 + single := &Recipe{Name: "One Cup", CoffeeAmount: 15, WaterAmount: 250} 99 + batch := &Recipe{Name: "Party Brew", CoffeeAmount: 15, WaterAmount: 500} 100 + 101 + assert.True(t, MatchesFilter(single, RecipeFilter{Category: "single"})) 102 + assert.False(t, MatchesFilter(batch, RecipeFilter{Category: "single"})) 103 + } 104 + 105 + func TestMatchesFilter_CategoryBatch(t *testing.T) { 106 + single := &Recipe{Name: "One Cup", WaterAmount: 250} 107 + batch := &Recipe{Name: "Party Brew", WaterAmount: 600} 108 + 109 + assert.False(t, MatchesFilter(single, RecipeFilter{Category: "batch"})) 110 + assert.True(t, MatchesFilter(batch, RecipeFilter{Category: "batch"})) 111 + } 112 + 113 + func TestMatchesFilter_CategoryExplicitOverride(t *testing.T) { 114 + // Category "small" sets MaxCoffee=20, but explicit MaxCoffee=25 overrides 115 + recipe := &Recipe{Name: "Medium", CoffeeAmount: 22} 116 + assert.False(t, MatchesFilter(recipe, RecipeFilter{Category: "small"})) 117 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "small", MaxCoffee: 25})) 118 + } 119 + 120 + func TestMatchesFilter_UnknownCategory(t *testing.T) { 121 + recipe := &Recipe{Name: "Test", CoffeeAmount: 18} 122 + // Unknown category is ignored 123 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "unknown"})) 124 + } 125 + 126 + func TestMatchesFilter_ZeroCoffeeAmount(t *testing.T) { 127 + // Recipe with zero coffee amount should not match MinCoffee filter 128 + recipe := &Recipe{Name: "No Coffee Set", CoffeeAmount: 0} 129 + assert.False(t, MatchesFilter(recipe, RecipeFilter{MinCoffee: 10})) 130 + // But should match MaxCoffee (0 <= max) 131 + assert.True(t, MatchesFilter(recipe, RecipeFilter{MaxCoffee: 10})) 132 + } 133 + 134 + func TestFilterRecipes(t *testing.T) { 135 + recipes := []*Recipe{ 136 + {Name: "V60 Light", CoffeeAmount: 15, BrewerType: "Pour-Over"}, 137 + {Name: "French Press Bold", CoffeeAmount: 30, BrewerType: "French Press"}, 138 + {Name: "Espresso Shot", CoffeeAmount: 18, BrewerType: "Espresso"}, 139 + {Name: "V60 Strong", CoffeeAmount: 20, BrewerType: "Pour-Over"}, 140 + } 141 + 142 + result := FilterRecipes(recipes, RecipeFilter{Query: "V60"}) 143 + assert.Len(t, result, 2) 144 + assert.Equal(t, "V60 Light", result[0].Name) 145 + assert.Equal(t, "V60 Strong", result[1].Name) 146 + 147 + result = FilterRecipes(recipes, RecipeFilter{BrewerType: "Pour-Over"}) 148 + assert.Len(t, result, 2) 149 + 150 + result = FilterRecipes(recipes, RecipeFilter{MinCoffee: 20}) 151 + assert.Len(t, result, 2) 152 + 153 + // Empty filter returns all 154 + result = FilterRecipes(recipes, RecipeFilter{}) 155 + assert.Len(t, result, 4) 156 + }
+11
internal/routing/routing.go
··· 85 85 mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewUpdate))) 86 86 mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewDelete))) 87 87 mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 88 + mux.HandleFunc("GET /recipes/explore", h.HandleRecipeExplore) 88 89 89 90 // API routes for CRUD operations 90 91 mux.Handle("POST /api/beans", cop.Handler(http.HandlerFunc(h.HandleBeanCreate))) ··· 103 104 mux.Handle("PUT /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerUpdate))) 104 105 mux.Handle("DELETE /api/brewers/{id}", cop.Handler(http.HandlerFunc(h.HandleBrewerDelete))) 105 106 107 + mux.HandleFunc("GET /api/recipes", h.HandleRecipeList) 108 + mux.HandleFunc("GET /api/recipes/suggestions", h.HandleRecipeSuggestions) 109 + mux.HandleFunc("GET /api/recipes/{id}", h.HandleRecipeGet) 110 + mux.Handle("POST /api/recipes", cop.Handler(http.HandlerFunc(h.HandleRecipeCreate))) 111 + mux.Handle("PUT /api/recipes/{id}", cop.Handler(http.HandlerFunc(h.HandleRecipeUpdate))) 112 + mux.Handle("DELETE /api/recipes/{id}", cop.Handler(http.HandlerFunc(h.HandleRecipeDelete))) 113 + mux.Handle("POST /api/recipes/from-brew/{id}", cop.Handler(http.HandlerFunc(h.HandleRecipeCreateFromBrew))) 114 + 106 115 mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) 107 116 mux.Handle("POST /api/report", cop.Handler(http.HandlerFunc(h.HandleReport))) 108 117 ··· 120 129 mux.HandleFunc("GET /api/modals/brewer/{id}", h.HandleBrewerModalEdit) 121 130 mux.HandleFunc("GET /api/modals/roaster/new", h.HandleRoasterModalNew) 122 131 mux.HandleFunc("GET /api/modals/roaster/{id}", h.HandleRoasterModalEdit) 132 + mux.HandleFunc("GET /api/modals/recipe/new", h.HandleRecipeModalNew) 133 + mux.HandleFunc("GET /api/modals/recipe/{id}", h.HandleRecipeModalEdit) 123 134 124 135 // Notification routes 125 136 mux.HandleFunc("GET /notifications", h.HandleNotifications)
+127 -1
internal/web/components/dialog_modals.templ
··· 1 1 package components 2 2 3 - import "arabica/internal/models" 3 + import ( 4 + "arabica/internal/models" 5 + "fmt" 6 + ) 4 7 5 8 // DialogModalProps defines properties for a native HTML5 dialog modal 6 9 type DialogModalProps struct { ··· 488 491 </dialog> 489 492 } 490 493 494 + // RecipeDialogModal renders the recipe creation/edit modal using native <dialog> 495 + templ RecipeDialogModal(recipe *models.Recipe, brewers []models.Brewer) { 496 + <dialog id="entity-modal" class="modal-dialog"> 497 + <div class="modal-content"> 498 + <h3 class="modal-title"> 499 + if recipe != nil { 500 + Edit Recipe 501 + } else { 502 + Add Recipe 503 + } 504 + </h3> 505 + <form 506 + if recipe != nil { 507 + hx-put={ "/api/recipes/" + recipe.RKey } 508 + } else { 509 + hx-post="/api/recipes" 510 + } 511 + hx-trigger="submit" 512 + hx-swap="none" 513 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 514 + class="space-y-4" 515 + > 516 + <input 517 + type="text" 518 + name="name" 519 + value={ getStringValue(recipe, "name") } 520 + placeholder="Name *" 521 + required 522 + class="w-full form-input" 523 + /> 524 + <select 525 + name="brewer_rkey" 526 + class="w-full form-input" 527 + > 528 + <option value="">Select Brewer (Optional)</option> 529 + for _, brewer := range brewers { 530 + <option 531 + value={ brewer.RKey } 532 + if recipe != nil && recipe.BrewerRKey == brewer.RKey { 533 + selected 534 + } 535 + > 536 + { brewer.Name } 537 + </option> 538 + } 539 + </select> 540 + <input 541 + type="text" 542 + name="brewer_type" 543 + value={ getStringValue(recipe, "brewer_type") } 544 + placeholder="Brewer Type (e.g., Pour-Over, Immersion)" 545 + class="w-full form-input" 546 + /> 547 + <input 548 + type="number" 549 + name="coffee_amount" 550 + value={ getStringValue(recipe, "coffee_amount") } 551 + placeholder="Coffee Amount (grams)" 552 + step="0.1" 553 + class="w-full form-input" 554 + /> 555 + <input 556 + type="number" 557 + name="water_amount" 558 + value={ getStringValue(recipe, "water_amount") } 559 + placeholder="Water Amount (grams)" 560 + step="0.1" 561 + class="w-full form-input" 562 + /> 563 + <input 564 + type="text" 565 + name="grind_size" 566 + value={ getStringValue(recipe, "grind_size") } 567 + placeholder="Grind Size (e.g., Medium, 18, Fine)" 568 + class="w-full form-input" 569 + /> 570 + <textarea 571 + name="notes" 572 + placeholder="Notes" 573 + rows="3" 574 + class="w-full form-textarea" 575 + >{ getStringValue(recipe, "notes") }</textarea> 576 + <div class="flex gap-2"> 577 + <button type="submit" class="flex-1 btn-primary"> 578 + Save 579 + </button> 580 + <button 581 + type="button" 582 + @click="$el.closest('dialog').close()" 583 + class="flex-1 btn-secondary" 584 + > 585 + Cancel 586 + </button> 587 + </div> 588 + </form> 589 + </div> 590 + </dialog> 591 + } 592 + 491 593 // ReportDialogModalProps defines properties for the report dialog 492 594 type ReportDialogModalProps struct { 493 595 SubjectURI string ··· 646 748 return e.Location 647 749 case "website": 648 750 return e.Website 751 + } 752 + case *models.Recipe: 753 + if e == nil { 754 + return "" 755 + } 756 + switch field { 757 + case "name": 758 + return e.Name 759 + case "brewer_type": 760 + return e.BrewerType 761 + case "grind_size": 762 + return e.GrindSize 763 + case "notes": 764 + return e.Notes 765 + case "coffee_amount": 766 + if e.CoffeeAmount > 0 { 767 + return fmt.Sprintf("%.1f", e.CoffeeAmount) 768 + } 769 + return "" 770 + case "water_amount": 771 + if e.WaterAmount > 0 { 772 + return fmt.Sprintf("%.1f", e.WaterAmount) 773 + } 774 + return "" 649 775 } 650 776 } 651 777
+86
internal/web/components/entity_tables.templ
··· 315 315 </div> 316 316 } 317 317 } 318 + 319 + // RecipesTableProps defines props for the shared recipes table 320 + type RecipesTableProps struct { 321 + Recipes []*models.Recipe 322 + ShowActions bool 323 + } 324 + 325 + // RecipesTable renders a table of recipes with configurable actions 326 + templ RecipesTable(props RecipesTableProps) { 327 + if len(props.Recipes) == 0 { 328 + @EmptyState(EmptyStateProps{Message: "No recipes yet."}) 329 + } else { 330 + <div class="table-container overflow-x-auto"> 331 + <table class="table"> 332 + <thead class="table-header"> 333 + <tr> 334 + <th class="table-th whitespace-nowrap">Name</th> 335 + <th class="table-th whitespace-nowrap">Coffee</th> 336 + <th class="table-th whitespace-nowrap">Water</th> 337 + <th class="table-th whitespace-nowrap">Grind</th> 338 + <th class="table-th whitespace-nowrap">Brewer</th> 339 + if props.ShowActions { 340 + <th class="table-th whitespace-nowrap">Actions</th> 341 + } 342 + </tr> 343 + </thead> 344 + <tbody class="table-body"> 345 + for _, recipe := range props.Recipes { 346 + <tr class="table-row"> 347 + <td class="px-6 py-4 text-sm font-medium text-brown-900"> 348 + { recipe.Name } 349 + </td> 350 + <td class="px-6 py-4 text-sm text-brown-900"> 351 + if recipe.CoffeeAmount > 0 { 352 + { fmt.Sprintf("%.1fg", recipe.CoffeeAmount) } 353 + } else { 354 + <span class="text-brown-400">-</span> 355 + } 356 + </td> 357 + <td class="px-6 py-4 text-sm text-brown-900"> 358 + if recipe.WaterAmount > 0 { 359 + { fmt.Sprintf("%.1fg", recipe.WaterAmount) } 360 + } else { 361 + <span class="text-brown-400">-</span> 362 + } 363 + </td> 364 + <td class="px-6 py-4 text-sm text-brown-900"> 365 + if recipe.GrindSize != "" { 366 + { recipe.GrindSize } 367 + } else { 368 + <span class="text-brown-400">-</span> 369 + } 370 + </td> 371 + <td class="px-6 py-4 text-sm text-brown-900"> 372 + if recipe.BrewerObj != nil { 373 + { recipe.BrewerObj.Name } 374 + } else if recipe.BrewerType != "" { 375 + { recipe.BrewerType } 376 + } else { 377 + <span class="text-brown-400">-</span> 378 + } 379 + </td> 380 + if props.ShowActions { 381 + <td class="px-6 py-4 text-sm font-medium space-x-2"> 382 + <button 383 + hx-get={ "/api/modals/recipe/" + recipe.RKey } 384 + hx-target="#modal-container" 385 + hx-swap="innerHTML" 386 + class="text-brown-700 hover:text-brown-900 font-medium" 387 + >Edit</button> 388 + <button 389 + hx-delete={ "/api/recipes/" + recipe.RKey } 390 + hx-confirm="Are you sure you want to delete this recipe?" 391 + hx-target="closest tr" 392 + hx-swap="outerHTML swap:0.5s" 393 + class="text-brown-600 hover:text-brown-800 font-medium" 394 + >Delete</button> 395 + </td> 396 + } 397 + </tr> 398 + } 399 + </tbody> 400 + </table> 401 + </div> 402 + } 403 + }
+23
internal/web/components/manage_partial.templ
··· 8 8 Roasters []*models.Roaster 9 9 Grinders []*models.Grinder 10 10 Brewers []*models.Brewer 11 + Recipes []*models.Recipe 11 12 } 12 13 13 14 // ManagePartial renders the manage page content tables (for HTMX loading) ··· 16 17 @ManageRoastersTab(props.Roasters) 17 18 @ManageGrindersTab(props.Grinders) 18 19 @ManageBrewersTab(props.Brewers) 20 + @ManageRecipesTab(props.Recipes) 19 21 } 20 22 21 23 // ManageBeansTab renders the beans tab content with open/closed sections ··· 113 115 }) 114 116 </div> 115 117 } 118 + 119 + // ManageRecipesTab renders the recipes tab content 120 + templ ManageRecipesTab(recipes []*models.Recipe) { 121 + <div x-show="tab === 'recipes'"> 122 + <div class="mb-4 flex justify-between items-center"> 123 + <h3 class="text-xl font-semibold text-brown-900">Recipes</h3> 124 + <button 125 + hx-get="/api/modals/recipe/new" 126 + hx-target="#modal-container" 127 + hx-swap="innerHTML" 128 + class="btn-primary shadow-lg hover:shadow-xl" 129 + > 130 + + Add Recipe 131 + </button> 132 + </div> 133 + @RecipesTable(RecipesTableProps{ 134 + Recipes: recipes, 135 + ShowActions: true, 136 + }) 137 + </div> 138 + }
+89
internal/web/pages/brew_form.templ
··· 15 15 Grinders []models.Grinder 16 16 Brewers []models.Brewer 17 17 Roasters []models.Roaster 18 + Recipes []models.Recipe 18 19 19 20 // Derived JSON for pours (if editing) 20 21 PoursJSON string ··· 71 72 } 72 73 x-init="init()" 73 74 > 75 + @RecipeSelectField(props) 74 76 @BeanSelectField(props) 75 77 @CoffeeAmountField(props) 76 78 @GrinderSelectField(props) ··· 84 86 @RatingField(props) 85 87 @SubmitButton(props) 86 88 </form> 89 + } 90 + 91 + // RecipeSelectField renders the recipe selection with autofill and filtering 92 + templ RecipeSelectField(props BrewFormProps) { 93 + <div> 94 + <label class="form-label">Recipe (Optional)</label> 95 + <p class="text-sm text-brown-600 mb-2">Select a recipe to autofill brew parameters</p> 96 + <!-- Category filter chips --> 97 + <div class="flex flex-wrap gap-1.5 mb-2"> 98 + <button 99 + type="button" 100 + @click="setCategory('')" 101 + :class="activeCategory === '' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 102 + class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 103 + > 104 + All 105 + </button> 106 + <button 107 + type="button" 108 + @click="setCategory('small')" 109 + :class="activeCategory === 'small' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 110 + class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 111 + > 112 + Small dose 113 + </button> 114 + <button 115 + type="button" 116 + @click="setCategory('large')" 117 + :class="activeCategory === 'large' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 118 + class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 119 + > 120 + Large batch 121 + </button> 122 + <button 123 + type="button" 124 + @click="setCategory('single')" 125 + :class="activeCategory === 'single' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 126 + class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 127 + > 128 + Single cup 129 + </button> 130 + </div> 131 + <!-- Search input --> 132 + <input 133 + type="text" 134 + x-model="searchQuery" 135 + @input="filterRecipes()" 136 + placeholder="Search recipes..." 137 + class="w-full form-input mb-2 text-sm" 138 + /> 139 + <!-- Recipe select + New button --> 140 + <div class="flex gap-2"> 141 + <select 142 + name="recipe_rkey" 143 + class="flex-1 form-select" 144 + @change="applyRecipe($event.target.value)" 145 + > 146 + <option value="">No recipe</option> 147 + for _, recipe := range props.Recipes { 148 + <option 149 + value={ recipe.RKey } 150 + if props.Brew != nil && props.Brew.RecipeRKey == recipe.RKey { 151 + selected 152 + } 153 + class="truncate" 154 + > 155 + { recipe.Name } 156 + </option> 157 + } 158 + if props.Brew != nil && props.Brew.RecipeRKey != "" && len(props.Recipes) == 0 { 159 + <option value={ props.Brew.RecipeRKey } selected>Loading...</option> 160 + } 161 + </select> 162 + <button 163 + type="button" 164 + hx-get="/api/modals/recipe/new" 165 + hx-target="#modal-container" 166 + hx-swap="innerHTML" 167 + class="btn-secondary" 168 + > 169 + + New 170 + </button> 171 + </div> 172 + <p class="text-xs text-brown-500 mt-1" x-show="filteredCount !== totalCount" x-cloak> 173 + Showing <span x-text="filteredCount"></span> of <span x-text="totalCount"></span> recipes 174 + </p> 175 + </div> 87 176 } 88 177 89 178 // BeanSelectField renders the bean selection field
+86
internal/web/pages/brew_view.templ
··· 50 50 } 51 51 @BrewBeanSection(props.Brew, getOwnerFromShareURL(props.ShareURL)) 52 52 @BrewParametersGrid(props.Brew, getOwnerFromShareURL(props.ShareURL)) 53 + if props.Brew.RecipeObj != nil { 54 + @BrewRecipeSection(props.Brew.RecipeObj) 55 + } 53 56 if props.Brew.Pours != nil && len(props.Brew.Pours) > 0 { 54 57 @BrewPoursSection(props.Brew.Pours) 55 58 } 56 59 if props.Brew.TastingNotes != "" { 57 60 @BrewTastingNotes(props.Brew.TastingNotes) 61 + } 62 + if props.IsOwnProfile && props.Brew.RecipeObj == nil { 63 + @SaveAsRecipeButton(props.Brew.RKey) 58 64 } 59 65 <div class="flex justify-between items-center"> 60 66 @components.BackButton() ··· 249 255 return brew.Bean.Origin 250 256 } 251 257 return "Coffee Brew" 258 + } 259 + 260 + // SaveAsRecipeButton renders a button to save brew parameters as a recipe 261 + templ SaveAsRecipeButton(brewRKey string) { 262 + <div class="section-box" x-data={ saveAsRecipeData(brewRKey) }> 263 + <template x-if="!showForm && !success"> 264 + <button 265 + @click="showForm = true" 266 + class="w-full btn-secondary text-sm" 267 + > 268 + 📋 Save as Recipe 269 + </button> 270 + </template> 271 + <template x-if="showForm && !success"> 272 + <div class="space-y-3"> 273 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider">Save as Recipe</h3> 274 + <input 275 + type="text" 276 + x-model="name" 277 + placeholder="Recipe name *" 278 + class="w-full form-input" 279 + /> 280 + <template x-if="error"> 281 + <div class="text-red-600 text-sm" x-text="error"></div> 282 + </template> 283 + <div class="flex gap-2"> 284 + <button 285 + @click="saveRecipe()" 286 + :disabled="saving" 287 + class="flex-1 btn-primary text-sm" 288 + > 289 + <span x-show="!saving">Save</span> 290 + <span x-show="saving">Saving...</span> 291 + </button> 292 + <button 293 + @click="showForm = false; error = ''" 294 + class="flex-1 btn-secondary text-sm" 295 + > 296 + Cancel 297 + </button> 298 + </div> 299 + </div> 300 + </template> 301 + <template x-if="success"> 302 + <div class="text-center text-green-700 text-sm font-medium py-2"> 303 + Recipe saved! 304 + </div> 305 + </template> 306 + </div> 307 + } 308 + 309 + func saveAsRecipeData(brewRKey string) string { 310 + return fmt.Sprintf("{ showForm: false, name: '', saving: false, error: '', success: false, brewRKey: '%s', saveRecipe() { if (!this.name.trim()) { this.error = 'Name is required'; return; } this.saving = true; this.error = ''; const body = new URLSearchParams({ name: this.name }); fetch('/api/recipes/from-brew/' + this.brewRKey, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: body, credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); }).then(() => { this.success = true; this.saving = false; }).catch(() => { this.error = 'Failed to save recipe'; this.saving = false; }); } }", brewRKey) 311 + } 312 + 313 + // BrewRecipeSection renders the linked recipe info 314 + templ BrewRecipeSection(recipe *models.Recipe) { 315 + <div class="section-box"> 316 + <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2">📋 Recipe</h3> 317 + <div class="font-bold text-lg text-brown-900">{ recipe.Name }</div> 318 + <div class="flex flex-wrap gap-3 mt-2 text-sm text-brown-600"> 319 + if recipe.CoffeeAmount > 0 { 320 + <span>☕ { fmt.Sprintf("%.1fg coffee", recipe.CoffeeAmount) }</span> 321 + } 322 + if recipe.WaterAmount > 0 { 323 + <span>💧 { fmt.Sprintf("%.1fg water", recipe.WaterAmount) }</span> 324 + } 325 + if recipe.GrindSize != "" { 326 + <span>🔧 { recipe.GrindSize }</span> 327 + } 328 + if recipe.BrewerObj != nil { 329 + <span>🫖 { recipe.BrewerObj.Name }</span> 330 + } else if recipe.BrewerType != "" { 331 + <span>🫖 { recipe.BrewerType }</span> 332 + } 333 + </div> 334 + if recipe.Notes != "" { 335 + <div class="mt-2 text-sm text-brown-700 italic">"{ recipe.Notes }"</div> 336 + } 337 + </div> 252 338 } 253 339 254 340 // BrewPoursSection renders the pours section
+51
internal/web/pages/feed.templ
··· 38 38 {Label: "Roasters", Value: "roaster"}, 39 39 {Label: "Grinders", Value: "grinder"}, 40 40 {Label: "Brewers", Value: "brewer"}, 41 + {Label: "Recipes", Value: "recipe"}, 41 42 } 42 43 } 43 44 ··· 298 299 } else { 299 300 { item.Action } 300 301 } 302 + case lexicons.RecordTypeRecipe: 303 + if item.Recipe != nil { 304 + added a 305 + <a href={ templ.SafeURL(getFeedItemShareURL(item)) } class="underline hover:text-brown-900">new recipe</a> 306 + } else { 307 + { item.Action } 308 + } 301 309 default: 302 310 { item.Action } 303 311 } ··· 417 425 } 418 426 } 419 427 428 + // FeedRecipeContent renders recipe content in a feed card 429 + templ FeedRecipeContent(item *feed.FeedItem) { 430 + if item.Recipe != nil { 431 + <div class="feed-content-box-sm"> 432 + <div class="text-base mb-2"> 433 + <span class="font-bold text-brown-900">{ item.Recipe.Name }</span> 434 + </div> 435 + <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 436 + if item.Recipe.CoffeeAmount > 0 { 437 + <span class="inline-flex items-center gap-0.5">☕ { fmt.Sprintf("%.1fg", item.Recipe.CoffeeAmount) }</span> 438 + } 439 + if item.Recipe.WaterAmount > 0 { 440 + <span class="inline-flex items-center gap-0.5">💧 { fmt.Sprintf("%.1fg", item.Recipe.WaterAmount) }</span> 441 + } 442 + if item.Recipe.GrindSize != "" { 443 + <span class="inline-flex items-center gap-0.5">🔧 { item.Recipe.GrindSize }</span> 444 + } 445 + if item.Recipe.BrewerObj != nil { 446 + <span class="inline-flex items-center gap-0.5">🫖 { item.Recipe.BrewerObj.Name }</span> 447 + } else if item.Recipe.BrewerType != "" { 448 + <span class="inline-flex items-center gap-0.5">🫖 { item.Recipe.BrewerType }</span> 449 + } 450 + </div> 451 + if item.Recipe.Notes != "" { 452 + <div class="mt-2 text-sm text-brown-800 italic">"{ item.Recipe.Notes }"</div> 453 + } 454 + </div> 455 + } 456 + } 457 + 420 458 // Helper functions for share button 421 459 func getFeedItemShareURL(item *feed.FeedItem) string { 422 460 switch item.RecordType { ··· 440 478 if item.Brewer != nil { 441 479 return fmt.Sprintf("/brewers/%s?owner=%s", item.Brewer.RKey, item.Author.DID) 442 480 } 481 + case lexicons.RecordTypeRecipe: 482 + if item.Recipe != nil { 483 + return fmt.Sprintf("/recipes/%s?owner=%s", item.Recipe.RKey, item.Author.DID) 484 + } 443 485 } 444 486 return fmt.Sprintf("/profile/%s", item.Author.DID) 445 487 } ··· 477 519 return item.Brewer.Name 478 520 } 479 521 return "Brewer" 522 + case lexicons.RecordTypeRecipe: 523 + if item.Recipe != nil { 524 + return item.Recipe.Name 525 + } 526 + return "Recipe" 480 527 } 481 528 return "Arabica" 482 529 } ··· 523 570 case lexicons.RecordTypeBrewer: 524 571 if item.Brewer != nil { 525 572 return fmt.Sprintf("/api/brewers/%s", item.Brewer.RKey) 573 + } 574 + case lexicons.RecordTypeRecipe: 575 + if item.Recipe != nil { 576 + return fmt.Sprintf("/api/recipes/%s", item.Recipe.RKey) 526 577 } 527 578 } 528 579 return ""
+45
internal/web/pages/manage.templ
··· 55 55 > 56 56 Brewers 57 57 </button> 58 + <button 59 + @click="tab = 'recipes'" 60 + :class="tab === 'recipes' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 61 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 62 + > 63 + Recipes 64 + </button> 58 65 </nav> 59 66 </div> 60 67 } ··· 203 210 <tr class="table-row"> 204 211 <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 205 212 <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-48"></div></td> 213 + <td class="px-6 py-4"> 214 + <div class="flex gap-2"> 215 + <div class="h-4 bg-brown-300 rounded w-10"></div> 216 + <div class="h-4 bg-brown-300 rounded w-12"></div> 217 + </div> 218 + </td> 219 + </tr> 220 + } 221 + </tbody> 222 + </table> 223 + </div> 224 + </div> 225 + <!-- Recipes Tab Skeleton --> 226 + <div x-show="tab === 'recipes'"> 227 + <div class="mb-4 flex justify-between items-center"> 228 + <h3 class="text-xl font-semibold text-brown-900">Recipes</h3> 229 + <div class="h-10 bg-brown-300 rounded w-28"></div> 230 + </div> 231 + <div class="table-container overflow-x-auto"> 232 + <table class="table"> 233 + <thead class="table-header"> 234 + <tr> 235 + <th class="table-th whitespace-nowrap">Name</th> 236 + <th class="table-th whitespace-nowrap">Coffee</th> 237 + <th class="table-th whitespace-nowrap">Water</th> 238 + <th class="table-th whitespace-nowrap">Grind</th> 239 + <th class="table-th whitespace-nowrap">Brewer</th> 240 + <th class="table-th whitespace-nowrap">Actions</th> 241 + </tr> 242 + </thead> 243 + <tbody class="table-body"> 244 + for range ntabs { 245 + <tr class="table-row"> 246 + <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-28"></div></td> 247 + <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 248 + <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-16"></div></td> 249 + <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-20"></div></td> 250 + <td class="px-6 py-4"><div class="h-4 bg-brown-300 rounded w-24"></div></td> 206 251 <td class="px-6 py-4"> 207 252 <div class="flex gap-2"> 208 253 <div class="h-4 bg-brown-300 rounded w-10"></div>
+226
internal/web/pages/recipe_explore.templ
··· 1 + package pages 2 + 3 + import ( 4 + "arabica/internal/models" 5 + "arabica/internal/web/components" 6 + "fmt" 7 + ) 8 + 9 + // RecipeExploreProps defines the data for the recipe explore page 10 + type RecipeExploreProps struct{} 11 + 12 + // RecipeExplorePage renders the full recipe explore page 13 + templ RecipeExplorePage(layout *components.LayoutData, props RecipeExploreProps) { 14 + @components.Layout(layout, RecipeExploreContent(props)) 15 + } 16 + 17 + // RecipeExploreContent renders the recipe explore page content 18 + templ RecipeExploreContent(props RecipeExploreProps) { 19 + <script src="/static/js/recipe-explore.js?v=0.1.0"></script> 20 + <div class="page-container-xl" x-data="recipeExplore"> 21 + <div class="flex items-center gap-3 mb-6"> 22 + @components.BackButton() 23 + <h2 class="text-3xl font-bold text-brown-900">Explore Recipes</h2> 24 + </div> 25 + <!-- Search and filters --> 26 + <div class="card card-inner mb-6"> 27 + <div class="space-y-4"> 28 + <!-- Text search --> 29 + <div> 30 + <input 31 + type="text" 32 + x-model="query" 33 + @input.debounce.300ms="search()" 34 + placeholder="Search recipes by name..." 35 + class="w-full form-input" 36 + /> 37 + </div> 38 + <!-- Category chips --> 39 + <div class="flex flex-wrap gap-2"> 40 + <button 41 + type="button" 42 + @click="setCategory('')" 43 + :class="category === '' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 44 + class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 45 + > 46 + All 47 + </button> 48 + <button 49 + type="button" 50 + @click="setCategory('small')" 51 + :class="category === 'small' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 52 + class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 53 + > 54 + Small dose (&le;20g) 55 + </button> 56 + <button 57 + type="button" 58 + @click="setCategory('large')" 59 + :class="category === 'large' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 60 + class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 61 + > 62 + Large batch (30g+) 63 + </button> 64 + <button 65 + type="button" 66 + @click="setCategory('single')" 67 + :class="category === 'single' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 68 + class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 69 + > 70 + Single cup 71 + </button> 72 + <button 73 + type="button" 74 + @click="setCategory('batch')" 75 + :class="category === 'batch' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 76 + class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 77 + > 78 + Batch brew (500g+ water) 79 + </button> 80 + </div> 81 + <!-- Brewer type filter --> 82 + <div class="flex gap-4 items-end"> 83 + <div class="flex-1"> 84 + <label class="form-label text-sm">Brewer Type</label> 85 + <input 86 + type="text" 87 + x-model="brewerType" 88 + @input.debounce.300ms="search()" 89 + placeholder="e.g. Pour-Over, French Press..." 90 + class="w-full form-input text-sm" 91 + /> 92 + </div> 93 + <div class="w-28"> 94 + <label class="form-label text-sm">Min coffee (g)</label> 95 + <input 96 + type="number" 97 + x-model="minCoffee" 98 + @input.debounce.300ms="search()" 99 + placeholder="0" 100 + step="1" 101 + class="w-full form-input text-sm" 102 + /> 103 + </div> 104 + <div class="w-28"> 105 + <label class="form-label text-sm">Max coffee (g)</label> 106 + <input 107 + type="number" 108 + x-model="maxCoffee" 109 + @input.debounce.300ms="search()" 110 + placeholder="any" 111 + step="1" 112 + class="w-full form-input text-sm" 113 + /> 114 + </div> 115 + </div> 116 + </div> 117 + </div> 118 + <!-- Results --> 119 + <div> 120 + <template x-if="loading"> 121 + @components.LoadingSkeletonTable(components.LoadingSkeletonTableProps{Columns: 6, Rows: 3}) 122 + </template> 123 + <template x-if="!loading && recipes.length === 0"> 124 + <div class="card card-inner text-center py-8"> 125 + <p class="text-brown-700 text-lg font-medium">No recipes found</p> 126 + <p class="text-sm text-brown-600 mt-2">Try adjusting your filters or search terms</p> 127 + </div> 128 + </template> 129 + <template x-if="!loading && recipes.length > 0"> 130 + <div class="card card-inner"> 131 + <p class="text-sm text-brown-600 mb-3"> 132 + <span x-text="recipes.length"></span> recipe(s) found 133 + </p> 134 + <div class="table-container overflow-x-auto"> 135 + <table class="table"> 136 + <thead class="table-header"> 137 + <tr> 138 + <th class="table-th whitespace-nowrap">Name</th> 139 + <th class="table-th whitespace-nowrap">Coffee</th> 140 + <th class="table-th whitespace-nowrap">Water</th> 141 + <th class="table-th whitespace-nowrap">Ratio</th> 142 + <th class="table-th whitespace-nowrap">Grind</th> 143 + <th class="table-th whitespace-nowrap">Brewer</th> 144 + </tr> 145 + </thead> 146 + <tbody class="table-body"> 147 + <template x-for="recipe in recipes" :key="recipe.rkey"> 148 + <tr class="table-row cursor-pointer hover:bg-brown-50" @click="selectRecipe(recipe)"> 149 + <td class="px-6 py-4 text-sm font-medium text-brown-900" x-text="recipe.name"></td> 150 + <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.coffee_amount > 0 ? recipe.coffee_amount.toFixed(1) + 'g' : '-'"></td> 151 + <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></td> 152 + <td class="px-6 py-4 text-sm text-brown-900" x-text="formatRatio(recipe)"></td> 153 + <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.grind_size || '-'"></td> 154 + <td class="px-6 py-4 text-sm text-brown-900" x-text="getBrewerDisplay(recipe)"></td> 155 + </tr> 156 + </template> 157 + </tbody> 158 + </table> 159 + </div> 160 + </div> 161 + </template> 162 + </div> 163 + <!-- Recipe detail panel --> 164 + <template x-if="selectedRecipe"> 165 + <div class="card card-inner mt-4"> 166 + <div class="flex justify-between items-start mb-4"> 167 + <h3 class="text-xl font-bold text-brown-900" x-text="selectedRecipe.name"></h3> 168 + <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold">&times;</button> 169 + </div> 170 + <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> 171 + <div> 172 + <span class="text-xs text-brown-600 uppercase">Coffee</span> 173 + <p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p> 174 + </div> 175 + <div> 176 + <span class="text-xs text-brown-600 uppercase">Water</span> 177 + <p class="font-medium text-brown-900" x-text="selectedRecipe.water_amount > 0 ? selectedRecipe.water_amount.toFixed(1) + 'g' : '-'"></p> 178 + </div> 179 + <div> 180 + <span class="text-xs text-brown-600 uppercase">Ratio</span> 181 + <p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p> 182 + </div> 183 + <div> 184 + <span class="text-xs text-brown-600 uppercase">Grind</span> 185 + <p class="font-medium text-brown-900" x-text="selectedRecipe.grind_size || '-'"></p> 186 + </div> 187 + </div> 188 + <template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0"> 189 + <div class="mb-4"> 190 + <span class="text-xs text-brown-600 uppercase">Pours</span> 191 + <div class="space-y-1 mt-1"> 192 + <template x-for="(pour, i) in selectedRecipe.pours" :key="i"> 193 + <div class="flex gap-4 text-sm bg-brown-50 p-2 rounded border border-brown-200"> 194 + <span class="font-medium text-brown-800" x-text="'Pour ' + (i+1)"></span> 195 + <span class="text-brown-700" x-text="pour.water_amount + 'g'"></span> 196 + <span class="text-brown-600" x-text="pour.time_seconds + 's'"></span> 197 + </div> 198 + </template> 199 + </div> 200 + </div> 201 + </template> 202 + <template x-if="selectedRecipe.notes"> 203 + <div class="mb-4"> 204 + <span class="text-xs text-brown-600 uppercase">Notes</span> 205 + <p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p> 206 + </div> 207 + </template> 208 + <a 209 + :href="'/brews/new?recipe=' + selectedRecipe.rkey" 210 + class="btn-primary inline-block" 211 + > 212 + Use in Brew 213 + </a> 214 + </div> 215 + </template> 216 + </div> 217 + } 218 + 219 + // recipeRatioDisplay is used by the explore page JS 220 + func recipeRatioDisplay(recipe *models.Recipe) string { 221 + if recipe.CoffeeAmount > 0 && recipe.WaterAmount > 0 { 222 + ratio := recipe.WaterAmount / recipe.CoffeeAmount 223 + return fmt.Sprintf("1:%.1f", ratio) 224 + } 225 + return "-" 226 + }
+5
lexicons/social.arabica.alpha.brew.json
··· 56 56 "format": "at-uri", 57 57 "description": "AT-URI reference to the brewer/device used" 58 58 }, 59 + "recipeRef": { 60 + "type": "string", 61 + "format": "at-uri", 62 + "description": "AT-URI reference to the recipe used for this brew" 63 + }, 59 64 "tastingNotes": { 60 65 "type": "string", 61 66 "maxLength": 2000,
+82
lexicons/social.arabica.alpha.recipe.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.arabica.alpha.recipe", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "A reusable brewing recipe with parameters for repeatable coffee preparation", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "maxLength": 200, 16 + "description": "User-given name for the recipe (e.g., 'James Hoffmann V60')" 17 + }, 18 + "brewerRef": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI reference to a specific brewer record" 22 + }, 23 + "brewerType": { 24 + "type": "string", 25 + "maxLength": 100, 26 + "description": "Fallback brewer type when no specific brewer is referenced (e.g., 'Pour-Over', 'French Press')" 27 + }, 28 + "coffeeAmount": { 29 + "type": "integer", 30 + "minimum": 0, 31 + "description": "Amount of coffee in tenths of grams (e.g., 180 = 18.0g)" 32 + }, 33 + "waterAmount": { 34 + "type": "integer", 35 + "minimum": 0, 36 + "description": "Amount of water in tenths of grams (e.g., 3000 = 300.0g)" 37 + }, 38 + "grindSize": { 39 + "type": "string", 40 + "maxLength": 100, 41 + "description": "Grind size setting (can be numeric like '18' or descriptive like 'Medium-Fine')" 42 + }, 43 + "pours": { 44 + "type": "array", 45 + "description": "Array of pour information for multi-pour methods", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#pour" 49 + } 50 + }, 51 + "notes": { 52 + "type": "string", 53 + "maxLength": 2000, 54 + "description": "Free-text instructions, tips, or notes about the recipe" 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime", 59 + "description": "Timestamp when the recipe was created" 60 + } 61 + } 62 + } 63 + }, 64 + "pour": { 65 + "type": "object", 66 + "description": "Information about a single pour in a multi-pour brewing method", 67 + "required": ["waterAmount", "timeSeconds"], 68 + "properties": { 69 + "waterAmount": { 70 + "type": "integer", 71 + "minimum": 0, 72 + "description": "Amount of water in this pour (grams or ml)" 73 + }, 74 + "timeSeconds": { 75 + "type": "integer", 76 + "minimum": 0, 77 + "description": "Time of this pour relative to brew start (seconds)" 78 + } 79 + } 80 + } 81 + } 82 + }
+145
static/js/brew-form.js
··· 11 11 rating: 5, 12 12 pours: [], 13 13 14 + // Recipe filter state 15 + searchQuery: "", 16 + activeCategory: "", 17 + filteredCount: 0, 18 + totalCount: 0, 19 + recipes: [], 20 + 14 21 // Dropdown manager instance 15 22 dropdownManager: null, 16 23 ··· 42 49 // Populate dropdowns from cache using stale-while-revalidate pattern 43 50 await this.dropdownManager.loadDropdownData(); 44 51 this.dropdownManager.populateDropdowns(); 52 + 53 + // Initialize recipe filter state from loaded data 54 + this.recipes = this.dropdownManager.recipes || []; 55 + this.totalCount = this.recipes.length; 56 + this.filteredCount = this.recipes.length; 57 + 58 + // Re-sync recipes when cache refreshes 59 + if (window.ArabicaCache) { 60 + window.ArabicaCache.addListener((data) => { 61 + this.recipes = data.recipes || []; 62 + this.filterRecipes(); 63 + }); 64 + } 65 + 66 + // Auto-apply recipe from URL param (e.g. /brews/new?recipe=abc123) 67 + const urlParams = new URLSearchParams(window.location.search); 68 + const recipeRKey = urlParams.get("recipe"); 69 + if (recipeRKey) { 70 + const recipeSelect = this.$el.querySelector('form select[name="recipe_rkey"]'); 71 + if (recipeSelect) { 72 + recipeSelect.value = recipeRKey; 73 + } 74 + await this.applyRecipe(recipeRKey); 75 + } 45 76 }, 46 77 47 78 initEntityManagers() { ··· 140 171 }); 141 172 }, 142 173 174 + // Recipe autofill 175 + async applyRecipe(rkey) { 176 + const form = this.$el.querySelector("form"); 177 + if (!form) return; 178 + 179 + // If no recipe selected, clear all recipe-populated fields 180 + if (!rkey) { 181 + this.clearRecipeFields(form); 182 + return; 183 + } 184 + 185 + try { 186 + const resp = await fetch(`/api/recipes/${rkey}`, { credentials: "same-origin" }); 187 + if (!resp.ok) return; 188 + const recipe = await resp.json(); 189 + 190 + // Set or clear each field based on recipe data 191 + this.setFormField(form, "coffee_amount", recipe.coffee_amount > 0 ? Math.round(recipe.coffee_amount) : ""); 192 + this.setFormField(form, "water_amount", recipe.water_amount > 0 ? Math.round(recipe.water_amount) : ""); 193 + this.setFormField(form, "grind_size", recipe.grind_size || ""); 194 + this.setFormField(form, "brewer_rkey", recipe.brewer_rkey || ""); 195 + 196 + // Always reset pours, then apply recipe pours if present 197 + this.pours = (recipe.pours && recipe.pours.length > 0) 198 + ? recipe.pours.map(p => ({ water: p.water_amount || "", time: p.time_seconds || "" })) 199 + : []; 200 + } catch (e) { 201 + console.error("Failed to apply recipe:", e); 202 + } 203 + }, 204 + 205 + setFormField(form, name, value) { 206 + const el = form.querySelector(`[name="${name}"]`); 207 + if (el) { 208 + el.value = value; 209 + el.dispatchEvent(new Event("input", { bubbles: true })); 210 + } 211 + }, 212 + 213 + clearRecipeFields(form) { 214 + this.setFormField(form, "coffee_amount", ""); 215 + this.setFormField(form, "water_amount", ""); 216 + this.setFormField(form, "grind_size", ""); 217 + this.setFormField(form, "brewer_rkey", ""); 218 + this.pours = []; 219 + }, 220 + 143 221 // Pours management (brew-specific logic) 144 222 addPour() { 145 223 this.pours.push({ water: "", time: "" }); ··· 238 316 239 317 async saveBrewer() { 240 318 await this.brewerManager.save(); 319 + }, 320 + 321 + // Recipe filter methods 322 + recipeCategories: { 323 + small: { maxCoffee: 20 }, 324 + large: { minCoffee: 30 }, 325 + single: { maxCoffee: 20, maxWater: 300 }, 326 + batch: { minWater: 500 }, 327 + }, 328 + 329 + setCategory(cat) { 330 + this.activeCategory = cat; 331 + this.filterRecipes(); 332 + }, 333 + 334 + filterRecipes() { 335 + const select = this.$el.querySelector('form select[name="recipe_rkey"]'); 336 + if (!select) return; 337 + 338 + const query = this.searchQuery.toLowerCase().trim(); 339 + const cat = this.recipeCategories[this.activeCategory]; 340 + 341 + let total = 0; 342 + let shown = 0; 343 + 344 + // Rebuild options: keep placeholder, filter the rest 345 + const selectedValue = select.value; 346 + select.innerHTML = ""; 347 + 348 + const placeholder = document.createElement("option"); 349 + placeholder.value = ""; 350 + placeholder.textContent = "No recipe"; 351 + select.appendChild(placeholder); 352 + 353 + for (const recipe of this.recipes) { 354 + total++; 355 + const name = (recipe.name || recipe.Name || "").toLowerCase(); 356 + const coffee = recipe.coffee_amount || 0; 357 + const water = recipe.water_amount || 0; 358 + 359 + // Text filter 360 + if (query && !name.includes(query)) continue; 361 + 362 + // Category filter 363 + if (cat) { 364 + if (cat.maxCoffee && coffee > cat.maxCoffee) continue; 365 + if (cat.minCoffee && coffee < cat.minCoffee) continue; 366 + if (cat.maxWater && water > cat.maxWater) continue; 367 + if (cat.minWater && water < cat.minWater) continue; 368 + // Skip recipes with no amount data when filtering by category 369 + if ((cat.maxCoffee || cat.minCoffee) && coffee === 0) continue; 370 + if ((cat.maxWater || cat.minWater) && water === 0) continue; 371 + } 372 + 373 + shown++; 374 + const option = document.createElement("option"); 375 + option.value = recipe.rkey || recipe.RKey; 376 + option.textContent = recipe.name || recipe.Name; 377 + option.className = "truncate"; 378 + if ((recipe.rkey || recipe.RKey) === selectedValue) { 379 + option.selected = true; 380 + } 381 + select.appendChild(option); 382 + } 383 + 384 + this.totalCount = total; 385 + this.filteredCount = shown; 241 386 }, 242 387 })); 243 388 });
+36
static/js/dropdown-manager.js
··· 15 15 grinders: [], 16 16 brewers: [], 17 17 roasters: [], 18 + recipes: [], 18 19 dataLoaded: false, 19 20 20 21 /** ··· 70 71 this.grinders = data.grinders || []; 71 72 this.brewers = data.brewers || []; 72 73 this.roasters = data.roasters || []; 74 + this.recipes = data.recipes || []; 73 75 this.dataLoaded = true; 74 76 }, 75 77 ··· 82 84 this.populateGrinders(); 83 85 this.populateBrewers(); 84 86 this.populateRoasters(); 87 + this.populateRecipes(); 85 88 }, 86 89 87 90 /** ··· 217 220 option.selected = true; 218 221 } 219 222 roasterSelect.appendChild(option); 223 + }); 224 + }, 225 + 226 + /** 227 + * Populates recipe dropdown 228 + * @param {string} selectSelector - CSS selector for the select element (optional) 229 + */ 230 + populateRecipes(selectSelector = 'form select[name="recipe_rkey"]') { 231 + const recipeSelect = document.querySelector(selectSelector); 232 + if (!recipeSelect || this.recipes.length === 0) return; 233 + 234 + const selectedRecipe = recipeSelect.value || ""; 235 + 236 + // Clear existing options 237 + recipeSelect.innerHTML = ""; 238 + 239 + // Add placeholder 240 + const placeholderOption = document.createElement("option"); 241 + placeholderOption.value = ""; 242 + placeholderOption.textContent = "No recipe"; 243 + recipeSelect.appendChild(placeholderOption); 244 + 245 + // Add recipe options 246 + this.recipes.forEach((recipe) => { 247 + const option = document.createElement("option"); 248 + option.value = recipe.rkey || recipe.RKey; 249 + // Using textContent ensures all user input is safely escaped 250 + option.textContent = recipe.Name || recipe.name; 251 + option.className = "truncate"; 252 + if ((recipe.rkey || recipe.RKey) === selectedRecipe) { 253 + option.selected = true; 254 + } 255 + recipeSelect.appendChild(option); 220 256 }); 221 257 }, 222 258
+69
static/js/recipe-explore.js
··· 1 + /** 2 + * Alpine.js component for the recipe explore page 3 + * Handles search, filtering, and recipe detail display 4 + */ 5 + document.addEventListener("alpine:init", () => { 6 + Alpine.data("recipeExplore", () => ({ 7 + query: "", 8 + category: "", 9 + brewerType: "", 10 + minCoffee: "", 11 + maxCoffee: "", 12 + loading: false, 13 + recipes: [], 14 + selectedRecipe: null, 15 + 16 + init() { 17 + this.search(); 18 + }, 19 + 20 + setCategory(cat) { 21 + this.category = cat; 22 + this.search(); 23 + }, 24 + 25 + async search() { 26 + this.loading = true; 27 + try { 28 + const params = new URLSearchParams(); 29 + if (this.query) params.set("q", this.query); 30 + if (this.category) params.set("category", this.category); 31 + if (this.brewerType) params.set("brewer_type", this.brewerType); 32 + if (this.minCoffee) params.set("min_coffee", this.minCoffee); 33 + if (this.maxCoffee) params.set("max_coffee", this.maxCoffee); 34 + 35 + const resp = await fetch(`/api/recipes/suggestions?${params}`, { 36 + credentials: "same-origin", 37 + }); 38 + if (!resp.ok) throw new Error("Failed to fetch"); 39 + this.recipes = await resp.json(); 40 + // If no results returned, ensure it's an array 41 + if (!Array.isArray(this.recipes)) this.recipes = []; 42 + } catch (e) { 43 + console.error("Failed to search recipes:", e); 44 + this.recipes = []; 45 + } finally { 46 + this.loading = false; 47 + } 48 + }, 49 + 50 + selectRecipe(recipe) { 51 + this.selectedRecipe = recipe; 52 + }, 53 + 54 + formatRatio(recipe) { 55 + if (recipe.coffee_amount > 0 && recipe.water_amount > 0) { 56 + const ratio = recipe.water_amount / recipe.coffee_amount; 57 + return `1:${ratio.toFixed(1)}`; 58 + } 59 + return "-"; 60 + }, 61 + 62 + getBrewerDisplay(recipe) { 63 + if (recipe.brewer_obj && recipe.brewer_obj.name) { 64 + return recipe.brewer_obj.name; 65 + } 66 + return recipe.brewer_type || "-"; 67 + }, 68 + })); 69 + });