···11-# Brew Form Overhaul
22-33-Reducing friction for new and returning users logging brews.
44-55-## 1. Kill the Mode Chooser
66-77-Remove the Recipe vs Freeform gate. Start directly in the form. Recipe
88-selection becomes an optional field at the top (collapsed or a small link).
99-1010-## 2. Progressive Disclosure (Collapsible Sections)
1111-1212-- Only **Coffee** section is open by default
1313-- **Brewing** and **Results** collapse into accordions with one-line summaries
1414- ("250g water, 93°C, 3:00" or "No details yet")
1515-- Bean + Rating is the minimum viable brew — make that feel intentional, not
1616- like skipping the form
1717-1818-## 3. Inline Entity Creation (Typeahead Selects)
1919-2020-**Highest-impact change.** Replace the current select + "+ New" modal pattern
2121-with a combo input that supports both searching existing entities and creating
2222-new ones inline.
2323-2424-- User types in the bean/brewer/grinder field
2525-- Matching entities appear as suggestions
2626-- If no match, offer "Create [typed name]" as an option
2727-- Selecting that creates a minimal record (just the name) on the fly
2828-- User can flesh out details (origin, roast level, etc.) later from Manage page
2929-- Eliminates the multi-step modal detour that blocks first-brew logging
3030-3131-## 4. Pre-seeded Brewer Suggestions
3232-3333-On first use (no brewers exist), show common brewers as quick-pick buttons:
3434-V60, Chemex, Aeropress, French Press, Espresso Machine, Moka Pot.
3535-3636-One tap creates the entity with name + brewer type pre-filled. No modal, no
3737-form fields.
3838-3939-## 5. Beverage Field
4040-4141-Add a `beverage` field to the brew record (separate from brew method):
4242-Black, Latte, Cappuccino, Cortado, Americano, Flat White, Iced, etc.
4343-4444-This separates "how you extracted" from "what you made with it" — handles milk
4545-drinks cleanly without polluting the brew method taxonomy.
4646-4747-## 6. Tasting Wheel (Future)
4848-4949-Structured tasting notes via scored axes (sweet, acidic, floral, body, etc.)
5050-instead of/alongside free text. Enables comparison and visualization across
5151-brews. Bigger lift — save for later.
-141
docs/ideas.md
···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-| `timeSeconds` | int | Total brew time |
1919-| `pours` | array | `[{waterAmount, timeSeconds}]` — pour schedule |
2020-| `tags` | []string | Optional: ["fruity", "light roast", "comp-prep"] |
2121-| `beanNotes` | string | Optional: "works well with washed Ethiopians" |
2222-| `createdAt` | datetime | |
2323-2424-### Interaction Flows
2525-2626-- **Likes / Comments** — already works; just point the existing `like` and `comment` records at the
2727- recipe AT-URI.
2828-- **"Try This"** — button on a recipe opens the brew form pre-populated with the recipe's
2929- parameters. The resulting brew record optionally stores `basedOn: {uri, cid}` referencing the
3030- recipe (strong ref).
3131-- **"Save as Recipe"** — button on a completed brew detail page creates a recipe record from that
3232- brew's parameters (minus the specific bean).
3333-- **"X people tried this"** — count brews across the index whose `basedOn` field references the
3434- recipe's AT-URI.
3535-3636-### Social Flywheel
3737-3838-Recipe → community tries it → brews reference it → author sees who tried it → ratings provide
3939-feedback → better recipes emerge.
4040-4141-### Feed Integration
4242-4343-Recipes should appear in the community feed as a new record type. The feed already supports
4444-filtering by type, so recipes slot in naturally.
4545-4646----
4747-4848-## 2. Roaster Analytics
4949-5050-A **public analytics page** for each roaster, aggregated from community brews via the AT Protocol
5151-ref chain: `brew.beanRef → bean.roasterRef → roaster`.
5252-5353-No schema changes required — the firehose index already stores all records and their refs. SQLite's
5454-`json_extract()` lets us follow the ref chain in a single JOIN query.
5555-5656-### URL
5757-5858-`/roasters/{did}/{rkey}` → constructs `at://{did}/social.arabica.alpha.roaster/{rkey}`
5959-6060-### Analytics Data
6161-6262-| Metric | Query approach |
6363-| ------------------- | ------------------------------------------------------- |
6464-| Total brews | COUNT brews whose bean references this roaster |
6565-| Total beans indexed | COUNT distinct beans referencing this roaster |
6666-| Active brewers | COUNT DISTINCT brew.did |
6767-| Avg rating | AVG(json_extract(brew.record, '$.rating')) |
6868-| Median rating | Fetch all ratings, sort in Go, pick middle |
6969-| Top beans | GROUP BY bean, AVG rating DESC |
7070-| Brew method mix | GROUP BY json_extract(brew.record, '$.method'), COUNT |
7171-| Rating by month | GROUP BY strftime('%Y-%m', created_at), AVG rating |
7272-7373-### Core SQL Pattern
7474-7575-```sql
7676-SELECT
7777- bean.uri,
7878- json_extract(bean.record, '$.name') AS bean_name,
7979- COUNT(brew.uri) AS brew_count,
8080- AVG(json_extract(brew.record, '$.rating')) AS avg_rating,
8181- COUNT(DISTINCT brew.did) AS brewer_count
8282-FROM records brew
8383-JOIN records bean ON bean.uri = json_extract(brew.record, '$.beanRef')
8484-WHERE brew.collection = 'social.arabica.alpha.brew'
8585- AND bean.collection = 'social.arabica.alpha.bean'
8686- AND json_extract(bean.record, '$.roasterRef') = ? -- roaster AT-URI
8787-GROUP BY bean.uri
8888-ORDER BY avg_rating DESC
8989-```
9090-9191-### Page Sections
9292-9393-1. **Header** — Roaster name, location, website, total brews / beans / brewers
9494-2. **Rating summary** — Avg ★, median ★, total rated brews
9595-3. **Top beans** — Table: bean name, brew count, avg rating
9696-4. **Brew method breakdown** — Bar/list showing V60, Aeropress, etc.
9797-5. **Rating trend** — Month-by-month avg rating (simple list or sparkline)
9898-9999-### Linking
100100-101101-Anywhere a roaster name appears in the feed or on a brew/bean detail page, link to
102102-`/roasters/{did}/{rkey}`.
103103-104104-### Public Access
105105-106106-The analytics page is fully public (no auth required) since all data is already public in the
107107-firehose index.
108108-109109----
110110-111111-## 3. Personal Analytics Dashboard
112112-113113-A private `/me/stats` page showing the authenticated user's own brewing trends, computed from
114114-their PDS records (no cross-user aggregation needed).
115115-116116-### Metrics
117117-118118-| Metric | Source |
119119-| -------------------- | ----------------------------------------- |
120120-| Brews per week/month | Count brews by created_at bucket |
121121-| Avg rating over time | Avg rating by month |
122122-| Favourite bean | Most brewed bean (+ highest avg rating) |
123123-| Favourite method | Most used brew method |
124124-| Equipment usage | Most used grinder / brewer |
125125-| Taste evolution | Rating trend over time |
126126-| Bags opened/closed | Count beans by `closed` flag |
127127-128128-### Implementation Notes
129129-130130-- Query user's own PDS via `store.ListBrews()` — no firehose needed.
131131-- Aggregate in Go (small data set per user, no need for SQL aggregation).
132132-- Cache in `SessionCache` to avoid repeated PDS fetches.
133133-- No new lexicons required.
134134-135135----
136136-137137-## Priority
138138-139139-1. **Roaster analytics** — immediate value, no schema changes, pure SQL over existing indexed refs
140140-2. **Recipes** — high social value, new lexicon + feed integration required
141141-3. **Personal stats** — lower complexity, pure client-side aggregation, quality-of-life feature
+63-16
docs/nix-install.md
···11-# NixOS Installation
11+# Nix/NixOS Installation
22+33+## NixOS Module
2433-## Using the Module
55+This repo exposes a NixOS module at `nixosModules.default`.
66+77+### Via flake input
88+99+```nix
1010+{
1111+ inputs.arabica.url = "github:<you>/arabica";
1212+1313+ outputs = { self, nixpkgs, arabica, ... }: {
1414+ nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
1515+ system = "x86_64-linux";
1616+ modules = [
1717+ arabica.nixosModules.default
1818+ ({ ... }: {
1919+ services.arabica = {
2020+ enable = true;
2121+ dataDir = "/var/lib/arabica";
2222+2323+ settings = {
2424+ port = 18910;
2525+ logLevel = "info";
2626+ secureCookies = true;
2727+ # publicUrl = "https://arabica.example.com";
2828+ };
2929+3030+ oauth = {
3131+ clientId = "https://arabica.example.com/client-metadata.json";
3232+ redirectUri = "https://arabica.example.com/oauth/callback";
3333+ };
3434+ };
3535+ })
3636+ ];
3737+ };
3838+ };
3939+}
4040+```
44155-Add to your configuration.nix:
4242+### Via local checkout
643744```nix
845{
99- imports = [ ./arabica-site/module.nix ];
1010-4646+ imports = [ ./nix/module.nix ];
4747+1148 services.arabica = {
1249 enable = true;
1313- port = 18910;
1450 dataDir = "/var/lib/arabica";
1515- logLevel = "info";
1616- secureCookies = false; # Set true if behind HTTPS proxy
5151+5252+ settings = {
5353+ port = 18910;
5454+ logLevel = "info";
5555+ secureCookies = false; # only for local/dev http
5656+ };
5757+5858+ oauth = {
5959+ clientId = "https://arabica.example.com/client-metadata.json";
6060+ redirectUri = "https://arabica.example.com/oauth/callback";
6161+ };
1762 };
1863}
1964```
20652121-## Manual Installation
2222-2323-Build and run directly:
6666+## Build/Run Manually (flake)
24672568```bash
2626-# Build
2727-nix-build -E 'with import <nixpkgs> {}; callPackage ./default.nix {}'
6969+# Build package
7070+nix build .#arabica
7171+7272+# Run built binary
7373+./result/bin/arabica
28742929-# Run
3030-result/bin/arabica
7575+# Or run directly
7676+nix run .#arabica
3177```
32783333-The data directory will be created at `~/.local/share/arabica/` by default.
7979+By default the wrapper stores data at `~/.local/share/arabica/arabica.db` when
8080+`ARABICA_DB_PATH` is not set.
···11-# Brewer Type Enum & Method-Specific Brew Params
22-33-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44-55-**Goal:** Standardize brewer types as a known-values enum, then use the selected brewer's type to conditionally show method-specific parameter fields (espresso, pour-over) in the brew form.
66-77-**Architecture:** Add `knownValues` to the brewer lexicon's `brewerType` field. Add `espressoParams` and `pouroverParams` optional sub-objects to the brew lexicon. Update the Go models, AT Protocol record conversion, brew form template, and Alpine.js to show/hide method-specific fields based on the selected brewer's type. Leave TODOs for future method types (immersion, moka pot, cold brew, cupping).
88-99-**Tech Stack:** Go, templ, Alpine.js, HTMX, AT Protocol lexicons, Tailwind CSS
1010-1111----
1212-1313-## Context
1414-1515-### Brewer Type Categories
1616-1717-| Canonical value | Examples | Extra form fields |
1818-|---|---|---|
1919-| `pourover` | V60, Chemex, Kalita Wave, Origami | Bloom water (g), bloom time (s), drawdown time (s), bypass water (g) |
2020-| `espresso` | Machine, lever, manual | Yield weight (g), pressure (bar), pre-infusion time (s) |
2121-| `immersion` | French press, Clever Dripper, siphon, Aeropress | TODO: future |
2222-| `mokapot` | Moka pot, Bialetti | TODO: future (heat level) |
2323-| `coldbrew` | Cold brew, cold drip | TODO: future |
2424-| `cupping` | Cupping | TODO: future |
2525-| `other` | Turkish, custom | No extra fields |
2626-2727-### Backwards Compatibility
2828-2929-- Existing brewer records with freeform strings (e.g. "Pour-Over") continue to work
3030-- New optional fields on the brew lexicon are additive — old records simply lack them
3131-- The app normalizes freeform values to canonical enum values on read for form logic
3232-- Old clients ignore unknown fields per AT Protocol convention
3333-3434-### Key Files
3535-3636-- `lexicons/social.arabica.alpha.brewer.json` — brewer lexicon
3737-- `lexicons/social.arabica.alpha.brew.json` — brew lexicon
3838-- `internal/models/models.go` — Go domain models
3939-- `internal/atproto/records.go` — AT Protocol record ↔ model conversion
4040-- `internal/handlers/brew.go` — brew create/update handlers
4141-- `internal/web/pages/brew_form.templ` — brew form template
4242-- `static/js/brew-form.js` — Alpine.js brew form component
4343-- `static/js/dropdown-manager.js` — brewer type lookup
4444-- `internal/web/components/dialog_modals.templ` — brewer create/edit modal
4545-- `internal/web/pages/profile.templ` — inline brewer form
4646-- `internal/web/pages/brew_view.templ` — brew detail display
4747-- `internal/web/components/icons.templ` — SVG icons
4848-4949----
5050-5151-## Task 1: Update Brewer Lexicon with `knownValues`
5252-5353-**Files:**
5454-- Modify: `lexicons/social.arabica.alpha.brewer.json`
5555-5656-**Step 1: Update the brewerType field to include knownValues**
5757-5858-The `brewerType` field is currently a free string. Add `knownValues` to guide clients while keeping it an open string (AT Protocol convention — `knownValues` is advisory, not enforced).
5959-6060-```json
6161-"brewerType": {
6262- "type": "string",
6363- "maxLength": 100,
6464- "knownValues": [
6565- "pourover",
6666- "espresso",
6767- "immersion",
6868- "mokapot",
6969- "coldbrew",
7070- "cupping",
7171- "other"
7272- ],
7373- "description": "Category of brewer. Known values: pourover, espresso, immersion, mokapot, coldbrew, cupping, other"
7474-}
7575-```
7676-7777-**Step 2: Commit**
7878-7979-```
8080-feat: add knownValues to brewer lexicon brewerType field
8181-```
8282-8383----
8484-8585-## Task 2: Update Brew Lexicon with Method-Specific Params
8686-8787-**Files:**
8888-- Modify: `lexicons/social.arabica.alpha.brew.json`
8989-9090-**Step 1: Add espressoParams and pouroverParams sub-objects**
9191-9292-Add two new optional fields to the brew record, and two new `#defs` sub-objects:
9393-9494-In `properties` of the main record, add:
9595-9696-```json
9797-"espressoParams": {
9898- "type": "ref",
9999- "ref": "#espressoParams",
100100- "description": "Espresso-specific brewing parameters (optional)"
101101-},
102102-"pouroverParams": {
103103- "type": "ref",
104104- "ref": "#pouroverParams",
105105- "description": "Pour-over-specific brewing parameters (optional)"
106106-}
107107-```
108108-109109-Add new defs alongside the existing `pour` def:
110110-111111-```json
112112-"espressoParams": {
113113- "type": "object",
114114- "description": "Parameters specific to espresso brewing",
115115- "properties": {
116116- "yieldWeight": {
117117- "type": "integer",
118118- "minimum": 0,
119119- "description": "Espresso yield/output weight in tenths of a gram (e.g., 360 = 36.0g)"
120120- },
121121- "pressure": {
122122- "type": "integer",
123123- "minimum": 0,
124124- "description": "Brewing pressure in tenths of a bar (e.g., 90 = 9.0 bar)"
125125- },
126126- "preInfusionSeconds": {
127127- "type": "integer",
128128- "minimum": 0,
129129- "description": "Pre-infusion time in seconds"
130130- }
131131- }
132132-},
133133-"pouroverParams": {
134134- "type": "object",
135135- "description": "Parameters specific to pour-over brewing",
136136- "properties": {
137137- "bloomWater": {
138138- "type": "integer",
139139- "minimum": 0,
140140- "description": "Water used for bloom in grams"
141141- },
142142- "bloomSeconds": {
143143- "type": "integer",
144144- "minimum": 0,
145145- "description": "Bloom wait time in seconds"
146146- },
147147- "drawdownSeconds": {
148148- "type": "integer",
149149- "minimum": 0,
150150- "description": "Drawdown time in seconds (time after last pour until bed is dry)"
151151- },
152152- "bypassWater": {
153153- "type": "integer",
154154- "minimum": 0,
155155- "description": "Bypass water added after brewing in grams"
156156- }
157157- }
158158-}
159159-```
160160-161161-**Step 2: Commit**
162162-163163-```
164164-feat: add espressoParams and pouroverParams to brew lexicon
165165-```
166166-167167----
168168-169169-## Task 3: Add Brewer Type Constants and Method Param Models
170170-171171-**Files:**
172172-- Modify: `internal/models/models.go`
173173-- Test: `internal/models/models_test.go`
174174-175175-**Step 1: Add brewer type constants**
176176-177177-Add after the existing field length constants block:
178178-179179-```go
180180-// Brewer type categories (knownValues from lexicon)
181181-const (
182182- BrewerTypePourover = "pourover"
183183- BrewerTypeEspresso = "espresso"
184184- BrewerTypeImmersion = "immersion"
185185- BrewerTypeMokaPot = "mokapot"
186186- BrewerTypeColdBrew = "coldbrew"
187187- BrewerTypeCupping = "cupping"
188188- BrewerTypeOther = "other"
189189-)
190190-191191-// BrewerTypeLabels maps canonical brewer type values to display labels
192192-var BrewerTypeLabels = map[string]string{
193193- BrewerTypePourover: "Pour-over",
194194- BrewerTypeEspresso: "Espresso",
195195- BrewerTypeImmersion: "Immersion",
196196- BrewerTypeMokaPot: "Moka Pot",
197197- BrewerTypeColdBrew: "Cold Brew",
198198- BrewerTypeCupping: "Cupping",
199199- BrewerTypeOther: "Other",
200200-}
201201-202202-// BrewerTypeKnownValues is the ordered list for form dropdowns
203203-var BrewerTypeKnownValues = []string{
204204- BrewerTypePourover,
205205- BrewerTypeEspresso,
206206- BrewerTypeImmersion,
207207- BrewerTypeMokaPot,
208208- BrewerTypeColdBrew,
209209- BrewerTypeCupping,
210210- BrewerTypeOther,
211211-}
212212-```
213213-214214-**Step 2: Add NormalizeBrewerType function**
215215-216216-This maps legacy freeform strings to canonical values:
217217-218218-```go
219219-// NormalizeBrewerType maps freeform brewer type strings to canonical values.
220220-// Returns the input unchanged if no mapping is found (preserves unknown values).
221221-func NormalizeBrewerType(raw string) string {
222222- lower := strings.ToLower(strings.TrimSpace(raw))
223223- switch {
224224- case lower == "pourover" || lower == "pour-over" || lower == "pour over" || lower == "dripper":
225225- return BrewerTypePourover
226226- case lower == "espresso" || lower == "espresso machine" || lower == "lever espresso" || lower == "lever espresso machine":
227227- return BrewerTypeEspresso
228228- case lower == "immersion" || lower == "french press" || lower == "aeropress" || lower == "siphon" || lower == "clever" || lower == "clever dripper":
229229- return BrewerTypeImmersion
230230- case lower == "mokapot" || lower == "moka pot" || lower == "moka" || lower == "bialetti":
231231- return BrewerTypeMokaPot
232232- case lower == "coldbrew" || lower == "cold brew" || lower == "cold drip":
233233- return BrewerTypeColdBrew
234234- case lower == "cupping":
235235- return BrewerTypeCupping
236236- case lower == "other":
237237- return BrewerTypeOther
238238- default:
239239- return raw // preserve unknown values
240240- }
241241-}
242242-```
243243-244244-Note: add `"strings"` to the import block in models.go.
245245-246246-**Step 3: Add EspressoParams and PouroverParams structs**
247247-248248-Add after the `Pour` struct:
249249-250250-```go
251251-// EspressoParams holds espresso-specific brewing parameters
252252-type EspressoParams struct {
253253- YieldWeight float64 `json:"yield_weight"` // Output weight in grams
254254- Pressure float64 `json:"pressure"` // Pressure in bar
255255- PreInfusionSeconds int `json:"pre_infusion_seconds"` // Pre-infusion time
256256-}
257257-258258-// PouroverParams holds pour-over-specific brewing parameters
259259-type PouroverParams struct {
260260- BloomWater int `json:"bloom_water"` // Bloom water in grams
261261- BloomSeconds int `json:"bloom_seconds"` // Bloom wait time in seconds
262262- DrawdownSeconds int `json:"drawdown_seconds"` // Drawdown time in seconds
263263- BypassWater int `json:"bypass_water"` // Bypass water in grams
264264-}
265265-```
266266-267267-**Step 4: Add fields to Brew model**
268268-269269-Add to the `Brew` struct, after the `Pours` field:
270270-271271-```go
272272- EspressoParams *EspressoParams `json:"espresso_params,omitempty"`
273273- PouroverParams *PouroverParams `json:"pourover_params,omitempty"`
274274-```
275275-276276-**Step 5: Add fields to CreateBrewRequest**
277277-278278-Add to `CreateBrewRequest`, after `Pours`:
279279-280280-```go
281281- EspressoParams *EspressoParams `json:"espresso_params,omitempty"`
282282- PouroverParams *PouroverParams `json:"pourover_params,omitempty"`
283283-```
284284-285285-**Step 6: Write tests for NormalizeBrewerType**
286286-287287-In `internal/models/models_test.go`, add:
288288-289289-```go
290290-func TestNormalizeBrewerType(t *testing.T) {
291291- tests := []struct {
292292- input string
293293- expected string
294294- }{
295295- {"pourover", "pourover"},
296296- {"Pour-Over", "pourover"},
297297- {"pour over", "pourover"},
298298- {"Dripper", "pourover"},
299299- {"espresso", "espresso"},
300300- {"Espresso Machine", "espresso"},
301301- {"Lever Espresso Machine", "espresso"},
302302- {"immersion", "immersion"},
303303- {"French Press", "immersion"},
304304- {"Aeropress", "immersion"},
305305- {"Clever Dripper", "immersion"},
306306- {"mokapot", "mokapot"},
307307- {"Moka Pot", "mokapot"},
308308- {"coldbrew", "coldbrew"},
309309- {"Cold Brew", "coldbrew"},
310310- {"cupping", "cupping"},
311311- {"other", "other"},
312312- {"SomeUnknownType", "SomeUnknownType"}, // preserved
313313- {"", ""},
314314- }
315315-316316- for _, tt := range tests {
317317- t.Run(tt.input, func(t *testing.T) {
318318- assert.Equal(t, tt.expected, NormalizeBrewerType(tt.input))
319319- })
320320- }
321321-}
322322-```
323323-324324-**Step 7: Run tests**
325325-326326-```bash
327327-go test ./internal/models/... -v -run TestNormalizeBrewerType
328328-```
329329-330330-**Step 8: Run vet and build**
331331-332332-```bash
333333-go vet ./... && go build ./...
334334-```
335335-336336-**Step 9: Commit**
337337-338338-```
339339-feat: add brewer type constants, method param models, and NormalizeBrewerType
340340-```
341341-342342----
343343-344344-## Task 4: Update AT Protocol Record Conversion
345345-346346-**Files:**
347347-- Modify: `internal/atproto/records.go`
348348-- Test: `internal/atproto/records_test.go`
349349-350350-**Step 1: Update BrewToRecord to serialize new params**
351351-352352-In the `BrewToRecord` function, after the pours serialization block (after `record["pours"] = pours`), add:
353353-354354-```go
355355- // Espresso-specific params
356356- if brew.EspressoParams != nil {
357357- ep := map[string]interface{}{}
358358- if brew.EspressoParams.YieldWeight > 0 {
359359- ep["yieldWeight"] = int(brew.EspressoParams.YieldWeight * 10) // tenths of a gram
360360- }
361361- if brew.EspressoParams.Pressure > 0 {
362362- ep["pressure"] = int(brew.EspressoParams.Pressure * 10) // tenths of a bar
363363- }
364364- if brew.EspressoParams.PreInfusionSeconds > 0 {
365365- ep["preInfusionSeconds"] = brew.EspressoParams.PreInfusionSeconds
366366- }
367367- if len(ep) > 0 {
368368- record["espressoParams"] = ep
369369- }
370370- }
371371-372372- // Pour-over-specific params
373373- if brew.PouroverParams != nil {
374374- pp := map[string]interface{}{}
375375- if brew.PouroverParams.BloomWater > 0 {
376376- pp["bloomWater"] = brew.PouroverParams.BloomWater
377377- }
378378- if brew.PouroverParams.BloomSeconds > 0 {
379379- pp["bloomSeconds"] = brew.PouroverParams.BloomSeconds
380380- }
381381- if brew.PouroverParams.DrawdownSeconds > 0 {
382382- pp["drawdownSeconds"] = brew.PouroverParams.DrawdownSeconds
383383- }
384384- if brew.PouroverParams.BypassWater > 0 {
385385- pp["bypassWater"] = brew.PouroverParams.BypassWater
386386- }
387387- if len(pp) > 0 {
388388- record["pouroverParams"] = pp
389389- }
390390- }
391391-```
392392-393393-**Step 2: Update RecordToBrew to deserialize new params**
394394-395395-In the `RecordToBrew` function, after the pours deserialization block, add:
396396-397397-```go
398398- // Espresso params
399399- if epRaw, ok := record["espressoParams"].(map[string]interface{}); ok {
400400- ep := &models.EspressoParams{}
401401- if v, ok := epRaw["yieldWeight"].(float64); ok {
402402- ep.YieldWeight = v / 10.0 // tenths back to grams
403403- }
404404- if v, ok := epRaw["pressure"].(float64); ok {
405405- ep.Pressure = v / 10.0 // tenths back to bar
406406- }
407407- if v, ok := epRaw["preInfusionSeconds"].(float64); ok {
408408- ep.PreInfusionSeconds = int(v)
409409- }
410410- brew.EspressoParams = ep
411411- }
412412-413413- // Pour-over params
414414- if ppRaw, ok := record["pouroverParams"].(map[string]interface{}); ok {
415415- pp := &models.PouroverParams{}
416416- if v, ok := ppRaw["bloomWater"].(float64); ok {
417417- pp.BloomWater = int(v)
418418- }
419419- if v, ok := ppRaw["bloomSeconds"].(float64); ok {
420420- pp.BloomSeconds = int(v)
421421- }
422422- if v, ok := ppRaw["drawdownSeconds"].(float64); ok {
423423- pp.DrawdownSeconds = int(v)
424424- }
425425- if v, ok := ppRaw["bypassWater"].(float64); ok {
426426- pp.BypassWater = int(v)
427427- }
428428- brew.PouroverParams = pp
429429- }
430430-```
431431-432432-**Step 3: Add round-trip tests**
433433-434434-In `internal/atproto/records_test.go`, add tests for brew records with espresso and pourover params. Follow the existing `TestBrewRoundTrip` pattern:
435435-436436-```go
437437-func TestBrewRoundTrip_EspressoParams(t *testing.T) {
438438- original := &models.Brew{
439439- BeanRKey: "abc123",
440440- Temperature: 93.5,
441441- Rating: 8,
442442- CreatedAt: time.Now().Truncate(time.Second),
443443- EspressoParams: &models.EspressoParams{
444444- YieldWeight: 36.0,
445445- Pressure: 9.0,
446446- PreInfusionSeconds: 5,
447447- },
448448- }
449449-450450- record, err := BrewToRecord(original, "at://did:plc:test/social.arabica.alpha.bean/abc123", "", "", "")
451451- assert.NoError(t, err)
452452-453453- // Verify espressoParams is in the record
454454- ep, ok := record["espressoParams"].(map[string]interface{})
455455- assert.True(t, ok)
456456- assert.Equal(t, 360, ep["yieldWeight"]) // 36.0 * 10
457457- assert.Equal(t, 90, ep["pressure"]) // 9.0 * 10
458458- assert.Equal(t, 5, ep["preInfusionSeconds"])
459459-460460- restored, err := RecordToBrew(record, "at://did:plc:test/social.arabica.alpha.brew/tid123")
461461- assert.NoError(t, err)
462462- assert.NotNil(t, restored.EspressoParams)
463463- assert.InDelta(t, 36.0, restored.EspressoParams.YieldWeight, 0.1)
464464- assert.InDelta(t, 9.0, restored.EspressoParams.Pressure, 0.1)
465465- assert.Equal(t, 5, restored.EspressoParams.PreInfusionSeconds)
466466-}
467467-468468-func TestBrewRoundTrip_PouroverParams(t *testing.T) {
469469- original := &models.Brew{
470470- BeanRKey: "abc123",
471471- CreatedAt: time.Now().Truncate(time.Second),
472472- PouroverParams: &models.PouroverParams{
473473- BloomWater: 50,
474474- BloomSeconds: 45,
475475- DrawdownSeconds: 30,
476476- BypassWater: 100,
477477- },
478478- }
479479-480480- record, err := BrewToRecord(original, "at://did:plc:test/social.arabica.alpha.bean/abc123", "", "", "")
481481- assert.NoError(t, err)
482482-483483- pp, ok := record["pouroverParams"].(map[string]interface{})
484484- assert.True(t, ok)
485485- assert.Equal(t, 50, pp["bloomWater"])
486486- assert.Equal(t, 45, pp["bloomSeconds"])
487487- assert.Equal(t, 30, pp["drawdownSeconds"])
488488- assert.Equal(t, 100, pp["bypassWater"])
489489-490490- restored, err := RecordToBrew(record, "at://did:plc:test/social.arabica.alpha.brew/tid123")
491491- assert.NoError(t, err)
492492- assert.NotNil(t, restored.PouroverParams)
493493- assert.Equal(t, 50, restored.PouroverParams.BloomWater)
494494- assert.Equal(t, 45, restored.PouroverParams.BloomSeconds)
495495- assert.Equal(t, 30, restored.PouroverParams.DrawdownSeconds)
496496- assert.Equal(t, 100, restored.PouroverParams.BypassWater)
497497-}
498498-```
499499-500500-**Step 4: Run tests**
501501-502502-```bash
503503-go test ./internal/atproto/... -v -run TestBrewRoundTrip
504504-```
505505-506506-**Step 5: Commit**
507507-508508-```
509509-feat: serialize/deserialize espresso and pourover params in AT Protocol records
510510-```
511511-512512----
513513-514514-## Task 5: Update Brewer Form to Use Dropdown
515515-516516-**Files:**
517517-- Modify: `internal/web/components/dialog_modals.templ`
518518-- Modify: `internal/web/pages/profile.templ`
519519-520520-This changes the free-text `brewer_type` input to a `<select>` dropdown with known values plus an "Other" option that shows a text input for custom types.
521521-522522-**Step 1: Update the brewer modal in dialog_modals.templ**
523523-524524-Find the brewer type text input (around line 367-373):
525525-526526-```html
527527-<input
528528- type="text"
529529- name="brewer_type"
530530- value={ getStringValue(brewer, "brewer_type") }
531531- placeholder="Type (e.g., Pour-Over, Immersion, Espresso)"
532532- class="w-full form-input"
533533-/>
534534-```
535535-536536-Replace with a select + conditional text input using Alpine.js:
537537-538538-```html
539539-<div x-data="{ customType: false }" x-init="customType = !['', 'pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'].includes(document.querySelector('[name=brewer_type_select]')?.value)">
540540- <select
541541- name="brewer_type_select"
542542- @change="customType = ($event.target.value === '__custom__'); if (!customType) { $el.closest('form').querySelector('[name=brewer_type]').value = $event.target.value; }"
543543- class="w-full form-select"
544544- >
545545- <option value="">Select type...</option>
546546- for _, bt := range models.BrewerTypeKnownValues {
547547- <option
548548- value={ bt }
549549- if getStringValue(brewer, "brewer_type") == bt || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == bt {
550550- selected
551551- }
552552- >
553553- { models.BrewerTypeLabels[bt] }
554554- </option>
555555- }
556556- <option value="__custom__"
557557- if v := getStringValue(brewer, "brewer_type"); v != "" && models.NormalizeBrewerType(v) == v && !isKnownBrewerType(v) {
558558- selected
559559- }
560560- >
561561- Custom...
562562- </option>
563563- </select>
564564- <input
565565- type="hidden"
566566- name="brewer_type"
567567- value={ getBrewerTypeValue(brewer) }
568568- />
569569- <input
570570- x-show="customType"
571571- x-cloak
572572- type="text"
573573- @input="$el.closest('form').querySelector('[name=brewer_type]').value = $event.target.value"
574574- value={ getCustomBrewerTypeValue(brewer) }
575575- placeholder="Enter custom brewer type..."
576576- class="w-full form-input mt-2"
577577- />
578578-</div>
579579-```
580580-581581-Note: You'll need to add helper functions to the `getStringValue` helper area at the bottom of `dialog_modals.templ`:
582582-583583-```go
584584-func isKnownBrewerType(v string) bool {
585585- for _, bt := range models.BrewerTypeKnownValues {
586586- if v == bt {
587587- return true
588588- }
589589- }
590590- return false
591591-}
592592-593593-func getBrewerTypeValue(entity interface{}) string {
594594- raw := ""
595595- switch e := entity.(type) {
596596- case *models.Brewer:
597597- if e != nil {
598598- raw = e.BrewerType
599599- }
600600- }
601601- if raw == "" {
602602- return ""
603603- }
604604- normalized := models.NormalizeBrewerType(raw)
605605- if isKnownBrewerType(normalized) {
606606- return normalized
607607- }
608608- return raw
609609-}
610610-611611-func getCustomBrewerTypeValue(entity interface{}) string {
612612- raw := ""
613613- switch e := entity.(type) {
614614- case *models.Brewer:
615615- if e != nil {
616616- raw = e.BrewerType
617617- }
618618- }
619619- normalized := models.NormalizeBrewerType(raw)
620620- if isKnownBrewerType(normalized) {
621621- return ""
622622- }
623623- return raw
624624-}
625625-```
626626-627627-Also add `"arabica/internal/models"` to the imports if not already present.
628628-629629-**Step 2: Update the inline brewer form in profile.templ**
630630-631631-Find the brewer_type text input in profile.templ (around line 390):
632632-633633-```html
634634-<input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" class="w-full form-input"/>
635635-```
636636-637637-Replace with a similar select pattern. Since this form uses Alpine.js x-model, it's simpler:
638638-639639-```html
640640-<select x-model="brewerForm.brewer_type" class="w-full form-select">
641641- <option value="">Select type...</option>
642642- <option value="pourover">Pour-over</option>
643643- <option value="espresso">Espresso</option>
644644- <option value="immersion">Immersion</option>
645645- <option value="mokapot">Moka Pot</option>
646646- <option value="coldbrew">Cold Brew</option>
647647- <option value="cupping">Cupping</option>
648648- <option value="other">Other</option>
649649-</select>
650650-```
651651-652652-Note: The profile page inline form doesn't need the "custom" escape hatch since it's a simpler create-only flow. Users needing custom types can edit via the modal.
653653-654654-**Step 3: Run templ generate and verify**
655655-656656-```bash
657657-templ generate
658658-go vet ./...
659659-go build ./...
660660-```
661661-662662-**Step 4: Commit**
663663-664664-```
665665-feat: replace brewer type free-text with enum dropdown in brewer forms
666666-```
667667-668668----
669669-670670-## Task 6: Update Brew Handler to Parse Method Params
671671-672672-**Files:**
673673-- Modify: `internal/handlers/brew.go`
674674-675675-**Step 1: Add param parsing helper functions**
676676-677677-Add these helper functions near the existing `validateBrewRequest` function:
678678-679679-```go
680680-// parseEspressoParams extracts espresso-specific params from form values.
681681-// Returns nil if no espresso params were provided.
682682-func parseEspressoParams(r *http.Request) *models.EspressoParams {
683683- yieldStr := r.FormValue("espresso_yield_weight")
684684- pressureStr := r.FormValue("espresso_pressure")
685685- preInfStr := r.FormValue("espresso_pre_infusion_seconds")
686686-687687- if yieldStr == "" && pressureStr == "" && preInfStr == "" {
688688- return nil
689689- }
690690-691691- ep := &models.EspressoParams{}
692692- if v, err := strconv.ParseFloat(yieldStr, 64); err == nil && v > 0 {
693693- ep.YieldWeight = v
694694- }
695695- if v, err := strconv.ParseFloat(pressureStr, 64); err == nil && v > 0 {
696696- ep.Pressure = v
697697- }
698698- if v, err := strconv.Atoi(preInfStr); err == nil && v > 0 {
699699- ep.PreInfusionSeconds = v
700700- }
701701- return ep
702702-}
703703-704704-// parsePouroverParams extracts pour-over-specific params from form values.
705705-// Returns nil if no pour-over params were provided.
706706-func parsePouroverParams(r *http.Request) *models.PouroverParams {
707707- bloomWaterStr := r.FormValue("pourover_bloom_water")
708708- bloomSecsStr := r.FormValue("pourover_bloom_seconds")
709709- drawdownStr := r.FormValue("pourover_drawdown_seconds")
710710- bypassStr := r.FormValue("pourover_bypass_water")
711711-712712- if bloomWaterStr == "" && bloomSecsStr == "" && drawdownStr == "" && bypassStr == "" {
713713- return nil
714714- }
715715-716716- pp := &models.PouroverParams{}
717717- if v, err := strconv.Atoi(bloomWaterStr); err == nil && v > 0 {
718718- pp.BloomWater = v
719719- }
720720- if v, err := strconv.Atoi(bloomSecsStr); err == nil && v > 0 {
721721- pp.BloomSeconds = v
722722- }
723723- if v, err := strconv.Atoi(drawdownStr); err == nil && v > 0 {
724724- pp.DrawdownSeconds = v
725725- }
726726- if v, err := strconv.Atoi(bypassStr); err == nil && v > 0 {
727727- pp.BypassWater = v
728728- }
729729- return pp
730730-}
731731-```
732732-733733-**Step 2: Wire into HandleBrewCreate**
734734-735735-In `HandleBrewCreate`, after building the `CreateBrewRequest` (after `Pours: pours,`), add:
736736-737737-```go
738738- req.EspressoParams = parseEspressoParams(r)
739739- req.PouroverParams = parsePouroverParams(r)
740740-```
741741-742742-**Step 3: Wire into HandleBrewUpdate**
743743-744744-Find the equivalent spot in `HandleBrewUpdate` and add the same two lines.
745745-746746-**Step 4: Ensure strconv is imported**
747747-748748-Check that `"strconv"` is in the imports of `brew.go`. It likely already is for existing number parsing.
749749-750750-**Step 5: Run vet and build**
751751-752752-```bash
753753-go vet ./... && go build ./...
754754-```
755755-756756-**Step 6: Commit**
757757-758758-```
759759-feat: parse espresso and pourover params from brew form submissions
760760-```
761761-762762----
763763-764764-## Task 7: Update Store to Pass Method Params Through
765765-766766-**Files:**
767767-- Modify: `internal/atproto/store.go`
768768-769769-The store's `CreateBrew` method builds a `Brew` model from the request and calls `BrewToRecord`. We need to pass the new params through.
770770-771771-**Step 1: Find where CreateBrewRequest is converted to Brew**
772772-773773-Search for where `CreateBrewRequest` fields are mapped to `Brew` fields in `store.go`. Add after pours mapping:
774774-775775-```go
776776- brew.EspressoParams = req.EspressoParams
777777- brew.PouroverParams = req.PouroverParams
778778-```
779779-780780-Do the same for the update path.
781781-782782-**Step 2: Run vet and build**
783783-784784-```bash
785785-go vet ./... && go build ./...
786786-```
787787-788788-**Step 3: Commit**
789789-790790-```
791791-feat: pass method-specific params through store layer
792792-```
793793-794794----
795795-796796-## Task 8: Add Method-Specific Form Sections to Brew Form
797797-798798-**Files:**
799799-- Modify: `internal/web/pages/brew_form.templ`
800800-801801-**Step 1: Add new templ components for method-specific fields**
802802-803803-Add these after the existing `PoursSection` component:
804804-805805-```templ
806806-// EspressoParamsSection renders espresso-specific fields (shown when brewer type is espresso)
807807-templ EspressoParamsSection(props BrewFormProps) {
808808- <div x-show="brewerCategory === 'espresso'" x-cloak>
809809- <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0">
810810- <legend class="text-sm font-semibold text-brown-800 px-2">Espresso</legend>
811811- @components.FormField(
812812- components.FormFieldProps{
813813- Label: "Yield Weight (grams)",
814814- HelperText: "Weight of espresso output",
815815- },
816816- components.NumberInput(components.NumberInputProps{
817817- Name: "espresso_yield_weight",
818818- Value: getEspressoYieldWeight(props),
819819- Placeholder: "e.g. 36",
820820- Step: "0.1",
821821- Class: "w-full form-input-lg",
822822- }),
823823- )
824824- @components.FormField(
825825- components.FormFieldProps{
826826- Label: "Pressure (bar)",
827827- HelperText: "Brewing pressure",
828828- },
829829- components.NumberInput(components.NumberInputProps{
830830- Name: "espresso_pressure",
831831- Value: getEspressoPressure(props),
832832- Placeholder: "e.g. 9",
833833- Step: "0.1",
834834- Class: "w-full form-input-lg",
835835- }),
836836- )
837837- @components.FormField(
838838- components.FormFieldProps{Label: "Pre-infusion Time (seconds)"},
839839- components.NumberInput(components.NumberInputProps{
840840- Name: "espresso_pre_infusion_seconds",
841841- Value: getEspressoPreInfusion(props),
842842- Placeholder: "e.g. 5",
843843- Class: "w-full form-input-lg",
844844- }),
845845- )
846846- </fieldset>
847847- </div>
848848-}
849849-850850-// PouroverParamsSection renders pour-over-specific fields (shown when brewer type is pourover)
851851-templ PouroverParamsSection(props BrewFormProps) {
852852- <div x-show="brewerCategory === 'pourover'" x-cloak>
853853- <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0">
854854- <legend class="text-sm font-semibold text-brown-800 px-2">Pour-over Details</legend>
855855- <div class="grid grid-cols-2 gap-4">
856856- @components.FormField(
857857- components.FormFieldProps{
858858- Label: "Bloom Water (grams)",
859859- HelperText: "Water for bloom",
860860- },
861861- components.NumberInput(components.NumberInputProps{
862862- Name: "pourover_bloom_water",
863863- Value: getPouroverBloomWater(props),
864864- Placeholder: "e.g. 50",
865865- Class: "w-full form-input-lg",
866866- }),
867867- )
868868- @components.FormField(
869869- components.FormFieldProps{
870870- Label: "Bloom Time (seconds)",
871871- HelperText: "Bloom wait time",
872872- },
873873- components.NumberInput(components.NumberInputProps{
874874- Name: "pourover_bloom_seconds",
875875- Value: getPouroverBloomSeconds(props),
876876- Placeholder: "e.g. 45",
877877- Class: "w-full form-input-lg",
878878- }),
879879- )
880880- </div>
881881- @components.FormField(
882882- components.FormFieldProps{
883883- Label: "Drawdown Time (seconds)",
884884- HelperText: "Time after last pour until bed is dry",
885885- },
886886- components.NumberInput(components.NumberInputProps{
887887- Name: "pourover_drawdown_seconds",
888888- Value: getPouroverDrawdown(props),
889889- Placeholder: "e.g. 30",
890890- Class: "w-full form-input-lg",
891891- }),
892892- )
893893- @components.FormField(
894894- components.FormFieldProps{
895895- Label: "Bypass Water (grams)",
896896- HelperText: "Water added after brewing",
897897- },
898898- components.NumberInput(components.NumberInputProps{
899899- Name: "pourover_bypass_water",
900900- Value: getPouroverBypass(props),
901901- Placeholder: "e.g. 100",
902902- Class: "w-full form-input-lg",
903903- }),
904904- )
905905- </fieldset>
906906- </div>
907907-}
908908-```
909909-910910-**Step 2: Add Go helper functions for reading existing values**
911911-912912-```go
913913-func getEspressoYieldWeight(props BrewFormProps) string {
914914- if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.YieldWeight > 0 {
915915- return fmt.Sprintf("%.1f", props.Brew.EspressoParams.YieldWeight)
916916- }
917917- return ""
918918-}
919919-920920-func getEspressoPressure(props BrewFormProps) string {
921921- if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.Pressure > 0 {
922922- return fmt.Sprintf("%.1f", props.Brew.EspressoParams.Pressure)
923923- }
924924- return ""
925925-}
926926-927927-func getEspressoPreInfusion(props BrewFormProps) string {
928928- if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.PreInfusionSeconds > 0 {
929929- return fmt.Sprintf("%d", props.Brew.EspressoParams.PreInfusionSeconds)
930930- }
931931- return ""
932932-}
933933-934934-func getPouroverBloomWater(props BrewFormProps) string {
935935- if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BloomWater > 0 {
936936- return fmt.Sprintf("%d", props.Brew.PouroverParams.BloomWater)
937937- }
938938- return ""
939939-}
940940-941941-func getPouroverBloomSeconds(props BrewFormProps) string {
942942- if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BloomSeconds > 0 {
943943- return fmt.Sprintf("%d", props.Brew.PouroverParams.BloomSeconds)
944944- }
945945- return ""
946946-}
947947-948948-func getPouroverDrawdown(props BrewFormProps) string {
949949- if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.DrawdownSeconds > 0 {
950950- return fmt.Sprintf("%d", props.Brew.PouroverParams.DrawdownSeconds)
951951- }
952952- return ""
953953-}
954954-955955-func getPouroverBypass(props BrewFormProps) string {
956956- if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BypassWater > 0 {
957957- return fmt.Sprintf("%d", props.Brew.PouroverParams.BypassWater)
958958- }
959959- return ""
960960-}
961961-```
962962-963963-**Step 3: Wire the sections into both form modes**
964964-965965-In both `RecipeModeSection` and `FreeformModeSection`, add the method-specific sections after the Brewing fieldset and before the Results fieldset:
966966-967967-```templ
968968- @EspressoParamsSection(props)
969969- @PouroverParamsSection(props)
970970-```
971971-972972-**Step 4: Run templ generate and verify**
973973-974974-```bash
975975-templ generate
976976-go vet ./...
977977-go build ./...
978978-```
979979-980980-**Step 5: Commit**
981981-982982-```
983983-feat: add espresso and pourover parameter sections to brew form
984984-```
985985-986986----
987987-988988-## Task 9: Add Alpine.js Brewer Category Logic
989989-990990-**Files:**
991991-- Modify: `static/js/brew-form.js`
992992-993993-**Step 1: Add brewerCategory state and logic**
994994-995995-In the Alpine.js `brewForm` data object, add a new reactive property:
996996-997997-```js
998998- brewerCategory: '', // 'pourover' | 'espresso' | 'immersion' | ... | ''
999999-```
10001000-10011001-**Step 2: Update the `onBrewerChange` method**
10021002-10031003-Replace the existing `onBrewerChange` method:
10041004-10051005-```js
10061006- onBrewerChange(rkey) {
10071007- const brewerType = this.dropdownManager?.getBrewerType(rkey) || '';
10081008- this.brewerCategory = this.normalizeBrewerCategory(brewerType);
10091009-10101010- // Auto-show pours for pour-over brewers
10111011- if (this.brewerCategory === 'pourover') {
10121012- this.showPours = true;
10131013- }
10141014- },
10151015-10161016- // Map brewer type strings to canonical categories
10171017- normalizeBrewerCategory(raw) {
10181018- if (!raw) return '';
10191019- const lower = raw.toLowerCase().trim();
10201020-10211021- // Pour-over variants
10221022- if (['pourover', 'pour-over', 'pour over', 'dripper'].includes(lower)) return 'pourover';
10231023-10241024- // Espresso variants
10251025- if (['espresso', 'espresso machine', 'lever espresso', 'lever espresso machine'].includes(lower)) return 'espresso';
10261026-10271027- // Immersion variants
10281028- if (['immersion', 'french press', 'aeropress', 'siphon', 'clever', 'clever dripper'].includes(lower)) return 'immersion';
10291029-10301030- // TODO: future method types
10311031- // if (['mokapot', 'moka pot', 'moka', 'bialetti'].includes(lower)) return 'mokapot';
10321032- // if (['coldbrew', 'cold brew', 'cold drip'].includes(lower)) return 'coldbrew';
10331033- // if (lower === 'cupping') return 'cupping';
10341034-10351035- // Direct match on canonical values
10361036- if (['pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'].includes(lower)) return lower;
10371037-10381038- return '';
10391039- },
10401040-```
10411041-10421042-**Step 3: Update init to set brewerCategory on load**
10431043-10441044-In the `init()` method, where it already calls `this.onBrewerChange(sel.value)` in the `$nextTick`, this will now also set `brewerCategory`. No change needed — the existing code already calls `onBrewerChange` which now sets the category.
10451045-10461046-**Step 4: Update applyRecipe to set brewerCategory**
10471047-10481048-In the `applyRecipe` method, after `this.setFormField(form, 'brewer_rkey', recipe.brewer_rkey || '');`, add:
10491049-10501050-```js
10511051- // Update brewer category from recipe's brewer
10521052- if (recipe.brewer_rkey) {
10531053- this.onBrewerChange(recipe.brewer_rkey);
10541054- }
10551055-```
10561056-10571057-**Step 5: Bump the version query param**
10581058-10591059-In `internal/web/components/layout.templ`, find the brew-form.js script tag and bump its version:
10601060-10611061-```html
10621062-<script src="/static/js/brew-form.js?v=0.4.0" defer></script>
10631063-```
10641064-10651065-(Find the current version and increment it.)
10661066-10671067-**Step 6: Commit**
10681068-10691069-```
10701070-feat: add brewer category detection to Alpine.js brew form for conditional field display
10711071-```
10721072-10731073----
10741074-10751075-## Task 10: Display Method Params on Brew View Page
10761076-10771077-**Files:**
10781078-- Modify: `internal/web/pages/brew_view.templ`
10791079-- Modify: `internal/web/components/icons.templ` (if new icons needed)
10801080-10811081-**Step 1: Add display sections for method params**
10821082-10831083-In `BrewParametersGrid`, after the brew time field and before the closing `</div>`, add conditional sections:
10841084-10851085-```templ
10861086- if brew.EspressoParams != nil {
10871087- if brew.EspressoParams.YieldWeight > 0 {
10881088- @components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Yield", Value: fmt.Sprintf("%.1fg", brew.EspressoParams.YieldWeight)})
10891089- }
10901090- if brew.EspressoParams.Pressure > 0 {
10911091- @components.DetailField(components.DetailFieldProps{Icon: components.IconBarChart(), Label: "Pressure", Value: fmt.Sprintf("%.1f bar", brew.EspressoParams.Pressure)})
10921092- }
10931093- if brew.EspressoParams.PreInfusionSeconds > 0 {
10941094- @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Pre-infusion", Value: fmt.Sprintf("%ds", brew.EspressoParams.PreInfusionSeconds)})
10951095- }
10961096- }
10971097- if brew.PouroverParams != nil {
10981098- if brew.PouroverParams.BloomWater > 0 || brew.PouroverParams.BloomSeconds > 0 {
10991099- @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bloom", Value: formatBloom(brew.PouroverParams)})
11001100- }
11011101- if brew.PouroverParams.DrawdownSeconds > 0 {
11021102- @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Drawdown", Value: fmt.Sprintf("%ds", brew.PouroverParams.DrawdownSeconds)})
11031103- }
11041104- if brew.PouroverParams.BypassWater > 0 {
11051105- @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bypass Water", Value: fmt.Sprintf("%dg", brew.PouroverParams.BypassWater)})
11061106- }
11071107- }
11081108-```
11091109-11101110-**Step 2: Add formatBloom helper**
11111111-11121112-```go
11131113-func formatBloom(pp *models.PouroverParams) string {
11141114- if pp.BloomWater > 0 && pp.BloomSeconds > 0 {
11151115- return fmt.Sprintf("%dg for %ds", pp.BloomWater, pp.BloomSeconds)
11161116- }
11171117- if pp.BloomWater > 0 {
11181118- return fmt.Sprintf("%dg", pp.BloomWater)
11191119- }
11201120- if pp.BloomSeconds > 0 {
11211121- return fmt.Sprintf("%ds", pp.BloomSeconds)
11221122- }
11231123- return ""
11241124-}
11251125-```
11261126-11271127-**Step 3: Run templ generate and verify**
11281128-11291129-```bash
11301130-templ generate
11311131-go vet ./...
11321132-go build ./...
11331133-```
11341134-11351135-**Step 4: Commit**
11361136-11371137-```
11381138-feat: display espresso and pourover params on brew view page
11391139-```
11401140-11411141----
11421142-11431143-## Task 11: Update Feed Display for Method Params (Optional)
11441144-11451145-**Files:**
11461146-- Modify: `internal/web/pages/feed.templ`
11471147-11481148-This is optional but nice to have — show a small badge or extra detail on feed cards when espresso/pourover params are present.
11491149-11501150-**Step 1: Check if feed cards already show brew params**
11511151-11521152-If feed cards show brew variables (coffee, water, temp), consider adding yield weight for espresso brews inline. This is a small cosmetic addition.
11531153-11541154-**Step 2: Commit if changed**
11551155-11561156-```
11571157-feat: show method-specific params in feed cards
11581158-```
11591159-11601160----
11611161-11621162-## Task 12: Rebuild CSS and Final Verification
11631163-11641164-**Files:**
11651165-- None new
11661166-11671167-**Step 1: Rebuild Tailwind CSS**
11681168-11691169-```bash
11701170-just style
11711171-```
11721172-11731173-**Step 2: Run full test suite**
11741174-11751175-```bash
11761176-go test ./...
11771177-```
11781178-11791179-**Step 3: Run go vet**
11801180-11811181-```bash
11821182-go vet ./...
11831183-```
11841184-11851185-**Step 4: Bump CSS version for cache busting**
11861186-11871187-In `internal/web/components/layout.templ`, bump the CSS version query parameter.
11881188-11891189-**Step 5: Final commit**
11901190-11911191-```
11921192-chore: rebuild CSS and bump cache versions
11931193-```
11941194-11951195----
11961196-11971197-## Summary of Changes
11981198-11991199-| Area | What changes |
12001200-|---|---|
12011201-| **Lexicons** | `brewer.json` gets `knownValues`, `brew.json` gets `espressoParams` and `pouroverParams` sub-objects |
12021202-| **Models** | Brewer type constants, `NormalizeBrewerType()`, `EspressoParams`/`PouroverParams` structs, new fields on `Brew` and `CreateBrewRequest` |
12031203-| **Records** | Serialization/deserialization for new params in `BrewToRecord`/`RecordToBrew` |
12041204-| **Handlers** | `parseEspressoParams()`/`parsePouroverParams()` helpers, wired into create/update |
12051205-| **Store** | Pass-through of new params |
12061206-| **Brew form** | New `EspressoParamsSection` and `PouroverParamsSection` templ components, conditional on `brewerCategory` |
12071207-| **Alpine.js** | `brewerCategory` state, `normalizeBrewerCategory()` method, updated `onBrewerChange()` |
12081208-| **Brewer form** | Free text → select dropdown with known values + "Custom..." option |
12091209-| **Brew view** | Conditional display of method-specific params in parameters grid |
12101210-12111211-### Future TODOs left in code
12121212-12131213-- `normalizeBrewerCategory()` in JS has commented-out cases for `mokapot`, `coldbrew`, `cupping`
12141214-- Each future method type needs: a form section component, a handler parser, and view display logic
12151215-- The pattern is established — copy `EspressoParamsSection`/`parseEspressoParams` as templates
-985
docs/plans/2026-03-25-inline-entity-typeahead.md
···11-# Inline Entity Typeahead Implementation Plan
22-33-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44-55-**Goal:** Replace the select + "+ New" modal pattern in the brew form with a combo input that searches existing entities, shows suggestions from other users, and allows creating new entities inline with just a name.
66-77-**Architecture:** Build a reusable Alpine.js `comboSelect` component that wraps a text input with a dropdown. The dropdown shows the user's existing entities (from the dropdown-manager cache), server-side suggestions from other users (from `/api/suggestions`), and a "Create [name]" option. Selecting an existing entity sets a hidden `_rkey` input. Selecting "Create" calls the entity API to create a minimal record, then sets the rkey. The component replaces the current `<select>` + `<button>` pattern in all three brew form entity fields (bean, brewer, grinder).
88-99-**Tech Stack:** Alpine.js, HTMX, Go/templ, Tailwind CSS
1010-1111----
1212-1313-## Context
1414-1515-### Current Flow (painful)
1616-1. User sees a `<select>` dropdown with their existing entities
1717-2. If entity doesn't exist, user clicks "+ New" button
1818-3. Modal opens (fetched via HTMX), user fills multi-field form
1919-4. Modal submits, closes, dropdown refreshes
2020-5. User must re-select the new entity in the dropdown
2121-2222-### New Flow (smooth)
2323-1. User types in a combo input (e.g., "Yirga...")
2424-2. Dropdown appears with three sections:
2525- - **Your entities** — filtered matches from user's collection
2626- - **Community** — suggestions from other users (via `/api/suggestions`)
2727- - **Create** — "Create «Yirgacheffe Natural»" option at bottom
2828-3. Selecting a user entity → sets hidden rkey, shows name in input
2929-4. Selecting a community suggestion → calls entity creation API with suggestion data (name + fields), sets rkey
3030-5. Selecting "Create" → calls entity creation API with just the name, sets rkey
3131-6. All inline, no modal, no page navigation
3232-3333-### Existing Infrastructure
3434-- `dropdown-manager.js` — cached user entities, already loaded on brew form init
3535-- `entity-suggest.js` — server-side suggestion search (`/api/suggestions/{type}`)
3636-- `entity-manager.js` — entity CRUD via API (`POST /api/{type}`)
3737-- `/api/list-all` — returns all user entities (beans, grinders, brewers, etc.)
3838-- `/api/suggestions/{type}?q={query}` — returns community suggestions
3939-- `POST /api/{type}` — creates entity, returns JSON with rkey
4040-4141-### Key Files
4242-- `static/js/combo-select.js` — **NEW** — reusable combo select component
4343-- `static/js/brew-form.js` — wire combo selects into brew form
4444-- `internal/web/pages/brew_form.templ` — replace select+button with combo input HTML
4545-- `internal/web/components/layout.templ` — add script tag for combo-select.js
4646-- `static/css/app.css` — combo select dropdown styling
4747-4848----
4949-5050-## Task 1: Create the Combo Select Alpine.js Component
5151-5252-**Files:**
5353-- Create: `static/js/combo-select.js`
5454-5555-This is the core reusable component. It manages:
5656-- Text input state and filtering
5757-- Dropdown visibility and keyboard navigation
5858-- Three result sections (user entities, community suggestions, create option)
5959-- Entity selection (sets hidden rkey input)
6060-- Inline entity creation via API
6161-6262-**Step 1: Write the combo-select.js component**
6363-6464-```js
6565-/**
6666- * Reusable combo select component for entity selection + inline creation.
6767- *
6868- * Usage in templ:
6969- * <div x-data="comboSelect({
7070- * entityType: 'bean',
7171- * apiEndpoint: '/api/beans',
7272- * suggestEndpoint: '/api/suggestions/beans',
7373- * inputName: 'bean_rkey',
7474- * placeholder: 'Search or create a bean...',
7575- * formatLabel: (e) => e.name + ' (' + e.origin + ')',
7676- * formatCreateData: (name, suggestion) => ({ name, origin: suggestion?.fields?.origin || '' }),
7777- * })">
7878- * <input type="hidden" :name="inputName" :value="selectedRKey" />
7979- * <input type="text" x-model="query" @input.debounce.200ms="search()"
8080- * @focus="open()" @keydown.escape="close()" @keydown.arrow-down.prevent="moveDown()"
8181- * @keydown.arrow-up.prevent="moveUp()" @keydown.enter.prevent="selectHighlighted()" />
8282- * <div x-show="isOpen" class="combo-dropdown">
8383- * <!-- results rendered here -->
8484- * </div>
8585- * </div>
8686- */
8787-8888-document.addEventListener("alpine:init", () => {
8989- Alpine.data("comboSelect", (config) => ({
9090- // Config
9191- entityType: config.entityType || "",
9292- apiEndpoint: config.apiEndpoint || "",
9393- suggestEndpoint: config.suggestEndpoint || "",
9494- inputName: config.inputName || "",
9595- placeholder: config.placeholder || "Search...",
9696- formatLabel: config.formatLabel || ((e) => e.name || e.Name || ""),
9797- formatCreateData: config.formatCreateData || ((name) => ({ name })),
9898- required: config.required || false,
9999-100100- // State
101101- query: "",
102102- selectedRKey: "",
103103- selectedLabel: "",
104104- isOpen: false,
105105- highlightIndex: -1,
106106- isCreating: false,
107107-108108- // Results
109109- userResults: [],
110110- communityResults: [],
111111-112112- // All items for flat indexing (for keyboard nav)
113113- get allItems() {
114114- const items = [];
115115- for (const r of this.userResults) {
116116- items.push({ type: "user", entity: r });
117117- }
118118- for (const r of this.communityResults) {
119119- items.push({ type: "community", suggestion: r });
120120- }
121121- if (this.query.trim() && !this.exactMatch) {
122122- items.push({ type: "create", name: this.query.trim() });
123123- }
124124- return items;
125125- },
126126-127127- // Whether query exactly matches an existing entity
128128- get exactMatch() {
129129- const q = this.query.trim().toLowerCase();
130130- return this.userResults.some(
131131- (e) => (e.name || e.Name || "").toLowerCase() === q,
132132- );
133133- },
134134-135135- init() {
136136- // If editing, populate from initial value
137137- const initial = config.initialValue;
138138- if (initial) {
139139- this.selectedRKey = initial.rkey || "";
140140- this.selectedLabel = this.formatLabel(initial);
141141- this.query = this.selectedLabel;
142142- }
143143- },
144144-145145- open() {
146146- this.isOpen = true;
147147- this.highlightIndex = -1;
148148- this.search();
149149- },
150150-151151- close() {
152152- // Delay to allow click events on dropdown items
153153- setTimeout(() => {
154154- this.isOpen = false;
155155- // Restore label if user didn't complete selection
156156- if (this.selectedRKey && this.query !== this.selectedLabel) {
157157- this.query = this.selectedLabel;
158158- }
159159- }, 150);
160160- },
161161-162162- async search() {
163163- const q = this.query.trim().toLowerCase();
164164-165165- // Filter user's entities from cache
166166- const entities = this.getUserEntities();
167167- if (q) {
168168- this.userResults = entities.filter((e) => {
169169- const label = this.formatLabel(e).toLowerCase();
170170- return label.includes(q);
171171- });
172172- } else {
173173- this.userResults = entities.slice(0, 10);
174174- }
175175-176176- // Fetch community suggestions
177177- if (q.length >= 2 && this.suggestEndpoint) {
178178- try {
179179- const resp = await fetch(
180180- `${this.suggestEndpoint}?q=${encodeURIComponent(q)}&limit=5`,
181181- { credentials: "same-origin" },
182182- );
183183- if (resp.ok) {
184184- const data = await resp.json();
185185- // Filter out entities the user already has (by name match)
186186- const userNames = new Set(
187187- entities.map((e) => (e.name || e.Name || "").toLowerCase()),
188188- );
189189- this.communityResults = (data || []).filter(
190190- (s) => !userNames.has((s.name || "").toLowerCase()),
191191- );
192192- }
193193- } catch (e) {
194194- console.error("Suggestion fetch failed:", e);
195195- }
196196- } else {
197197- this.communityResults = [];
198198- }
199199-200200- this.highlightIndex = -1;
201201- if (!this.isOpen && this.query) {
202202- this.isOpen = true;
203203- }
204204- },
205205-206206- getUserEntities() {
207207- const dm = window.ArabicaCache?.getData?.() || {};
208208- switch (this.entityType) {
209209- case "bean":
210210- return (dm.beans || []).filter((b) => !b.closed && !b.Closed);
211211- case "brewer":
212212- return dm.brewers || [];
213213- case "grinder":
214214- return dm.grinders || [];
215215- default:
216216- return [];
217217- }
218218- },
219219-220220- // Select an existing user entity
221221- selectEntity(entity) {
222222- const rkey = entity.rkey || entity.RKey;
223223- this.selectedRKey = rkey;
224224- this.selectedLabel = this.formatLabel(entity);
225225- this.query = this.selectedLabel;
226226- this.isOpen = false;
227227-228228- // Dispatch change event for other listeners (e.g., onBrewerChange)
229229- this.$nextTick(() => {
230230- this.$dispatch("combo-change", {
231231- entityType: this.entityType,
232232- rkey,
233233- entity,
234234- });
235235- });
236236- },
237237-238238- // Select a community suggestion — creates the entity first
239239- async selectSuggestion(suggestion) {
240240- this.isCreating = true;
241241- try {
242242- const data = this.formatCreateData(
243243- suggestion.name,
244244- suggestion,
245245- );
246246- if (suggestion.source_uri) {
247247- data.source_ref = suggestion.source_uri;
248248- }
249249- const resp = await fetch(this.apiEndpoint, {
250250- method: "POST",
251251- headers: { "Content-Type": "application/json" },
252252- credentials: "same-origin",
253253- body: JSON.stringify(data),
254254- });
255255- if (!resp.ok) throw new Error(`Create failed: ${resp.status}`);
256256- const created = await resp.json();
257257- const rkey = created.rkey || created.RKey;
258258-259259- this.selectedRKey = rkey;
260260- this.selectedLabel = suggestion.name;
261261- this.query = suggestion.name;
262262- this.isOpen = false;
263263-264264- // Invalidate cache so entity appears in future searches
265265- if (window.ArabicaCache) {
266266- window.ArabicaCache.invalidate();
267267- }
268268-269269- this.$nextTick(() => {
270270- this.$dispatch("combo-change", {
271271- entityType: this.entityType,
272272- rkey,
273273- });
274274- });
275275- } catch (e) {
276276- console.error("Failed to create from suggestion:", e);
277277- } finally {
278278- this.isCreating = false;
279279- }
280280- },
281281-282282- // Create a brand new entity with just the name
283283- async createNew() {
284284- const name = this.query.trim();
285285- if (!name) return;
286286-287287- this.isCreating = true;
288288- try {
289289- const data = this.formatCreateData(name, null);
290290- const resp = await fetch(this.apiEndpoint, {
291291- method: "POST",
292292- headers: { "Content-Type": "application/json" },
293293- credentials: "same-origin",
294294- body: JSON.stringify(data),
295295- });
296296- if (!resp.ok) throw new Error(`Create failed: ${resp.status}`);
297297- const created = await resp.json();
298298- const rkey = created.rkey || created.RKey;
299299-300300- this.selectedRKey = rkey;
301301- this.selectedLabel = name;
302302- this.query = name;
303303- this.isOpen = false;
304304-305305- if (window.ArabicaCache) {
306306- window.ArabicaCache.invalidate();
307307- }
308308-309309- this.$nextTick(() => {
310310- this.$dispatch("combo-change", {
311311- entityType: this.entityType,
312312- rkey,
313313- });
314314- });
315315- } catch (e) {
316316- console.error("Failed to create entity:", e);
317317- } finally {
318318- this.isCreating = false;
319319- }
320320- },
321321-322322- // Keyboard navigation
323323- moveDown() {
324324- if (this.highlightIndex < this.allItems.length - 1) {
325325- this.highlightIndex++;
326326- }
327327- },
328328-329329- moveUp() {
330330- if (this.highlightIndex > 0) {
331331- this.highlightIndex--;
332332- }
333333- },
334334-335335- selectHighlighted() {
336336- const item = this.allItems[this.highlightIndex];
337337- if (!item) return;
338338- if (item.type === "user") this.selectEntity(item.entity);
339339- else if (item.type === "community")
340340- this.selectSuggestion(item.suggestion);
341341- else if (item.type === "create") this.createNew();
342342- },
343343-344344- // Clear selection
345345- clear() {
346346- this.selectedRKey = "";
347347- this.selectedLabel = "";
348348- this.query = "";
349349- this.$dispatch("combo-change", {
350350- entityType: this.entityType,
351351- rkey: "",
352352- });
353353- },
354354- }));
355355-});
356356-```
357357-358358-**Step 2: Commit**
359359-360360-```
361361-feat: add reusable combo-select Alpine.js component for typeahead entity selection
362362-```
363363-364364----
365365-366366-## Task 2: Add Combo Select Styles
367367-368368-**Files:**
369369-- Modify: `static/css/app.css`
370370-371371-**Step 1: Add CSS for the combo dropdown**
372372-373373-Add in the form styling section (after `.form-select`):
374374-375375-```css
376376- /* Combo select dropdown */
377377- .combo-select {
378378- @apply relative;
379379- }
380380-381381- .combo-dropdown {
382382- @apply absolute z-50 w-full mt-1 rounded-lg overflow-hidden;
383383- background: var(--card-bg);
384384- border: 1px solid var(--input-border);
385385- box-shadow: var(--shadow-lg);
386386- max-height: 280px;
387387- overflow-y: auto;
388388- }
389389-390390- .combo-section-label {
391391- @apply text-xs font-medium uppercase tracking-wider px-3 py-1.5;
392392- color: var(--text-muted);
393393- background: var(--surface-bg);
394394- }
395395-396396- .combo-item {
397397- @apply px-3 py-2 cursor-pointer text-sm;
398398- color: var(--text-primary);
399399- }
400400-401401- .combo-item:hover,
402402- .combo-item[data-highlighted="true"] {
403403- background: var(--surface-bg);
404404- }
405405-406406- .combo-item-create {
407407- @apply px-3 py-2 cursor-pointer text-sm font-medium;
408408- color: var(--accent-primary);
409409- border-top: 1px solid var(--surface-border);
410410- }
411411-412412- .combo-item-create:hover,
413413- .combo-item-create[data-highlighted="true"] {
414414- background: var(--surface-bg);
415415- }
416416-417417- .combo-item-sub {
418418- @apply text-xs;
419419- color: var(--text-muted);
420420- }
421421-422422- .combo-creating {
423423- @apply px-3 py-2 text-sm text-center;
424424- color: var(--text-muted);
425425- }
426426-```
427427-428428-**Step 2: Rebuild CSS**
429429-430430-```bash
431431-just style
432432-```
433433-434434-**Step 3: Commit**
435435-436436-```
437437-feat: add combo-select dropdown CSS styles
438438-```
439439-440440----
441441-442442-## Task 3: Add Script Tag and Verify Cache API
443443-444444-**Files:**
445445-- Modify: `internal/web/components/layout.templ`
446446-- Modify: `static/js/dropdown-manager.js` (if needed)
447447-448448-The combo-select component reads from `window.ArabicaCache.getData()`. We need
449449-to verify this API exists in the cache layer, and add the script tag.
450450-451451-**Step 1: Check dropdown-manager.js for getData**
452452-453453-The combo-select needs `window.ArabicaCache.getData()` to return the cached
454454-entity data. Read `static/js/dropdown-manager.js` and find how the cache stores
455455-data. If there's no `getData()` method, add one that returns the current cached
456456-data object `{ beans, grinders, brewers, roasters, recipes }`.
457457-458458-Likely the cache already stores data internally. Add a `getData()` method if
459459-missing:
460460-461461-```js
462462-getData() {
463463- return this._data || {};
464464-},
465465-```
466466-467467-**Step 2: Add script tag in layout.templ**
468468-469469-Add after the entity-suggest.js script and before brew-form.js:
470470-471471-```html
472472-<script src="/static/js/combo-select.js?v=0.1.0"></script>
473473-```
474474-475475-**Step 3: Verify build**
476476-477477-```bash
478478-templ generate
479479-go vet ./...
480480-go build ./...
481481-```
482482-483483-**Step 4: Commit**
484484-485485-```
486486-feat: add combo-select script to layout and ensure cache getData API
487487-```
488488-489489----
490490-491491-## Task 4: Replace Bean Select with Combo Input
492492-493493-**Files:**
494494-- Modify: `internal/web/pages/brew_form.templ`
495495-- Modify: `static/js/brew-form.js`
496496-497497-This is the first entity conversion. Bean is the most important because it's
498498-required.
499499-500500-**Step 1: Replace BeanSelectField in brew_form.templ**
501501-502502-Replace the current `BeanSelectField` component. The new version uses the
503503-combo-select pattern:
504504-505505-```templ
506506-// BeanSelectField renders the bean combo-select with typeahead + inline creation
507507-templ BeanSelectField(props BrewFormProps) {
508508- <div
509509- class="combo-select"
510510- x-data="comboSelect({
511511- entityType: 'bean',
512512- apiEndpoint: '/api/beans',
513513- suggestEndpoint: '/api/suggestions/beans',
514514- inputName: 'bean_rkey',
515515- placeholder: 'Search or create a bean...',
516516- required: true,
517517- formatLabel: (e) => {
518518- const name = e.name || e.Name || '';
519519- const origin = e.origin || e.Origin || '';
520520- const roast = e.roast_level || e.RoastLevel || '';
521521- if (origin && roast) return name + ' (' + origin + ' - ' + roast + ')';
522522- if (origin) return name + ' (' + origin + ')';
523523- return name;
524524- },
525525- formatCreateData: (name, suggestion) => {
526526- const data = { name };
527527- if (suggestion && suggestion.fields) {
528528- if (suggestion.fields.origin) data.origin = suggestion.fields.origin;
529529- if (suggestion.fields.roastLevel) data.roast_level = suggestion.fields.roastLevel;
530530- if (suggestion.fields.process) data.process = suggestion.fields.process;
531531- }
532532- return data;
533533- },
534534- })"
535535- if props.Brew != nil && props.Brew.BeanRKey != "" {
536536- x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BeanRKey, getBeanLabel(props)) }
537537- }
538538- >
539539- <label class="form-label">
540540- Coffee Bean
541541- <span class="text-red-500">*</span>
542542- </label>
543543- <input type="hidden" :name="inputName" :value="selectedRKey" x-ref="rkey"/>
544544- <div class="relative">
545545- <input
546546- type="text"
547547- x-model="query"
548548- @input.debounce.200ms="search()"
549549- @focus="open()"
550550- @blur="close()"
551551- @keydown.escape.prevent="close()"
552552- @keydown.arrow-down.prevent="moveDown()"
553553- @keydown.arrow-up.prevent="moveUp()"
554554- @keydown.enter.prevent="selectHighlighted()"
555555- :placeholder="placeholder"
556556- class="w-full form-input-lg"
557557- autocomplete="off"
558558- />
559559- <!-- Clear button -->
560560- <button
561561- type="button"
562562- x-show="selectedRKey"
563563- @click="clear()"
564564- class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600"
565565- x-cloak
566566- >
567567- @components.IconX()
568568- </button>
569569- </div>
570570- <!-- Dropdown -->
571571- <div x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent>
572572- <!-- Creating indicator -->
573573- <div x-show="isCreating" x-cloak class="combo-creating">Creating...</div>
574574- <template x-if="!isCreating">
575575- <div>
576576- <!-- User's entities -->
577577- <template x-if="userResults.length > 0">
578578- <div>
579579- <div class="combo-section-label">Your beans</div>
580580- <template x-for="(entity, i) in userResults" :key="entity.rkey || entity.RKey">
581581- <div
582582- class="combo-item"
583583- :data-highlighted="highlightIndex === i"
584584- @click="selectEntity(entity)"
585585- @mouseenter="highlightIndex = i"
586586- >
587587- <span x-text="formatLabel(entity)"></span>
588588- </div>
589589- </template>
590590- </div>
591591- </template>
592592- <!-- Community suggestions -->
593593- <template x-if="communityResults.length > 0">
594594- <div>
595595- <div class="combo-section-label">Community</div>
596596- <template x-for="(s, j) in communityResults" :key="s.source_uri || j">
597597- <div
598598- class="combo-item"
599599- :data-highlighted="highlightIndex === userResults.length + j"
600600- @click="selectSuggestion(s)"
601601- @mouseenter="highlightIndex = userResults.length + j"
602602- >
603603- <div x-text="s.name"></div>
604604- <div class="combo-item-sub">
605605- <span x-show="s.fields?.origin" x-text="s.fields?.origin"></span>
606606- <span x-show="s.fields?.origin && s.fields?.roastLevel"> · </span>
607607- <span x-show="s.fields?.roastLevel" x-text="s.fields?.roastLevel"></span>
608608- <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span>
609609- </div>
610610- </div>
611611- </template>
612612- </div>
613613- </template>
614614- <!-- Create option -->
615615- <template x-if="query.trim() && !exactMatch">
616616- <div
617617- class="combo-item-create"
618618- :data-highlighted="highlightIndex === userResults.length + communityResults.length"
619619- @click="createNew()"
620620- @mouseenter="highlightIndex = userResults.length + communityResults.length"
621621- >
622622- Create "<span x-text="query.trim()"></span>"
623623- </div>
624624- </template>
625625- <!-- Empty state -->
626626- <template x-if="allItems.length === 0 && query.trim()">
627627- <div class="combo-creating">No matches found</div>
628628- </template>
629629- </div>
630630- </template>
631631- </div>
632632- </div>
633633-}
634634-```
635635-636636-Add the helper function:
637637-638638-```go
639639-func getBeanLabel(props BrewFormProps) string {
640640- if props.Brew != nil && props.Brew.Bean != nil {
641641- return formatBeanLabel(*props.Brew.Bean)
642642- }
643643- return ""
644644-}
645645-```
646646-647647-**Step 2: Update brew-form.js to listen for combo-change events**
648648-649649-In the brew form Alpine component's `init()`, add a listener for brewer
650650-combo changes (to update `brewerCategory`):
651651-652652-```js
653653-// Listen for combo-select changes
654654-this.$el.addEventListener('combo-change', (e) => {
655655- if (e.detail.entityType === 'brewer') {
656656- const brewerType = e.detail.entity?.brewer_type || e.detail.entity?.BrewerType || '';
657657- this.brewerCategory = this.normalizeBrewerCategory(brewerType);
658658- if (this.brewerCategory === 'pourover') {
659659- this.showPours = true;
660660- }
661661- }
662662-});
663663-```
664664-665665-**Step 3: Run templ generate and verify**
666666-667667-```bash
668668-templ generate
669669-go vet ./...
670670-go build ./...
671671-```
672672-673673-**Step 4: Commit**
674674-675675-```
676676-feat: replace bean select+modal with inline combo-select typeahead
677677-```
678678-679679----
680680-681681-## Task 5: Replace Brewer Select with Combo Input
682682-683683-**Files:**
684684-- Modify: `internal/web/pages/brew_form.templ`
685685-686686-**Step 1: Replace BrewerSelectField**
687687-688688-Same pattern as bean, but with brewer-specific config:
689689-690690-```templ
691691-templ BrewerSelectField(props BrewFormProps) {
692692- <div
693693- class="combo-select"
694694- x-data="comboSelect({
695695- entityType: 'brewer',
696696- apiEndpoint: '/api/brewers',
697697- suggestEndpoint: '/api/suggestions/brewers',
698698- inputName: 'brewer_rkey',
699699- placeholder: 'Search or create a brew method...',
700700- formatLabel: (e) => e.name || e.Name || '',
701701- formatCreateData: (name, suggestion) => {
702702- const data = { name };
703703- if (suggestion && suggestion.fields) {
704704- if (suggestion.fields.brewerType) data.brewer_type = suggestion.fields.brewerType;
705705- }
706706- return data;
707707- },
708708- })"
709709- if props.Brew != nil && props.Brew.BrewerRKey != "" {
710710- x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BrewerRKey, getBrewerLabel(props)) }
711711- }
712712- >
713713- <label class="form-label">Brew Method</label>
714714- <input type="hidden" :name="inputName" :value="selectedRKey"/>
715715- <!-- Same dropdown structure as bean, but with "Your brewers" / "Community" sections -->
716716- <!-- ... (identical HTML structure, just different section labels) -->
717717- </div>
718718-}
719719-```
720720-721721-Note: since the HTML dropdown structure is identical across all three entity
722722-types, the implementing engineer should extract the shared dropdown markup into
723723-the templ component itself. The only differences are:
724724-- Section label text ("Your beans" vs "Your brewers" vs "Your grinders")
725725-- Subtitle fields shown in community results
726726-727727-To avoid duplicating the dropdown HTML three times, create a shared templ
728728-component `ComboSelectDropdown` that accepts a section label prop, or simply
729729-include the dropdown markup inline in each field (it's ~30 lines and the
730730-differences are minor enough that extraction isn't required).
731731-732732-Add helper:
733733-734734-```go
735735-func getBrewerLabel(props BrewFormProps) string {
736736- if props.Brew != nil && props.Brew.BrewerObj != nil {
737737- return props.Brew.BrewerObj.Name
738738- }
739739- return ""
740740-}
741741-```
742742-743743-**Step 2: Commit**
744744-745745-```
746746-feat: replace brewer select+modal with inline combo-select typeahead
747747-```
748748-749749----
750750-751751-## Task 6: Replace Grinder Select with Combo Input
752752-753753-**Files:**
754754-- Modify: `internal/web/pages/brew_form.templ`
755755-756756-**Step 1: Replace GrinderSelectField**
757757-758758-Same pattern, grinder-specific:
759759-760760-```templ
761761-templ GrinderSelectField(props BrewFormProps) {
762762- <div
763763- class="combo-select"
764764- x-data="comboSelect({
765765- entityType: 'grinder',
766766- apiEndpoint: '/api/grinders',
767767- suggestEndpoint: '/api/suggestions/grinders',
768768- inputName: 'grinder_rkey',
769769- placeholder: 'Search or create a grinder...',
770770- formatLabel: (e) => e.name || e.Name || '',
771771- formatCreateData: (name, suggestion) => {
772772- const data = { name };
773773- if (suggestion && suggestion.fields) {
774774- if (suggestion.fields.grinderType) data.grinder_type = suggestion.fields.grinderType;
775775- if (suggestion.fields.burrType) data.burr_type = suggestion.fields.burrType;
776776- }
777777- return data;
778778- },
779779- })"
780780- if props.Brew != nil && props.Brew.GrinderRKey != "" {
781781- x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.GrinderRKey, getGrinderLabel(props)) }
782782- }
783783- >
784784- <label class="form-label">Grinder</label>
785785- <input type="hidden" :name="inputName" :value="selectedRKey"/>
786786- <!-- Same dropdown structure -->
787787- </div>
788788-}
789789-```
790790-791791-Add helper:
792792-793793-```go
794794-func getGrinderLabel(props BrewFormProps) string {
795795- if props.Brew != nil && props.Brew.GrinderObj != nil {
796796- return props.Brew.GrinderObj.Name
797797- }
798798- return ""
799799-}
800800-```
801801-802802-**Step 2: Commit**
803803-804804-```
805805-feat: replace grinder select+modal with inline combo-select typeahead
806806-```
807807-808808----
809809-810810-## Task 7: Update Recipe Autofill to Work with Combo Selects
811811-812812-**Files:**
813813-- Modify: `static/js/brew-form.js`
814814-815815-The recipe autofill currently calls `this.setFormField(form, 'brewer_rkey',
816816-...)` which sets `<select>` values. With combo selects, we need to dispatch
817817-events or directly update the combo component state.
818818-819819-**Step 1: Update applyRecipe**
820820-821821-Replace the `setFormField` calls for `bean_rkey`, `brewer_rkey` with direct
822822-Alpine component communication. The simplest approach: dispatch custom events
823823-that the combo-select listens for.
824824-825825-In `combo-select.js`, add to `init()`:
826826-827827-```js
828828-// Listen for external set events (e.g., from recipe autofill)
829829-this.$el.addEventListener('combo-set', (e) => {
830830- if (e.detail.rkey) {
831831- this.selectedRKey = e.detail.rkey;
832832- this.selectedLabel = e.detail.label || '';
833833- this.query = this.selectedLabel;
834834- }
835835-});
836836-```
837837-838838-In `brew-form.js`, update `applyRecipe`:
839839-840840-```js
841841-// Instead of: this.setFormField(form, 'brewer_rkey', recipe.brewer_rkey || '');
842842-// Do:
843843-const brewerCombo = form.querySelector('[x-data*="entityType: \'brewer\'"]');
844844-if (brewerCombo) {
845845- brewerCombo.dispatchEvent(new CustomEvent('combo-set', {
846846- detail: { rkey: recipe.brewer_rkey || '', label: brewerName },
847847- bubbles: false,
848848- }));
849849-}
850850-```
851851-852852-**Step 2: Commit**
853853-854854-```
855855-feat: update recipe autofill to work with combo-select components
856856-```
857857-858858----
859859-860860-## Task 8: Update Entity Creation API for Minimal Records
861861-862862-**Files:**
863863-- Modify: `internal/handlers/entities.go`
864864-865865-Currently `HandleBeanCreate` requires both `name` and `origin`. For inline
866866-creation with just a name, we need to relax the bean validation so only `name`
867867-is required. Check if `origin` is enforced in the handler or model validation.
868868-869869-**Step 1: Check and relax bean creation validation**
870870-871871-In `internal/models/models.go`, `CreateBeanRequest.Validate()` requires `name`
872872-but does NOT require `origin` (only the handler may check). Verify that the
873873-handler doesn't reject beans without an origin.
874874-875875-If the handler has additional validation beyond `Validate()`, relax it to allow
876876-name-only beans.
877877-878878-**Step 2: Verify grinder and brewer only require name**
879879-880880-Check that `CreateGrinderRequest.Validate()` and `CreateBrewerRequest.Validate()`
881881-only require `name`. They should already be fine.
882882-883883-**Step 3: Commit (if changes needed)**
884884-885885-```
886886-fix: allow creating entities with just a name for inline typeahead creation
887887-```
888888-889889----
890890-891891-## Task 9: Clean Up Removed Code
892892-893893-**Files:**
894894-- Modify: `internal/web/pages/brew_form.templ`
895895-- Modify: `static/js/brew-form.js`
896896-897897-**Step 1: Remove entity manager initialization from brew-form.js**
898898-899899-The `initEntityManagers()` method and related `beanManager`, `grinderManager`,
900900-`brewerManager` properties are no longer needed since we're not using modals.
901901-Remove:
902902-- `initEntityManagers()` method
903903-- `beanManager`, `grinderManager`, `brewerManager` properties
904904-- `saveBean()`, `saveGrinder()`, `saveBrewer()` delegates
905905-- `showBeanForm`, `showGrinderForm`, `showBrewerForm` getters/setters
906906-- `editingBean`, `editingGrinder`, `editingBrewer` getters
907907-- `beanForm`, `grinderForm`, `brewerForm` getters/setters
908908-909909-Keep: `dropdownManager` (still used for recipe data and brewer type lookup).
910910-911911-**Step 2: Remove "+ New" modal buttons from brew_form.templ**
912912-913913-These were part of the old select fields and should already be gone after Tasks
914914-4-6. Verify no remnants exist.
915915-916916-**Step 3: Remove HTMX modal loading comment**
917917-918918-Remove the `<!-- Entity modals now loaded via HTMX into #modal-container -->`
919919-comment from `BrewFormContent`.
920920-921921-**Step 4: Run tests**
922922-923923-```bash
924924-go test ./...
925925-```
926926-927927-**Step 5: Commit**
928928-929929-```
930930-refactor: remove entity modal code from brew form (replaced by combo-select)
931931-```
932932-933933----
934934-935935-## Task 10: Rebuild and Final Verification
936936-937937-**Files:**
938938-- None new
939939-940940-**Step 1: Rebuild CSS**
941941-942942-```bash
943943-just style
944944-```
945945-946946-**Step 2: Run full test suite**
947947-948948-```bash
949949-templ generate
950950-go vet ./...
951951-go test ./...
952952-```
953953-954954-**Step 3: Bump cache versions**
955955-956956-In `layout.templ`, bump CSS and JS versions as needed.
957957-958958-**Step 4: Commit**
959959-960960-```
961961-chore: rebuild CSS and bump cache versions for combo-select
962962-```
963963-964964----
965965-966966-## Summary
967967-968968-| Task | What | Key files |
969969-|---|---|---|
970970-| 1 | Combo select Alpine.js component | `static/js/combo-select.js` (new) |
971971-| 2 | Dropdown CSS styles | `static/css/app.css` |
972972-| 3 | Script tag + cache API | `layout.templ`, `dropdown-manager.js` |
973973-| 4 | Bean combo input | `brew_form.templ`, `brew-form.js` |
974974-| 5 | Brewer combo input | `brew_form.templ` |
975975-| 6 | Grinder combo input | `brew_form.templ` |
976976-| 7 | Recipe autofill integration | `brew-form.js`, `combo-select.js` |
977977-| 8 | Relax entity creation validation | `handlers/entities.go` |
978978-| 9 | Clean up removed modal code | `brew-form.js`, `brew_form.templ` |
979979-| 10 | Final rebuild + verification | CSS, tests, versions |
980980-981981-### What's NOT in scope
982982-- Recipe select (stays as-is — recipes are a different interaction pattern)
983983-- Manage page entity forms (keep modal pattern, different context)
984984-- Profile page entity forms (keep inline Alpine forms, different context)
985985-- The mode chooser removal and section collapsing (separate work items)
-1108
docs/plans/2026-03-27-my-coffee-consolidation.md
···11-# My Coffee Page Consolidation & Home Dashboard
22-33-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44-55-**Goal:** Consolidate My Brews + Manage into a single "My Coffee" page, add an authenticated home dashboard with incomplete record nudges, and expand inline creation modals with progressive disclosure.
66-77-**Architecture:** Replace two overlapping pages (`/brews` and `/manage`) with a single `/my-coffee` page that has tabs for Brews, Beans, Roasters, Grinders, Brewers, and Recipes. Add a dashboard section to the authenticated home page that surfaces quick actions and incomplete records. Entity edit modals open directly from the home dashboard via HTMX. Expand inline combo-select creation with optional "more details" fields.
88-99-**Tech Stack:** Go + Templ (server-side), HTMX (dynamic loading), Alpine.js (client state), Tailwind CSS (styling)
1010-1111----
1212-1313-## Phase 1: Consolidate My Brews + Manage → "My Coffee"
1414-1515-### Task 1: Create My Coffee page template
1616-1717-This task creates the new unified page that combines brew list + manage tabs.
1818-1919-**Files:**
2020-- Modify: `internal/web/pages/manage.templ` (rename to my_coffee concept, reuse as-is)
2121-- Create: `internal/web/pages/my_coffee.templ`
2222-2323-**Step 1: Create the My Coffee page template**
2424-2525-Create `internal/web/pages/my_coffee.templ` with 6 tabs: Brews (default), Beans, Roasters, Grinders, Brewers, Recipes. The Brews tab embeds the brew list content. The other 5 tabs reuse the existing manage partial content.
2626-2727-```templ
2828-package pages
2929-3030-import "arabica/internal/web/components"
3131-3232-type MyCoffeeProps struct{}
3333-3434-templ MyCoffee(layout *components.LayoutData, props MyCoffeeProps) {
3535- @components.Layout(layout, MyCoffeeContent(props))
3636-}
3737-3838-templ MyCoffeeContent(props MyCoffeeProps) {
3939- <script src="/static/js/manage-page.js?v=0.4.0"></script>
4040- <div class="page-container-xl" x-data="managePage()">
4141- <div class="flex items-center gap-3 mb-6">
4242- <h2 class="text-2xl font-semibold text-brown-900">My Coffee</h2>
4343- <div class="ml-auto flex items-center gap-2">
4444- <a href="/brews/new" class="btn-primary shadow-lg hover:shadow-xl">+ New Brew</a>
4545- @ManageRefreshButton()
4646- </div>
4747- </div>
4848- @MyCoffeeTabs()
4949- <!-- Brews tab: standalone HTMX loader -->
5050- <div x-show="tab === 'brews'">
5151- <div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML">
5252- @BrewListLoadingSkeleton()
5353- </div>
5454- </div>
5555- <!-- Entity tabs: loaded from manage partial -->
5656- <div id="manage-content" x-show="tab !== 'brews'" hx-get="/api/manage" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML">
5757- @ManageLoadingSkeleton()
5858- </div>
5959- </div>
6060-}
6161-6262-templ MyCoffeeTabs() {
6363- <div class="mb-6 border-b-2 border-brown-300">
6464- <nav class="-mb-px flex space-x-8 overflow-x-auto">
6565- <button
6666- @click="tab = 'brews'"
6767- :class="tab === 'brews' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
6868- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
6969- >
7070- Brews
7171- </button>
7272- <button
7373- @click="tab = 'beans'"
7474- :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
7575- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
7676- >
7777- Beans
7878- </button>
7979- <button
8080- @click="tab = 'roasters'"
8181- :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
8282- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
8383- >
8484- Roasters
8585- </button>
8686- <button
8787- @click="tab = 'grinders'"
8888- :class="tab === 'grinders' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
8989- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
9090- >
9191- Grinders
9292- </button>
9393- <button
9494- @click="tab = 'brewers'"
9595- :class="tab === 'brewers' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
9696- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
9797- >
9898- Brewers
9999- </button>
100100- <button
101101- @click="tab = 'recipes'"
102102- :class="tab === 'recipes' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'"
103103- class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
104104- >
105105- Recipes
106106- </button>
107107- </nav>
108108- </div>
109109-}
110110-```
111111-112112-Key design decisions:
113113-- Reuses existing `ManageRefreshButton()`, `ManageLoadingSkeleton()` from `manage.templ`
114114-- Brews tab uses the same `/api/brews` HTMX endpoint the old brew list used
115115-- Entity tabs use the same `/api/manage` HTMX endpoint the old manage page used
116116-- The `managePage()` Alpine component already handles tab persistence via localStorage — it just needs the default changed to `'brews'`
117117-- `+ New Brew` button is always visible in the header (not tab-dependent)
118118-119119-**Step 2: Update manage-page.js default tab**
120120-121121-In `static/js/manage-page.js`, change the default tab from `'beans'` to `'brews'`:
122122-123123-```js
124124-// Line 8: change default
125125-tab: localStorage.getItem("manageTab") || "brews",
126126-```
127127-128128-**Step 3: Run templ generate and verify build**
129129-130130-```bash
131131-templ generate
132132-go vet ./...
133133-go build ./...
134134-```
135135-136136-**Step 4: Commit**
137137-138138-```bash
139139-git add internal/web/pages/my_coffee.templ static/js/manage-page.js
140140-git commit -m "feat: add My Coffee page template combining brews and manage"
141141-```
142142-143143----
144144-145145-### Task 2: Add handler and route for My Coffee
146146-147147-Wire up the new page to the router, and add redirects from old URLs.
148148-149149-**Files:**
150150-- Modify: `internal/handlers/entities.go` (add HandleMyCoffee, or reuse HandleManage)
151151-- Modify: `internal/routing/routing.go` (add `/my-coffee` route, redirect old routes)
152152-153153-**Step 1: Add HandleMyCoffee handler**
154154-155155-Add to `internal/handlers/entities.go` (right after or in place of `HandleManage`):
156156-157157-```go
158158-// HandleMyCoffee renders the unified My Coffee page (replaces both /brews and /manage)
159159-func (h *Handler) HandleMyCoffee(w http.ResponseWriter, r *http.Request) {
160160- _, authenticated := h.getAtprotoStore(r)
161161- if !authenticated {
162162- http.Redirect(w, r, "/login", http.StatusFound)
163163- return
164164- }
165165-166166- layoutData, _, _ := h.layoutDataFromRequest(r, "My Coffee")
167167-168168- if err := pages.MyCoffee(layoutData, pages.MyCoffeeProps{}).Render(r.Context(), w); err != nil {
169169- http.Error(w, "Failed to render page", http.StatusInternalServerError)
170170- log.Error().Err(err).Msg("Failed to render my coffee page")
171171- }
172172-}
173173-```
174174-175175-**Step 2: Update routes in routing.go**
176176-177177-In `internal/routing/routing.go`, replace the old `/brews` and `/manage` page routes:
178178-179179-```go
180180-// Replace:
181181-// mux.HandleFunc("GET /manage", h.HandleManage)
182182-// mux.HandleFunc("GET /brews", h.HandleBrewList)
183183-// With:
184184-mux.HandleFunc("GET /my-coffee", h.HandleMyCoffee)
185185-186186-// Add redirects for old URLs
187187-mux.HandleFunc("GET /manage", func(w http.ResponseWriter, r *http.Request) {
188188- http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently)
189189-})
190190-mux.HandleFunc("GET /brews", func(w http.ResponseWriter, r *http.Request) {
191191- http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently)
192192-})
193193-```
194194-195195-Keep ALL existing routes under `/brews/new`, `/brews/{id}`, `/brews/{id}/edit`, `/api/brews`, `/api/manage`, etc. — those are still needed for brew CRUD and HTMX partials.
196196-197197-**Step 3: Verify build**
198198-199199-```bash
200200-templ generate
201201-go vet ./...
202202-go build ./...
203203-```
204204-205205-**Step 4: Commit**
206206-207207-```bash
208208-git add internal/handlers/entities.go internal/routing/routing.go
209209-git commit -m "feat: add /my-coffee route with redirects from /brews and /manage"
210210-```
211211-212212----
213213-214214-### Task 3: Update navigation header
215215-216216-Replace "My Brews" and "Manage Records" dropdown links with a single "My Coffee" link.
217217-218218-**Files:**
219219-- Modify: `internal/web/components/header.templ`
220220-221221-**Step 1: Update header dropdown**
222222-223223-In `internal/web/components/header.templ`, find the dropdown links section (around lines 74-85) and replace:
224224-225225-```templ
226226-// Replace these two links:
227227-<a href="/brews" class="dropdown-item">
228228- My Brews
229229-</a>
230230-<a href="/recipes" class="dropdown-item">
231231- Recipes
232232-</a>
233233-<a href="/manage" class="dropdown-item">
234234- Manage Records
235235-</a>
236236-237237-// With:
238238-<a href="/my-coffee" class="dropdown-item">
239239- My Coffee
240240-</a>
241241-<a href="/recipes" class="dropdown-item">
242242- Recipes
243243-</a>
244244-```
245245-246246-**Step 2: Update welcome card links**
247247-248248-In `internal/web/components/shared.templ`, the `WelcomeAuthenticated` component (around line 226) has links to `/brews/new` and `/brews`. Update the "View All Brews" link:
249249-250250-```templ
251251-// Change href="/brews" to href="/my-coffee"
252252-<a
253253- href="/my-coffee"
254254- class="home-action-secondary block text-center py-4 px-6 rounded-xl"
255255- hx-get="/my-coffee"
256256- hx-target="main"
257257- hx-swap="innerHTML show:top"
258258- hx-select="main > *"
259259- hx-push-url="true"
260260->
261261- <span class="text-lg font-semibold">My Coffee</span>
262262-</a>
263263-```
264264-265265-**Step 3: Verify build**
266266-267267-```bash
268268-templ generate
269269-go vet ./...
270270-go build ./...
271271-```
272272-273273-**Step 4: Commit**
274274-275275-```bash
276276-git add internal/web/components/header.templ internal/web/components/shared.templ
277277-git commit -m "feat: update nav to link to /my-coffee instead of /brews and /manage"
278278-```
279279-280280----
281281-282282-### Task 4: Add modal container to My Coffee page
283283-284284-The entity edit/create modals need a `#modal-container` div on the page to receive HTMX-loaded dialog HTML. The old manage page got this from the manage partial. The My Coffee page needs it too, specifically for the Brews tab where it didn't exist before.
285285-286286-**Files:**
287287-- Modify: `internal/web/pages/my_coffee.templ`
288288-289289-**Step 1: Add modal container**
290290-291291-Add a `#modal-container` div at the end of the page content (inside the `page-container-xl` div but after all tabs):
292292-293293-```templ
294294-<!-- Modal container for HTMX-loaded dialogs -->
295295-<div id="modal-container"></div>
296296-```
297297-298298-This is the target for all `hx-get="/api/modals/..."` requests that load entity edit/create dialogs.
299299-300300-**Step 2: Verify build**
301301-302302-```bash
303303-templ generate
304304-go vet ./...
305305-go build ./...
306306-```
307307-308308-**Step 3: Commit**
309309-310310-```bash
311311-git add internal/web/pages/my_coffee.templ
312312-git commit -m "feat: add modal container to My Coffee page for entity dialogs"
313313-```
314314-315315----
316316-317317-## Phase 2: Authenticated Home Dashboard
318318-319319-### Task 5: Define incomplete records data model
320320-321321-Before building the UI, define how to detect incomplete records. An entity is "incomplete" when key fields are empty.
322322-323323-**Files:**
324324-- Modify: `internal/models/models.go` (add IsIncomplete methods)
325325-326326-**Step 1: Add IsIncomplete methods to models**
327327-328328-Add methods to each entity type. These define what "incomplete" means per entity:
329329-330330-```go
331331-// IsIncomplete returns true if the bean is missing key fields beyond name/origin.
332332-func (b *Bean) IsIncomplete() bool {
333333- return b.RoasterRKey == "" || b.RoastLevel == "" || b.Process == ""
334334-}
335335-336336-// MissingFields returns a human-readable list of missing fields.
337337-func (b *Bean) MissingFields() []string {
338338- var missing []string
339339- if b.RoasterRKey == "" {
340340- missing = append(missing, "roaster")
341341- }
342342- if b.RoastLevel == "" {
343343- missing = append(missing, "roast level")
344344- }
345345- if b.Process == "" {
346346- missing = append(missing, "process")
347347- }
348348- return missing
349349-}
350350-351351-// IsIncomplete returns true if the grinder is missing its type.
352352-func (g *Grinder) IsIncomplete() bool {
353353- return g.GrinderType == ""
354354-}
355355-356356-// MissingFields returns a human-readable list of missing fields.
357357-func (g *Grinder) MissingFields() []string {
358358- var missing []string
359359- if g.GrinderType == "" {
360360- missing = append(missing, "grinder type")
361361- }
362362- return missing
363363-}
364364-365365-// IsIncomplete returns true if the brewer is missing its type.
366366-func (b *Brewer) IsIncomplete() bool {
367367- return b.BrewerType == ""
368368-}
369369-370370-// MissingFields returns a human-readable list of missing fields.
371371-func (b *Brewer) MissingFields() []string {
372372- var missing []string
373373- if b.BrewerType == "" {
374374- missing = append(missing, "brewer type")
375375- }
376376- return missing
377377-}
378378-```
379379-380380-Note: Roasters don't get IsIncomplete — name is the only required field, and location/website are truly optional.
381381-382382-**Step 2: Write tests**
383383-384384-Add to `internal/models/models_test.go` (create if it doesn't exist):
385385-386386-```go
387387-package models
388388-389389-import (
390390- "testing"
391391-392392- "github.com/stretchr/testify/assert"
393393-)
394394-395395-func TestBeanIsIncomplete(t *testing.T) {
396396- // Complete bean
397397- complete := &Bean{Name: "Test", Origin: "Ethiopia", RoasterRKey: "abc", RoastLevel: "Light", Process: "Washed"}
398398- assert.False(t, complete.IsIncomplete())
399399- assert.Empty(t, complete.MissingFields())
400400-401401- // Incomplete bean — missing roaster
402402- incomplete := &Bean{Name: "Test", Origin: "Ethiopia", RoastLevel: "Light", Process: "Washed"}
403403- assert.True(t, incomplete.IsIncomplete())
404404- assert.Contains(t, incomplete.MissingFields(), "roaster")
405405-406406- // Stub bean — name only
407407- stub := &Bean{Name: "Test"}
408408- assert.True(t, stub.IsIncomplete())
409409- assert.Len(t, stub.MissingFields(), 3)
410410-}
411411-412412-func TestGrinderIsIncomplete(t *testing.T) {
413413- complete := &Grinder{Name: "Test", GrinderType: "Hand"}
414414- assert.False(t, complete.IsIncomplete())
415415-416416- incomplete := &Grinder{Name: "Test"}
417417- assert.True(t, incomplete.IsIncomplete())
418418- assert.Contains(t, incomplete.MissingFields(), "grinder type")
419419-}
420420-421421-func TestBrewerIsIncomplete(t *testing.T) {
422422- complete := &Brewer{Name: "V60", BrewerType: "pourover"}
423423- assert.False(t, complete.IsIncomplete())
424424-425425- incomplete := &Brewer{Name: "V60"}
426426- assert.True(t, incomplete.IsIncomplete())
427427- assert.Contains(t, incomplete.MissingFields(), "brewer type")
428428-}
429429-```
430430-431431-**Step 3: Run tests**
432432-433433-```bash
434434-go test ./internal/models/... -v
435435-```
436436-437437-**Step 4: Commit**
438438-439439-```bash
440440-git add internal/models/models.go internal/models/models_test.go
441441-git commit -m "feat: add IsIncomplete and MissingFields methods to entity models"
442442-```
443443-444444----
445445-446446-### Task 6: Add incomplete records API endpoint
447447-448448-Create an HTMX partial that returns incomplete record items for the home dashboard. This keeps the home page handler lightweight — the dashboard section loads async.
449449-450450-**Files:**
451451-- Modify: `internal/handlers/entities.go` (add handler)
452452-- Modify: `internal/routing/routing.go` (add route)
453453-- Create: `internal/web/components/incomplete_records.templ` (new component)
454454-455455-**Step 1: Create the incomplete records component**
456456-457457-Create `internal/web/components/incomplete_records.templ`:
458458-459459-```templ
460460-package components
461461-462462-import (
463463- "arabica/internal/models"
464464- "fmt"
465465- "strings"
466466-)
467467-468468-// IncompleteRecord represents a single entity that needs attention
469469-type IncompleteRecord struct {
470470- EntityType string // "bean", "grinder", "brewer"
471471- RKey string
472472- Name string
473473- MissingFields []string
474474-}
475475-476476-type IncompleteRecordsProps struct {
477477- Records []IncompleteRecord
478478-}
479479-480480-templ IncompleteRecords(props IncompleteRecordsProps) {
481481- if len(props.Records) > 0 {
482482- <div class="card p-4 sm:p-6 mb-6">
483483- <div class="flex items-center gap-2 mb-3">
484484- <svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
485485- <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"></path>
486486- </svg>
487487- <h3 class="text-lg font-semibold text-brown-900">
488488- { fmt.Sprintf("%d", len(props.Records)) } { incompleteNoun(len(props.Records)) } need details
489489- </h3>
490490- </div>
491491- <div class="space-y-2">
492492- for _, rec := range props.Records {
493493- <div class="flex items-center justify-between p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);">
494494- <div>
495495- <span class="font-medium text-brown-900">{ rec.Name }</span>
496496- <span class="text-sm text-brown-600 ml-2">
497497- missing { strings.Join(rec.MissingFields, ", ") }
498498- </span>
499499- </div>
500500- <button
501501- hx-get={ fmt.Sprintf("/api/modals/%s/%s", rec.EntityType, rec.RKey) }
502502- hx-target="#modal-container"
503503- hx-swap="innerHTML"
504504- class="text-sm font-medium text-brown-700 hover:text-brown-900 cursor-pointer"
505505- >
506506- Complete
507507- </button>
508508- </div>
509509- }
510510- </div>
511511- if len(props.Records) > 3 {
512512- <a href="/my-coffee" class="block text-center text-sm text-brown-600 hover:text-brown-800 mt-3">
513513- View all in My Coffee
514514- </a>
515515- }
516516- </div>
517517- <!-- Modal container for HTMX-loaded dialogs -->
518518- <div id="modal-container" hx-on::after-request="if(event.detail.successful && event.target.closest('dialog')) { htmx.ajax('GET', '/api/incomplete-records', {target: '#incomplete-records-section', swap: 'innerHTML'}); }"></div>
519519- }
520520-}
521521-522522-func incompleteNoun(count int) string {
523523- if count == 1 {
524524- return "record"
525525- }
526526- return "records"
527527-}
528528-529529-// CollectIncompleteRecords scans all entities and returns incomplete ones (max limit).
530530-func CollectIncompleteRecords(beans []*models.Bean, grinders []*models.Grinder, brewers []*models.Brewer, limit int) []IncompleteRecord {
531531- var records []IncompleteRecord
532532-533533- for _, b := range beans {
534534- if b.IsIncomplete() && !b.Closed {
535535- records = append(records, IncompleteRecord{
536536- EntityType: "bean",
537537- RKey: b.RKey,
538538- Name: b.Name,
539539- MissingFields: b.MissingFields(),
540540- })
541541- }
542542- }
543543- for _, g := range grinders {
544544- if g.IsIncomplete() {
545545- records = append(records, IncompleteRecord{
546546- EntityType: "grinder",
547547- RKey: g.RKey,
548548- Name: g.Name,
549549- MissingFields: g.MissingFields(),
550550- })
551551- }
552552- }
553553- for _, b := range brewers {
554554- if b.IsIncomplete() {
555555- records = append(records, IncompleteRecord{
556556- EntityType: "brewer",
557557- RKey: b.RKey,
558558- Name: b.Name,
559559- MissingFields: b.MissingFields(),
560560- })
561561- }
562562- }
563563-564564- if limit > 0 && len(records) > limit {
565565- return records[:limit]
566566- }
567567- return records
568568-}
569569-```
570570-571571-**Step 2: Add the HTMX partial handler**
572572-573573-Add to `internal/handlers/entities.go`:
574574-575575-```go
576576-// HandleIncompleteRecordsPartial returns HTML fragment for incomplete records section.
577577-func (h *Handler) HandleIncompleteRecordsPartial(w http.ResponseWriter, r *http.Request) {
578578- store, authenticated := h.getAtprotoStore(r)
579579- if !authenticated {
580580- // Return empty — not an error, just no content for unauthenticated
581581- return
582582- }
583583-584584- ctx := r.Context()
585585- g, ctx := errgroup.WithContext(ctx)
586586-587587- var beans []*models.Bean
588588- var grinders []*models.Grinder
589589- var brewers []*models.Brewer
590590-591591- g.Go(func() error {
592592- var err error
593593- beans, err = store.ListBeans(ctx)
594594- return err
595595- })
596596- g.Go(func() error {
597597- var err error
598598- grinders, err = store.ListGrinders(ctx)
599599- return err
600600- })
601601- g.Go(func() error {
602602- var err error
603603- brewers, err = store.ListBrewers(ctx)
604604- return err
605605- })
606606-607607- if err := g.Wait(); err != nil {
608608- log.Error().Err(err).Msg("Failed to fetch data for incomplete records")
609609- return
610610- }
611611-612612- records := components.CollectIncompleteRecords(beans, grinders, brewers, 5)
613613-614614- if err := components.IncompleteRecords(components.IncompleteRecordsProps{
615615- Records: records,
616616- }).Render(r.Context(), w); err != nil {
617617- log.Error().Err(err).Msg("Failed to render incomplete records")
618618- }
619619-}
620620-```
621621-622622-**Step 3: Add the route**
623623-624624-In `internal/routing/routing.go`, add with the other HTMX partials:
625625-626626-```go
627627-mux.Handle("GET /api/incomplete-records", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleIncompleteRecordsPartial)))
628628-```
629629-630630-**Step 4: Verify build**
631631-632632-```bash
633633-templ generate
634634-go vet ./...
635635-go build ./...
636636-```
637637-638638-**Step 5: Commit**
639639-640640-```bash
641641-git add internal/web/components/incomplete_records.templ internal/handlers/entities.go internal/routing/routing.go
642642-git commit -m "feat: add incomplete records API endpoint and component"
643643-```
644644-645645----
646646-647647-### Task 7: Add dashboard section to home page
648648-649649-Add the dashboard section above the community feed for authenticated users.
650650-651651-**Files:**
652652-- Modify: `internal/web/pages/home.templ`
653653-- Modify: `internal/web/components/shared.templ` (update WelcomeAuthenticated)
654654-655655-**Step 1: Update home page content**
656656-657657-In `internal/web/pages/home.templ`, add a dashboard section between the welcome card and the feed for authenticated users:
658658-659659-```templ
660660-templ HomeContent(props HomeProps) {
661661- <div class="page-container-lg">
662662- @components.WelcomeCard(components.WelcomeCardProps{
663663- IsAuthenticated: props.IsAuthenticated,
664664- UserDID: props.UserDID,
665665- })
666666- if props.IsAuthenticated {
667667- <!-- Incomplete records loaded async -->
668668- <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML">
669669- </div>
670670- }
671671- if !props.IsAuthenticated {
672672- @components.AboutInfoCard()
673673- }
674674- @CommunityFeedSection(props.IsAuthenticated)
675675- if props.IsAuthenticated {
676676- @components.AboutInfoCard()
677677- }
678678- </div>
679679-}
680680-```
681681-682682-Key: The `hx-trigger` includes `refreshManage from:body` so that when a user completes a record via the edit modal (which triggers `refreshManage`), the incomplete records section auto-refreshes.
683683-684684-**Step 2: Update WelcomeAuthenticated quick actions**
685685-686686-In `internal/web/components/shared.templ`, update the `WelcomeAuthenticated` component to have three action buttons instead of two:
687687-688688-```templ
689689-templ WelcomeAuthenticated(userDID string) {
690690- <div class="mb-6">
691691- <p class="text-sm text-brown-700">
692692- Logged in as: <span class="font-mono text-brown-900 font-semibold">{ userDID }</span>
693693- <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a>
694694- </p>
695695- </div>
696696- <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
697697- <a
698698- href="/brews/new"
699699- class="home-action-primary block text-center py-4 px-6 rounded-xl"
700700- >
701701- <span class="text-lg font-semibold">Log Brew</span>
702702- </a>
703703- <a
704704- href="/my-coffee"
705705- class="home-action-secondary block text-center py-4 px-6 rounded-xl"
706706- >
707707- <span class="text-lg font-semibold">My Coffee</span>
708708- </a>
709709- <a
710710- href={ templ.SafeURL("/profile/" + userDID) }
711711- class="home-action-secondary block text-center py-4 px-6 rounded-xl"
712712- >
713713- <span class="text-lg font-semibold">Profile</span>
714714- </a>
715715- </div>
716716-}
717717-```
718718-719719-Note: Remove the `hx-get`/`hx-target`/`hx-swap`/`hx-select`/`hx-push-url` attributes from the action links. They cause issues when navigating away from the home page — standard `<a href>` links are simpler and work correctly.
720720-721721-**Step 3: Verify build**
722722-723723-```bash
724724-templ generate
725725-go vet ./...
726726-go build ./...
727727-```
728728-729729-**Step 4: Commit**
730730-731731-```bash
732732-git add internal/web/pages/home.templ internal/web/components/shared.templ
733733-git commit -m "feat: add dashboard with incomplete records nudge to home page"
734734-```
735735-736736----
737737-738738-### Task 8: Handle modal refresh on home page
739739-740740-When a user clicks "Complete" on the home dashboard and saves in the modal, the incomplete records section should refresh. The modal's `hx-on::after-request` triggers `refreshManage` on the body. Since the home page's incomplete records section listens for `refreshManage from:body` (added in Task 7), this should work automatically.
741741-742742-However, the `#modal-container` div needs to exist on the home page. It's part of the `IncompleteRecords` component (added in Task 6), but only renders when there ARE incomplete records.
743743-744744-**Files:**
745745-- Modify: `internal/web/pages/home.templ`
746746-747747-**Step 1: Add fallback modal container**
748748-749749-Add a `#modal-container` div to the home page that always exists (the one inside `IncompleteRecords` will overwrite it when loaded):
750750-751751-```templ
752752-if props.IsAuthenticated {
753753- <!-- Incomplete records loaded async -->
754754- <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML">
755755- </div>
756756- <!-- Modal container for entity edit dialogs opened from dashboard -->
757757- <div id="modal-container"></div>
758758-}
759759-```
760760-761761-Wait — there's a problem. The IncompleteRecords component already includes a `#modal-container`. If the home page also has one, there will be duplicate IDs. Solution: remove the `#modal-container` from the IncompleteRecords component and keep it only in the pages that use the component (home page, my coffee page).
762762-763763-**Step 2: Update IncompleteRecords component**
764764-765765-In `internal/web/components/incomplete_records.templ`, remove the `#modal-container` div from inside the component. The parent page is responsible for providing it.
766766-767767-**Step 3: Verify build**
768768-769769-```bash
770770-templ generate
771771-go vet ./...
772772-go build ./...
773773-```
774774-775775-**Step 4: Commit**
776776-777777-```bash
778778-git add internal/web/pages/home.templ internal/web/components/incomplete_records.templ
779779-git commit -m "fix: ensure modal container exists on home page for entity edit dialogs"
780780-```
781781-782782----
783783-784784-## Phase 3: Expandable Inline Creation in Brew Form
785785-786786-### Task 9: Add "more details" toggle to combo-select create flow
787787-788788-When the combo-select's `createNew()` fires (user types a name that doesn't match, clicks "Create [name]"), instead of immediately POSTing with just the name, show an expandable section with extra fields.
789789-790790-This is a JS-only change to the combo-select component. The approach: when the user clicks "Create [name]", instead of immediately calling the API, set a `showCreateDetails` flag that reveals additional fields inline in the dropdown. A "Save" button in that expanded section performs the actual POST with all the data.
791791-792792-**Files:**
793793-- Modify: `static/js/combo-select.js` (add create-with-details flow)
794794-- Modify: `internal/web/pages/brew_form.templ` (add extra field config to comboSelectInit)
795795-796796-**Step 1: Add expandable create fields to combo-select**
797797-798798-In `static/js/combo-select.js`, add new state and methods:
799799-800800-```js
801801-// Add to the Alpine.data("comboSelect") return object:
802802-803803-// New state for inline creation with details
804804-showCreateForm: false,
805805-createFormData: {},
806806-807807-// Modified createNew — shows inline form instead of immediately creating
808808-createNewWithDetails() {
809809- const name = this.query.trim();
810810- if (!name) return;
811811-812812- // Initialize form data based on entity type
813813- this.createFormData = { name };
814814- if (this.extraFields) {
815815- for (const field of this.extraFields) {
816816- this.createFormData[field.name] = "";
817817- }
818818- }
819819- this.showCreateForm = true;
820820- this.isOpen = false;
821821-},
822822-823823-// Submit the create form with all details
824824-async submitCreateForm() {
825825- const data = { ...this.createFormData };
826826- this.isCreating = true;
827827- try {
828828- const resp = await fetch(this.apiEndpoint, {
829829- method: "POST",
830830- headers: { "Content-Type": "application/json" },
831831- credentials: "same-origin",
832832- body: JSON.stringify(data),
833833- });
834834- if (!resp.ok) throw new Error(`Create failed: ${resp.status}`);
835835- const created = await resp.json();
836836- const rkey = created.rkey || created.RKey;
837837-838838- this.selectedRKey = rkey;
839839- this.selectedLabel = data.name;
840840- this.query = data.name;
841841- this.showCreateForm = false;
842842-843843- if (window.ArabicaCache) {
844844- window.ArabicaCache.invalidateCache();
845845- }
846846-847847- this.$nextTick(() => {
848848- this.$dispatch("combo-change", {
849849- entityType: this.entityType,
850850- rkey,
851851- });
852852- });
853853- } catch (e) {
854854- console.error("Failed to create entity:", e);
855855- } finally {
856856- this.isCreating = false;
857857- }
858858-},
859859-860860-cancelCreateForm() {
861861- this.showCreateForm = false;
862862- this.createFormData = {};
863863-},
864864-```
865865-866866-The `extraFields` config is provided per entity type (see Step 2).
867867-868868-**Step 2: Add extra field config to brew form combo-select init**
869869-870870-In `internal/web/pages/brew_form.templ`, update the `comboSelectInit` function to include extra fields config per entity type. Add to the config object:
871871-872872-For beans:
873873-```js
874874-extraFields: [
875875- { name: 'roast_level', label: 'Roast Level', type: 'select', options: ['Light', 'Medium-Light', 'Medium', 'Medium-Dark', 'Dark'] },
876876- { name: 'process', label: 'Process', type: 'text', placeholder: 'e.g. Washed, Natural, Honey' },
877877- { name: 'variety', label: 'Variety', type: 'text', placeholder: 'e.g. SL28, Typica, Gesha' },
878878-]
879879-```
880880-881881-For grinders:
882882-```js
883883-extraFields: [
884884- { name: 'grinder_type', label: 'Type', type: 'select', options: ['Hand', 'Electric', 'Portable Electric'] },
885885- { name: 'burr_type', label: 'Burr Type', type: 'select', options: ['Conical', 'Flat', 'Blade'] },
886886-]
887887-```
888888-889889-For brewers:
890890-```js
891891-extraFields: [
892892- { name: 'brewer_type', label: 'Type', type: 'select', options: ['pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'] },
893893-]
894894-```
895895-896896-**Step 3: Add create form template to combo-select markup**
897897-898898-In `internal/web/pages/brew_form.templ`, update the `comboSelectInput` template to include a create form section that shows when `showCreateForm` is true:
899899-900900-```templ
901901-<!-- After the dropdown list, add: -->
902902-<div x-show="showCreateForm" x-transition class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);">
903903- <p class="text-sm font-medium text-brown-900 mb-2">
904904- Creating: <span x-text="createFormData.name" class="font-semibold"></span>
905905- </p>
906906- <template x-if="extraFields && extraFields.length > 0">
907907- <div class="space-y-2">
908908- <template x-for="field in extraFields" :key="field.name">
909909- <div>
910910- <template x-if="field.type === 'select'">
911911- <select
912912- :name="field.name"
913913- x-model="createFormData[field.name]"
914914- class="w-full form-input text-sm"
915915- >
916916- <option value="" x-text="field.label + ' (optional)'"></option>
917917- <template x-for="opt in field.options" :key="opt">
918918- <option :value="opt" x-text="opt"></option>
919919- </template>
920920- </select>
921921- </template>
922922- <template x-if="field.type === 'text'">
923923- <input
924924- type="text"
925925- :placeholder="field.placeholder || field.label"
926926- x-model="createFormData[field.name]"
927927- class="w-full form-input text-sm"
928928- />
929929- </template>
930930- </div>
931931- </template>
932932- <div class="flex gap-2 mt-2">
933933- <button
934934- type="button"
935935- @click="submitCreateForm()"
936936- class="flex-1 btn-primary text-sm py-1.5"
937937- :disabled="isCreating"
938938- >
939939- <span x-show="!isCreating">Save</span>
940940- <span x-show="isCreating">Saving...</span>
941941- </button>
942942- <button
943943- type="button"
944944- @click="cancelCreateForm()"
945945- class="flex-1 btn-secondary text-sm py-1.5"
946946- >
947947- Cancel
948948- </button>
949949- </div>
950950- </div>
951951- </template>
952952-</div>
953953-```
954954-955955-**Step 4: Update createNew to use createNewWithDetails when extraFields exist**
956956-957957-In the combo-select dropdown, change the "Create [name]" button to call `createNewWithDetails()` when extra fields are configured, and `createNew()` when not:
958958-959959-```js
960960-// In the allItems getter, the "create" type still appears.
961961-// In selectHighlighted, change:
962962-else if (item.type === "create") {
963963- if (this.extraFields && this.extraFields.length > 0) {
964964- this.createNewWithDetails();
965965- } else {
966966- this.createNew();
967967- }
968968-}
969969-```
970970-971971-**Step 5: Verify build**
972972-973973-```bash
974974-templ generate
975975-go vet ./...
976976-go build ./...
977977-```
978978-979979-**Step 6: Commit**
980980-981981-```bash
982982-git add static/js/combo-select.js internal/web/pages/brew_form.templ
983983-git commit -m "feat: add expandable details to inline entity creation in brew form"
984984-```
985985-986986----
987987-988988-## Phase 4: Post-Save Nudge (Optional)
989989-990990-### Task 10: Show toast after brew save if entities are incomplete
991991-992992-After successfully creating a brew, check if the referenced bean/grinder/brewer is incomplete and show a toast notification with a "Complete" link that opens the edit modal.
993993-994994-**Files:**
995995-- Modify: `internal/handlers/brew.go` (add incomplete check to HandleBrewCreate response)
996996-- Modify: `static/js/brew-form.js` (handle toast response)
997997-998998-**Step 1: Add incomplete info to brew create response**
999999-10001000-In `internal/handlers/brew.go`, after successfully creating a brew, check if the referenced entities are incomplete. Add a JSON field to the response:
10011001-10021002-```go
10031003-// After successful brew creation, check for incomplete entities
10041004-type brewCreateResponse struct {
10051005- RKey string `json:"rkey"`
10061006- Incomplete []incompleteEntityInfo `json:"incomplete,omitempty"`
10071007-}
10081008-10091009-type incompleteEntityInfo struct {
10101010- EntityType string `json:"entity_type"`
10111011- RKey string `json:"rkey"`
10121012- Name string `json:"name"`
10131013- MissingFields []string `json:"missing_fields"`
10141014-}
10151015-```
10161016-10171017-After creating the brew, fetch the referenced bean/grinder/brewer and check:
10181018-10191019-```go
10201020-var incomplete []incompleteEntityInfo
10211021-10221022-if req.BeanRKey != "" {
10231023- if bean, err := store.GetBean(ctx, req.BeanRKey); err == nil && bean != nil && bean.IsIncomplete() {
10241024- incomplete = append(incomplete, incompleteEntityInfo{
10251025- EntityType: "bean",
10261026- RKey: bean.RKey,
10271027- Name: bean.Name,
10281028- MissingFields: bean.MissingFields(),
10291029- })
10301030- }
10311031-}
10321032-// Similarly for grinder and brewer...
10331033-10341034-resp := brewCreateResponse{RKey: rkey, Incomplete: incomplete}
10351035-```
10361036-10371037-**Step 2: Show toast in brew form JS**
10381038-10391039-In `static/js/brew-form.js`, after a successful brew save, check the response for incomplete entities and show a toast:
10401040-10411041-```js
10421042-// After successful save:
10431043-if (data.incomplete && data.incomplete.length > 0) {
10441044- const item = data.incomplete[0];
10451045- const msg = `${item.name} is missing ${item.missing_fields.join(", ")}`;
10461046- showToast(msg, `/api/modals/${item.entity_type}/${item.rkey}`);
10471047-}
10481048-```
10491049-10501050-Toast implementation: a simple fixed-position div at the bottom of the screen with auto-dismiss after 8 seconds, and a "Complete" button that fetches the edit modal.
10511051-10521052-**Step 3: Verify build**
10531053-10541054-```bash
10551055-templ generate
10561056-go vet ./...
10571057-go build ./...
10581058-```
10591059-10601060-**Step 4: Commit**
10611061-10621062-```bash
10631063-git add internal/handlers/brew.go static/js/brew-form.js
10641064-git commit -m "feat: show toast nudge after brew save if entities are incomplete"
10651065-```
10661066-10671067----
10681068-10691069-## Bump JS/CSS Versions
10701070-10711071-### Task 11: Bump script versions for cache busting
10721072-10731073-After all changes, bump the version query params on JS files to bust Cloudflare and service worker caches.
10741074-10751075-**Files:**
10761076-- Modify: `internal/web/components/layout.templ` (bump version for combo-select.js, brew-form.js, manage-page.js)
10771077-- Modify: `internal/web/pages/my_coffee.templ` (set version for manage-page.js)
10781078-10791079-**Step 1: Update versions**
10801080-10811081-Find all `?v=` query strings on the modified JS files and increment them.
10821082-10831083-**Step 2: Commit**
10841084-10851085-```bash
10861086-git add internal/web/components/layout.templ internal/web/pages/my_coffee.templ
10871087-git commit -m "chore: bump JS versions for cache busting"
10881088-```
10891089-10901090----
10911091-10921092-## Verification Checklist
10931093-10941094-After all tasks:
10951095-10961096-1. `go vet ./...` passes
10971097-2. `go build ./...` passes
10981098-3. `go test ./...` passes
10991099-4. Visiting `/brews` redirects to `/my-coffee`
11001100-5. Visiting `/manage` redirects to `/my-coffee`
11011101-6. `/my-coffee` shows Brews tab by default with brew list
11021102-7. Switching to Beans/Grinders/Brewers/Roasters/Recipes tabs works
11031103-8. Entity create/edit modals work from My Coffee page
11041104-9. Home page shows incomplete records section when records exist
11051105-10. Clicking "Complete" on home page opens edit modal
11061106-11. After saving in modal, incomplete records section refreshes
11071107-12. Header dropdown shows "My Coffee" instead of "My Brews" and "Manage Records"
11081108-13. Inline creation in brew form shows expandable details section
-97
docs/recipes.norg
···11-@document.meta
22-title: Arabica Recipes
33-authors: @pdewey.com
44-created: 03/17/26
55-categories: [spec, lexicon]
66-@end
77-88-* Recipe Records
99-1010-** Motivation
1111-1212- When inputting a new brew, it is tedious to type out all the fields when many
1313- of them are always the same. A "recipe" is supposed to solve parts of this
1414- tedium by auto-filling a number of the fields (most notably pours; count,
1515- water amount, and time). A user should be able to create/save a recipe from
1616- one of their brews (or maybe another users brews as well?).
1717-1818- Recipes are also have potential as a social construct that can be easily
1919- shared around and used. Saving another users recipe is something I would like
2020- to build around in the future. Analytics would also be super cool for
2121- recipes.
2222-2323-** Lexicon Fields
2424-2525- - `name` (string)
2626- - `brewerRef` (ref)
2727- - `brewerType` (string)
2828- - `coffeeAmount` (int -- should be * 10 but isn't)
2929- - `waterAmount` (int * 10)
3030- - `pours` (array of `#pour`) (references {*** Pour Schema})`
3131- - `notes`: (string) reeform description field
3232-3333- It would probably also make sense to ceraete a `brewRef` field that draws a
3434- link to the brew that the original version of the recipe was created from.
3535-3636-*** Pour Schema
3737-3838- Both required:
3939- - `waterAmount` (int - multiplied by 10)
4040- - `timeSeconds` (int)
4141-4242-** Implementation
4343-4444- - Once selected, a recipe should autofill all non-null fields in the brew
4545- - A user should be able to save a recipe off of any brew (by them or by
4646- another user)
4747- - Users should be able to create new recipes from scratch (requires new modal
4848- and view page)
4949-5050-** Debugging and Changes
5151-5252-*** Explore page
5353-5454- The explore page filters are a bit weird, and values between certain ones
5555- seem a bit wonky (i.e. a 15g dose brew shows up in single, small, and large
5656- filters when it should probably only show up in the single cup one (small
5757- should probably be 12 or less? -- not sure about the exact values).
5858-5959- Recipes should also show the profile picture and username of the creator of
6060- the recipe.
6161-6262- Some recipe fields should be interpolated from other fields when missing
6363- (i.e. water amount and ratio, from pours and coffee amount. Ratio may be
6464- transitive)
6565-6666- "Search recipes" entry doesn't currently work. This should be retooled to
6767- show a list of matches in the same style as login handle entry, and either
6868- show just the user's recipes, or maybe show suggested recipes from other
6969- users (not sure how they would be rated though, since that would probably
7070- need to be part of this)
7171-7272-*** Using Other User's Recipes In Brews
7373-7474- Currently, this behavior does not work as expected, as the server tries to
7575- look up the record in the logged-in user's PDS, rather than the one
7676- belonging to the owner. This prevents other users from using recipes that
7777- don't belong to them and is not the intended behavior.
7878-7979- (Brewer fuzzy finding might also not work, but its hard to say since the
8080- recipe lookup fails first)
8181-8282-** Open Questions
8383-8484- For links between a brew and recipe, which should have the optional ref?
8585- Probably brew, but should a recipe contain a ref to the original brew that it
8686- /may/ have been saved from.
8787-8888-** Alternate Recipe Design
8989-9090- Recipes only contain minimal structure and are more freeform, (build a custom
9191- recipe), with just a content field in the lexicon that contains arbitrary
9292- content that can be customized as desired. This leads to more utility that
9393- what just a pourover recipe provides (e.g. maybe milk drink stuff for
9494- espresso?).
9595-9696- This would require some sort of recipe customizer page that allows adding
9797- arbitrary key-value pairs (or pours/refs to gear/beans).
-45
docs/schema-design.md
···11-# Lexicon Schemas
22-33-## Record Types
44-55-Arabica defines 5 lexicon schemas:
66-77-### social.arabica.alpha.bean
88-Coffee bean records with origin, roast level, process, and roaster reference.
99-1010-### social.arabica.alpha.roaster
1111-Coffee roaster records with name, location, and website.
1212-1313-### social.arabica.alpha.grinder
1414-Grinder records with type (hand/electric), burr type (conical/flat), and notes.
1515-1616-### social.arabica.alpha.brewer
1717-Brewing device records with name and description.
1818-1919-### social.arabica.alpha.brew
2020-Brew session records including:
2121-- Bean reference (AT-URI)
2222-- Brewing parameters (temperature, time, water, coffee amounts)
2323-- Grinder and brewer references (optional)
2424-- Grind size, method, tasting notes, rating
2525-- Pours array (embedded, not separate records)
2626-2727-## Design Decisions
2828-2929-### References
3030-All references use AT-URIs pointing to user's own records.
3131-Example: `at://did:plc:abc123/social.arabica.alpha.bean/3jxy123`
3232-3333-### Temperature Storage
3434-Stored as integer in tenths of degrees Celsius.
3535-Example: 935 represents 93.5°C
3636-3737-### Pours
3838-Embedded in brew records as an array rather than separate collection.
3939-4040-### Required Fields
4141-Minimal requirements - most fields are optional for flexibility.
4242-4343-## Schema Files
4444-4545-See `lexicons/` directory for complete JSON schemas.