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: non-brew records in community feed
pdewey.com
1 month ago
2d7c59cd
4c57d13b
verified
This commit was signed with the committer's
known signature
.
pdewey.com
SSH Key Fingerprint:
SHA256:ePOVkJstqVLchGK8m9/OGQG+aFNHD5XN3xjvW9wKCA4=
+579
-59
5 changed files
expand all
collapse all
unified
split
AGENTS.md
CLAUDE.md
default.nix
internal
feed
service.go
templates
partials
feed.tmpl
+1
AGENTS.md
···
1
1
+
CLAUDE.md
+284
CLAUDE.md
···
1
1
+
# Arabica - Project Context for AI Agents
2
2
+
3
3
+
Coffee brew tracking application using AT Protocol for decentralized storage.
4
4
+
5
5
+
## Tech Stack
6
6
+
7
7
+
- **Language:** Go 1.21+
8
8
+
- **HTTP:** stdlib `net/http` with Go 1.22 routing
9
9
+
- **Storage:** AT Protocol PDS (user data), BoltDB (sessions/feed registry)
10
10
+
- **Frontend:** HTMX + Alpine.js + Tailwind CSS
11
11
+
- **Templates:** html/template
12
12
+
- **Logging:** zerolog
13
13
+
14
14
+
## Project Structure
15
15
+
16
16
+
```
17
17
+
cmd/server/main.go # Application entry point
18
18
+
internal/
19
19
+
atproto/ # AT Protocol integration
20
20
+
client.go # Authenticated PDS client (XRPC calls)
21
21
+
oauth.go # OAuth flow with PKCE/DPOP
22
22
+
store.go # database.Store implementation using PDS
23
23
+
cache.go # Per-session in-memory cache
24
24
+
records.go # Model <-> ATProto record conversion
25
25
+
resolver.go # AT-URI parsing and reference resolution
26
26
+
public_client.go # Unauthenticated public API access
27
27
+
nsid.go # Collection NSIDs and AT-URI builders
28
28
+
handlers/
29
29
+
handlers.go # HTTP handlers for all routes
30
30
+
auth.go # OAuth login/logout/callback
31
31
+
bff/
32
32
+
render.go # Template rendering helpers
33
33
+
helpers.go # View helpers (formatting, etc.)
34
34
+
database/
35
35
+
store.go # Store interface definition
36
36
+
boltstore/ # BoltDB implementation for sessions
37
37
+
feed/
38
38
+
service.go # Community feed aggregation
39
39
+
registry.go # User registration for feed
40
40
+
models/
41
41
+
models.go # Domain models and request types
42
42
+
middleware/
43
43
+
logging.go # Request logging middleware
44
44
+
routing/
45
45
+
routing.go # Router setup and middleware chain
46
46
+
lexicons/ # AT Protocol lexicon definitions (JSON)
47
47
+
templates/ # HTML templates
48
48
+
web/static/ # CSS, JS, manifest
49
49
+
```
50
50
+
51
51
+
## Key Concepts
52
52
+
53
53
+
### AT Protocol Integration
54
54
+
55
55
+
User data stored in their Personal Data Server (PDS), not locally. The app:
56
56
+
57
57
+
1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP)
58
58
+
2. Gets access token scoped to user's DID
59
59
+
3. Performs CRUD via XRPC calls to user's PDS
60
60
+
61
61
+
**Collections (NSIDs):**
62
62
+
63
63
+
- `social.arabica.alpha.bean` - Coffee beans
64
64
+
- `social.arabica.alpha.roaster` - Roasters
65
65
+
- `social.arabica.alpha.grinder` - Grinders
66
66
+
- `social.arabica.alpha.brewer` - Brewing devices
67
67
+
- `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer)
68
68
+
69
69
+
**Record keys:** TID format (timestamp-based identifiers)
70
70
+
71
71
+
**References:** Records reference each other via AT-URIs (`at://did/collection/rkey`)
72
72
+
73
73
+
### Store Interface
74
74
+
75
75
+
`internal/database/store.go` defines the `Store` interface. Two implementations:
76
76
+
77
77
+
- `AtprotoStore` - Production, stores in user's PDS
78
78
+
- BoltDB stores only sessions and feed registry (not user data)
79
79
+
80
80
+
All Store methods take `context.Context` as first parameter.
81
81
+
82
82
+
### Request Flow
83
83
+
84
84
+
1. Request hits middleware (logging, auth check)
85
85
+
2. Auth middleware extracts DID + session ID from cookies
86
86
+
3. Handler creates `AtprotoStore` scoped to user
87
87
+
4. Store methods make XRPC calls to user's PDS
88
88
+
5. Results rendered via BFF templates or returned as JSON
89
89
+
90
90
+
### Caching
91
91
+
92
92
+
`SessionCache` caches user data in memory (5-minute TTL):
93
93
+
94
94
+
- Avoids repeated PDS calls for same data
95
95
+
- Invalidated on writes
96
96
+
- Background cleanup removes expired entries
97
97
+
98
98
+
## Common Tasks
99
99
+
100
100
+
### Run Development Server
101
101
+
102
102
+
```bash
103
103
+
go run cmd/server/main.go
104
104
+
# or
105
105
+
nix run
106
106
+
```
107
107
+
108
108
+
### Run Tests
109
109
+
110
110
+
```bash
111
111
+
go test ./...
112
112
+
```
113
113
+
114
114
+
### Build
115
115
+
116
116
+
```bash
117
117
+
go build -o arabica cmd/server/main.go
118
118
+
```
119
119
+
120
120
+
## Environment Variables
121
121
+
122
122
+
| Variable | Default | Description |
123
123
+
| ------------------- | --------------------------------- | ---------------------------- |
124
124
+
| `PORT` | 18910 | HTTP server port |
125
125
+
| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy |
126
126
+
| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path |
127
127
+
| `SECURE_COOKIES` | false | Set true for HTTPS |
128
128
+
| `LOG_LEVEL` | info | debug/info/warn/error |
129
129
+
| `LOG_FORMAT` | console | console/json |
130
130
+
131
131
+
## Code Patterns
132
132
+
133
133
+
### Creating a Store
134
134
+
135
135
+
```go
136
136
+
// In handlers, store is created per-request
137
137
+
store, authenticated := h.getAtprotoStore(r)
138
138
+
if !authenticated {
139
139
+
http.Error(w, "Authentication required", http.StatusUnauthorized)
140
140
+
return
141
141
+
}
142
142
+
143
143
+
// Use store with request context
144
144
+
brews, err := store.ListBrews(r.Context(), userID)
145
145
+
```
146
146
+
147
147
+
### Record Conversion
148
148
+
149
149
+
```go
150
150
+
// Model -> ATProto record
151
151
+
record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI)
152
152
+
153
153
+
// ATProto record -> Model
154
154
+
brew, err := RecordToBrew(record, atURI)
155
155
+
```
156
156
+
157
157
+
### AT-URI Handling
158
158
+
159
159
+
```go
160
160
+
// Build AT-URI
161
161
+
uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc
162
162
+
163
163
+
// Parse AT-URI
164
164
+
components, err := ResolveATURI(uri)
165
165
+
// components.DID, components.Collection, components.RKey
166
166
+
```
167
167
+
168
168
+
## Future Vision: Social Features
169
169
+
170
170
+
The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature.
171
171
+
172
172
+
### Planned Lexicons
173
173
+
174
174
+
```
175
175
+
social.arabica.alpha.like - Like a brew (references brew AT-URI)
176
176
+
social.arabica.alpha.comment - Comment on a brew
177
177
+
social.arabica.alpha.follow - Follow another user
178
178
+
social.arabica.alpha.share - Re-share a brew to your feed
179
179
+
```
180
180
+
181
181
+
### Like Record (Planned)
182
182
+
```json
183
183
+
{
184
184
+
"lexicon": 1,
185
185
+
"id": "social.arabica.alpha.like",
186
186
+
"defs": {
187
187
+
"main": {
188
188
+
"type": "record",
189
189
+
"key": "tid",
190
190
+
"record": {
191
191
+
"type": "object",
192
192
+
"required": ["subject", "createdAt"],
193
193
+
"properties": {
194
194
+
"subject": {
195
195
+
"type": "ref",
196
196
+
"ref": "com.atproto.repo.strongRef",
197
197
+
"description": "The brew being liked"
198
198
+
},
199
199
+
"createdAt": { "type": "string", "format": "datetime" }
200
200
+
}
201
201
+
}
202
202
+
}
203
203
+
}
204
204
+
}
205
205
+
```
206
206
+
207
207
+
### Comment Record (Planned)
208
208
+
```json
209
209
+
{
210
210
+
"lexicon": 1,
211
211
+
"id": "social.arabica.alpha.comment",
212
212
+
"defs": {
213
213
+
"main": {
214
214
+
"type": "record",
215
215
+
"key": "tid",
216
216
+
"record": {
217
217
+
"type": "object",
218
218
+
"required": ["subject", "text", "createdAt"],
219
219
+
"properties": {
220
220
+
"subject": {
221
221
+
"type": "ref",
222
222
+
"ref": "com.atproto.repo.strongRef",
223
223
+
"description": "The brew being commented on"
224
224
+
},
225
225
+
"text": {
226
226
+
"type": "string",
227
227
+
"maxLength": 1000,
228
228
+
"maxGraphemes": 300
229
229
+
},
230
230
+
"createdAt": { "type": "string", "format": "datetime" }
231
231
+
}
232
232
+
}
233
233
+
}
234
234
+
}
235
235
+
}
236
236
+
```
237
237
+
238
238
+
### Implementation Approach
239
239
+
240
240
+
**Cross-user interactions:**
241
241
+
- Likes/comments stored in the actor's PDS (not the brew owner's)
242
242
+
- Use `public_client.go` to read other users' brews
243
243
+
- Aggregate likes/comments via relay/firehose or direct PDS queries
244
244
+
245
245
+
**Feed aggregation:**
246
246
+
- Current: Poll registered users' PDS for brews
247
247
+
- Future: Subscribe to firehose for real-time updates
248
248
+
- Index social interactions in local DB for fast queries
249
249
+
250
250
+
**UI patterns:**
251
251
+
- Like button on brew cards in feed
252
252
+
- Comment thread below brew detail view
253
253
+
- Share button to re-post with optional note
254
254
+
- Notification system for interactions on your brews
255
255
+
256
256
+
### Key Design Decisions
257
257
+
258
258
+
1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed
259
259
+
2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's
260
260
+
3. **Public by default** - Social interactions are public records, readable by anyone
261
261
+
4. **Portable identity** - Users can switch PDS and keep their social graph
262
262
+
263
263
+
## Known Issues / TODOs
264
264
+
265
265
+
See todo list in conversation for tracked issues. Key areas:
266
266
+
267
267
+
- Context should flow through methods (some fixed, verify all paths)
268
268
+
- Cache race conditions need copy-on-write pattern
269
269
+
- Missing CID validation on record updates (AT Protocol best practice)
270
270
+
- Rate limiting for PDS calls not implemented
271
271
+
272
272
+
## Testing
273
273
+
274
274
+
Tests exist for:
275
275
+
276
276
+
- `internal/atproto/` - Record conversion, NSID parsing, resolver
277
277
+
- `internal/bff/` - Template helpers
278
278
+
- `internal/middleware/` - Logging
279
279
+
280
280
+
Missing coverage:
281
281
+
282
282
+
- HTTP handlers
283
283
+
- OAuth flow
284
284
+
- Feed service
+27
-28
default.nix
···
4
4
pname = "arabica";
5
5
version = "0.1.0";
6
6
src = ./.;
7
7
-
vendorHash = "sha256-5TKLHMHWaOTJqZKS+UtALR+bYh9gl6wroN/eRWauxY4=";
7
7
+
vendorHash = "sha256-phKYuiA0Lh7wy/JHcTOWFce9MwPrkz/yjxq4Z8bbEnQ=";
8
8
9
9
nativeBuildInputs = [ tailwindcss ];
10
10
···
18
18
runHook postBuild
19
19
'';
20
20
21
21
-
installPhase =
22
22
-
let
23
23
-
wrapperScript = ''
24
24
-
#!/bin/sh
25
25
-
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
26
26
-
SHARE_DIR="$SCRIPT_DIR/../share/arabica"
21
21
+
installPhase = let
22
22
+
wrapperScript = ''
23
23
+
#!/bin/sh
24
24
+
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
25
25
+
SHARE_DIR="$SCRIPT_DIR/../share/arabica"
27
26
28
28
-
# Set default database path if not specified
29
29
-
# Uses XDG_DATA_HOME or falls back to ~/.local/share
30
30
-
if [ -z "$ARABICA_DB_PATH" ]; then
31
31
-
DATA_DIR="''${XDG_DATA_HOME:-$HOME/.local/share}/arabica"
32
32
-
mkdir -p "$DATA_DIR"
33
33
-
export ARABICA_DB_PATH="$DATA_DIR/arabica.db"
34
34
-
fi
27
27
+
# Set default database path if not specified
28
28
+
# Uses XDG_DATA_HOME or falls back to ~/.local/share
29
29
+
if [ -z "$ARABICA_DB_PATH" ]; then
30
30
+
DATA_DIR="''${XDG_DATA_HOME:-$HOME/.local/share}/arabica"
31
31
+
mkdir -p "$DATA_DIR"
32
32
+
export ARABICA_DB_PATH="$DATA_DIR/arabica.db"
33
33
+
fi
35
34
36
36
-
cd "$SHARE_DIR"
37
37
-
exec "$SCRIPT_DIR/arabica-unwrapped" "$@"
38
38
-
'';
39
39
-
in ''
40
40
-
mkdir -p $out/bin
41
41
-
mkdir -p $out/share/arabica
35
35
+
cd "$SHARE_DIR"
36
36
+
exec "$SCRIPT_DIR/arabica-unwrapped" "$@"
37
37
+
'';
38
38
+
in ''
39
39
+
mkdir -p $out/bin
40
40
+
mkdir -p $out/share/arabica
42
41
43
43
-
# Copy static files and templates
44
44
-
cp -r web $out/share/arabica/
45
45
-
cp -r templates $out/share/arabica/
46
46
-
cp arabica $out/bin/arabica-unwrapped
47
47
-
cat > $out/bin/arabica <<'WRAPPER'
48
48
-
${wrapperScript}
49
49
-
WRAPPER
50
50
-
chmod +x $out/bin/arabica
42
42
+
# Copy static files and templates
43
43
+
cp -r web $out/share/arabica/
44
44
+
cp -r templates $out/share/arabica/
45
45
+
cp arabica $out/bin/arabica-unwrapped
46
46
+
cat > $out/bin/arabica <<'WRAPPER'
47
47
+
${wrapperScript}
48
48
+
WRAPPER
49
49
+
chmod +x $out/bin/arabica
51
50
'';
52
51
53
52
meta = with lib; {
+188
-29
internal/feed/service.go
···
13
13
"github.com/rs/zerolog/log"
14
14
)
15
15
16
16
-
// FeedItem represents a brew in the social feed with author info
16
16
+
// FeedItem represents an activity in the social feed with author info
17
17
type FeedItem struct {
18
18
-
Brew *models.Brew
18
18
+
// Record type and data (only one will be non-nil)
19
19
+
RecordType string // "brew", "bean", "roaster", "grinder", "brewer"
20
20
+
Action string // "added a new brew", "added a new bean", etc.
21
21
+
22
22
+
Brew *models.Brew
23
23
+
Bean *models.Bean
24
24
+
Roaster *models.Roaster
25
25
+
Grinder *models.Grinder
26
26
+
Brewer *models.Brewer
27
27
+
19
28
Author *atproto.Profile
20
29
Timestamp time.Time
21
30
TimeAgo string // "2 hours ago", "yesterday", etc.
···
35
44
}
36
45
}
37
46
38
38
-
// GetRecentBrews fetches recent brews from all registered users
47
47
+
// GetRecentBrews fetches recent activity (brews and other records) from all registered users
39
48
// Returns up to `limit` items sorted by most recent first
40
49
func (s *Service) GetRecentBrews(ctx context.Context, limit int) ([]*FeedItem, error) {
41
50
dids := s.registry.List()
···
44
53
return nil, nil
45
54
}
46
55
47
47
-
log.Debug().Int("user_count", len(dids)).Msg("feed: fetching brews from registered users")
56
56
+
log.Debug().Int("user_count", len(dids)).Msg("feed: fetching activity from registered users")
48
57
49
49
-
// Fetch brews from all users in parallel
50
50
-
type userBrews struct {
51
51
-
did string
52
52
-
profile *atproto.Profile
53
53
-
brews []*models.Brew
54
54
-
err error
58
58
+
// Fetch all records from all users in parallel
59
59
+
type userActivity struct {
60
60
+
did string
61
61
+
profile *atproto.Profile
62
62
+
brews []*models.Brew
63
63
+
beans []*models.Bean
64
64
+
roasters []*models.Roaster
65
65
+
grinders []*models.Grinder
66
66
+
brewers []*models.Brewer
67
67
+
err error
55
68
}
56
69
57
57
-
results := make(chan userBrews, len(dids))
70
70
+
results := make(chan userActivity, len(dids))
58
71
var wg sync.WaitGroup
59
72
60
73
for _, did := range dids {
···
62
75
go func(did string) {
63
76
defer wg.Done()
64
77
65
65
-
result := userBrews{did: did}
78
78
+
result := userActivity{did: did}
66
79
67
80
// Fetch profile
68
81
profile, err := s.publicClient.GetProfile(ctx, did)
···
83
96
return
84
97
}
85
98
99
99
+
// Fetch recent beans
100
100
+
beansOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 10)
101
101
+
if err != nil {
102
102
+
log.Warn().Err(err).Str("did", did).Msg("failed to fetch beans for feed")
103
103
+
}
104
104
+
105
105
+
// Fetch recent roasters
106
106
+
roastersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 10)
107
107
+
if err != nil {
108
108
+
log.Warn().Err(err).Str("did", did).Msg("failed to fetch roasters for feed")
109
109
+
}
110
110
+
111
111
+
// Fetch recent grinders
112
112
+
grindersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 10)
113
113
+
if err != nil {
114
114
+
log.Warn().Err(err).Str("did", did).Msg("failed to fetch grinders for feed")
115
115
+
}
116
116
+
117
117
+
// Fetch recent brewers
118
118
+
brewersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 10)
119
119
+
if err != nil {
120
120
+
log.Warn().Err(err).Str("did", did).Msg("failed to fetch brewers for feed")
121
121
+
}
122
122
+
86
123
// 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)
124
124
+
allBeansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100)
125
125
+
allRoastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100)
126
126
+
allBrewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100)
127
127
+
allGrindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100)
91
128
92
129
// Build lookup maps (keyed by AT-URI)
93
130
beanMap := make(map[string]*models.Bean)
···
97
134
grinderMap := make(map[string]*models.Grinder)
98
135
99
136
// Populate bean map
100
100
-
if beansOutput != nil {
101
101
-
for _, beanRecord := range beansOutput.Records {
137
137
+
if allBeansOutput != nil {
138
138
+
for _, beanRecord := range allBeansOutput.Records {
102
139
bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI)
103
140
if err == nil {
104
141
beanMap[beanRecord.URI] = bean
···
111
148
}
112
149
113
150
// Populate roaster map
114
114
-
if roastersOutput != nil {
115
115
-
for _, roasterRecord := range roastersOutput.Records {
151
151
+
if allRoastersOutput != nil {
152
152
+
for _, roasterRecord := range allRoastersOutput.Records {
116
153
roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI)
117
154
if err == nil {
118
155
roasterMap[roasterRecord.URI] = roaster
···
121
158
}
122
159
123
160
// Populate brewer map
124
124
-
if brewersOutput != nil {
125
125
-
for _, brewerRecord := range brewersOutput.Records {
161
161
+
if allBrewersOutput != nil {
162
162
+
for _, brewerRecord := range allBrewersOutput.Records {
126
163
brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI)
127
164
if err == nil {
128
165
brewerMap[brewerRecord.URI] = brewer
···
131
168
}
132
169
133
170
// Populate grinder map
134
134
-
if grindersOutput != nil {
135
135
-
for _, grinderRecord := range grindersOutput.Records {
171
171
+
if allGrindersOutput != nil {
172
172
+
for _, grinderRecord := range allGrindersOutput.Records {
136
173
grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI)
137
174
if err == nil {
138
175
grinderMap[grinderRecord.URI] = grinder
···
181
218
}
182
219
result.brews = brews
183
220
221
221
+
// Convert beans to models and resolve roaster references
222
222
+
beans := make([]*models.Bean, 0)
223
223
+
if beansOutput != nil {
224
224
+
for _, record := range beansOutput.Records {
225
225
+
bean, err := atproto.RecordToBean(record.Value, record.URI)
226
226
+
if err != nil {
227
227
+
log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse bean record")
228
228
+
continue
229
229
+
}
230
230
+
231
231
+
// Resolve roaster reference
232
232
+
if roasterRef, found := beanRoasterRefMap[record.URI]; found {
233
233
+
if roaster, found := roasterMap[roasterRef]; found {
234
234
+
bean.Roaster = roaster
235
235
+
}
236
236
+
}
237
237
+
238
238
+
beans = append(beans, bean)
239
239
+
}
240
240
+
}
241
241
+
result.beans = beans
242
242
+
243
243
+
// Convert roasters to models
244
244
+
roasters := make([]*models.Roaster, 0)
245
245
+
if roastersOutput != nil {
246
246
+
for _, record := range roastersOutput.Records {
247
247
+
roaster, err := atproto.RecordToRoaster(record.Value, record.URI)
248
248
+
if err != nil {
249
249
+
log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse roaster record")
250
250
+
continue
251
251
+
}
252
252
+
roasters = append(roasters, roaster)
253
253
+
}
254
254
+
}
255
255
+
result.roasters = roasters
256
256
+
257
257
+
// Convert grinders to models
258
258
+
grinders := make([]*models.Grinder, 0)
259
259
+
if grindersOutput != nil {
260
260
+
for _, record := range grindersOutput.Records {
261
261
+
grinder, err := atproto.RecordToGrinder(record.Value, record.URI)
262
262
+
if err != nil {
263
263
+
log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse grinder record")
264
264
+
continue
265
265
+
}
266
266
+
grinders = append(grinders, grinder)
267
267
+
}
268
268
+
}
269
269
+
result.grinders = grinders
270
270
+
271
271
+
// Convert brewers to models
272
272
+
brewers := make([]*models.Brewer, 0)
273
273
+
if brewersOutput != nil {
274
274
+
for _, record := range brewersOutput.Records {
275
275
+
brewer, err := atproto.RecordToBrewer(record.Value, record.URI)
276
276
+
if err != nil {
277
277
+
log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse brewer record")
278
278
+
continue
279
279
+
}
280
280
+
brewers = append(brewers, brewer)
281
281
+
}
282
282
+
}
283
283
+
result.brewers = brewers
284
284
+
184
285
results <- result
185
286
}(did)
186
287
}
···
198
299
continue
199
300
}
200
301
302
302
+
totalRecords := len(result.brews) + len(result.beans) + len(result.roasters) + len(result.grinders) + len(result.brewers)
303
303
+
201
304
log.Debug().
202
305
Str("did", result.did).
203
306
Str("handle", result.profile.Handle).
204
307
Int("brew_count", len(result.brews)).
205
205
-
Msg("feed: collected brews from user")
308
308
+
Int("bean_count", len(result.beans)).
309
309
+
Int("roaster_count", len(result.roasters)).
310
310
+
Int("grinder_count", len(result.grinders)).
311
311
+
Int("brewer_count", len(result.brewers)).
312
312
+
Int("total_records", totalRecords).
313
313
+
Msg("feed: collected records from user")
206
314
315
315
+
// Add brews to feed
207
316
for _, brew := range result.brews {
208
317
items = append(items, &FeedItem{
209
209
-
Brew: brew,
210
210
-
Author: result.profile,
211
211
-
Timestamp: brew.CreatedAt,
212
212
-
TimeAgo: FormatTimeAgo(brew.CreatedAt),
318
318
+
RecordType: "brew",
319
319
+
Action: "added a new brew",
320
320
+
Brew: brew,
321
321
+
Author: result.profile,
322
322
+
Timestamp: brew.CreatedAt,
323
323
+
TimeAgo: FormatTimeAgo(brew.CreatedAt),
324
324
+
})
325
325
+
}
326
326
+
327
327
+
// Add beans to feed
328
328
+
for _, bean := range result.beans {
329
329
+
items = append(items, &FeedItem{
330
330
+
RecordType: "bean",
331
331
+
Action: "added a new bean",
332
332
+
Bean: bean,
333
333
+
Author: result.profile,
334
334
+
Timestamp: bean.CreatedAt,
335
335
+
TimeAgo: FormatTimeAgo(bean.CreatedAt),
336
336
+
})
337
337
+
}
338
338
+
339
339
+
// Add roasters to feed
340
340
+
for _, roaster := range result.roasters {
341
341
+
items = append(items, &FeedItem{
342
342
+
RecordType: "roaster",
343
343
+
Action: "added a new roaster",
344
344
+
Roaster: roaster,
345
345
+
Author: result.profile,
346
346
+
Timestamp: roaster.CreatedAt,
347
347
+
TimeAgo: FormatTimeAgo(roaster.CreatedAt),
348
348
+
})
349
349
+
}
350
350
+
351
351
+
// Add grinders to feed
352
352
+
for _, grinder := range result.grinders {
353
353
+
items = append(items, &FeedItem{
354
354
+
RecordType: "grinder",
355
355
+
Action: "added a new grinder",
356
356
+
Grinder: grinder,
357
357
+
Author: result.profile,
358
358
+
Timestamp: grinder.CreatedAt,
359
359
+
TimeAgo: FormatTimeAgo(grinder.CreatedAt),
360
360
+
})
361
361
+
}
362
362
+
363
363
+
// Add brewers to feed
364
364
+
for _, brewer := range result.brewers {
365
365
+
items = append(items, &FeedItem{
366
366
+
RecordType: "brewer",
367
367
+
Action: "added a new brewer",
368
368
+
Brewer: brewer,
369
369
+
Author: result.profile,
370
370
+
Timestamp: brewer.CreatedAt,
371
371
+
TimeAgo: FormatTimeAgo(brewer.CreatedAt),
213
372
})
214
373
}
215
374
}
+79
-2
templates/partials/feed.tmpl
···
23
23
</div>
24
24
</div>
25
25
26
26
+
<!-- Action header -->
27
27
+
<div class="mb-2 text-sm text-gray-600 italic">
28
28
+
{{.Action}}
29
29
+
</div>
30
30
+
31
31
+
<!-- Record content -->
32
32
+
{{if eq .RecordType "brew"}}
26
33
<!-- Brew info -->
27
34
<div class="bg-gray-50 rounded-lg p-3">
28
35
<!-- Bean and roaster info -->
···
87
94
</div>
88
95
{{end}}
89
96
</div>
97
97
+
{{else if eq .RecordType "bean"}}
98
98
+
<!-- Bean info -->
99
99
+
<div class="bg-gray-50 rounded-lg p-3">
100
100
+
<div class="text-base mb-2">
101
101
+
<span class="font-bold text-gray-900">
102
102
+
{{if .Bean.Name}}{{.Bean.Name}}{{else}}{{.Bean.Origin}}{{end}}
103
103
+
</span>
104
104
+
{{if and .Bean.Roaster .Bean.Roaster.Name}}
105
105
+
<span class="text-gray-600"> from {{.Bean.Roaster.Name}}</span>
106
106
+
{{end}}
107
107
+
</div>
108
108
+
<div class="text-sm text-gray-600 space-y-1">
109
109
+
{{if .Bean.Origin}}
110
110
+
<div><span class="text-gray-500">Origin:</span> {{.Bean.Origin}}</div>
111
111
+
{{end}}
112
112
+
{{if .Bean.RoastLevel}}
113
113
+
<div><span class="text-gray-500">Roast:</span> {{.Bean.RoastLevel}}</div>
114
114
+
{{end}}
115
115
+
{{if .Bean.Process}}
116
116
+
<div><span class="text-gray-500">Process:</span> {{.Bean.Process}}</div>
117
117
+
{{end}}
118
118
+
{{if .Bean.Description}}
119
119
+
<div class="mt-2 text-gray-700 italic">"{{.Bean.Description}}"</div>
120
120
+
{{end}}
121
121
+
</div>
122
122
+
</div>
123
123
+
{{else if eq .RecordType "roaster"}}
124
124
+
<!-- Roaster info -->
125
125
+
<div class="bg-gray-50 rounded-lg p-3">
126
126
+
<div class="text-base mb-2">
127
127
+
<span class="font-bold text-gray-900">{{.Roaster.Name}}</span>
128
128
+
</div>
129
129
+
<div class="text-sm text-gray-600 space-y-1">
130
130
+
{{if .Roaster.Location}}
131
131
+
<div><span class="text-gray-500">Location:</span> {{.Roaster.Location}}</div>
132
132
+
{{end}}
133
133
+
{{if .Roaster.Website}}
134
134
+
<div><span class="text-gray-500">Website:</span> <a href="{{.Roaster.Website}}" target="_blank" class="text-blue-600 hover:underline">{{.Roaster.Website}}</a></div>
135
135
+
{{end}}
136
136
+
</div>
137
137
+
</div>
138
138
+
{{else if eq .RecordType "grinder"}}
139
139
+
<!-- Grinder info -->
140
140
+
<div class="bg-gray-50 rounded-lg p-3">
141
141
+
<div class="text-base mb-2">
142
142
+
<span class="font-bold text-gray-900">{{.Grinder.Name}}</span>
143
143
+
</div>
144
144
+
<div class="text-sm text-gray-600 space-y-1">
145
145
+
{{if .Grinder.GrinderType}}
146
146
+
<div><span class="text-gray-500">Type:</span> {{.Grinder.GrinderType}}</div>
147
147
+
{{end}}
148
148
+
{{if .Grinder.BurrType}}
149
149
+
<div><span class="text-gray-500">Burr:</span> {{.Grinder.BurrType}}</div>
150
150
+
{{end}}
151
151
+
{{if .Grinder.Notes}}
152
152
+
<div class="mt-2 text-gray-700 italic">"{{.Grinder.Notes}}"</div>
153
153
+
{{end}}
154
154
+
</div>
155
155
+
</div>
156
156
+
{{else if eq .RecordType "brewer"}}
157
157
+
<!-- Brewer info -->
158
158
+
<div class="bg-gray-50 rounded-lg p-3">
159
159
+
<div class="text-base mb-2">
160
160
+
<span class="font-bold text-gray-900">{{.Brewer.Name}}</span>
161
161
+
</div>
162
162
+
{{if .Brewer.Description}}
163
163
+
<div class="text-sm text-gray-700 italic">"{{.Brewer.Description}}"</div>
164
164
+
{{end}}
165
165
+
</div>
166
166
+
{{end}}
90
167
</div>
91
168
{{end}}
92
169
{{else}}
93
170
<div class="bg-gray-50 rounded-lg p-6 text-center text-gray-500">
94
94
-
<p class="mb-2">No brews in the feed yet.</p>
95
95
-
<p class="text-sm">Be the first to log a brew!</p>
171
171
+
<p class="mb-2">No activity in the feed yet.</p>
172
172
+
<p class="text-sm">Be the first to add something!</p>
96
173
</div>
97
174
{{end}}
98
175
</div>