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.

fix: interpolate missing values in recipe, misc fixes

authored by

Patrick Dewey and committed by tangled.org fd220334 69aad181

+192 -40
+12
docs/recipes.norg
··· 63 63 Some recipe fields should be interpolated from other fields when missing 64 64 (i.e. water amount and ratio, from pours and coffee amount. Ratio may be 65 65 transitive) 66 + 67 + "Search recipes" entry doesn't currently work. This should be retooled to 68 + show a list of matches in the same style as login handle entry, and either 69 + show just the user's recipes, or maybe show suggested recipes from other 70 + users (not sure how they would be rated though, since that would probably 71 + need to be part of this) 72 + 73 + ** Open Questions 74 + 75 + For links between a brew and recipe, which should have the optional ref? 76 + Probably brew, but should a recipe contain a ref to the original brew that it 77 + /may/ have been saved from.
+11
internal/handlers/recipe.go
··· 5 5 "net/http" 6 6 "strconv" 7 7 8 + "arabica/internal/atproto" 8 9 "arabica/internal/models" 9 10 "arabica/internal/web/components" 10 11 "arabica/internal/web/pages" ··· 172 173 return 173 174 } 174 175 176 + recipe.Interpolate() 175 177 writeJSON(w, recipe, "recipe") 176 178 } 177 179 ··· 296 298 for _, b := range brewers { 297 299 brewerMap[b.RKey] = b 298 300 } 301 + userDID, _ := atproto.GetAuthenticatedDID(r.Context()) 302 + userProfile := h.getUserProfile(r.Context(), userDID) 299 303 for _, recipe := range recipes { 300 304 if recipe.BrewerRKey != "" { 301 305 recipe.BrewerObj = brewerMap[recipe.BrewerRKey] ··· 304 308 if recipe.BrewerType == "" && recipe.BrewerObj != nil { 305 309 recipe.BrewerType = recipe.BrewerObj.BrewerType 306 310 } 311 + recipe.AuthorDID = userDID 312 + if userProfile != nil { 313 + recipe.AuthorHandle = userProfile.Handle 314 + recipe.AuthorAvatar = userProfile.Avatar 315 + recipe.AuthorDisplay = userProfile.DisplayName 316 + } 317 + recipe.Interpolate() 307 318 } 308 319 309 320 filtered := models.FilterRecipes(recipes, filter)
+25
internal/models/models.go
··· 107 107 // Joined data for display 108 108 BrewerObj *Brewer `json:"brewer_obj,omitempty"` 109 109 Pours []*Pour `json:"pours,omitempty"` 110 + 111 + // Computed fields (populated by Interpolate or handler) 112 + Ratio float64 `json:"ratio,omitempty"` // water:coffee ratio (e.g. 16.7 for 1:16.7) 113 + AuthorDID string `json:"author_did,omitempty"` // DID of the recipe creator 114 + AuthorHandle string `json:"author_handle,omitempty"` // handle of the creator 115 + AuthorAvatar string `json:"author_avatar,omitempty"` // avatar URL of the creator 116 + AuthorDisplay string `json:"author_display,omitempty"` // display name of the creator 117 + } 118 + 119 + // Interpolate fills in computed/derived fields from existing data. 120 + // - WaterAmount from sum of pours if not set 121 + // - Ratio from water/coffee amounts 122 + func (r *Recipe) Interpolate() { 123 + // Derive water amount from pours if missing 124 + if r.WaterAmount == 0 && len(r.Pours) > 0 { 125 + var total int 126 + for _, p := range r.Pours { 127 + total += p.WaterAmount 128 + } 129 + r.WaterAmount = float64(total) 130 + } 131 + // Compute ratio 132 + if r.CoffeeAmount > 0 && r.WaterAmount > 0 { 133 + r.Ratio = r.WaterAmount / r.CoffeeAmount 134 + } 110 135 } 111 136 112 137 type Brew struct {
+6 -4
internal/models/recipe_filter.go
··· 15 15 16 16 // RecipeCategories maps category names to their filter criteria. 17 17 var RecipeCategories = map[string]RecipeFilter{ 18 - "small": {MaxCoffee: 20}, 19 - "large": {MinCoffee: 30}, 20 - "single": {MaxCoffee: 20, MaxWater: 300}, 21 - "batch": {MinWater: 500}, 18 + "small": {MaxCoffee: 12}, // espresso, small cups (≤12g) 19 + "single": {MinCoffee: 12, MaxCoffee: 22, MaxWater: 400}, // typical single pour-over (12-22g) 20 + "large": {MinCoffee: 22}, // large brews (22g+) 21 + "batch": {MinWater: 500}, // batch brew by water volume (500g+) 22 22 } 23 23 24 24 // MatchesFilter returns true if the recipe satisfies all non-zero filter criteria. 25 25 // Criteria are combined with AND logic; zero-value fields are ignored. 26 + // Calls Interpolate on the recipe to ensure derived fields are available. 26 27 func MatchesFilter(recipe *Recipe, filter RecipeFilter) bool { 28 + recipe.Interpolate() 27 29 // Apply category defaults first (explicit fields override) 28 30 f := resolveCategory(filter) 29 31
+89 -13
internal/models/recipe_filter_test.go
··· 77 77 } 78 78 79 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} 80 + espresso := &Recipe{Name: "Espresso", CoffeeAmount: 9} 81 + borderline := &Recipe{Name: "Borderline", CoffeeAmount: 12} 82 + pourover := &Recipe{Name: "Pour Over", CoffeeAmount: 15} 83 83 84 - assert.True(t, MatchesFilter(small, RecipeFilter{Category: "small"})) 84 + assert.True(t, MatchesFilter(espresso, RecipeFilter{Category: "small"})) 85 85 assert.True(t, MatchesFilter(borderline, RecipeFilter{Category: "small"})) 86 - assert.False(t, MatchesFilter(large, RecipeFilter{Category: "small"})) 86 + assert.False(t, MatchesFilter(pourover, RecipeFilter{Category: "small"})) 87 87 } 88 88 89 89 func TestMatchesFilter_CategoryLarge(t *testing.T) { 90 - small := &Recipe{Name: "Small Dose", CoffeeAmount: 12} 90 + single := &Recipe{Name: "Single Cup", CoffeeAmount: 15} 91 + borderline := &Recipe{Name: "Borderline", CoffeeAmount: 22} 91 92 large := &Recipe{Name: "Big Batch", CoffeeAmount: 35} 92 93 93 - assert.False(t, MatchesFilter(small, RecipeFilter{Category: "large"})) 94 + assert.False(t, MatchesFilter(single, RecipeFilter{Category: "large"})) 95 + assert.True(t, MatchesFilter(borderline, RecipeFilter{Category: "large"})) 94 96 assert.True(t, MatchesFilter(large, RecipeFilter{Category: "large"})) 95 97 } 96 98 97 99 func TestMatchesFilter_CategorySingle(t *testing.T) { 100 + small := &Recipe{Name: "Espresso", CoffeeAmount: 9, WaterAmount: 40} 98 101 single := &Recipe{Name: "One Cup", CoffeeAmount: 15, WaterAmount: 250} 99 - batch := &Recipe{Name: "Party Brew", CoffeeAmount: 15, WaterAmount: 500} 102 + tooMuchWater := &Recipe{Name: "Party Brew", CoffeeAmount: 15, WaterAmount: 500} 103 + tooBigDose := &Recipe{Name: "Large", CoffeeAmount: 25, WaterAmount: 300} 104 + 105 + assert.False(t, MatchesFilter(small, RecipeFilter{Category: "single"})) // coffee too low 106 + assert.True(t, MatchesFilter(single, RecipeFilter{Category: "single"})) // perfect fit 107 + assert.False(t, MatchesFilter(tooMuchWater, RecipeFilter{Category: "single"})) // water too high 108 + assert.False(t, MatchesFilter(tooBigDose, RecipeFilter{Category: "single"})) // coffee too high 109 + } 110 + 111 + func TestMatchesFilter_CategoriesNoOverlap(t *testing.T) { 112 + // A 15g dose should only match "single", not "small" or "large" 113 + recipe := &Recipe{Name: "V60", CoffeeAmount: 15, WaterAmount: 250} 100 114 101 - assert.True(t, MatchesFilter(single, RecipeFilter{Category: "single"})) 102 - assert.False(t, MatchesFilter(batch, RecipeFilter{Category: "single"})) 115 + assert.False(t, MatchesFilter(recipe, RecipeFilter{Category: "small"})) 116 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "single"})) 117 + assert.False(t, MatchesFilter(recipe, RecipeFilter{Category: "large"})) 103 118 } 104 119 105 120 func TestMatchesFilter_CategoryBatch(t *testing.T) { ··· 111 126 } 112 127 113 128 func TestMatchesFilter_CategoryExplicitOverride(t *testing.T) { 114 - // Category "small" sets MaxCoffee=20, but explicit MaxCoffee=25 overrides 115 - recipe := &Recipe{Name: "Medium", CoffeeAmount: 22} 129 + // Category "small" sets MaxCoffee=12, but explicit MaxCoffee=18 overrides 130 + recipe := &Recipe{Name: "Medium", CoffeeAmount: 15} 116 131 assert.False(t, MatchesFilter(recipe, RecipeFilter{Category: "small"})) 117 - assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "small", MaxCoffee: 25})) 132 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "small", MaxCoffee: 18})) 118 133 } 119 134 120 135 func TestMatchesFilter_UnknownCategory(t *testing.T) { ··· 129 144 assert.False(t, MatchesFilter(recipe, RecipeFilter{MinCoffee: 10})) 130 145 // But should match MaxCoffee (0 <= max) 131 146 assert.True(t, MatchesFilter(recipe, RecipeFilter{MaxCoffee: 10})) 147 + } 148 + 149 + func TestRecipeInterpolate_WaterFromPours(t *testing.T) { 150 + recipe := &Recipe{ 151 + Name: "V60", 152 + CoffeeAmount: 15, 153 + WaterAmount: 0, // not set 154 + Pours: []*Pour{ 155 + {WaterAmount: 50}, 156 + {WaterAmount: 100}, 157 + {WaterAmount: 100}, 158 + }, 159 + } 160 + recipe.Interpolate() 161 + assert.Equal(t, 250.0, recipe.WaterAmount) 162 + assert.InDelta(t, 16.67, recipe.Ratio, 0.01) 163 + } 164 + 165 + func TestRecipeInterpolate_WaterAlreadySet(t *testing.T) { 166 + recipe := &Recipe{ 167 + Name: "V60", 168 + CoffeeAmount: 15, 169 + WaterAmount: 300, 170 + Pours: []*Pour{ 171 + {WaterAmount: 50}, 172 + {WaterAmount: 100}, 173 + }, 174 + } 175 + recipe.Interpolate() 176 + // Should keep existing water amount, not sum pours 177 + assert.Equal(t, 300.0, recipe.WaterAmount) 178 + assert.InDelta(t, 20.0, recipe.Ratio, 0.01) 179 + } 180 + 181 + func TestRecipeInterpolate_RatioOnly(t *testing.T) { 182 + recipe := &Recipe{CoffeeAmount: 18, WaterAmount: 300} 183 + recipe.Interpolate() 184 + assert.InDelta(t, 16.67, recipe.Ratio, 0.01) 185 + } 186 + 187 + func TestRecipeInterpolate_NoCoffee(t *testing.T) { 188 + recipe := &Recipe{WaterAmount: 300} 189 + recipe.Interpolate() 190 + assert.Equal(t, 0.0, recipe.Ratio) // can't compute ratio without coffee 191 + } 192 + 193 + func TestMatchesFilter_InterpolatesWaterFromPours(t *testing.T) { 194 + // Recipe with no water_amount but pours that sum to 250g 195 + recipe := &Recipe{ 196 + Name: "Pour-over", 197 + CoffeeAmount: 15, 198 + Pours: []*Pour{ 199 + {WaterAmount: 50}, 200 + {WaterAmount: 100}, 201 + {WaterAmount: 100}, 202 + }, 203 + } 204 + // Should match single cup (water 250 <= 400) after interpolation 205 + assert.True(t, MatchesFilter(recipe, RecipeFilter{Category: "single"})) 206 + // Should not match batch (water 250 < 500) 207 + assert.False(t, MatchesFilter(recipe, RecipeFilter{Category: "batch"})) 132 208 } 133 209 134 210 func TestFilterRecipes(t *testing.T) {
+7 -7
internal/web/pages/brew_form.templ
··· 109 109 :class="activeCategory === 'small' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 110 110 class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 111 111 > 112 - Small dose 112 + Small (&le;12g) 113 113 </button> 114 114 <button 115 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'" 116 + @click="setCategory('single')" 117 + :class="activeCategory === 'single' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 118 118 class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 119 119 > 120 - Large batch 120 + Single cup 121 121 </button> 122 122 <button 123 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'" 124 + @click="setCategory('large')" 125 + :class="activeCategory === 'large' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 126 126 class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors" 127 127 > 128 - Single cup 128 + Large (22g+) 129 129 </button> 130 130 </div> 131 131 <!-- Search input -->
+32 -9
internal/web/pages/recipe_explore.templ
··· 51 51 :class="category === 'small' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 52 52 class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 53 53 > 54 - Small dose (&le;20g) 54 + Small (&le;12g) 55 55 </button> 56 56 <button 57 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'" 58 + @click="setCategory('single')" 59 + :class="category === 'single' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 60 60 class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 61 61 > 62 - Large batch (30g+) 62 + Single cup (12-22g) 63 63 </button> 64 64 <button 65 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'" 66 + @click="setCategory('large')" 67 + :class="category === 'large' ? 'bg-brown-700 text-white' : 'bg-brown-100 text-brown-700 hover:bg-brown-200'" 68 68 class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors" 69 69 > 70 - Single cup 70 + Large (22g+) 71 71 </button> 72 72 <button 73 73 type="button" ··· 118 118 <!-- Results --> 119 119 <div> 120 120 <template x-if="loading"> 121 - @components.LoadingSkeletonTable(components.LoadingSkeletonTableProps{Columns: 6, Rows: 3}) 121 + @components.LoadingSkeletonTable(components.LoadingSkeletonTableProps{Columns: 7, Rows: 3}) 122 122 </template> 123 123 <template x-if="!loading && recipes.length === 0"> 124 124 <div class="card card-inner text-center py-8"> ··· 135 135 <table class="table"> 136 136 <thead class="table-header"> 137 137 <tr> 138 + <th class="table-th whitespace-nowrap">Author</th> 138 139 <th class="table-th whitespace-nowrap">Name</th> 139 140 <th class="table-th whitespace-nowrap">Coffee</th> 140 141 <th class="table-th whitespace-nowrap">Water</th> ··· 146 147 <tbody class="table-body"> 147 148 <template x-for="recipe in recipes" :key="recipe.rkey"> 148 149 <tr class="table-row cursor-pointer hover:bg-brown-50" @click="selectRecipe(recipe)"> 150 + <td class="px-6 py-4 text-sm text-brown-900"> 151 + <div class="flex items-center gap-2"> 152 + <template x-if="recipe.author_avatar"> 153 + <img :src="recipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''"/> 154 + </template> 155 + <template x-if="!recipe.author_avatar"> 156 + <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> 157 + </template> 158 + <span class="truncate text-xs" x-text="recipe.author_display || recipe.author_handle || ''"></span> 159 + </div> 160 + </td> 149 161 <td class="px-6 py-4 text-sm font-medium text-brown-900" x-text="recipe.name"></td> 150 162 <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 163 <td class="px-6 py-4 text-sm text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></td> ··· 164 176 <template x-if="selectedRecipe"> 165 177 <div class="card card-inner mt-4"> 166 178 <div class="flex justify-between items-start mb-4"> 167 - <h3 class="text-xl font-bold text-brown-900" x-text="selectedRecipe.name"></h3> 179 + <div> 180 + <h3 class="text-xl font-bold text-brown-900" x-text="selectedRecipe.name"></h3> 181 + <div class="flex items-center gap-2 mt-1"> 182 + <template x-if="selectedRecipe.author_avatar"> 183 + <img :src="selectedRecipe.author_avatar" class="w-5 h-5 rounded-full object-cover" :alt="selectedRecipe.author_display || ''"/> 184 + </template> 185 + <template x-if="!selectedRecipe.author_avatar"> 186 + <div class="w-5 h-5 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(selectedRecipe.author_display || selectedRecipe.author_handle || '?')[0].toUpperCase()"></div> 187 + </template> 188 + <span class="text-sm text-brown-600" x-text="selectedRecipe.author_display || selectedRecipe.author_handle || ''"></span> 189 + </div> 190 + </div> 168 191 <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold">&times;</button> 169 192 </div> 170 193 <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
+8 -4
static/js/brew-form.js
··· 320 320 321 321 // Recipe filter methods 322 322 recipeCategories: { 323 - small: { maxCoffee: 20 }, 324 - large: { minCoffee: 30 }, 325 - single: { maxCoffee: 20, maxWater: 300 }, 323 + small: { maxCoffee: 12 }, 324 + single: { minCoffee: 12, maxCoffee: 22, maxWater: 400 }, 325 + large: { minCoffee: 22 }, 326 326 batch: { minWater: 500 }, 327 327 }, 328 328 ··· 354 354 total++; 355 355 const name = (recipe.name || recipe.Name || "").toLowerCase(); 356 356 const coffee = recipe.coffee_amount || 0; 357 - const water = recipe.water_amount || 0; 357 + // Interpolate water from pours if not set 358 + let water = recipe.water_amount || 0; 359 + if (water === 0 && recipe.pours && recipe.pours.length > 0) { 360 + water = recipe.pours.reduce((sum, p) => sum + (p.water_amount || 0), 0); 361 + } 358 362 359 363 // Text filter 360 364 if (query && !name.includes(query)) continue;
+2 -3
static/js/recipe-explore.js
··· 52 52 }, 53 53 54 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)}`; 55 + if (recipe.ratio > 0) { 56 + return `1:${recipe.ratio.toFixed(1)}`; 58 57 } 59 58 return "-"; 60 59 },