···11+# Arabica Feature Ideas
22+33+## 1. Recipes
44+55+A **recipe** is a reusable, shareable brew procedure — distinct from a brew log, which records a
66+specific cup. Recipes are the social object people want to discover and re-use.
77+88+### New Lexicon: `social.arabica.alpha.recipe`
99+1010+| Field | Type | Description |
1111+| -------------- | ----------------- | ------------------------------------------------ |
1212+| `title` | string (required) | "My Hario Switch Recipe for Light Roasts" |
1313+| `description` | string | Longer notes, tips, rationale |
1414+| `method` | string | V60, Aeropress, etc. |
1515+| `temperature` | int (tenths °C) | Same encoding as brew |
1616+| `coffeeAmount` | int (grams) | Dose |
1717+| `waterAmount` | int (grams) | Total water |
1818+| `grindSize` | string | "medium-fine" |
1919+| `timeSeconds` | int | Total brew time |
2020+| `pours` | array | `[{waterAmount, timeSeconds}]` — pour schedule |
2121+| `tags` | []string | Optional: ["fruity", "light roast", "comp-prep"] |
2222+| `beanNotes` | string | Optional: "works well with washed Ethiopians" |
2323+| `createdAt` | datetime | |
2424+2525+### Interaction Flows
2626+2727+- **Likes / Comments** — already works; just point the existing `like` and `comment` records at the
2828+ recipe AT-URI.
2929+- **"Try This"** — button on a recipe opens the brew form pre-populated with the recipe's
3030+ parameters. The resulting brew record optionally stores `basedOn: {uri, cid}` referencing the
3131+ recipe (strong ref).
3232+- **"Save as Recipe"** — button on a completed brew detail page creates a recipe record from that
3333+ brew's parameters (minus the specific bean).
3434+- **"X people tried this"** — count brews across the index whose `basedOn` field references the
3535+ recipe's AT-URI.
3636+3737+### Social Flywheel
3838+3939+Recipe → community tries it → brews reference it → author sees who tried it → ratings provide
4040+feedback → better recipes emerge.
4141+4242+### Feed Integration
4343+4444+Recipes should appear in the community feed as a new record type. The feed already supports
4545+filtering by type, so recipes slot in naturally.
4646+4747+---
4848+4949+## 2. Roaster Analytics
5050+5151+A **public analytics page** for each roaster, aggregated from community brews via the AT Protocol
5252+ref chain: `brew.beanRef → bean.roasterRef → roaster`.
5353+5454+No schema changes required — the firehose index already stores all records and their refs. SQLite's
5555+`json_extract()` lets us follow the ref chain in a single JOIN query.
5656+5757+### URL
5858+5959+`/roasters/{did}/{rkey}` → constructs `at://{did}/social.arabica.alpha.roaster/{rkey}`
6060+6161+### Analytics Data
6262+6363+| Metric | Query approach |
6464+| ------------------- | ------------------------------------------------------- |
6565+| Total brews | COUNT brews whose bean references this roaster |
6666+| Total beans indexed | COUNT distinct beans referencing this roaster |
6767+| Active brewers | COUNT DISTINCT brew.did |
6868+| Avg rating | AVG(json_extract(brew.record, '$.rating')) |
6969+| Median rating | Fetch all ratings, sort in Go, pick middle |
7070+| Top beans | GROUP BY bean, AVG rating DESC |
7171+| Brew method mix | GROUP BY json_extract(brew.record, '$.method'), COUNT |
7272+| Rating by month | GROUP BY strftime('%Y-%m', created_at), AVG rating |
7373+7474+### Core SQL Pattern
7575+7676+```sql
7777+SELECT
7878+ bean.uri,
7979+ json_extract(bean.record, '$.name') AS bean_name,
8080+ COUNT(brew.uri) AS brew_count,
8181+ AVG(json_extract(brew.record, '$.rating')) AS avg_rating,
8282+ COUNT(DISTINCT brew.did) AS brewer_count
8383+FROM records brew
8484+JOIN records bean ON bean.uri = json_extract(brew.record, '$.beanRef')
8585+WHERE brew.collection = 'social.arabica.alpha.brew'
8686+ AND bean.collection = 'social.arabica.alpha.bean'
8787+ AND json_extract(bean.record, '$.roasterRef') = ? -- roaster AT-URI
8888+GROUP BY bean.uri
8989+ORDER BY avg_rating DESC
9090+```
9191+9292+### Page Sections
9393+9494+1. **Header** — Roaster name, location, website, total brews / beans / brewers
9595+2. **Rating summary** — Avg ★, median ★, total rated brews
9696+3. **Top beans** — Table: bean name, brew count, avg rating
9797+4. **Brew method breakdown** — Bar/list showing V60, Aeropress, etc.
9898+5. **Rating trend** — Month-by-month avg rating (simple list or sparkline)
9999+100100+### Linking
101101+102102+Anywhere a roaster name appears in the feed or on a brew/bean detail page, link to
103103+`/roasters/{did}/{rkey}`.
104104+105105+### Public Access
106106+107107+The analytics page is fully public (no auth required) since all data is already public in the
108108+firehose index.
109109+110110+---
111111+112112+## 3. Personal Analytics Dashboard
113113+114114+A private `/me/stats` page showing the authenticated user's own brewing trends, computed from
115115+their PDS records (no cross-user aggregation needed).
116116+117117+### Metrics
118118+119119+| Metric | Source |
120120+| -------------------- | ----------------------------------------- |
121121+| Brews per week/month | Count brews by created_at bucket |
122122+| Avg rating over time | Avg rating by month |
123123+| Favourite bean | Most brewed bean (+ highest avg rating) |
124124+| Favourite method | Most used brew method |
125125+| Equipment usage | Most used grinder / brewer |
126126+| Taste evolution | Rating trend over time |
127127+| Bags opened/closed | Count beans by `closed` flag |
128128+129129+### Implementation Notes
130130+131131+- Query user's own PDS via `store.ListBrews()` — no firehose needed.
132132+- Aggregate in Go (small data set per user, no need for SQL aggregation).
133133+- Cache in `SessionCache` to avoid repeated PDS fetches.
134134+- No new lexicons required.
135135+136136+---
137137+138138+## Priority
139139+140140+1. **Roaster analytics** — immediate value, no schema changes, pure SQL over existing indexed refs
141141+2. **Recipes** — high social value, new lexicon + feed integration required
142142+3. **Personal stats** — lower complexity, pure client-side aggregation, quality-of-life feature
+393
docs/quickslice-implementation-plan.md
···11+# Quickslice Integration Plan
22+33+Quickslice is a self-hostable AppView framework for AT Protocol. It connects to Jetstream,
44+indexes records matching your lexicons into SQLite/Postgres, and exposes a GraphQL API with
55+built-in joins across record types.
66+77+**Goal:** Reduce PDS fetches for reads by querying a local quickslice instance, keeping the
88+existing PDS path as a fallback.
99+1010+---
1111+1212+## Value Proposition
1313+1414+### The Problem With PDS-First Architecture
1515+1616+Arabica's current architecture makes reads directly against users' Personal Data Servers.
1717+This is correct for writes (AT Protocol mandates it), but for reads it has significant costs:
1818+1919+**Sequential, unbatched fetches.** When you view a brew, the app makes individual `getRecord`
2020+calls — one for the brew, then one for the bean, then one for the roaster nested inside that
2121+bean, then one for the grinder, then one for the brewer. These are sequential because each
2222+reference is resolved after the previous record arrives. That's 4-5 round trips per brew
2323+view, each one a network call to an external PDS.
2424+2525+**N+1 on the community feed.** The feed currently polls registered users' PDS instances
2626+for their brews. Resolving each brew's references for display (bean name, roaster, grinder)
2727+requires additional PDS calls per brew card. As the user base grows, this becomes
2828+expensive fast.
2929+3030+**Cross-user reads have no cache.** The 5-minute session cache covers own-user data well.
3131+But when viewing another user's public brew, there's no caching — every page load hits
3232+their PDS for 4-6 calls.
3333+3434+**PDS polling for the feed.** The feed service periodically polls each registered user's
3535+PDS. This doesn't scale well and is the reason the backfill system exists. Jetstream
3636+already has all this data in a push model.
3737+3838+### What Quickslice Provides
3939+4040+Quickslice acts as a **local read index** — it subscribes to Jetstream and maintains a
4141+local SQLite/Postgres copy of all arabica records from across the network. Because the data
4242+is local, reads are fast and joined queries are cheap.
4343+4444+The key feature is **automatic join generation from lexicons**. Because arabica's lexicons
4545+define typed references between records (`beanRef`, `grinderRef`, `brewerRef`), quickslice
4646+generates GraphQL joins that follow those references in a single query. What currently
4747+takes 4-5 sequential PDS calls becomes one local query.
4848+4949+```
5050+Current: brew → PDS → bean → PDS → roaster → PDS → grinder → PDS → brewer → PDS
5151+ (5 sequential network calls, each blocked on the previous)
5252+5353+With QS: ──────────────────► quickslice (1 local query, all joins in one response) ◄──────
5454+```
5555+5656+### Where This Matters Most
5757+5858+**Public brew views (highest value).** Viewing another user's brew currently requires 4-6
5959+calls to their PDS via `public_client`. There's no caching. Quickslice replaces this with
6060+a single local query, and the result could be cached locally. This is the biggest latency
6161+improvement for the most visible user-facing operation.
6262+6363+**Community feed enrichment.** Each brew card in the feed shows bean name, roaster, and
6464+equipment. Currently this requires fetching that data per-brew. A single quickslice query
6565+can return feed brews with all references resolved, replacing N×5 PDS calls with 1 query.
6666+6767+**Reference resolution after brew creation.** When you create a brew, the app immediately
6868+resolves its references to populate the full model (for rendering the response). That's
6969+2-4 extra PDS calls. Quickslice can't help immediately after a write (Jetstream lag), but
7070+the references themselves (bean, grinder, brewer) are already indexed — so a targeted
7171+query for those specific records is instant.
7272+7373+**Own-user reads on cold cache.** The session cache (5-minute TTL) covers most own-user
7474+list operations after the first load. But on first load or after cache expiry, `ListBrews`
7575+makes 5 PDS calls. Quickslice serves this from local storage instead.
7676+7777+**Future social features.** Likes and comments will need cross-user aggregation — "how many
7878+people liked this brew?" requires querying many users' PDS instances. With quickslice, this
7979+becomes a reverse-join query (find all `social.arabica.alpha.like` records pointing at a
8080+given brew URI), served locally.
8181+8282+### What Quickslice Does Not Fix
8383+8484+- **Writes** always go to the user's PDS. OAuth, DPOP, and mutations are unaffected.
8585+- **Freshness after writes**: Jetstream has seconds of lag. A record you just created
8686+ won't be in quickslice immediately. The existing PDS path must remain for post-write reads.
8787+- **Auth-gated data**: Quickslice indexes public records only (AT Protocol records are
8888+ public by default, so this is fine for arabica).
8989+- **Scale ceiling**: Quickslice is early-stage (v0.20.x). At very high scale, you'd
9090+ eventually need a more mature indexing pipeline. But for arabica's current scale, SQLite
9191+ is more than sufficient.
9292+9393+---
9494+9595+## Current Pain Points
9696+9797+| Operation | Current PDS calls |
9898+|-----------|:-:|
9999+| `GetBrewByRKey` | 1 (brew) + 1 (bean) + 1 (roaster) + 1 (grinder) + 1 (brewer) = **up to 5** |
100100+| `ListBrews` (cold cache) | 1 (brews) + 1 (beans) + 1 (roasters) + 1 (grinders) + 1 (brewers) = **5** |
101101+| Public brew view (cross-user) | 1 (brew) + 1 (bean) + 1 (roaster) + 1 (grinder) + 1 (brewer) = **up to 5** |
102102+| Community feed brew cards | 5 calls × N brews (N+1 problem on ref resolution) |
103103+104104+With quickslice, each of the above becomes **1 GraphQL query** with forward joins.
105105+106106+---
107107+108108+## Architecture
109109+110110+```
111111+arabica server
112112+ ├─ Writes ──────────────────────────────► User's PDS (unchanged)
113113+ └─ Reads ──► quickslice GraphQL client ──► quickslice HTTP API
114114+ │ (on error/miss)
115115+ └──────────────────────► existing PDS path (fallback)
116116+117117+quickslice service
118118+ ├─ Subscribes to Jetstream (firehose)
119119+ ├─ Indexes arabica lexicon records → SQLite or Postgres
120120+ └─ Exposes GraphQL API at :8080/graphql (no auth required for reads)
121121+```
122122+123123+Quickslice is a **read-only index**. All writes (create/update/delete) continue to go to the
124124+user's PDS. Quickslice self-populates via Jetstream, so no backfill code changes are needed.
125125+126126+---
127127+128128+## Constraints and Caveats
129129+130130+- **Eventual consistency**: Jetstream has ~seconds of lag. A record just written to the PDS
131131+ may not appear in quickslice immediately. After any write operation, skip quickslice and
132132+ read directly from PDS for that request.
133133+- **Early-stage software**: Quickslice is v0.20.x, "APIs may change without notice". Wrap it
134134+ behind an interface so swapping it out is painless.
135135+- **Gleam runtime**: Quickslice is written in Gleam. Debugging upstream issues requires
136136+ learning a new language — treat it as a black box.
137137+- **Writes stay on PDS**: OAuth sessions, DPOP tokens, and all mutations are unaffected.
138138+139139+---
140140+141141+## Phase 1: Infrastructure
142142+143143+**Goal:** Get quickslice running and indexing arabica records.
144144+145145+### 1a. Add quickslice to docker-compose
146146+147147+Add a `quickslice` service to the project's docker-compose (or nix deployment config):
148148+149149+```yaml
150150+quickslice:
151151+ image: ghcr.io/slices-network/quickslice:latest
152152+ environment:
153153+ DATABASE_URL: "" # empty = use SQLite
154154+ LEXICON_DIR: /lexicons
155155+ JETSTREAM_URL: wss://jetstream2.us-east.bsky.network/subscribe
156156+ volumes:
157157+ - ./lexicons:/lexicons:ro
158158+ - quickslice-data:/data
159159+ ports:
160160+ - "8080:8080"
161161+```
162162+163163+Point `LEXICON_DIR` at the repo's existing `lexicons/` directory. Quickslice reads lexicon
164164+JSON files and auto-generates its GraphQL schema from them.
165165+166166+### 1b. Verify indexing
167167+168168+After startup, check the GraphQL playground at `http://localhost:8080` and run a sample
169169+query for `social.arabica.alpha.brew` records. Confirm that records from the firehose appear
170170+with the expected fields.
171171+172172+### 1c. Add environment variable to arabica
173173+174174+```
175175+QUICKSLICE_URL=http://localhost:8080/graphql
176176+```
177177+178178+When unset or empty, quickslice integration is disabled and arabica falls back to PDS-only
179179+mode. This makes it safe to deploy without quickslice running.
180180+181181+---
182182+183183+## Phase 2: Go GraphQL Client
184184+185185+**Goal:** Write a minimal quickslice client in Go. No codegen — just HTTP + JSON.
186186+187187+### File: `internal/quickslice/client.go`
188188+189189+```go
190190+type Client struct {
191191+ endpoint string
192192+ httpClient *http.Client
193193+}
194194+195195+func New(endpoint string) *Client { ... }
196196+197197+// Returns nil, ErrNotFound if the record isn't indexed yet (freshness gap after writes).
198198+func (c *Client) GetBrew(ctx context.Context, did, rkey string) (*models.Brew, error) { ... }
199199+func (c *Client) ListBrews(ctx context.Context, did string) ([]*models.Brew, error) { ... }
200200+func (c *Client) GetBeanWithRoaster(ctx context.Context, did, rkey string) (*models.Bean, error) { ... }
201201+202202+// helpers
203203+func (c *Client) query(ctx context.Context, q string, vars map[string]any, out any) error { ... }
204204+```
205205+206206+GraphQL queries use forward joins to resolve references in a single round-trip. Example for
207207+`GetBrew`:
208208+209209+```graphql
210210+query GetBrew($did: String!, $rkey: String!) {
211211+ socialArabicaAlphaBrew(where: { did: { eq: $did }, rkey: { eq: $rkey } }, first: 1) {
212212+ edges {
213213+ node {
214214+ uri cid did rkey createdAt
215215+ # forward join: beanRef → bean record
216216+ beanRefResolved {
217217+ ... on SocialArabicaAlphaBeanRecord {
218218+ uri rkey name origin process roastLevel
219219+ # nested: roasterRef → roaster record
220220+ roasterRefResolved {
221221+ ... on SocialArabicaAlphaRoasterRecord { uri rkey name location }
222222+ }
223223+ }
224224+ }
225225+ grinderRefResolved {
226226+ ... on SocialArabicaAlphaGrinderRecord { uri rkey brand model burrType }
227227+ }
228228+ brewerRefResolved {
229229+ ... on SocialArabicaAlphaBrewerRecord { uri rkey brand model type }
230230+ }
231231+ }
232232+ }
233233+ }
234234+}
235235+```
236236+237237+**Note:** The exact GraphQL field names are generated from lexicon IDs. Verify them against
238238+the quickslice playground after Phase 1 is complete.
239239+240240+---
241241+242242+## Phase 3: Wire Into AtprotoStore
243243+244244+**Goal:** Add quickslice as an optional fast path in `AtprotoStore`, falling back to existing
245245+PDS code on any error.
246246+247247+### 3a. Add field to AtprotoStore
248248+249249+```go
250250+// internal/atproto/store.go
251251+type AtprotoStore struct {
252252+ client *Client
253253+ quickslice *quickslice.Client // nil if QUICKSLICE_URL is unset
254254+ did string
255255+ sessionID string
256256+ cache *SessionCache
257257+}
258258+```
259259+260260+Wire `quickslice.Client` in during store construction (in `handlers.go` or wherever
261261+`AtprotoStore` is instantiated).
262262+263263+### 3b. Wrap Get* methods
264264+265265+Pattern for all Get* methods:
266266+267267+```go
268268+func (s *AtprotoStore) GetBrewByRKey(ctx context.Context, rkey string) (*models.Brew, error) {
269269+ if s.quickslice != nil {
270270+ brew, err := s.quickslice.GetBrew(ctx, s.did, rkey)
271271+ if err == nil {
272272+ return brew, nil
273273+ }
274274+ // log at debug level, fall through
275275+ }
276276+ // existing implementation unchanged below
277277+ ...
278278+}
279279+```
280280+281281+Do **not** wrap immediately after a write. The handler already has the created record in
282282+hand at that point — no PDS read needed.
283283+284284+### 3c. Wrap ListBrews (cold-cache path only)
285285+286286+`ListBrews` already checks `SessionCache` first. The quickslice path sits between cache miss
287287+and PDS:
288288+289289+```go
290290+func (s *AtprotoStore) ListBrews(ctx context.Context, userID string) ([]*models.Brew, error) {
291291+ if brews := s.cache.GetBrews(s.sessionID); brews != nil {
292292+ return brews, nil
293293+ }
294294+ if s.quickslice != nil {
295295+ brews, err := s.quickslice.ListBrews(ctx, s.did)
296296+ if err == nil {
297297+ s.cache.SetBrews(s.sessionID, brews)
298298+ return brews, nil
299299+ }
300300+ }
301301+ // existing PDS implementation unchanged
302302+ ...
303303+}
304304+```
305305+306306+Apply the same pattern to `ListBeans`, `ListRoasters`, `ListGrinders`, `ListBrewers`.
307307+308308+---
309309+310310+## Phase 4: Cross-User Reads (Highest Value)
311311+312312+**Goal:** Replace the `resolveBrewReferences` function in `internal/handlers/brew.go` that
313313+handles public brew view (other users' brews). This is the highest-value target because
314314+there is no session cache here — every page view currently makes 4-6 PDS calls to
315315+`public_client`.
316316+317317+### Current flow (brew.go ~line 313)
318318+319319+```go
320320+func resolveBrewReferences(ctx context.Context, client PublicClient, brew *models.Brew) error {
321321+ // Individual GetRecord calls for bean, roaster, grinder, brewer
322322+}
323323+```
324324+325325+### New flow
326326+327327+```go
328328+func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew) error {
329329+ if h.quickslice != nil {
330330+ resolved, err := h.quickslice.GetBrew(ctx, brew.AuthorDID, brew.RKey)
331331+ if err == nil {
332332+ *brew = *resolved // copy resolved refs onto brew
333333+ return nil
334334+ }
335335+ }
336336+ // existing public_client fallback
337337+}
338338+```
339339+340340+This also benefits the community feed — brew cards in the feed can be enriched with bean
341341+names and roaster info in a single batch query instead of N×5 PDS calls.
342342+343343+---
344344+345345+## Phase 5: Community Feed (Optional, Later)
346346+347347+The feed service (`internal/feed/service.go`) polls registered users' PDS for brews.
348348+Quickslice already indexes all arabica records from the firehose network-wide.
349349+350350+Long-term, the feed query could become a single GraphQL query with sorting and pagination:
351351+352352+```graphql
353353+query CommunityFeed($limit: Int!, $after: String) {
354354+ socialArabicaAlphaBrew(
355355+ first: $limit, after: $after
356356+ sortBy: [{ field: "createdAt", direction: DESC }]
357357+ ) {
358358+ edges {
359359+ node {
360360+ uri did actorHandle createdAt
361361+ beanRefResolved { ... }
362362+ }
363363+ cursor
364364+ }
365365+ pageInfo { hasNextPage endCursor }
366366+ }
367367+}
368368+```
369369+370370+This replaces the polling-based aggregation entirely. Hold off until the quickslice API
371371+stabilizes (it's still early-stage).
372372+373373+---
374374+375375+## Implementation Order
376376+377377+1. **Phase 1** — Infrastructure (quickslice running, lexicons loaded, env var wired)
378378+2. **Phase 2** — Go client with `GetBrew` and verify joins work against the live instance
379379+3. **Phase 4** — Cross-user reads (public brew view) — highest value, no auth complexity
380380+4. **Phase 3** — Own-user Get* reads with fallback
381381+5. **Phase 3c** — List* reads (lower priority — session cache already covers most of this)
382382+6. **Phase 5** — Feed service replacement (deferred until API stabilizes)
383383+384384+---
385385+386386+## Testing Strategy
387387+388388+- Unit test the quickslice client with a mock HTTP server returning known GraphQL responses
389389+- Integration test: start quickslice in docker, write a record to a test PDS, wait for
390390+ indexing, query via client
391391+- Smoke test fallback: with `QUICKSLICE_URL` unset, all existing behavior must be unchanged
392392+- After any write, assert the next read uses the PDS path (not quickslice) to avoid
393393+ stale-data bugs