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