+26
-28
AGENTS.md
+26
-28
AGENTS.md
···
11
- **Testing:** Standard library testing + [shutter](https://github.com/ptdewey/shutter) for snapshot tests
12
- **Logging:** zerolog
13
14
## Project Structure
15
16
```
···
143
**Location:** `internal/handlers/api_snapshot_test.go`
144
145
**Covered endpoints:**
146
- Authentication: `/api/me`, `/client-metadata.json`
147
- Data fetching: `/api/data`, `/api/feed-json`, `/api/profile-json/{actor}`
148
- CRUD operations: Create/Update/Delete for beans, roasters, grinders, brewers, brews
149
150
**Running snapshot tests:**
151
```bash
152
cd internal/handlers && go test -v -run "Snapshot"
153
```
154
155
**Working with snapshots:**
156
```bash
157
# Accept all new/changed snapshots
158
shutter accept-all
···
165
```
166
167
**Snapshot patterns used:**
168
- `shutter.ScrubTimestamp()` - Removes timestamp values for deterministic tests
169
- `shutter.IgnoreKey("created_at")` - Ignores specific JSON keys
170
- `shutter.IgnoreKey("rkey")` - Ignores AT Protocol record keys (TIDs are time-based)
···
177
go build -o arabica cmd/arabica-server/main.go
178
```
179
180
-
## Command-Line Flags
181
-
182
-
| Flag | Type | Default | Description |
183
-
| --------------- | ------ | ------- | ----------------------------------------------------- |
184
-
| `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) |
185
-
| `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) |
186
-
187
-
**Known DIDs File Format:**
188
-
- One DID per line (e.g., `did:plc:abc123xyz`)
189
-
- Lines starting with `#` are comments
190
-
- Empty lines are ignored
191
-
- See `known-dids.txt.example` for reference
192
-
193
## Environment Variables
194
195
-
| Variable | Default | Description |
196
-
| --------------------------- | --------------------------------- | ---------------------------------- |
197
-
| `PORT` | 18910 | HTTP server port |
198
-
| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) |
199
-
| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
200
-
| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
201
-
| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
202
-
| `LOG_LEVEL` | info | debug/info/warn/error |
203
-
| `LOG_FORMAT` | console | console/json |
204
205
## Code Patterns
206
···
353
354
## Known Issues / TODOs
355
356
-
Key areas:
357
-
358
-
- Context should flow through methods (some fixed, verify all paths)
359
-
- Cache race conditions need copy-on-write pattern
360
-
- Missing CID validation on record updates (AT Protocol best practice)
361
-
- Rate limiting for PDS calls not implemented
···
11
- **Testing:** Standard library testing + [shutter](https://github.com/ptdewey/shutter) for snapshot tests
12
- **Logging:** zerolog
13
14
+
## Use Go Tooling Effectively
15
+
16
+
- To see source files from a dependency, or to answer questions
17
+
about a dependency, run `go mod download -json MODULE` and use
18
+
the returned `Dir` path to read the files.
19
+
20
+
- Use `go doc foo.Bar` or `go doc -all foo` to read documentation
21
+
for packages, types, functions, etc.
22
+
23
+
- Use `go run .` or `go run ./cmd/foo` instead of `go build` to
24
+
run programs, to avoid leaving behind build artifacts.
25
+
26
## Project Structure
27
28
```
···
155
**Location:** `internal/handlers/api_snapshot_test.go`
156
157
**Covered endpoints:**
158
+
159
- Authentication: `/api/me`, `/client-metadata.json`
160
- Data fetching: `/api/data`, `/api/feed-json`, `/api/profile-json/{actor}`
161
- CRUD operations: Create/Update/Delete for beans, roasters, grinders, brewers, brews
162
163
**Running snapshot tests:**
164
+
165
```bash
166
cd internal/handlers && go test -v -run "Snapshot"
167
```
168
169
**Working with snapshots:**
170
+
171
```bash
172
# Accept all new/changed snapshots
173
shutter accept-all
···
180
```
181
182
**Snapshot patterns used:**
183
+
184
- `shutter.ScrubTimestamp()` - Removes timestamp values for deterministic tests
185
- `shutter.IgnoreKey("created_at")` - Ignores specific JSON keys
186
- `shutter.IgnoreKey("rkey")` - Ignores AT Protocol record keys (TIDs are time-based)
···
193
go build -o arabica cmd/arabica-server/main.go
194
```
195
196
## Environment Variables
197
198
+
| Variable | Default | Description |
199
+
| --------------------------- | ------------------------------------ | ---------------------------------------------------------------- |
200
+
| `PORT` | 18910 | HTTP server port |
201
+
| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) |
202
+
| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
203
+
| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
204
+
| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
205
+
| `LOG_LEVEL` | info | debug/info/warn/error |
206
+
| `LOG_FORMAT` | console | console/json |
207
208
## Code Patterns
209
···
356
357
## Known Issues / TODOs
358
359
+
See @BACKLOG.md
+16
-2
BACKLOG.md
+16
-2
BACKLOG.md
···
20
- Manage + brews list together probably makes sense
21
22
- IMPORTANT: If this platform gains any traction, we will need some form of content moderation
23
-
- Due to the nature of arabica, this will only really be text based (text and hyperlinks)
24
- Malicious link scanning may be reasonable, not sure about deeper text analysis
25
- Need to do more research into security
26
- Need admin tooling at the app level that will allow deleting records (may not be possible),
27
removing from appview, blacklisting users (and maybe IPs?), possibly more
28
- Having accounts with admin rights may be an approach to this (configured with flags at startup time?)
29
@arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably)
30
31
## Features
32
···
57
- Might be able to just save to the db when backfilling a profile's records
58
- NOTE: requires research into existing solustions (whatever tangled does is probably good)
59
60
## Fixes
61
62
- Migrate terms page text. Add links to about at top of non-authed home page
···
69
70
- Show "view" button on brews in profile page (same as on brews list page)
71
72
-
- Fix nix build, nix run, to build frontend as well
···
20
- Manage + brews list together probably makes sense
21
22
- IMPORTANT: If this platform gains any traction, we will need some form of content moderation
23
+
- Due to the nature of arabica, this will only really need to be text based (text and hyperlinks)
24
- Malicious link scanning may be reasonable, not sure about deeper text analysis
25
- Need to do more research into security
26
- Need admin tooling at the app level that will allow deleting records (may not be possible),
27
removing from appview, blacklisting users (and maybe IPs?), possibly more
28
- Having accounts with admin rights may be an approach to this (configured with flags at startup time?)
29
@arabica.social, @pdewey.com, maybe others? (need trusted users in other time zones probably)
30
+
- Add on some piece to the TOS that mentions I reserve the right to de-list content from the platform
31
+
- Continue limiting firehose posts to users who have been previously authenticated (keep a permanent record of "trusted" users)
32
+
- By logging in users agree to TOS -- can create records to be displayed on the appview ("signal" records)
33
+
Attestation signature from appview (or pds -- use key from pds) was source of record being created
34
+
- This is a pretty important consideration going forward, lots to consider
35
36
## Features
37
···
62
- Might be able to just save to the db when backfilling a profile's records
63
- NOTE: requires research into existing solustions (whatever tangled does is probably good)
64
65
+
- Opengraph metadata in brew entry page, to allow rich embeds in bluesky
66
+
- All pages should have opengraph metadat, but view brew, profile, and home/feed are probably the most important
67
+
68
+
- Maybe move water amount below pours in form, sum pours if they are entered first.
69
+
- Would need to not override if water amount is entered after pours
70
+
(maybe update after leaving pour input?).
71
+
72
## Fixes
73
74
- Migrate terms page text. Add links to about at top of non-authed home page
···
81
82
- Show "view" button on brews in profile page (same as on brews list page)
83
84
+
- The "back" button behaves kind of strangely
85
+
- Goes back to brews list after clicking on view bean in feed,
86
+
takes to profile for other users' brews.
-326
CLAUDE.md
-326
CLAUDE.md
···
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:** Svelte SPA with client-side routing
11
-
- **Legacy:** HTMX partials still used for some dynamic content (being phased out)
12
-
- **Logging:** zerolog
13
-
14
-
## Project Structure
15
-
16
-
```
17
-
cmd/arabica-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 (API endpoints + HTMX partials)
30
-
auth.go # OAuth login/logout/callback
31
-
bff/
32
-
render.go # Legacy template rendering (HTMX partials only)
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
-
frontend/ # Svelte SPA source code
47
-
src/
48
-
routes/ # Page components
49
-
components/ # Reusable components
50
-
stores/ # Svelte stores (auth, cache)
51
-
lib/ # Utilities (router, API client)
52
-
public/ # Built SPA assets
53
-
lexicons/ # AT Protocol lexicon definitions (JSON)
54
-
templates/partials/ # Legacy HTMX partial templates (being phased out)
55
-
static/ # Static assets (CSS, icons, service worker)
56
-
app/ # Built Svelte SPA
57
-
```
58
-
59
-
## Key Concepts
60
-
61
-
### AT Protocol Integration
62
-
63
-
User data stored in their Personal Data Server (PDS), not locally. The app:
64
-
65
-
1. Authenticates via OAuth (indigo SDK handles PKCE/DPOP)
66
-
2. Gets access token scoped to user's DID
67
-
3. Performs CRUD via XRPC calls to user's PDS
68
-
69
-
**Collections (NSIDs):**
70
-
71
-
- `social.arabica.alpha.bean` - Coffee beans
72
-
- `social.arabica.alpha.roaster` - Roasters
73
-
- `social.arabica.alpha.grinder` - Grinders
74
-
- `social.arabica.alpha.brewer` - Brewing devices
75
-
- `social.arabica.alpha.brew` - Brew sessions (references bean, grinder, brewer)
76
-
77
-
**Record keys:** TID format (timestamp-based identifiers)
78
-
79
-
**References:** Records reference each other via AT-URIs (`at://did/collection/rkey`)
80
-
81
-
### Store Interface
82
-
83
-
`internal/database/store.go` defines the `Store` interface. Two implementations:
84
-
85
-
- `AtprotoStore` - Production, stores in user's PDS
86
-
- BoltDB stores only sessions and feed registry (not user data)
87
-
88
-
All Store methods take `context.Context` as first parameter.
89
-
90
-
### Request Flow
91
-
92
-
1. Request hits middleware (logging, auth check)
93
-
2. Auth middleware extracts DID + session ID from cookies
94
-
3. For SPA routes: Serve index.html (client-side routing)
95
-
4. For API routes: Handler creates `AtprotoStore` scoped to user
96
-
5. Store methods make XRPC calls to user's PDS
97
-
6. Results returned as JSON (for SPA) or HTML fragments (legacy HTMX partials)
98
-
99
-
### Caching
100
-
101
-
`SessionCache` caches user data in memory (5-minute TTL):
102
-
103
-
- Avoids repeated PDS calls for same data
104
-
- Invalidated on writes
105
-
- Background cleanup removes expired entries
106
-
107
-
### Backfill Strategy
108
-
109
-
User records are backfilled from their PDS once per DID:
110
-
111
-
- **On startup**: Backfills registered users + known-dids file
112
-
- **On first login**: Backfills the user's historical records
113
-
- **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches
114
-
- **Idempotent**: Safe to call multiple times (checks backfill status first)
115
-
116
-
This prevents excessive PDS requests while ensuring new users' historical data is indexed.
117
-
118
-
## Common Tasks
119
-
120
-
### Run Development Server
121
-
122
-
```bash
123
-
# Run server (uses firehose mode by default)
124
-
go run cmd/arabica-server/main.go
125
-
126
-
# Backfill known DIDs on startup
127
-
go run cmd/arabica-server/main.go --known-dids known-dids.txt
128
-
129
-
# Using nix
130
-
nix run
131
-
```
132
-
133
-
### Run Tests
134
-
135
-
```bash
136
-
go test ./...
137
-
```
138
-
139
-
### Build
140
-
141
-
```bash
142
-
go build -o arabica cmd/arabica-server/main.go
143
-
```
144
-
145
-
## Command-Line Flags
146
-
147
-
| Flag | Type | Default | Description |
148
-
| --------------- | ------ | ------- | ----------------------------------------------------- |
149
-
| `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) |
150
-
| `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) |
151
-
152
-
**Known DIDs File Format:**
153
-
- One DID per line (e.g., `did:plc:abc123xyz`)
154
-
- Lines starting with `#` are comments
155
-
- Empty lines are ignored
156
-
- See `known-dids.txt.example` for reference
157
-
158
-
## Environment Variables
159
-
160
-
| Variable | Default | Description |
161
-
| --------------------------- | --------------------------------- | ---------------------------------- |
162
-
| `PORT` | 18910 | HTTP server port |
163
-
| `SERVER_PUBLIC_URL` | - | Public URL for reverse proxy (enables secure cookies when HTTPS) |
164
-
| `ARABICA_DB_PATH` | ~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
165
-
| `ARABICA_FEED_INDEX_PATH` | ~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
166
-
| `ARABICA_PROFILE_CACHE_TTL` | 1h | Profile cache duration |
167
-
| `LOG_LEVEL` | info | debug/info/warn/error |
168
-
| `LOG_FORMAT` | console | console/json |
169
-
170
-
## Code Patterns
171
-
172
-
### Creating a Store
173
-
174
-
```go
175
-
// In handlers, store is created per-request
176
-
store, authenticated := h.getAtprotoStore(r)
177
-
if !authenticated {
178
-
http.Error(w, "Authentication required", http.StatusUnauthorized)
179
-
return
180
-
}
181
-
182
-
// Use store with request context
183
-
brews, err := store.ListBrews(r.Context(), userID)
184
-
```
185
-
186
-
### Record Conversion
187
-
188
-
```go
189
-
// Model -> ATProto record
190
-
record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI)
191
-
192
-
// ATProto record -> Model
193
-
brew, err := RecordToBrew(record, atURI)
194
-
```
195
-
196
-
### AT-URI Handling
197
-
198
-
```go
199
-
// Build AT-URI
200
-
uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc
201
-
202
-
// Parse AT-URI
203
-
components, err := ResolveATURI(uri)
204
-
// components.DID, components.Collection, components.RKey
205
-
```
206
-
207
-
## Future Vision: Social Features
208
-
209
-
The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature.
210
-
211
-
### Planned Lexicons
212
-
213
-
```
214
-
social.arabica.alpha.like - Like a brew (references brew AT-URI)
215
-
social.arabica.alpha.comment - Comment on a brew
216
-
social.arabica.alpha.follow - Follow another user
217
-
social.arabica.alpha.share - Re-share a brew to your feed
218
-
```
219
-
220
-
### Like Record (Planned)
221
-
222
-
```json
223
-
{
224
-
"lexicon": 1,
225
-
"id": "social.arabica.alpha.like",
226
-
"defs": {
227
-
"main": {
228
-
"type": "record",
229
-
"key": "tid",
230
-
"record": {
231
-
"type": "object",
232
-
"required": ["subject", "createdAt"],
233
-
"properties": {
234
-
"subject": {
235
-
"type": "ref",
236
-
"ref": "com.atproto.repo.strongRef",
237
-
"description": "The brew being liked"
238
-
},
239
-
"createdAt": { "type": "string", "format": "datetime" }
240
-
}
241
-
}
242
-
}
243
-
}
244
-
}
245
-
```
246
-
247
-
### Comment Record (Planned)
248
-
249
-
```json
250
-
{
251
-
"lexicon": 1,
252
-
"id": "social.arabica.alpha.comment",
253
-
"defs": {
254
-
"main": {
255
-
"type": "record",
256
-
"key": "tid",
257
-
"record": {
258
-
"type": "object",
259
-
"required": ["subject", "text", "createdAt"],
260
-
"properties": {
261
-
"subject": {
262
-
"type": "ref",
263
-
"ref": "com.atproto.repo.strongRef",
264
-
"description": "The brew being commented on"
265
-
},
266
-
"text": {
267
-
"type": "string",
268
-
"maxLength": 1000,
269
-
"maxGraphemes": 300
270
-
},
271
-
"createdAt": { "type": "string", "format": "datetime" }
272
-
}
273
-
}
274
-
}
275
-
}
276
-
}
277
-
```
278
-
279
-
### Implementation Approach
280
-
281
-
**Cross-user interactions:**
282
-
283
-
- Likes/comments stored in the actor's PDS (not the brew owner's)
284
-
- Use `public_client.go` to read other users' brews
285
-
- Aggregate likes/comments via relay/firehose or direct PDS queries
286
-
287
-
**Feed aggregation:**
288
-
289
-
- Current: Poll registered users' PDS for brews
290
-
- Future: Subscribe to firehose for real-time updates
291
-
- Index social interactions in local DB for fast queries
292
-
293
-
**UI patterns:**
294
-
295
-
- Like button on brew cards in feed
296
-
- Comment thread below brew detail view
297
-
- Share button to re-post with optional note
298
-
- Notification system for interactions on your brews
299
-
300
-
### Key Design Decisions
301
-
302
-
1. **Strong references** - Likes/comments use `com.atproto.repo.strongRef` (URI + CID) to ensure the referenced brew hasn't changed
303
-
2. **Actor-owned data** - Your likes live in your PDS, not the brew owner's
304
-
3. **Public by default** - Social interactions are public records, readable by anyone
305
-
4. **Portable identity** - Users can switch PDS and keep their social graph
306
-
307
-
## Deployment Notes
308
-
309
-
### CSS Cache Busting
310
-
311
-
When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`:
312
-
313
-
```html
314
-
<link rel="stylesheet" href="/static/css/output.css?v=0.1.3" />
315
-
```
316
-
317
-
Cloudflare caches static assets, so incrementing the version ensures users get the updated styles.
318
-
319
-
## Known Issues / TODOs
320
-
321
-
Key areas:
322
-
323
-
- Context should flow through methods (some fixed, verify all paths)
324
-
- Cache race conditions need copy-on-write pattern
325
-
- Missing CID validation on record updates (AT Protocol best practice)
326
-
- Rate limiting for PDS calls not implemented
···
+1
-22
README.md
+1
-22
README.md
···
2
3
Coffee brew tracking application build on ATProto
4
5
-
Development is on GitHub, and is mirrored to Tangled:
6
7
- [Tangled](https://tangled.org/arabica.social/arabica)
8
- [GitHub](https://github.com/arabica-social/arabica)
9
10
-
GitHub is currently the primary repo, but that may change in the future.
11
-
12
## Features
13
14
- Track coffee brews with detailed parameters
15
- Store data in your AT Protocol Personal Data Server
16
- Community feed of recent brews from registered users (polling or real-time firehose)
17
- Manage beans, roasters, grinders, and brewers
18
-
- Export brew data as JSON
19
- Mobile-friendly PWA design
20
21
-
## Tech Stack
22
-
23
-
- Backend: Go with stdlib HTTP router
24
-
- Storage: AT Protocol Personal Data Servers + BoltDB for local cache
25
-
- Templates: html/template
26
-
- Frontend: HTMX + Alpine.js + Tailwind CSS
27
-
28
## Quick Start
29
30
```bash
···
70
- `LOG_LEVEL` - Logging level: debug, info, warn, error (default: info)
71
- `LOG_FORMAT` - Log format: console, json (default: console)
72
73
-
## Architecture
74
-
75
-
Data is stored in AT Protocol records on users' Personal Data Servers. The application uses OAuth to authenticate with the PDS and performs all CRUD operations via the AT Protocol API.
76
-
77
-
Local BoltDB stores:
78
-
79
-
- OAuth session data
80
-
- Feed registry (list of DIDs for community feed)
81
-
82
-
See docs/ for detailed documentation.
83
-
84
## Development
85
86
```bash
···
2
3
Coffee brew tracking application build on ATProto
4
5
+
Development is primarily happening on Tangled, and is mirrored to GitHub:
6
7
- [Tangled](https://tangled.org/arabica.social/arabica)
8
- [GitHub](https://github.com/arabica-social/arabica)
9
10
## Features
11
12
- Track coffee brews with detailed parameters
13
- Store data in your AT Protocol Personal Data Server
14
- Community feed of recent brews from registered users (polling or real-time firehose)
15
- Manage beans, roasters, grinders, and brewers
16
- Mobile-friendly PWA design
17
18
## Quick Start
19
20
```bash
···
60
- `LOG_LEVEL` - Logging level: debug, info, warn, error (default: info)
61
- `LOG_FORMAT` - Log format: console, json (default: console)
62
63
## Development
64
65
```bash
+1050
docs/likes-follows-comments-plan.md
+1050
docs/likes-follows-comments-plan.md
···
···
1
+
# Arabica Social Features Plan: Likes, Follows, and Comments
2
+
3
+
**Version:** 1.0
4
+
**Date:** January 25, 2026
5
+
**Status:** Planning
6
+
7
+
---
8
+
9
+
TODO:
10
+
11
+
- This is not going to be the current state, I don't love the plan claude made
12
+
- Likes will probably be their own lexicon (maybe with a lens to bsky likes? -- probably not)
13
+
- Comments tbd, I would like to avoid forcing users onto bsky for social features though
14
+
- Follows, allow importing social graph from bsky (might be able to use a sort of statndardized lexicon here?)
15
+
- Likely creating a custom lexicon that is structurally similar/the same as bsky (maybe standard.site sub/pub lex if that would work?)
16
+
17
+
---
18
+
19
+
## Executive Summary
20
+
21
+
This document outlines the implementation plan for adding social features to Arabica: likes, follows, and comments. The plan leverages AT Protocol's decentralized architecture while evaluating strategic reuse of Bluesky's existing social lexicons versus creating Arabica-specific ones.
22
+
23
+
## Table of Contents
24
+
25
+
1. [Goals & Non-Goals](#goals--non-goals)
26
+
2. [Architecture Overview](#architecture-overview)
27
+
3. [Lexicon Design Decisions](#lexicon-design-decisions)
28
+
4. [Implementation Phases](#implementation-phases)
29
+
5. [Technical Details](#technical-details)
30
+
6. [Bluesky Integration Strategies](#bluesky-integration-strategies)
31
+
7. [Data Flow & Storage](#data-flow--storage)
32
+
8. [UI/UX Considerations](#uiux-considerations)
33
+
9. [Migration & Rollout](#migration--rollout)
34
+
10. [Future Enhancements](#future-enhancements)
35
+
36
+
---
37
+
38
+
## Goals & Non-Goals
39
+
40
+
### Goals
41
+
42
+
- **Enable likes** on brews, beans, roasters, grinders, and brewers
43
+
- **Support follows** to create personalized feeds of coffee enthusiasts
44
+
- **Add comments** to enable discussions around brews and equipment
45
+
- **Maintain decentralization**: Social interactions stored in users' own PDS
46
+
- **Leverage existing infrastructure**: Use Bluesky's lexicons where beneficial
47
+
- **Preserve portability**: Users can take their data anywhere
48
+
- **Enable discoverability**: Surface popular content and active users
49
+
50
+
### Non-Goals
51
+
52
+
- Building a full social network (messaging, DMs, notifications beyond basic)
53
+
- Implementing moderation tools (initial phase)
54
+
- Creating a mobile app (web-first approach)
55
+
- Supporting multimedia beyond existing image support
56
+
57
+
---
58
+
59
+
## Architecture Overview
60
+
61
+
### Current State
62
+
63
+
```
64
+
User's PDS
65
+
├── social.arabica.alpha.bean (coffee beans)
66
+
├── social.arabica.alpha.roaster (roasters)
67
+
├── social.arabica.alpha.grinder (grinders)
68
+
├── social.arabica.alpha.brewer (brewing devices)
69
+
└── social.arabica.alpha.brew (brew sessions)
70
+
71
+
Arabica Server
72
+
├── Firehose Listener (crawls network for brew data)
73
+
├── Feed Index (BoltDB - aggregated feed)
74
+
├── Session Store (BoltDB - sessions/registry)
75
+
└── Profile Cache (in-memory, 1hr TTL)
76
+
```
77
+
78
+
### Proposed State
79
+
80
+
```
81
+
User's PDS
82
+
├── Arabica Records
83
+
│ ├── social.arabica.alpha.bean
84
+
│ ├── social.arabica.alpha.roaster
85
+
│ ├── social.arabica.alpha.grinder
86
+
│ ├── social.arabica.alpha.brewer
87
+
│ └── social.arabica.alpha.brew
88
+
│
89
+
├── Social Interactions (Option A: Arabica-specific)
90
+
│ ├── social.arabica.alpha.like
91
+
│ ├── social.arabica.alpha.follow
92
+
│ └── social.arabica.alpha.comment
93
+
│
94
+
└── Social Interactions (Option B: Bluesky lexicons)
95
+
├── app.bsky.feed.like (reuse for likes)
96
+
├── app.bsky.graph.follow (reuse for follows)
97
+
└── social.arabica.alpha.comment (custom for comments)
98
+
99
+
Arabica Server
100
+
├── Firehose Listener (+ like/follow/comment indexing)
101
+
├── Social Index (BoltDB - likes, follows, comments)
102
+
├── Feed Index (enhanced with social signals)
103
+
├── Session Store
104
+
└── Profile Cache
105
+
```
106
+
107
+
---
108
+
109
+
## Lexicon Design Decisions
110
+
111
+
### Decision Matrix
112
+
113
+
| Feature | Custom Lexicon | Bluesky Lexicon | Recommendation |
114
+
| ------------ | ------------------------------ | ------------------------------ | ----------------- |
115
+
| **Likes** | `social.arabica.alpha.like` | `app.bsky.feed.like` | **Use Bluesky** |
116
+
| **Follows** | `social.arabica.alpha.follow` | `app.bsky.graph.follow` | **Use Bluesky** |
117
+
| **Comments** | `social.arabica.alpha.comment` | `app.bsky.feed.post` (replies) | **Create Custom** |
118
+
119
+
### Rationale
120
+
121
+
#### ✅ Use `app.bsky.feed.like` for Likes
122
+
123
+
**Pros:**
124
+
125
+
- Simple, well-tested schema (just subject + timestamp)
126
+
- Enables cross-app discoverability (Bluesky users can see popular coffee content)
127
+
- No need to maintain our own lexicon
128
+
- Future compatibility with Bluesky social graph features
129
+
- Users' existing Bluesky likes are already in their PDS
130
+
131
+
**Cons:**
132
+
133
+
- Couples us to Bluesky's schema evolution
134
+
- Mixing Arabica and Bluesky content in like feeds
135
+
136
+
**Schema:**
137
+
138
+
```json
139
+
{
140
+
"lexicon": 1,
141
+
"id": "app.bsky.feed.like",
142
+
"defs": {
143
+
"main": {
144
+
"type": "record",
145
+
"key": "tid",
146
+
"record": {
147
+
"type": "object",
148
+
"required": ["subject", "createdAt"],
149
+
"properties": {
150
+
"subject": {
151
+
"type": "ref",
152
+
"ref": "com.atproto.repo.strongRef",
153
+
"description": "AT-URI + CID of the liked record"
154
+
},
155
+
"createdAt": {
156
+
"type": "string",
157
+
"format": "datetime"
158
+
}
159
+
}
160
+
}
161
+
}
162
+
}
163
+
}
164
+
```
165
+
166
+
**Example Record:**
167
+
168
+
```json
169
+
{
170
+
"$type": "app.bsky.feed.like",
171
+
"subject": {
172
+
"uri": "at://did:plc:user123/social.arabica.alpha.brew/abc123",
173
+
"cid": "bafyreibjifzpqj6o6wcq3hejh7y4z4z2vmiklkvykc57tw3pcbx3kxifpm"
174
+
},
175
+
"createdAt": "2026-01-25T12:30:00.000Z"
176
+
}
177
+
```
178
+
179
+
#### ✅ Use `app.bsky.graph.follow` for Follows
180
+
181
+
**Pros:**
182
+
183
+
- Standard social graph representation
184
+
- Interoperability: Arabica follows visible in Bluesky social graph
185
+
- Enables "import follows from Bluesky" (see below)
186
+
- Could power recommendations ("Users who brew X also follow Y")
187
+
- Simplifies social graph queries
188
+
189
+
**Cons:**
190
+
191
+
- Follow graph will mix Arabica and Bluesky users
192
+
- Need to filter by context in queries
193
+
194
+
**Schema:**
195
+
196
+
```json
197
+
{
198
+
"lexicon": 1,
199
+
"id": "app.bsky.graph.follow",
200
+
"defs": {
201
+
"main": {
202
+
"type": "record",
203
+
"key": "tid",
204
+
"record": {
205
+
"type": "object",
206
+
"required": ["subject", "createdAt"],
207
+
"properties": {
208
+
"subject": {
209
+
"type": "string",
210
+
"format": "did",
211
+
"description": "DID of the user being followed"
212
+
},
213
+
"createdAt": {
214
+
"type": "string",
215
+
"format": "datetime"
216
+
}
217
+
}
218
+
}
219
+
}
220
+
}
221
+
}
222
+
```
223
+
224
+
**Example Record:**
225
+
226
+
```json
227
+
{
228
+
"$type": "app.bsky.graph.follow",
229
+
"subject": "did:plc:coffee-enthusiast-456",
230
+
"createdAt": "2026-01-25T12:30:00.000Z"
231
+
}
232
+
```
233
+
234
+
#### ✅ Create `social.arabica.alpha.comment` for Comments
235
+
236
+
**Pros:**
237
+
238
+
- Coffee-specific comment features (e.g., ratings, tasting notes)
239
+
- Can extend with Arabica-specific fields
240
+
- Cleaner separation from Bluesky post threads
241
+
- No confusion between "replies" and "comments"
242
+
243
+
**Cons:**
244
+
245
+
- Maintains another lexicon
246
+
- Comments won't appear in Bluesky's thread views
247
+
- Need to build our own comment threading
248
+
249
+
**Proposed Schema:**
250
+
251
+
```json
252
+
{
253
+
"lexicon": 1,
254
+
"id": "social.arabica.alpha.comment",
255
+
"defs": {
256
+
"main": {
257
+
"type": "record",
258
+
"key": "tid",
259
+
"description": "A comment on a brew or equipment",
260
+
"record": {
261
+
"type": "object",
262
+
"required": ["subject", "text", "createdAt"],
263
+
"properties": {
264
+
"subject": {
265
+
"type": "ref",
266
+
"ref": "com.atproto.repo.strongRef",
267
+
"description": "The brew/bean/roaster/etc being commented on"
268
+
},
269
+
"text": {
270
+
"type": "string",
271
+
"maxLength": 2000,
272
+
"maxGraphemes": 500,
273
+
"description": "Comment text"
274
+
},
275
+
"parent": {
276
+
"type": "ref",
277
+
"ref": "com.atproto.repo.strongRef",
278
+
"description": "Parent comment for threading (optional)"
279
+
},
280
+
"facets": {
281
+
"type": "array",
282
+
"description": "Mentions, links, hashtags",
283
+
"items": {
284
+
"type": "ref",
285
+
"ref": "app.bsky.richtext.facet"
286
+
}
287
+
},
288
+
"rating": {
289
+
"type": "integer",
290
+
"minimum": 1,
291
+
"maximum": 10,
292
+
"description": "Optional rating (1-10)"
293
+
},
294
+
"createdAt": {
295
+
"type": "string",
296
+
"format": "datetime"
297
+
}
298
+
}
299
+
}
300
+
}
301
+
}
302
+
}
303
+
```
304
+
305
+
**Example Record:**
306
+
307
+
```json
308
+
{
309
+
"$type": "social.arabica.alpha.comment",
310
+
"subject": {
311
+
"uri": "at://did:plc:user123/social.arabica.alpha.brew/xyz789",
312
+
"cid": "bafyreig2fjxi3rptqdgylg7e5hmjl6mcke7rn2b6cugzlqq3i4zu6rq52q"
313
+
},
314
+
"text": "Lovely floral notes! What was your water temp?",
315
+
"rating": 8,
316
+
"createdAt": "2026-01-25T14:00:00.000Z"
317
+
}
318
+
```
319
+
320
+
---
321
+
322
+
## Implementation Phases
323
+
324
+
### Phase 1: Likes (2-3 weeks)
325
+
326
+
**Deliverables:**
327
+
328
+
1. ✅ Lexicon decision: Use `app.bsky.feed.like`
329
+
2. Backend: Index likes from firehose
330
+
3. Backend: Aggregate like counts per record
331
+
4. Backend: API endpoints for liking/unliking
332
+
5. Frontend: Like button UI on brew cards
333
+
6. Frontend: Display like counts
334
+
7. Testing: Snapshot tests for like endpoints
335
+
336
+
**Technical Tasks:**
337
+
338
+
- Update firehose listener to capture `app.bsky.feed.like` records
339
+
- Add `LikesIndex` to BoltDB (keyed by subject AT-URI)
340
+
- Implement `GetLikeCount(uri string)` function
341
+
- Implement `UserHasLiked(userDID, uri string)` function
342
+
- Create/delete like via PDS client
343
+
- Frontend: Like button component with optimistic updates
344
+
345
+
**Database Schema (BoltDB):**
346
+
347
+
```
348
+
Bucket: Likes
349
+
Key: <subject-at-uri>
350
+
Value: {
351
+
"count": 42,
352
+
"recent": ["did:plc:user1", "did:plc:user2", ...] // last 10 likers
353
+
}
354
+
355
+
Bucket: UserLikes
356
+
Key: <user-did>/<subject-at-uri>
357
+
Value: <like-record-uri> // for quick "has user liked this?" checks
358
+
```
359
+
360
+
### Phase 2: Follows (3-4 weeks)
361
+
362
+
**Deliverables:**
363
+
364
+
1. ✅ Lexicon decision: Use `app.bsky.graph.follow`
365
+
2. Backend: Index follows from firehose
366
+
3. Backend: Build follower/following graph
367
+
4. Backend: Personalized feed based on follows
368
+
5. Frontend: Follow button on user profiles
369
+
6. Frontend: Followers/Following pages
370
+
7. Feature: Import follows from Bluesky (see below)
371
+
372
+
**Technical Tasks:**
373
+
374
+
- Update firehose listener to capture `app.bsky.graph.follow` records
375
+
- Add `FollowsIndex` to BoltDB
376
+
- Implement `GetFollowers(did string)` function
377
+
- Implement `GetFollowing(did string)` function
378
+
- Implement `UserFollows(followerDID, followedDID string)` function
379
+
- Create/delete follow via PDS client
380
+
- Frontend: Follow button component
381
+
- Frontend: "Following" feed filter
382
+
383
+
**Database Schema (BoltDB):**
384
+
385
+
```
386
+
Bucket: Follows
387
+
Key: follower:<did>
388
+
Value: ["did:plc:followed1", "did:plc:followed2", ...]
389
+
390
+
Bucket: Followers
391
+
Key: followed:<did>
392
+
Value: ["did:plc:follower1", "did:plc:follower2", ...]
393
+
394
+
Bucket: FollowCounts
395
+
Key: <did>
396
+
Value: {
397
+
"followers": 120,
398
+
"following": 87
399
+
}
400
+
```
401
+
402
+
### Phase 3: Comments (4-5 weeks)
403
+
404
+
**Deliverables:**
405
+
406
+
1. ✅ Lexicon: Create `social.arabica.alpha.comment`
407
+
2. Backend: Index comments from firehose
408
+
3. Backend: Comment threading logic
409
+
4. Backend: Comment counts per record
410
+
5. Frontend: Comment display UI
411
+
6. Frontend: Comment creation form
412
+
7. Frontend: Comment threading/replies
413
+
414
+
**Technical Tasks:**
415
+
416
+
- Define and publish `social.arabica.alpha.comment` lexicon
417
+
- Update firehose listener to capture comment records
418
+
- Add `CommentsIndex` to BoltDB
419
+
- Implement `GetComments(uri string, limit, offset int)` function
420
+
- Implement comment threading/tree building
421
+
- Create comment via PDS client
422
+
- Frontend: Comment list component
423
+
- Frontend: Comment form with mentions/facets support
424
+
425
+
**Database Schema (BoltDB):**
426
+
427
+
```
428
+
Bucket: Comments
429
+
Key: <subject-at-uri>/<timestamp>
430
+
Value: {
431
+
"author": "did:plc:user1",
432
+
"text": "Great brew!",
433
+
"parent": "at://...", // null for top-level
434
+
"rating": 9,
435
+
"createdAt": "2026-01-25T12:00:00Z",
436
+
"uri": "at://did:plc:user1/social.arabica.alpha.comment/abc123"
437
+
}
438
+
439
+
Bucket: CommentCounts
440
+
Key: <subject-at-uri>
441
+
Value: 15
442
+
```
443
+
444
+
### Phase 4: Social Feed Enhancements (2-3 weeks)
445
+
446
+
**Deliverables:**
447
+
448
+
1. Following-only feed view
449
+
2. Popular brews (by like count)
450
+
3. Trending equipment
451
+
4. Active users widget
452
+
5. Social notifications (basic)
453
+
454
+
---
455
+
456
+
## Technical Details
457
+
458
+
### 1. Firehose Integration
459
+
460
+
**Current:**
461
+
462
+
- Listens for `social.arabica.alpha.*` records
463
+
- Indexes brews, beans, roasters, grinders, brewers
464
+
465
+
**Enhanced:**
466
+
467
+
```go
468
+
// internal/firehose/listener.go
469
+
470
+
func (l *Listener) handleFirehoseEvent(evt *events.RepoCommit) {
471
+
for _, op := range evt.Ops {
472
+
switch op.Collection {
473
+
// Existing collections
474
+
case atproto.NSIDBrew, atproto.NSIDBean, atproto.NSIDRoaster,
475
+
atproto.NSIDGrinder, atproto.NSIDBrewer:
476
+
l.handleArabicaRecord(op)
477
+
478
+
// Social interactions
479
+
case "app.bsky.feed.like":
480
+
l.handleLike(op)
481
+
case "app.bsky.graph.follow":
482
+
l.handleFollow(op)
483
+
case atproto.NSIDComment: // social.arabica.alpha.comment
484
+
l.handleComment(op)
485
+
}
486
+
}
487
+
}
488
+
489
+
func (l *Listener) handleLike(op *events.RepoOp) error {
490
+
// Parse like record
491
+
var like atproto.Like
492
+
if err := json.Unmarshal(op.Record, &like); err != nil {
493
+
return err
494
+
}
495
+
496
+
// Filter: only index likes on Arabica content
497
+
if !strings.HasPrefix(like.Subject.URI, "at://") {
498
+
return nil
499
+
}
500
+
components := atproto.ParseATURI(like.Subject.URI)
501
+
if !strings.HasPrefix(components.Collection, "social.arabica.alpha.") {
502
+
return nil // Skip non-Arabica likes
503
+
}
504
+
505
+
// Index the like
506
+
return l.socialIndex.IndexLike(op.Author, &like)
507
+
}
508
+
```
509
+
510
+
### 2. API Endpoints
511
+
512
+
**New endpoints:**
513
+
514
+
```
515
+
POST /api/likes # Create a like
516
+
DELETE /api/likes # Unlike
517
+
GET /api/likes?uri=<record-uri> # Get like count & likers
518
+
519
+
POST /api/follows # Follow a user
520
+
DELETE /api/follows # Unfollow
521
+
GET /api/followers?did=<did> # Get followers
522
+
GET /api/following?did=<did> # Get following list
523
+
POST /api/import-follows # Import from Bluesky
524
+
525
+
POST /api/comments # Create a comment
526
+
GET /api/comments?uri=<uri> # Get comments for a record
527
+
```
528
+
529
+
**Example: Like endpoint**
530
+
531
+
```go
532
+
// internal/handlers/likes.go
533
+
534
+
type LikeRequest struct {
535
+
SubjectURI string `json:"uri"`
536
+
SubjectCID string `json:"cid"`
537
+
}
538
+
539
+
func (h *Handlers) CreateLike(w http.ResponseWriter, r *http.Request) {
540
+
store, authenticated := h.getAtprotoStore(r)
541
+
if !authenticated {
542
+
http.Error(w, "Authentication required", http.StatusUnauthorized)
543
+
return
544
+
}
545
+
546
+
var req LikeRequest
547
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
548
+
http.Error(w, "Invalid request", http.StatusBadRequest)
549
+
return
550
+
}
551
+
552
+
// Create like record in user's PDS
553
+
like := &atproto.Like{
554
+
Type: "app.bsky.feed.like",
555
+
Subject: &atproto.StrongRef{
556
+
URI: req.SubjectURI,
557
+
CID: req.SubjectCID,
558
+
},
559
+
CreatedAt: time.Now().Format(time.RFC3339),
560
+
}
561
+
562
+
uri, err := store.CreateLike(r.Context(), like)
563
+
if err != nil {
564
+
http.Error(w, "Failed to create like", http.StatusInternalServerError)
565
+
return
566
+
}
567
+
568
+
json.NewEncoder(w).Encode(map[string]string{
569
+
"uri": uri,
570
+
})
571
+
}
572
+
```
573
+
574
+
### 3. Store Interface Extensions
575
+
576
+
```go
577
+
// internal/database/store.go
578
+
579
+
type Store interface {
580
+
// Existing methods...
581
+
582
+
// Likes
583
+
CreateLike(ctx context.Context, like *atproto.Like) (string, error)
584
+
DeleteLike(ctx context.Context, likeURI string) error
585
+
GetLikeCount(ctx context.Context, subjectURI string) (int, error)
586
+
GetLikers(ctx context.Context, subjectURI string, limit int) ([]*Profile, error)
587
+
UserHasLiked(ctx context.Context, userDID, subjectURI string) (bool, error)
588
+
589
+
// Follows
590
+
CreateFollow(ctx context.Context, follow *atproto.Follow) (string, error)
591
+
DeleteFollow(ctx context.Context, followURI string) error
592
+
GetFollowers(ctx context.Context, did string, limit, offset int) ([]*Profile, error)
593
+
GetFollowing(ctx context.Context, did string, limit, offset int) ([]*Profile, error)
594
+
UserFollows(ctx context.Context, followerDID, followedDID string) (bool, error)
595
+
596
+
// Comments
597
+
CreateComment(ctx context.Context, comment *atproto.Comment) (string, error)
598
+
GetComments(ctx context.Context, subjectURI string, limit, offset int) ([]*Comment, error)
599
+
GetCommentCount(ctx context.Context, subjectURI string) (int, error)
600
+
}
601
+
```
602
+
603
+
---
604
+
605
+
## Bluesky Integration Strategies
606
+
607
+
### Import Follows from Bluesky
608
+
609
+
**User Story:**
610
+
"As a coffee enthusiast on Bluesky, I want to import my Bluesky follows into Arabica so I can follow coffee friends without re-discovering them."
611
+
612
+
**Implementation:**
613
+
614
+
1. **Fetch Bluesky Follows**
615
+
- Use `app.bsky.graph.getFollows` API
616
+
- Query user's PDS: `GET /xrpc/app.bsky.graph.getFollows?actor={userDID}`
617
+
- Paginate through results (cursor-based)
618
+
619
+
2. **Filter for Arabica Users**
620
+
- Check if followed user has Arabica records
621
+
- Query: `listRecords` for `social.arabica.alpha.brew` in their PDS
622
+
- Cache results to avoid repeated lookups
623
+
624
+
3. **Create Follow Records**
625
+
- For each Arabica user in Bluesky follows, create `app.bsky.graph.follow` in user's PDS
626
+
- Skip if already following
627
+
628
+
**API Endpoint:**
629
+
630
+
```go
631
+
POST /api/import-follows
632
+
633
+
Request:
634
+
{
635
+
"source": "bluesky",
636
+
"filter": "arabica-users-only" // or "all"
637
+
}
638
+
639
+
Response:
640
+
{
641
+
"imported": 42,
642
+
"skipped": 8,
643
+
"failed": 1,
644
+
"details": [
645
+
{"did": "did:plc:user1", "handle": "@coffee-nerd.bsky.social", "status": "imported"},
646
+
{"did": "did:plc:user2", "handle": "@bean-expert.bsky.social", "status": "already-following"}
647
+
]
648
+
}
649
+
```
650
+
651
+
**Implementation:**
652
+
653
+
```go
654
+
func (h *Handlers) ImportFollows(w http.ResponseWriter, r *http.Request) {
655
+
store, authenticated := h.getAtprotoStore(r)
656
+
if !authenticated {
657
+
http.Error(w, "Authentication required", http.StatusUnauthorized)
658
+
return
659
+
}
660
+
661
+
userDID := h.getUserDID(r)
662
+
663
+
// 1. Fetch Bluesky follows
664
+
follows, err := h.fetchBlueskyFollows(r.Context(), userDID)
665
+
if err != nil {
666
+
http.Error(w, "Failed to fetch follows", http.StatusInternalServerError)
667
+
return
668
+
}
669
+
670
+
// 2. Filter for Arabica users
671
+
arabicaUsers := []string{}
672
+
for _, follow := range follows {
673
+
hasArabicaContent, err := h.hasArabicaRecords(r.Context(), follow.DID)
674
+
if err != nil {
675
+
log.Warn().Err(err).Str("did", follow.DID).Msg("Failed to check Arabica records")
676
+
continue
677
+
}
678
+
if hasArabicaContent {
679
+
arabicaUsers = append(arabicaUsers, follow.DID)
680
+
}
681
+
}
682
+
683
+
// 3. Create follow records
684
+
imported := 0
685
+
for _, targetDID := range arabicaUsers {
686
+
// Check if already following
687
+
alreadyFollows, _ := store.UserFollows(r.Context(), userDID, targetDID)
688
+
if alreadyFollows {
689
+
continue
690
+
}
691
+
692
+
follow := &atproto.Follow{
693
+
Type: "app.bsky.graph.follow",
694
+
Subject: targetDID,
695
+
CreatedAt: time.Now().Format(time.RFC3339),
696
+
}
697
+
_, err := store.CreateFollow(r.Context(), follow)
698
+
if err == nil {
699
+
imported++
700
+
}
701
+
}
702
+
703
+
json.NewEncoder(w).Encode(map[string]interface{}{
704
+
"imported": imported,
705
+
"total_follows": len(follows),
706
+
"arabica_users": len(arabicaUsers),
707
+
})
708
+
}
709
+
710
+
func (h *Handlers) hasArabicaRecords(ctx context.Context, did string) (bool, error) {
711
+
// Use public client to check for any Arabica records
712
+
client := atproto.NewPublicClient()
713
+
records, err := client.ListRecords(ctx, did, atproto.NSIDBrew, 1)
714
+
if err != nil {
715
+
return false, err
716
+
}
717
+
return len(records) > 0, nil
718
+
}
719
+
```
720
+
721
+
**Challenges:**
722
+
723
+
- **Rate limiting**: Bluesky API has rate limits; may need to batch/queue imports
724
+
- **Stale data**: Follows may be out of sync if user unfollows on Bluesky
725
+
- **Performance**: Checking each DID for Arabica content is slow
726
+
- **Solution**: Maintain a "known Arabica users" index from firehose
727
+
728
+
**Enhancement:** Two-way sync
729
+
730
+
- Export Arabica follows → Bluesky follows (optional)
731
+
- Periodic sync job to keep in sync
732
+
733
+
---
734
+
735
+
## Data Flow & Storage
736
+
737
+
### Like Flow
738
+
739
+
```
740
+
User clicks "Like" on a brew
741
+
↓
742
+
Frontend sends POST /api/likes
743
+
↓
744
+
Backend creates app.bsky.feed.like record in user's PDS
745
+
↓
746
+
PDS broadcasts record to Relay via firehose
747
+
↓
748
+
Arabica firehose listener receives event
749
+
↓
750
+
SocialIndex updates like count for subject URI
751
+
↓
752
+
Cache invalidated (optional)
753
+
↓
754
+
Feed refreshes with new like count
755
+
```
756
+
757
+
### Follow Flow
758
+
759
+
```
760
+
User clicks "Follow" on profile
761
+
↓
762
+
Frontend sends POST /api/follows
763
+
↓
764
+
Backend creates app.bsky.graph.follow record in user's PDS
765
+
↓
766
+
PDS broadcasts to firehose
767
+
↓
768
+
Arabica listener updates FollowsIndex
769
+
↓
770
+
User's feed now includes followed user's brews
771
+
```
772
+
773
+
### Comment Flow
774
+
775
+
```
776
+
User submits comment on brew
777
+
↓
778
+
Frontend sends POST /api/comments
779
+
↓
780
+
Backend creates social.arabica.alpha.comment in user's PDS
781
+
↓
782
+
Firehose broadcasts event
783
+
↓
784
+
Arabica listener indexes comment
785
+
↓
786
+
Comment appears on brew detail page
787
+
```
788
+
789
+
---
790
+
791
+
## UI/UX Considerations
792
+
793
+
### Like Button
794
+
795
+
**States:**
796
+
797
+
- Not liked: Gray heart outline
798
+
- Liked: Red filled heart
799
+
- Loading: Gray heart with spinner
800
+
801
+
**Display:**
802
+
803
+
- Show like count next to heart
804
+
- On hover: Show "X people liked this"
805
+
- Click: Optimistic update (instant UI change, API call in background)
806
+
807
+
**Location:**
808
+
809
+
- Brew cards in feed
810
+
- Brew detail page
811
+
- Bean/Roaster/Grinder/Brewer detail pages
812
+
813
+
### Follow Button
814
+
815
+
**States:**
816
+
817
+
- Not following: "Follow" button (blue)
818
+
- Following: "Following" button (gray, checkmark)
819
+
- Hover over "Following": "Unfollow" (red)
820
+
821
+
**Location:**
822
+
823
+
- User profile header
824
+
- Brew author byline (small follow button)
825
+
- Followers/Following lists
826
+
827
+
### Comments Section
828
+
829
+
**Layout:**
830
+
831
+
- Threaded comments (indented replies)
832
+
- Show comment count
833
+
- "Load more" pagination (20 per page)
834
+
- Sort by: Newest, Oldest, Most Liked
835
+
836
+
**Comment Form:**
837
+
838
+
- Textarea with mention support (@username autocomplete)
839
+
- Optional rating (1-10 stars)
840
+
- Cancel/Submit buttons
841
+
- Character count (500 max)
842
+
843
+
---
844
+
845
+
## Migration & Rollout
846
+
847
+
### Step 1: Backend Deployment (Week 1)
848
+
849
+
1. Deploy firehose listener with like/follow indexing
850
+
2. Backfill existing likes/follows from firehose history
851
+
3. Test API endpoints in staging
852
+
4. Monitor BoltDB storage growth
853
+
854
+
### Step 2: Frontend Soft Launch (Week 2)
855
+
856
+
1. Deploy like button (feature flag: enabled for beta users)
857
+
2. Collect feedback
858
+
3. Fix bugs
859
+
860
+
### Step 3: Public Launch (Week 3)
861
+
862
+
1. Enable likes for all users
863
+
2. Announce on Bluesky: "You can now like brews on Arabica!"
864
+
3. Monitor server load
865
+
866
+
### Step 4: Follow Feature (Week 4-5)
867
+
868
+
1. Deploy follow indexing
869
+
2. Add follow button to profiles
870
+
3. Add "Following" feed filter
871
+
4. Launch import-from-Bluesky tool
872
+
873
+
### Step 5: Comments (Week 6-8)
874
+
875
+
1. Define and publish comment lexicon
876
+
2. Deploy comment indexing
877
+
3. Add comment UI
878
+
4. Test threading
879
+
880
+
---
881
+
882
+
## Future Enhancements
883
+
884
+
### Phase 5+: Advanced Social Features
885
+
886
+
1. **Notifications**
887
+
- "X liked your brew"
888
+
- "Y commented on your brew"
889
+
- WebSocket-based real-time updates
890
+
891
+
2. **Social Discovery**
892
+
- "Trending brews this week"
893
+
- "Popular roasters"
894
+
- "Top coffee influencers"
895
+
896
+
3. **Activity Feed**
897
+
- "Your friend Alice brewed a new espresso"
898
+
- "Bob rated a bean you liked"
899
+
900
+
4. **Lists & Collections**
901
+
- "My favorite light roasts" (curated bean list)
902
+
- "Seattle coffee shops" (location-based)
903
+
904
+
5. **Collaborative Brewing**
905
+
- Share brew recipes
906
+
- Clone someone's brew with credit
907
+
- Brew challenges ("30 days of pour-over")
908
+
909
+
6. **Cross-App Features**
910
+
- Share brew to Bluesky as a post (with photo)
911
+
- Embed brew cards in Bluesky posts
912
+
- "Post to Bluesky" button on brew creation
913
+
914
+
---
915
+
916
+
## Appendix A: Lexicon Files
917
+
918
+
### Proposed: `social.arabica.alpha.like.json` (NOT RECOMMENDED)
919
+
920
+
If we decide NOT to use `app.bsky.feed.like`, here's our custom lexicon:
921
+
922
+
```json
923
+
{
924
+
"lexicon": 1,
925
+
"id": "social.arabica.alpha.like",
926
+
"defs": {
927
+
"main": {
928
+
"type": "record",
929
+
"key": "tid",
930
+
"description": "A like on a brew, bean, roaster, grinder, or brewer",
931
+
"record": {
932
+
"type": "object",
933
+
"required": ["subject", "createdAt"],
934
+
"properties": {
935
+
"subject": {
936
+
"type": "ref",
937
+
"ref": "com.atproto.repo.strongRef",
938
+
"description": "The record being liked"
939
+
},
940
+
"createdAt": {
941
+
"type": "string",
942
+
"format": "datetime"
943
+
}
944
+
}
945
+
}
946
+
}
947
+
}
948
+
}
949
+
```
950
+
951
+
### Proposed: `social.arabica.alpha.follow.json` (NOT RECOMMENDED)
952
+
953
+
```json
954
+
{
955
+
"lexicon": 1,
956
+
"id": "social.arabica.alpha.follow",
957
+
"defs": {
958
+
"main": {
959
+
"type": "record",
960
+
"key": "tid",
961
+
"description": "Following another coffee enthusiast",
962
+
"record": {
963
+
"type": "object",
964
+
"required": ["subject", "createdAt"],
965
+
"properties": {
966
+
"subject": {
967
+
"type": "string",
968
+
"format": "did",
969
+
"description": "DID of the user being followed"
970
+
},
971
+
"createdAt": {
972
+
"type": "string",
973
+
"format": "datetime"
974
+
}
975
+
}
976
+
}
977
+
}
978
+
}
979
+
}
980
+
```
981
+
982
+
---
983
+
984
+
## Appendix B: Estimated Effort
985
+
986
+
| Phase | Feature | Backend | Frontend | Testing | Total |
987
+
| --------- | ----------------- | ------- | -------- | ------- | ------------------------ |
988
+
| 1 | Likes | 5 days | 3 days | 2 days | **10 days** |
989
+
| 2 | Follows | 7 days | 5 days | 3 days | **15 days** |
990
+
| 3 | Comments | 8 days | 6 days | 4 days | **18 days** |
991
+
| 4 | Feed Enhancements | 4 days | 4 days | 2 days | **10 days** |
992
+
| **Total** | | | | | **53 days (10.6 weeks)** |
993
+
994
+
---
995
+
996
+
## Appendix C: Open Questions
997
+
998
+
1. **Moderation**: How do we handle spam comments or abusive likes?
999
+
- Use AT Protocol's label system?
1000
+
- Admin moderation tools?
1001
+
1002
+
2. **Privacy**: Should follows be private?
1003
+
- Current plan: Public (like Bluesky)
1004
+
- Could add private follows later
1005
+
1006
+
3. **Notifications**: What delivery mechanism?
1007
+
- WebSocket for real-time?
1008
+
- Polling API?
1009
+
- Email digests?
1010
+
1011
+
4. **Analytics**: Track engagement metrics?
1012
+
- Like/comment rates
1013
+
- User retention
1014
+
- Popular content
1015
+
1016
+
5. **Mobile**: When to build native apps?
1017
+
- After web is stable
1018
+
- Consider PWA first
1019
+
1020
+
---
1021
+
1022
+
## Conclusion
1023
+
1024
+
This plan provides a **phased, pragmatic approach** to adding social features to Arabica. By **reusing Bluesky's `like` and `follow` lexicons**, we gain:
1025
+
1026
+
- ✅ Cross-app discoverability
1027
+
- ✅ Simpler implementation
1028
+
- ✅ Follow import from Bluesky
1029
+
- ✅ Future-proof social graph
1030
+
1031
+
While **custom comments** allow:
1032
+
1033
+
- ✅ Coffee-specific features (ratings, tasting notes)
1034
+
- ✅ Cleaner separation from Bluesky threads
1035
+
- ✅ Control over threading UX
1036
+
1037
+
**Next Steps:**
1038
+
1039
+
1. Review and approve this plan
1040
+
2. Begin Phase 1 (Likes) implementation
1041
+
3. Iterate based on user feedback
1042
+
4. Expand to follows and comments
1043
+
1044
+
**Timeline:** ~11 weeks for all phases (with 1 developer)
1045
+
1046
+
---
1047
+
1048
+
**Document Status:** Draft for Review
1049
+
**Last Updated:** January 25, 2026
1050
+
**Author:** AI Assistant (with human review pending)
+157
-18
frontend/src/components/FeedCard.svelte
+157
-18
frontend/src/components/FeedCard.svelte
···
13
function hasValue(val) {
14
return val !== null && val !== undefined && val !== "";
15
}
16
</script>
17
18
<div
···
138
</div>
139
{/if}
140
141
<!-- Notes -->
142
{#if item.Brew.tasting_notes}
143
<div
144
-
class="mt-2 text-sm text-brown-800 italic border-l-2 border-brown-300 pl-3"
145
>
146
"{item.Brew.tasting_notes}"
147
</div>
148
{/if}
149
</div>
150
{:else if item.RecordType === "bean" && item.Bean}
151
<div
152
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
153
>
154
-
<div class="font-semibold text-brown-900">
155
-
{item.Bean.name || item.Bean.origin}
156
</div>
157
-
{#if item.Bean.origin}<div class="text-sm text-brown-700">
158
-
📍 {item.Bean.origin}
159
-
</div>{/if}
160
</div>
161
{:else if item.RecordType === "roaster" && item.Roaster}
162
<div
163
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
164
>
165
-
<div class="font-semibold text-brown-900">🏭 {item.Roaster.name}</div>
166
-
{#if item.Roaster.location}<div class="text-sm text-brown-700">
167
-
📍 {item.Roaster.location}
168
-
</div>{/if}
169
</div>
170
{:else if item.RecordType === "grinder" && item.Grinder}
171
<div
172
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
173
>
174
-
<div class="font-semibold text-brown-900">⚙️ {item.Grinder.name}</div>
175
-
{#if item.Grinder.grinder_type}<div class="text-sm text-brown-700">
176
-
{item.Grinder.grinder_type}
177
-
</div>{/if}
178
</div>
179
{:else if item.RecordType === "brewer" && item.Brewer}
180
<div
181
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
182
>
183
-
<div class="font-semibold text-brown-900">☕ {item.Brewer.name}</div>
184
-
{#if item.Brewer.brewer_type}<div class="text-sm text-brown-700">
185
-
{item.Brewer.brewer_type}
186
-
</div>{/if}
187
</div>
188
{/if}
189
</div>
···
13
function hasValue(val) {
14
return val !== null && val !== undefined && val !== "";
15
}
16
+
17
+
function formatTemperature(temp) {
18
+
if (!hasValue(temp)) return null;
19
+
const unit = temp <= 100 ? "C" : "F";
20
+
return `${temp}°${unit}`;
21
+
}
22
+
23
+
function formatTime(seconds) {
24
+
if (!hasValue(seconds)) return null;
25
+
const mins = Math.floor(seconds / 60);
26
+
const secs = seconds % 60;
27
+
if (mins > 0) {
28
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
29
+
}
30
+
return `${seconds}s`;
31
+
}
32
+
33
+
function safeWebsiteURL(url) {
34
+
if (!url) return null;
35
+
if (url.startsWith("https://") || url.startsWith("http://")) {
36
+
return url;
37
+
}
38
+
return null;
39
+
}
40
</script>
41
42
<div
···
162
</div>
163
{/if}
164
165
+
<!-- Brew parameters in compact grid -->
166
+
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-brown-700">
167
+
{#if item.Brew.grinder_obj}
168
+
<div>
169
+
<span class="text-brown-600">Grinder:</span>
170
+
{item.Brew.grinder_obj.name}{#if item.Brew.grind_size}
171
+
({item.Brew.grind_size}){/if}
172
+
</div>
173
+
{:else if item.Brew.grind_size}
174
+
<div>
175
+
<span class="text-brown-600">Grind:</span>
176
+
{item.Brew.grind_size}
177
+
</div>
178
+
{/if}
179
+
{#if item.Brew.pours && item.Brew.pours.length > 0}
180
+
<div class="col-span-2">
181
+
<span class="text-brown-600">Pours:</span>
182
+
{#each item.Brew.pours as pour}
183
+
<div class="pl-2 text-brown-600">
184
+
• {pour.water_amount}g @ {formatTime(pour.time_seconds)}
185
+
</div>
186
+
{/each}
187
+
</div>
188
+
{:else if hasValue(item.Brew.water_amount)}
189
+
<div>
190
+
<span class="text-brown-600">Water:</span>
191
+
{item.Brew.water_amount}g
192
+
</div>
193
+
{/if}
194
+
{#if formatTemperature(item.Brew.temperature)}
195
+
<div>
196
+
<span class="text-brown-600">Temp:</span>
197
+
{formatTemperature(item.Brew.temperature)}
198
+
</div>
199
+
{/if}
200
+
{#if hasValue(item.Brew.time_seconds)}
201
+
<div>
202
+
<span class="text-brown-600">Time:</span>
203
+
{formatTime(item.Brew.time_seconds)}
204
+
</div>
205
+
{/if}
206
+
</div>
207
+
208
<!-- Notes -->
209
{#if item.Brew.tasting_notes}
210
<div
211
+
class="mt-3 text-sm text-brown-800 italic border-t border-brown-200 pt-2"
212
>
213
"{item.Brew.tasting_notes}"
214
</div>
215
{/if}
216
+
217
+
<!-- View button -->
218
+
<div class="mt-3 border-t border-brown-200 pt-3">
219
+
<a
220
+
href="/brews/{item.Author.did}/{item.Brew.rkey}"
221
+
on:click|preventDefault={() =>
222
+
navigate(`/brews/${item.Author.did}/${item.Brew.rkey}`)}
223
+
class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"
224
+
>
225
+
View full details →
226
+
</a>
227
+
</div>
228
</div>
229
{:else if item.RecordType === "bean" && item.Bean}
230
<div
231
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
232
>
233
+
<div class="text-base mb-2">
234
+
<span class="font-bold text-brown-900">
235
+
{item.Bean.name || item.Bean.origin}
236
+
</span>
237
+
{#if item.Bean.roaster?.name}
238
+
<span class="text-brown-700"> from {item.Bean.roaster.name}</span>
239
+
{/if}
240
+
</div>
241
+
<div class="text-sm text-brown-700 space-y-1">
242
+
{#if item.Bean.origin}
243
+
<div><span class="text-brown-600">Origin:</span> {item.Bean.origin}</div>
244
+
{/if}
245
+
{#if item.Bean.roast_level}
246
+
<div>
247
+
<span class="text-brown-600">Roast:</span>
248
+
{item.Bean.roast_level}
249
+
</div>
250
+
{/if}
251
+
{#if item.Bean.process}
252
+
<div><span class="text-brown-600">Process:</span> {item.Bean.process}</div>
253
+
{/if}
254
+
{#if item.Bean.description}
255
+
<div class="mt-2 text-brown-800 italic">
256
+
"{item.Bean.description}"
257
+
</div>
258
+
{/if}
259
</div>
260
</div>
261
{:else if item.RecordType === "roaster" && item.Roaster}
262
<div
263
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
264
>
265
+
<div class="text-base mb-2">
266
+
<span class="font-bold text-brown-900">{item.Roaster.name}</span>
267
+
</div>
268
+
<div class="text-sm text-brown-700 space-y-1">
269
+
{#if item.Roaster.location}
270
+
<div>
271
+
<span class="text-brown-600">Location:</span>
272
+
{item.Roaster.location}
273
+
</div>
274
+
{/if}
275
+
{#if safeWebsiteURL(item.Roaster.website)}
276
+
<div>
277
+
<span class="text-brown-600">Website:</span>
278
+
<a
279
+
href={safeWebsiteURL(item.Roaster.website)}
280
+
target="_blank"
281
+
rel="noopener noreferrer"
282
+
class="text-brown-800 hover:underline"
283
+
>{safeWebsiteURL(item.Roaster.website)}</a
284
+
>
285
+
</div>
286
+
{/if}
287
+
</div>
288
</div>
289
{:else if item.RecordType === "grinder" && item.Grinder}
290
<div
291
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
292
>
293
+
<div class="text-base mb-2">
294
+
<span class="font-bold text-brown-900">{item.Grinder.name}</span>
295
+
</div>
296
+
<div class="text-sm text-brown-700 space-y-1">
297
+
{#if item.Grinder.grinder_type}
298
+
<div>
299
+
<span class="text-brown-600">Type:</span>
300
+
{item.Grinder.grinder_type}
301
+
</div>
302
+
{/if}
303
+
{#if item.Grinder.burr_type}
304
+
<div>
305
+
<span class="text-brown-600">Burr:</span>
306
+
{item.Grinder.burr_type}
307
+
</div>
308
+
{/if}
309
+
{#if item.Grinder.notes}
310
+
<div class="mt-2 text-brown-800 italic">"{item.Grinder.notes}"</div>
311
+
{/if}
312
+
</div>
313
</div>
314
{:else if item.RecordType === "brewer" && item.Brewer}
315
<div
316
class="bg-white/60 backdrop-blur rounded-lg p-3 border border-brown-200"
317
>
318
+
<div class="text-base mb-2">
319
+
<span class="font-bold text-brown-900">{item.Brewer.name}</span>
320
+
</div>
321
+
{#if item.Brewer.description}
322
+
<div class="text-sm text-brown-800 italic">
323
+
"{item.Brewer.description}"
324
+
</div>
325
+
{/if}
326
</div>
327
{/if}
328
</div>
+2
-2
frontend/src/routes/About.svelte
+2
-2
frontend/src/routes/About.svelte
···
175
<p class="text-brown-800 leading-relaxed">
176
Arabica is open source software. You can view the code, contribute, or
177
even run your own instance. Visit our <a
178
-
href="https://github.com/ptdewey/arabica"
179
class="text-brown-700 hover:underline font-medium"
180
target="_blank"
181
-
rel="noopener noreferrer">GitHub repository</a
182
> to learn more.
183
</p>
184
</section>
···
175
<p class="text-brown-800 leading-relaxed">
176
Arabica is open source software. You can view the code, contribute, or
177
even run your own instance. Visit our <a
178
+
href="https://tangled/arabica.social/arabica"
179
class="text-brown-700 hover:underline font-medium"
180
target="_blank"
181
+
rel="noopener noreferrer">Tangled repository</a
182
> to learn more.
183
</p>
184
</section>
+41
-37
frontend/src/routes/BrewForm.svelte
+41
-37
frontend/src/routes/BrewForm.svelte
···
243
</div>
244
{/if}
245
246
-
<form on:submit|preventDefault={handleSubmit} class="space-y-4 md:space-y-6">
247
<!-- Bean Selection -->
248
<div>
249
<label
···
375
<label
376
for="water-amount"
377
class="block text-sm font-medium text-brown-900 mb-2"
378
-
>Water Amount (ml)</label
379
>
380
<input
381
id="water-amount"
···
387
/>
388
</div>
389
390
-
<!-- Water Temperature -->
391
-
<div>
392
-
<label
393
-
for="water-temp"
394
-
class="block text-sm font-medium text-brown-900 mb-2"
395
-
>Water Temperature (°C)</label
396
-
>
397
-
<input
398
-
id="water-temp"
399
-
type="number"
400
-
bind:value={form.water_temp}
401
-
step="0.1"
402
-
placeholder="e.g. 93"
403
-
class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white"
404
-
/>
405
-
</div>
406
-
407
-
<!-- Brew Time -->
408
-
<div>
409
-
<label
410
-
for="brew-time"
411
-
class="block text-sm font-medium text-brown-900 mb-2"
412
-
>Total Brew Time (seconds)</label
413
-
>
414
-
<input
415
-
id="brew-time"
416
-
type="number"
417
-
bind:value={form.brew_time}
418
-
step="1"
419
-
placeholder="e.g. 210"
420
-
class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white"
421
-
/>
422
-
</div>
423
-
424
<!-- Pours -->
425
<div>
426
<div class="flex items-center justify-between mb-2">
···
442
<div
443
class="flex gap-2 items-center bg-brown-50 p-2 md:p-3 rounded-lg border border-brown-200"
444
>
445
-
<span class="text-xs md:text-sm font-medium text-brown-700 min-w-[50px] md:min-w-[60px]"
446
>Pour {i + 1}:</span
447
>
448
<input
···
470
{/if}
471
</div>
472
473
<!-- Rating -->
474
<div>
475
<label
···
243
</div>
244
{/if}
245
246
+
<form
247
+
on:submit|preventDefault={handleSubmit}
248
+
class="space-y-4 md:space-y-6"
249
+
>
250
<!-- Bean Selection -->
251
<div>
252
<label
···
378
<label
379
for="water-amount"
380
class="block text-sm font-medium text-brown-900 mb-2"
381
+
>Water Amount (optional)</label
382
>
383
<input
384
id="water-amount"
···
390
/>
391
</div>
392
393
<!-- Pours -->
394
<div>
395
<div class="flex items-center justify-between mb-2">
···
411
<div
412
class="flex gap-2 items-center bg-brown-50 p-2 md:p-3 rounded-lg border border-brown-200"
413
>
414
+
<span
415
+
class="text-xs md:text-sm font-medium text-brown-700 min-w-[50px] md:min-w-[60px]"
416
>Pour {i + 1}:</span
417
>
418
<input
···
440
{/if}
441
</div>
442
443
+
<!-- Water Temperature -->
444
+
<div>
445
+
<label
446
+
for="water-temp"
447
+
class="block text-sm font-medium text-brown-900 mb-2"
448
+
>Water Temperature (°C)</label
449
+
>
450
+
<input
451
+
id="water-temp"
452
+
type="number"
453
+
bind:value={form.water_temp}
454
+
step="0.1"
455
+
placeholder="e.g. 93"
456
+
class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white"
457
+
/>
458
+
</div>
459
+
460
+
<!-- Brew Time -->
461
+
<div>
462
+
<label
463
+
for="brew-time"
464
+
class="block text-sm font-medium text-brown-900 mb-2"
465
+
>Total Brew Time (seconds)</label
466
+
>
467
+
<input
468
+
id="brew-time"
469
+
type="number"
470
+
bind:value={form.brew_time}
471
+
step="1"
472
+
placeholder="e.g. 210"
473
+
class="w-full rounded-lg border-2 border-brown-300 shadow-sm focus:border-brown-600 focus:ring-brown-600 text-base py-2 md:py-3 px-3 md:px-4 bg-white"
474
+
/>
475
+
</div>
476
+
477
<!-- Rating -->
478
<div>
479
<label
+21
-5
frontend/src/routes/Brews.svelte
+21
-5
frontend/src/routes/Brews.svelte
···
132
<h3 class="text-xl font-bold text-brown-900">
133
{brew.bean.name || brew.bean.origin || "Unknown Bean"}
134
</h3>
135
-
{#if brew.bean.Roaster?.Name}
136
-
<p class="text-sm text-brown-700 mb-2">
137
-
🏭 {brew.bean.roaster.name}
138
-
</p>
139
-
{/if}
140
{:else}
141
<h3 class="text-xl font-bold text-brown-900">
142
Unknown Bean
···
163
{:else if brew.method}
164
<span>☕ {brew.method}</span>
165
{/if}
166
{#if hasValue(brew.temperature)}
167
<span>🌡️ {formatTemperature(brew.temperature)}</span>
168
{/if}
···
132
<h3 class="text-xl font-bold text-brown-900">
133
{brew.bean.name || brew.bean.origin || "Unknown Bean"}
134
</h3>
135
+
<div class="text-sm text-brown-700 mb-2 space-y-0.5">
136
+
{#if brew.bean.roaster}
137
+
<p>🏭 {brew.bean.roaster.name}</p>
138
+
{/if}
139
+
{#if brew.bean.roast_level || brew.bean.origin || brew.bean.process}
140
+
<p class="flex flex-wrap gap-x-3">
141
+
{#if brew.bean.roast_level}
142
+
<span>🔥 {brew.bean.roast_level}</span>
143
+
{/if}
144
+
{#if brew.bean.origin}
145
+
<span>🌍 {brew.bean.origin}</span>
146
+
{/if}
147
+
{#if brew.bean.process}
148
+
<span>⚗️ {brew.bean.process}</span>
149
+
{/if}
150
+
</p>
151
+
{/if}
152
+
</div>
153
{:else}
154
<h3 class="text-xl font-bold text-brown-900">
155
Unknown Bean
···
176
{:else if brew.method}
177
<span>☕ {brew.method}</span>
178
{/if}
179
+
{#if brew.grinder_obj}
180
+
<span>⚙️ {brew.grinder_obj.name}</span>
181
+
{/if}
182
{#if hasValue(brew.temperature)}
183
<span>🌡️ {formatTemperature(brew.temperature)}</span>
184
{/if}
+3
justfile
+3
justfile
+2
-2
static/app/index.html
+2
-2
static/app/index.html
···
22
23
<!-- Web Manifest for PWA -->
24
<link rel="manifest" href="/static/manifest.json" />
25
-
<script type="module" crossorigin src="/static/app/assets/index-CmBOU5Wv.js"></script>
26
-
<link rel="stylesheet" crossorigin href="/static/app/assets/index-Cd6pn8d5.css">
27
</head>
28
<body class="bg-brown-50 text-brown-900 min-h-screen">
29
<div id="app"></div>
···
22
23
<!-- Web Manifest for PWA -->
24
<link rel="manifest" href="/static/manifest.json" />
25
+
<script type="module" crossorigin src="/static/app/assets/index-qNP6okvG.js"></script>
26
+
<link rel="stylesheet" crossorigin href="/static/app/assets/index-Dnf8PWVW.css">
27
</head>
28
<body class="bg-brown-50 text-brown-900 min-h-screen">
29
<div id="app"></div>
History
1 round
0 comments
pdewey.com
submitted
#0
1 commit
expand
collapse
feat: improved styling of brews list page
expand 0 comments
closed without merging