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

feat: social feed

+155 -18
+6
internal/atproto/records.go
··· 35 35 if brew.WaterAmount > 0 { 36 36 record["waterAmount"] = brew.WaterAmount 37 37 } 38 + if brew.CoffeeAmount > 0 { 39 + record["coffeeAmount"] = brew.CoffeeAmount 40 + } 38 41 if brew.TimeSeconds > 0 { 39 42 record["timeSeconds"] = brew.TimeSeconds 40 43 } ··· 112 115 } 113 116 if waterAmount, ok := record["waterAmount"].(float64); ok { 114 117 brew.WaterAmount = int(waterAmount) 118 + } 119 + if coffeeAmount, ok := record["coffeeAmount"].(float64); ok { 120 + brew.CoffeeAmount = int(coffeeAmount) 115 121 } 116 122 if timeSeconds, ok := record["timeSeconds"].(float64); ok { 117 123 brew.TimeSeconds = int(timeSeconds)
+2
internal/atproto/store.go
··· 63 63 Method: brew.Method, 64 64 Temperature: brew.Temperature, 65 65 WaterAmount: brew.WaterAmount, 66 + CoffeeAmount: brew.CoffeeAmount, 66 67 TimeSeconds: brew.TimeSeconds, 67 68 GrindSize: brew.GrindSize, 68 69 TastingNotes: brew.TastingNotes, ··· 257 258 Method: brew.Method, 258 259 Temperature: brew.Temperature, 259 260 WaterAmount: brew.WaterAmount, 261 + CoffeeAmount: brew.CoffeeAmount, 260 262 TimeSeconds: brew.TimeSeconds, 261 263 GrindSize: brew.GrindSize, 262 264 TastingNotes: brew.TastingNotes,
+87 -1
internal/feed/service.go
··· 83 83 return 84 84 } 85 85 86 - // Convert records to Brew models 86 + // Fetch all beans, roasters, brewers, and grinders for this user to resolve references 87 + beansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100) 88 + roastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100) 89 + brewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100) 90 + grindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100) 91 + 92 + // Build lookup maps (keyed by AT-URI) 93 + beanMap := make(map[string]*models.Bean) 94 + beanRoasterRefMap := make(map[string]string) // bean URI -> roaster URI 95 + roasterMap := make(map[string]*models.Roaster) 96 + brewerMap := make(map[string]*models.Brewer) 97 + grinderMap := make(map[string]*models.Grinder) 98 + 99 + // Populate bean map 100 + if beansOutput != nil { 101 + for _, beanRecord := range beansOutput.Records { 102 + bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI) 103 + if err == nil { 104 + beanMap[beanRecord.URI] = bean 105 + // Store roaster reference if present 106 + if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" { 107 + beanRoasterRefMap[beanRecord.URI] = roasterRef 108 + } 109 + } 110 + } 111 + } 112 + 113 + // Populate roaster map 114 + if roastersOutput != nil { 115 + for _, roasterRecord := range roastersOutput.Records { 116 + roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI) 117 + if err == nil { 118 + roasterMap[roasterRecord.URI] = roaster 119 + } 120 + } 121 + } 122 + 123 + // Populate brewer map 124 + if brewersOutput != nil { 125 + for _, brewerRecord := range brewersOutput.Records { 126 + brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI) 127 + if err == nil { 128 + brewerMap[brewerRecord.URI] = brewer 129 + } 130 + } 131 + } 132 + 133 + // Populate grinder map 134 + if grindersOutput != nil { 135 + for _, grinderRecord := range grindersOutput.Records { 136 + grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI) 137 + if err == nil { 138 + grinderMap[grinderRecord.URI] = grinder 139 + } 140 + } 141 + } 142 + 143 + // Convert records to Brew models and resolve references 87 144 brews := make([]*models.Brew, 0, len(brewsOutput.Records)) 88 145 for _, record := range brewsOutput.Records { 89 146 brew, err := atproto.RecordToBrew(record.Value, record.URI) ··· 91 148 log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse brew record") 92 149 continue 93 150 } 151 + 152 + // Resolve bean reference 153 + if beanRef, ok := record.Value["beanRef"].(string); ok && beanRef != "" { 154 + if bean, found := beanMap[beanRef]; found { 155 + brew.Bean = bean 156 + 157 + // Resolve roaster reference for this bean 158 + if roasterRef, found := beanRoasterRefMap[beanRef]; found { 159 + if roaster, found := roasterMap[roasterRef]; found { 160 + brew.Bean.Roaster = roaster 161 + } 162 + } 163 + } 164 + } 165 + 166 + // Resolve brewer reference 167 + if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 168 + if brewer, found := brewerMap[brewerRef]; found { 169 + brew.BrewerObj = brewer 170 + } 171 + } 172 + 173 + // Resolve grinder reference 174 + if grinderRef, ok := record.Value["grinderRef"].(string); ok && grinderRef != "" { 175 + if grinder, found := grinderMap[grinderRef]; found { 176 + brew.GrinderObj = grinder 177 + } 178 + } 179 + 94 180 brews = append(brews, brew) 95 181 } 96 182 result.brews = brews
+4
internal/handlers/handlers.go
··· 225 225 226 226 temperature, _ := strconv.ParseFloat(r.FormValue("temperature"), 64) 227 227 waterAmount, _ := strconv.Atoi(r.FormValue("water_amount")) 228 + coffeeAmount, _ := strconv.Atoi(r.FormValue("coffee_amount")) 228 229 timeSeconds, _ := strconv.Atoi(r.FormValue("time_seconds")) 229 230 rating, _ := strconv.Atoi(r.FormValue("rating")) 230 231 ··· 259 260 Method: r.FormValue("method"), 260 261 Temperature: temperature, 261 262 WaterAmount: waterAmount, 263 + CoffeeAmount: coffeeAmount, 262 264 TimeSeconds: timeSeconds, 263 265 GrindSize: r.FormValue("grind_size"), 264 266 GrinderRKey: r.FormValue("grinder_rkey"), ··· 297 299 298 300 temperature, _ := strconv.ParseFloat(r.FormValue("temperature"), 64) 299 301 waterAmount, _ := strconv.Atoi(r.FormValue("water_amount")) 302 + coffeeAmount, _ := strconv.Atoi(r.FormValue("coffee_amount")) 300 303 timeSeconds, _ := strconv.Atoi(r.FormValue("time_seconds")) 301 304 rating, _ := strconv.Atoi(r.FormValue("rating")) 302 305 ··· 331 334 Method: r.FormValue("method"), 332 335 Temperature: temperature, 333 336 WaterAmount: waterAmount, 337 + CoffeeAmount: coffeeAmount, 334 338 TimeSeconds: timeSeconds, 335 339 GrindSize: r.FormValue("grind_size"), 336 340 GrinderRKey: r.FormValue("grinder_rkey"),
+2
internal/models/models.go
··· 53 53 Method string `json:"method,omitempty"` 54 54 Temperature float64 `json:"temperature"` 55 55 WaterAmount int `json:"water_amount"` 56 + CoffeeAmount int `json:"coffee_amount"` 56 57 TimeSeconds int `json:"time_seconds"` 57 58 GrindSize string `json:"grind_size"` 58 59 GrinderRKey string `json:"grinder_rkey"` ··· 73 74 Method string `json:"method"` 74 75 Temperature float64 `json:"temperature"` 75 76 WaterAmount int `json:"water_amount"` 77 + CoffeeAmount int `json:"coffee_amount"` 76 78 TimeSeconds int `json:"time_seconds"` 77 79 GrindSize string `json:"grind_size"` 78 80 GrinderRKey string `json:"grinder_rkey"`
+5
lexicons/social.arabica.alpha.brew.json
··· 15 15 "format": "at-uri", 16 16 "description": "AT-URI reference to the bean record used" 17 17 }, 18 + "coffeeAmount": { 19 + "type": "integer", 20 + "minimum": 0, 21 + "description": "Amount of coffee used in grams" 22 + }, 18 23 "method": { 19 24 "type": "string", 20 25 "maxLength": 100,
+13
templates/brew_form.tmpl
··· 49 49 {{template "new_bean_form" .}} 50 50 </div> 51 51 52 + <!-- Coffee Amount --> 53 + <div> 54 + <label class="block text-sm font-medium text-gray-700 mb-2">Coffee Amount (grams)</label> 55 + <input 56 + type="number" 57 + name="coffee_amount" 58 + step="0.1" 59 + {{if and .Brew (gt .Brew.CoffeeAmount 0)}}value="{{.Brew.CoffeeAmount}}"{{end}} 60 + placeholder="e.g. 18" 61 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 62 + <p class="text-sm text-gray-500 mt-1">Amount of ground coffee used</p> 63 + </div> 64 + 52 65 <!-- Grinder --> 53 66 <div> 54 67 <label class="block text-sm font-medium text-gray-700 mb-2">Grinder</label>
+36 -17
templates/partials/feed.tmpl
··· 25 25 26 26 <!-- Brew info --> 27 27 <div class="bg-gray-50 rounded-lg p-3"> 28 - <div class="flex items-center gap-2 mb-2"> 29 - <span class="text-lg">☕</span> 30 - {{if .Brew.Method}} 31 - <span class="font-medium text-gray-800">{{.Brew.Method}}</span> 32 - {{else}} 33 - <span class="font-medium text-gray-800">Brew</span> 34 - {{end}} 28 + <!-- Bean and roaster info --> 29 + <div class="flex items-start justify-between gap-2 mb-2"> 30 + <div class="flex-1 min-w-0"> 31 + {{if .Brew.Bean}} 32 + <!-- Bean name and roaster --> 33 + <div class="text-base"> 34 + <span class="text-gray-600">Bean:</span> 35 + <span class="font-bold text-gray-900"> 36 + {{if .Brew.Bean.Name}}{{.Brew.Bean.Name}}{{else}}{{.Brew.Bean.Origin}}{{end}}{{if and .Brew.Bean.Roaster .Brew.Bean.Roaster.Name}} - {{.Brew.Bean.Roaster.Name}}{{end}} 37 + </span> 38 + </div> 39 + <!-- Process, roast level, origin --> 40 + <div class="text-sm text-gray-600"> 41 + {{if .Brew.Bean.Process}}{{.Brew.Bean.Process}}{{end}}{{if and .Brew.Bean.Process .Brew.Bean.RoastLevel}} • {{end}}{{if .Brew.Bean.RoastLevel}}{{.Brew.Bean.RoastLevel}}{{end}}{{if and (or .Brew.Bean.Process .Brew.Bean.RoastLevel) .Brew.Bean.Origin}} • {{end}}{{if .Brew.Bean.Origin}}{{.Brew.Bean.Origin}}{{end}} 42 + </div> 43 + {{end}} 44 + </div> 35 45 {{if hasValue .Brew.Rating}} 36 - <span class="ml-auto text-yellow-500"> 37 - ★ {{.Brew.Rating}}/10 46 + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 flex-shrink-0"> 47 + ⭐ {{.Brew.Rating}}/10 38 48 </span> 39 49 {{end}} 40 50 </div> 41 51 52 + <!-- Brew parameters --> 42 53 <div class="grid grid-cols-2 gap-2 text-sm text-gray-600"> 43 - {{if hasTemp .Brew.Temperature}} 54 + {{if hasValue .Brew.CoffeeAmount}} 44 55 <div> 45 - <span class="text-gray-400">Temp:</span> {{formatTemp .Brew.Temperature}} 56 + <span class="text-gray-500">Coffee:</span> {{.Brew.CoffeeAmount}}g 46 57 </div> 47 58 {{end}} 48 - {{if hasValue .Brew.TimeSeconds}} 59 + {{if hasValue .Brew.WaterAmount}} 49 60 <div> 50 - <span class="text-gray-400">Time:</span> {{formatTime .Brew.TimeSeconds}} 61 + <span class="text-gray-500">Water:</span> {{.Brew.WaterAmount}}g 51 62 </div> 52 63 {{end}} 53 - {{if hasValue .Brew.WaterAmount}} 64 + {{if hasTemp .Brew.Temperature}} 54 65 <div> 55 - <span class="text-gray-400">Water:</span> {{.Brew.WaterAmount}}g 66 + <span class="text-gray-500">Temp:</span> {{formatTemp .Brew.Temperature}} 67 + </div> 68 + {{end}} 69 + {{if hasValue .Brew.TimeSeconds}} 70 + <div> 71 + <span class="text-gray-500">Time:</span> {{formatTime .Brew.TimeSeconds}} 56 72 </div> 57 73 {{end}} 58 74 {{if .Brew.GrindSize}} 59 75 <div> 60 - <span class="text-gray-400">Grind:</span> {{.Brew.GrindSize}} 76 + <span class="text-gray-500">Grind:</span> {{.Brew.GrindSize}}{{if .Brew.GrinderObj}} ({{.Brew.GrinderObj.Name}}){{end}} 61 77 </div> 62 78 {{end}} 79 + <div> 80 + <span class="text-gray-500">Brewer:</span> <span class="font-bold text-gray-900">{{if .Brew.BrewerObj}}{{.Brew.BrewerObj.Name}}{{else if .Brew.Method}}{{.Brew.Method}}{{else}}-{{end}}</span> 81 + </div> 63 82 </div> 64 83 65 84 {{if .Brew.TastingNotes}} 66 - <div class="mt-2 text-sm text-gray-700 italic"> 85 + <div class="mt-3 text-sm text-gray-700 italic border-t border-gray-200 pt-2"> 67 86 "{{.Brew.TastingNotes}}" 68 87 </div> 69 88 {{end}}