tangled
alpha
login
or
join now
arabica.social
/
arabica
7
fork
atom
Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
7
fork
atom
overview
issues
pulls
pipelines
feat: social feed
Patrick Dewey
2 months ago
8d6e620c
05ba0c7b
+155
-18
8 changed files
expand all
collapse all
unified
split
internal
atproto
records.go
store.go
feed
service.go
handlers
handlers.go
models
models.go
lexicons
social.arabica.alpha.brew.json
templates
brew_form.tmpl
partials
feed.tmpl
+6
internal/atproto/records.go
···
35
35
if brew.WaterAmount > 0 {
36
36
record["waterAmount"] = brew.WaterAmount
37
37
}
38
38
+
if brew.CoffeeAmount > 0 {
39
39
+
record["coffeeAmount"] = brew.CoffeeAmount
40
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
118
+
}
119
119
+
if coffeeAmount, ok := record["coffeeAmount"].(float64); ok {
120
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
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
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
86
-
// Convert records to Brew models
86
86
+
// Fetch all beans, roasters, brewers, and grinders for this user to resolve references
87
87
+
beansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100)
88
88
+
roastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100)
89
89
+
brewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100)
90
90
+
grindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100)
91
91
+
92
92
+
// Build lookup maps (keyed by AT-URI)
93
93
+
beanMap := make(map[string]*models.Bean)
94
94
+
beanRoasterRefMap := make(map[string]string) // bean URI -> roaster URI
95
95
+
roasterMap := make(map[string]*models.Roaster)
96
96
+
brewerMap := make(map[string]*models.Brewer)
97
97
+
grinderMap := make(map[string]*models.Grinder)
98
98
+
99
99
+
// Populate bean map
100
100
+
if beansOutput != nil {
101
101
+
for _, beanRecord := range beansOutput.Records {
102
102
+
bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI)
103
103
+
if err == nil {
104
104
+
beanMap[beanRecord.URI] = bean
105
105
+
// Store roaster reference if present
106
106
+
if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" {
107
107
+
beanRoasterRefMap[beanRecord.URI] = roasterRef
108
108
+
}
109
109
+
}
110
110
+
}
111
111
+
}
112
112
+
113
113
+
// Populate roaster map
114
114
+
if roastersOutput != nil {
115
115
+
for _, roasterRecord := range roastersOutput.Records {
116
116
+
roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI)
117
117
+
if err == nil {
118
118
+
roasterMap[roasterRecord.URI] = roaster
119
119
+
}
120
120
+
}
121
121
+
}
122
122
+
123
123
+
// Populate brewer map
124
124
+
if brewersOutput != nil {
125
125
+
for _, brewerRecord := range brewersOutput.Records {
126
126
+
brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI)
127
127
+
if err == nil {
128
128
+
brewerMap[brewerRecord.URI] = brewer
129
129
+
}
130
130
+
}
131
131
+
}
132
132
+
133
133
+
// Populate grinder map
134
134
+
if grindersOutput != nil {
135
135
+
for _, grinderRecord := range grindersOutput.Records {
136
136
+
grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI)
137
137
+
if err == nil {
138
138
+
grinderMap[grinderRecord.URI] = grinder
139
139
+
}
140
140
+
}
141
141
+
}
142
142
+
143
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
151
+
152
152
+
// Resolve bean reference
153
153
+
if beanRef, ok := record.Value["beanRef"].(string); ok && beanRef != "" {
154
154
+
if bean, found := beanMap[beanRef]; found {
155
155
+
brew.Bean = bean
156
156
+
157
157
+
// Resolve roaster reference for this bean
158
158
+
if roasterRef, found := beanRoasterRefMap[beanRef]; found {
159
159
+
if roaster, found := roasterMap[roasterRef]; found {
160
160
+
brew.Bean.Roaster = roaster
161
161
+
}
162
162
+
}
163
163
+
}
164
164
+
}
165
165
+
166
166
+
// Resolve brewer reference
167
167
+
if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" {
168
168
+
if brewer, found := brewerMap[brewerRef]; found {
169
169
+
brew.BrewerObj = brewer
170
170
+
}
171
171
+
}
172
172
+
173
173
+
// Resolve grinder reference
174
174
+
if grinderRef, ok := record.Value["grinderRef"].(string); ok && grinderRef != "" {
175
175
+
if grinder, found := grinderMap[grinderRef]; found {
176
176
+
brew.GrinderObj = grinder
177
177
+
}
178
178
+
}
179
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
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
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
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
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
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
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
18
+
"coffeeAmount": {
19
19
+
"type": "integer",
20
20
+
"minimum": 0,
21
21
+
"description": "Amount of coffee used in grams"
22
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
52
+
<!-- Coffee Amount -->
53
53
+
<div>
54
54
+
<label class="block text-sm font-medium text-gray-700 mb-2">Coffee Amount (grams)</label>
55
55
+
<input
56
56
+
type="number"
57
57
+
name="coffee_amount"
58
58
+
step="0.1"
59
59
+
{{if and .Brew (gt .Brew.CoffeeAmount 0)}}value="{{.Brew.CoffeeAmount}}"{{end}}
60
60
+
placeholder="e.g. 18"
61
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
62
+
<p class="text-sm text-gray-500 mt-1">Amount of ground coffee used</p>
63
63
+
</div>
64
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
28
-
<div class="flex items-center gap-2 mb-2">
29
29
-
<span class="text-lg">☕</span>
30
30
-
{{if .Brew.Method}}
31
31
-
<span class="font-medium text-gray-800">{{.Brew.Method}}</span>
32
32
-
{{else}}
33
33
-
<span class="font-medium text-gray-800">Brew</span>
34
34
-
{{end}}
28
28
+
<!-- Bean and roaster info -->
29
29
+
<div class="flex items-start justify-between gap-2 mb-2">
30
30
+
<div class="flex-1 min-w-0">
31
31
+
{{if .Brew.Bean}}
32
32
+
<!-- Bean name and roaster -->
33
33
+
<div class="text-base">
34
34
+
<span class="text-gray-600">Bean:</span>
35
35
+
<span class="font-bold text-gray-900">
36
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
37
+
</span>
38
38
+
</div>
39
39
+
<!-- Process, roast level, origin -->
40
40
+
<div class="text-sm text-gray-600">
41
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
42
+
</div>
43
43
+
{{end}}
44
44
+
</div>
35
45
{{if hasValue .Brew.Rating}}
36
36
-
<span class="ml-auto text-yellow-500">
37
37
-
★ {{.Brew.Rating}}/10
46
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
47
+
⭐ {{.Brew.Rating}}/10
38
48
</span>
39
49
{{end}}
40
50
</div>
41
51
52
52
+
<!-- Brew parameters -->
42
53
<div class="grid grid-cols-2 gap-2 text-sm text-gray-600">
43
43
-
{{if hasTemp .Brew.Temperature}}
54
54
+
{{if hasValue .Brew.CoffeeAmount}}
44
55
<div>
45
45
-
<span class="text-gray-400">Temp:</span> {{formatTemp .Brew.Temperature}}
56
56
+
<span class="text-gray-500">Coffee:</span> {{.Brew.CoffeeAmount}}g
46
57
</div>
47
58
{{end}}
48
48
-
{{if hasValue .Brew.TimeSeconds}}
59
59
+
{{if hasValue .Brew.WaterAmount}}
49
60
<div>
50
50
-
<span class="text-gray-400">Time:</span> {{formatTime .Brew.TimeSeconds}}
61
61
+
<span class="text-gray-500">Water:</span> {{.Brew.WaterAmount}}g
51
62
</div>
52
63
{{end}}
53
53
-
{{if hasValue .Brew.WaterAmount}}
64
64
+
{{if hasTemp .Brew.Temperature}}
54
65
<div>
55
55
-
<span class="text-gray-400">Water:</span> {{.Brew.WaterAmount}}g
66
66
+
<span class="text-gray-500">Temp:</span> {{formatTemp .Brew.Temperature}}
67
67
+
</div>
68
68
+
{{end}}
69
69
+
{{if hasValue .Brew.TimeSeconds}}
70
70
+
<div>
71
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
60
-
<span class="text-gray-400">Grind:</span> {{.Brew.GrindSize}}
76
76
+
<span class="text-gray-500">Grind:</span> {{.Brew.GrindSize}}{{if .Brew.GrinderObj}} ({{.Brew.GrinderObj.Name}}){{end}}
61
77
</div>
62
78
{{end}}
79
79
+
<div>
80
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
81
+
</div>
63
82
</div>
64
83
65
84
{{if .Brew.TastingNotes}}
66
66
-
<div class="mt-2 text-sm text-gray-700 italic">
85
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}}