Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: docs cleanup

+63 -3658
-51
docs/brew-form-overhaul.md
··· 1 - # Brew Form Overhaul 2 - 3 - Reducing friction for new and returning users logging brews. 4 - 5 - ## 1. Kill the Mode Chooser 6 - 7 - Remove the Recipe vs Freeform gate. Start directly in the form. Recipe 8 - selection becomes an optional field at the top (collapsed or a small link). 9 - 10 - ## 2. Progressive Disclosure (Collapsible Sections) 11 - 12 - - Only **Coffee** section is open by default 13 - - **Brewing** and **Results** collapse into accordions with one-line summaries 14 - ("250g water, 93°C, 3:00" or "No details yet") 15 - - Bean + Rating is the minimum viable brew — make that feel intentional, not 16 - like skipping the form 17 - 18 - ## 3. Inline Entity Creation (Typeahead Selects) 19 - 20 - **Highest-impact change.** Replace the current select + "+ New" modal pattern 21 - with a combo input that supports both searching existing entities and creating 22 - new ones inline. 23 - 24 - - User types in the bean/brewer/grinder field 25 - - Matching entities appear as suggestions 26 - - If no match, offer "Create [typed name]" as an option 27 - - Selecting that creates a minimal record (just the name) on the fly 28 - - User can flesh out details (origin, roast level, etc.) later from Manage page 29 - - Eliminates the multi-step modal detour that blocks first-brew logging 30 - 31 - ## 4. Pre-seeded Brewer Suggestions 32 - 33 - On first use (no brewers exist), show common brewers as quick-pick buttons: 34 - V60, Chemex, Aeropress, French Press, Espresso Machine, Moka Pot. 35 - 36 - One tap creates the entity with name + brewer type pre-filled. No modal, no 37 - form fields. 38 - 39 - ## 5. Beverage Field 40 - 41 - Add a `beverage` field to the brew record (separate from brew method): 42 - Black, Latte, Cappuccino, Cortado, Americano, Flat White, Iced, etc. 43 - 44 - This separates "how you extracted" from "what you made with it" — handles milk 45 - drinks cleanly without polluting the brew method taxonomy. 46 - 47 - ## 6. Tasting Wheel (Future) 48 - 49 - Structured tasting notes via scored axes (sweet, acidic, floral, body, etc.) 50 - instead of/alongside free text. Enables comparison and visualization across 51 - brews. Bigger lift — save for later.
-141
docs/ideas.md
··· 1 - # Arabica Feature Ideas 2 - 3 - ## 1. Recipes 4 - 5 - A **recipe** is a reusable, shareable brew procedure — distinct from a brew log, which records a 6 - specific cup. Recipes are the social object people want to discover and re-use. 7 - 8 - ### New Lexicon: `social.arabica.alpha.recipe` 9 - 10 - | Field | Type | Description | 11 - | -------------- | ----------------- | ------------------------------------------------ | 12 - | `title` | string (required) | "My Hario Switch Recipe for Light Roasts" | 13 - | `description` | string | Longer notes, tips, rationale | 14 - | `method` | string | V60, Aeropress, etc. | 15 - | `temperature` | int (tenths °C) | Same encoding as brew | 16 - | `coffeeAmount` | int (grams) | Dose | 17 - | `waterAmount` | int (grams) | Total water | 18 - | `timeSeconds` | int | Total brew time | 19 - | `pours` | array | `[{waterAmount, timeSeconds}]` — pour schedule | 20 - | `tags` | []string | Optional: ["fruity", "light roast", "comp-prep"] | 21 - | `beanNotes` | string | Optional: "works well with washed Ethiopians" | 22 - | `createdAt` | datetime | | 23 - 24 - ### Interaction Flows 25 - 26 - - **Likes / Comments** — already works; just point the existing `like` and `comment` records at the 27 - recipe AT-URI. 28 - - **"Try This"** — button on a recipe opens the brew form pre-populated with the recipe's 29 - parameters. The resulting brew record optionally stores `basedOn: {uri, cid}` referencing the 30 - recipe (strong ref). 31 - - **"Save as Recipe"** — button on a completed brew detail page creates a recipe record from that 32 - brew's parameters (minus the specific bean). 33 - - **"X people tried this"** — count brews across the index whose `basedOn` field references the 34 - recipe's AT-URI. 35 - 36 - ### Social Flywheel 37 - 38 - Recipe → community tries it → brews reference it → author sees who tried it → ratings provide 39 - feedback → better recipes emerge. 40 - 41 - ### Feed Integration 42 - 43 - Recipes should appear in the community feed as a new record type. The feed already supports 44 - filtering by type, so recipes slot in naturally. 45 - 46 - --- 47 - 48 - ## 2. Roaster Analytics 49 - 50 - A **public analytics page** for each roaster, aggregated from community brews via the AT Protocol 51 - ref chain: `brew.beanRef → bean.roasterRef → roaster`. 52 - 53 - No schema changes required — the firehose index already stores all records and their refs. SQLite's 54 - `json_extract()` lets us follow the ref chain in a single JOIN query. 55 - 56 - ### URL 57 - 58 - `/roasters/{did}/{rkey}` → constructs `at://{did}/social.arabica.alpha.roaster/{rkey}` 59 - 60 - ### Analytics Data 61 - 62 - | Metric | Query approach | 63 - | ------------------- | ------------------------------------------------------- | 64 - | Total brews | COUNT brews whose bean references this roaster | 65 - | Total beans indexed | COUNT distinct beans referencing this roaster | 66 - | Active brewers | COUNT DISTINCT brew.did | 67 - | Avg rating | AVG(json_extract(brew.record, '$.rating')) | 68 - | Median rating | Fetch all ratings, sort in Go, pick middle | 69 - | Top beans | GROUP BY bean, AVG rating DESC | 70 - | Brew method mix | GROUP BY json_extract(brew.record, '$.method'), COUNT | 71 - | Rating by month | GROUP BY strftime('%Y-%m', created_at), AVG rating | 72 - 73 - ### Core SQL Pattern 74 - 75 - ```sql 76 - SELECT 77 - bean.uri, 78 - json_extract(bean.record, '$.name') AS bean_name, 79 - COUNT(brew.uri) AS brew_count, 80 - AVG(json_extract(brew.record, '$.rating')) AS avg_rating, 81 - COUNT(DISTINCT brew.did) AS brewer_count 82 - FROM records brew 83 - JOIN records bean ON bean.uri = json_extract(brew.record, '$.beanRef') 84 - WHERE brew.collection = 'social.arabica.alpha.brew' 85 - AND bean.collection = 'social.arabica.alpha.bean' 86 - AND json_extract(bean.record, '$.roasterRef') = ? -- roaster AT-URI 87 - GROUP BY bean.uri 88 - ORDER BY avg_rating DESC 89 - ``` 90 - 91 - ### Page Sections 92 - 93 - 1. **Header** — Roaster name, location, website, total brews / beans / brewers 94 - 2. **Rating summary** — Avg ★, median ★, total rated brews 95 - 3. **Top beans** — Table: bean name, brew count, avg rating 96 - 4. **Brew method breakdown** — Bar/list showing V60, Aeropress, etc. 97 - 5. **Rating trend** — Month-by-month avg rating (simple list or sparkline) 98 - 99 - ### Linking 100 - 101 - Anywhere a roaster name appears in the feed or on a brew/bean detail page, link to 102 - `/roasters/{did}/{rkey}`. 103 - 104 - ### Public Access 105 - 106 - The analytics page is fully public (no auth required) since all data is already public in the 107 - firehose index. 108 - 109 - --- 110 - 111 - ## 3. Personal Analytics Dashboard 112 - 113 - A private `/me/stats` page showing the authenticated user's own brewing trends, computed from 114 - their PDS records (no cross-user aggregation needed). 115 - 116 - ### Metrics 117 - 118 - | Metric | Source | 119 - | -------------------- | ----------------------------------------- | 120 - | Brews per week/month | Count brews by created_at bucket | 121 - | Avg rating over time | Avg rating by month | 122 - | Favourite bean | Most brewed bean (+ highest avg rating) | 123 - | Favourite method | Most used brew method | 124 - | Equipment usage | Most used grinder / brewer | 125 - | Taste evolution | Rating trend over time | 126 - | Bags opened/closed | Count beans by `closed` flag | 127 - 128 - ### Implementation Notes 129 - 130 - - Query user's own PDS via `store.ListBrews()` — no firehose needed. 131 - - Aggregate in Go (small data set per user, no need for SQL aggregation). 132 - - Cache in `SessionCache` to avoid repeated PDS fetches. 133 - - No new lexicons required. 134 - 135 - --- 136 - 137 - ## Priority 138 - 139 - 1. **Roaster analytics** — immediate value, no schema changes, pure SQL over existing indexed refs 140 - 2. **Recipes** — high social value, new lexicon + feed integration required 141 - 3. **Personal stats** — lower complexity, pure client-side aggregation, quality-of-life feature
+63 -16
docs/nix-install.md
··· 1 - # NixOS Installation 1 + # Nix/NixOS Installation 2 + 3 + ## NixOS Module 2 4 3 - ## Using the Module 5 + This repo exposes a NixOS module at `nixosModules.default`. 6 + 7 + ### Via flake input 8 + 9 + ```nix 10 + { 11 + inputs.arabica.url = "github:<you>/arabica"; 12 + 13 + outputs = { self, nixpkgs, arabica, ... }: { 14 + nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { 15 + system = "x86_64-linux"; 16 + modules = [ 17 + arabica.nixosModules.default 18 + ({ ... }: { 19 + services.arabica = { 20 + enable = true; 21 + dataDir = "/var/lib/arabica"; 22 + 23 + settings = { 24 + port = 18910; 25 + logLevel = "info"; 26 + secureCookies = true; 27 + # publicUrl = "https://arabica.example.com"; 28 + }; 29 + 30 + oauth = { 31 + clientId = "https://arabica.example.com/client-metadata.json"; 32 + redirectUri = "https://arabica.example.com/oauth/callback"; 33 + }; 34 + }; 35 + }) 36 + ]; 37 + }; 38 + }; 39 + } 40 + ``` 4 41 5 - Add to your configuration.nix: 42 + ### Via local checkout 6 43 7 44 ```nix 8 45 { 9 - imports = [ ./arabica-site/module.nix ]; 10 - 46 + imports = [ ./nix/module.nix ]; 47 + 11 48 services.arabica = { 12 49 enable = true; 13 - port = 18910; 14 50 dataDir = "/var/lib/arabica"; 15 - logLevel = "info"; 16 - secureCookies = false; # Set true if behind HTTPS proxy 51 + 52 + settings = { 53 + port = 18910; 54 + logLevel = "info"; 55 + secureCookies = false; # only for local/dev http 56 + }; 57 + 58 + oauth = { 59 + clientId = "https://arabica.example.com/client-metadata.json"; 60 + redirectUri = "https://arabica.example.com/oauth/callback"; 61 + }; 17 62 }; 18 63 } 19 64 ``` 20 65 21 - ## Manual Installation 22 - 23 - Build and run directly: 66 + ## Build/Run Manually (flake) 24 67 25 68 ```bash 26 - # Build 27 - nix-build -E 'with import <nixpkgs> {}; callPackage ./default.nix {}' 69 + # Build package 70 + nix build .#arabica 71 + 72 + # Run built binary 73 + ./result/bin/arabica 28 74 29 - # Run 30 - result/bin/arabica 75 + # Or run directly 76 + nix run .#arabica 31 77 ``` 32 78 33 - The data directory will be created at `~/.local/share/arabica/` by default. 79 + By default the wrapper stores data at `~/.local/share/arabica/arabica.db` when 80 + `ARABICA_DB_PATH` is not set.
-1215
docs/plans/2026-03-25-brewer-type-enum-and-method-params.md
··· 1 - # Brewer Type Enum & Method-Specific Brew Params 2 - 3 - > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 - 5 - **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. 6 - 7 - **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). 8 - 9 - **Tech Stack:** Go, templ, Alpine.js, HTMX, AT Protocol lexicons, Tailwind CSS 10 - 11 - --- 12 - 13 - ## Context 14 - 15 - ### Brewer Type Categories 16 - 17 - | Canonical value | Examples | Extra form fields | 18 - |---|---|---| 19 - | `pourover` | V60, Chemex, Kalita Wave, Origami | Bloom water (g), bloom time (s), drawdown time (s), bypass water (g) | 20 - | `espresso` | Machine, lever, manual | Yield weight (g), pressure (bar), pre-infusion time (s) | 21 - | `immersion` | French press, Clever Dripper, siphon, Aeropress | TODO: future | 22 - | `mokapot` | Moka pot, Bialetti | TODO: future (heat level) | 23 - | `coldbrew` | Cold brew, cold drip | TODO: future | 24 - | `cupping` | Cupping | TODO: future | 25 - | `other` | Turkish, custom | No extra fields | 26 - 27 - ### Backwards Compatibility 28 - 29 - - Existing brewer records with freeform strings (e.g. "Pour-Over") continue to work 30 - - New optional fields on the brew lexicon are additive — old records simply lack them 31 - - The app normalizes freeform values to canonical enum values on read for form logic 32 - - Old clients ignore unknown fields per AT Protocol convention 33 - 34 - ### Key Files 35 - 36 - - `lexicons/social.arabica.alpha.brewer.json` — brewer lexicon 37 - - `lexicons/social.arabica.alpha.brew.json` — brew lexicon 38 - - `internal/models/models.go` — Go domain models 39 - - `internal/atproto/records.go` — AT Protocol record ↔ model conversion 40 - - `internal/handlers/brew.go` — brew create/update handlers 41 - - `internal/web/pages/brew_form.templ` — brew form template 42 - - `static/js/brew-form.js` — Alpine.js brew form component 43 - - `static/js/dropdown-manager.js` — brewer type lookup 44 - - `internal/web/components/dialog_modals.templ` — brewer create/edit modal 45 - - `internal/web/pages/profile.templ` — inline brewer form 46 - - `internal/web/pages/brew_view.templ` — brew detail display 47 - - `internal/web/components/icons.templ` — SVG icons 48 - 49 - --- 50 - 51 - ## Task 1: Update Brewer Lexicon with `knownValues` 52 - 53 - **Files:** 54 - - Modify: `lexicons/social.arabica.alpha.brewer.json` 55 - 56 - **Step 1: Update the brewerType field to include knownValues** 57 - 58 - 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). 59 - 60 - ```json 61 - "brewerType": { 62 - "type": "string", 63 - "maxLength": 100, 64 - "knownValues": [ 65 - "pourover", 66 - "espresso", 67 - "immersion", 68 - "mokapot", 69 - "coldbrew", 70 - "cupping", 71 - "other" 72 - ], 73 - "description": "Category of brewer. Known values: pourover, espresso, immersion, mokapot, coldbrew, cupping, other" 74 - } 75 - ``` 76 - 77 - **Step 2: Commit** 78 - 79 - ``` 80 - feat: add knownValues to brewer lexicon brewerType field 81 - ``` 82 - 83 - --- 84 - 85 - ## Task 2: Update Brew Lexicon with Method-Specific Params 86 - 87 - **Files:** 88 - - Modify: `lexicons/social.arabica.alpha.brew.json` 89 - 90 - **Step 1: Add espressoParams and pouroverParams sub-objects** 91 - 92 - Add two new optional fields to the brew record, and two new `#defs` sub-objects: 93 - 94 - In `properties` of the main record, add: 95 - 96 - ```json 97 - "espressoParams": { 98 - "type": "ref", 99 - "ref": "#espressoParams", 100 - "description": "Espresso-specific brewing parameters (optional)" 101 - }, 102 - "pouroverParams": { 103 - "type": "ref", 104 - "ref": "#pouroverParams", 105 - "description": "Pour-over-specific brewing parameters (optional)" 106 - } 107 - ``` 108 - 109 - Add new defs alongside the existing `pour` def: 110 - 111 - ```json 112 - "espressoParams": { 113 - "type": "object", 114 - "description": "Parameters specific to espresso brewing", 115 - "properties": { 116 - "yieldWeight": { 117 - "type": "integer", 118 - "minimum": 0, 119 - "description": "Espresso yield/output weight in tenths of a gram (e.g., 360 = 36.0g)" 120 - }, 121 - "pressure": { 122 - "type": "integer", 123 - "minimum": 0, 124 - "description": "Brewing pressure in tenths of a bar (e.g., 90 = 9.0 bar)" 125 - }, 126 - "preInfusionSeconds": { 127 - "type": "integer", 128 - "minimum": 0, 129 - "description": "Pre-infusion time in seconds" 130 - } 131 - } 132 - }, 133 - "pouroverParams": { 134 - "type": "object", 135 - "description": "Parameters specific to pour-over brewing", 136 - "properties": { 137 - "bloomWater": { 138 - "type": "integer", 139 - "minimum": 0, 140 - "description": "Water used for bloom in grams" 141 - }, 142 - "bloomSeconds": { 143 - "type": "integer", 144 - "minimum": 0, 145 - "description": "Bloom wait time in seconds" 146 - }, 147 - "drawdownSeconds": { 148 - "type": "integer", 149 - "minimum": 0, 150 - "description": "Drawdown time in seconds (time after last pour until bed is dry)" 151 - }, 152 - "bypassWater": { 153 - "type": "integer", 154 - "minimum": 0, 155 - "description": "Bypass water added after brewing in grams" 156 - } 157 - } 158 - } 159 - ``` 160 - 161 - **Step 2: Commit** 162 - 163 - ``` 164 - feat: add espressoParams and pouroverParams to brew lexicon 165 - ``` 166 - 167 - --- 168 - 169 - ## Task 3: Add Brewer Type Constants and Method Param Models 170 - 171 - **Files:** 172 - - Modify: `internal/models/models.go` 173 - - Test: `internal/models/models_test.go` 174 - 175 - **Step 1: Add brewer type constants** 176 - 177 - Add after the existing field length constants block: 178 - 179 - ```go 180 - // Brewer type categories (knownValues from lexicon) 181 - const ( 182 - BrewerTypePourover = "pourover" 183 - BrewerTypeEspresso = "espresso" 184 - BrewerTypeImmersion = "immersion" 185 - BrewerTypeMokaPot = "mokapot" 186 - BrewerTypeColdBrew = "coldbrew" 187 - BrewerTypeCupping = "cupping" 188 - BrewerTypeOther = "other" 189 - ) 190 - 191 - // BrewerTypeLabels maps canonical brewer type values to display labels 192 - var BrewerTypeLabels = map[string]string{ 193 - BrewerTypePourover: "Pour-over", 194 - BrewerTypeEspresso: "Espresso", 195 - BrewerTypeImmersion: "Immersion", 196 - BrewerTypeMokaPot: "Moka Pot", 197 - BrewerTypeColdBrew: "Cold Brew", 198 - BrewerTypeCupping: "Cupping", 199 - BrewerTypeOther: "Other", 200 - } 201 - 202 - // BrewerTypeKnownValues is the ordered list for form dropdowns 203 - var BrewerTypeKnownValues = []string{ 204 - BrewerTypePourover, 205 - BrewerTypeEspresso, 206 - BrewerTypeImmersion, 207 - BrewerTypeMokaPot, 208 - BrewerTypeColdBrew, 209 - BrewerTypeCupping, 210 - BrewerTypeOther, 211 - } 212 - ``` 213 - 214 - **Step 2: Add NormalizeBrewerType function** 215 - 216 - This maps legacy freeform strings to canonical values: 217 - 218 - ```go 219 - // NormalizeBrewerType maps freeform brewer type strings to canonical values. 220 - // Returns the input unchanged if no mapping is found (preserves unknown values). 221 - func NormalizeBrewerType(raw string) string { 222 - lower := strings.ToLower(strings.TrimSpace(raw)) 223 - switch { 224 - case lower == "pourover" || lower == "pour-over" || lower == "pour over" || lower == "dripper": 225 - return BrewerTypePourover 226 - case lower == "espresso" || lower == "espresso machine" || lower == "lever espresso" || lower == "lever espresso machine": 227 - return BrewerTypeEspresso 228 - case lower == "immersion" || lower == "french press" || lower == "aeropress" || lower == "siphon" || lower == "clever" || lower == "clever dripper": 229 - return BrewerTypeImmersion 230 - case lower == "mokapot" || lower == "moka pot" || lower == "moka" || lower == "bialetti": 231 - return BrewerTypeMokaPot 232 - case lower == "coldbrew" || lower == "cold brew" || lower == "cold drip": 233 - return BrewerTypeColdBrew 234 - case lower == "cupping": 235 - return BrewerTypeCupping 236 - case lower == "other": 237 - return BrewerTypeOther 238 - default: 239 - return raw // preserve unknown values 240 - } 241 - } 242 - ``` 243 - 244 - Note: add `"strings"` to the import block in models.go. 245 - 246 - **Step 3: Add EspressoParams and PouroverParams structs** 247 - 248 - Add after the `Pour` struct: 249 - 250 - ```go 251 - // EspressoParams holds espresso-specific brewing parameters 252 - type EspressoParams struct { 253 - YieldWeight float64 `json:"yield_weight"` // Output weight in grams 254 - Pressure float64 `json:"pressure"` // Pressure in bar 255 - PreInfusionSeconds int `json:"pre_infusion_seconds"` // Pre-infusion time 256 - } 257 - 258 - // PouroverParams holds pour-over-specific brewing parameters 259 - type PouroverParams struct { 260 - BloomWater int `json:"bloom_water"` // Bloom water in grams 261 - BloomSeconds int `json:"bloom_seconds"` // Bloom wait time in seconds 262 - DrawdownSeconds int `json:"drawdown_seconds"` // Drawdown time in seconds 263 - BypassWater int `json:"bypass_water"` // Bypass water in grams 264 - } 265 - ``` 266 - 267 - **Step 4: Add fields to Brew model** 268 - 269 - Add to the `Brew` struct, after the `Pours` field: 270 - 271 - ```go 272 - EspressoParams *EspressoParams `json:"espresso_params,omitempty"` 273 - PouroverParams *PouroverParams `json:"pourover_params,omitempty"` 274 - ``` 275 - 276 - **Step 5: Add fields to CreateBrewRequest** 277 - 278 - Add to `CreateBrewRequest`, after `Pours`: 279 - 280 - ```go 281 - EspressoParams *EspressoParams `json:"espresso_params,omitempty"` 282 - PouroverParams *PouroverParams `json:"pourover_params,omitempty"` 283 - ``` 284 - 285 - **Step 6: Write tests for NormalizeBrewerType** 286 - 287 - In `internal/models/models_test.go`, add: 288 - 289 - ```go 290 - func TestNormalizeBrewerType(t *testing.T) { 291 - tests := []struct { 292 - input string 293 - expected string 294 - }{ 295 - {"pourover", "pourover"}, 296 - {"Pour-Over", "pourover"}, 297 - {"pour over", "pourover"}, 298 - {"Dripper", "pourover"}, 299 - {"espresso", "espresso"}, 300 - {"Espresso Machine", "espresso"}, 301 - {"Lever Espresso Machine", "espresso"}, 302 - {"immersion", "immersion"}, 303 - {"French Press", "immersion"}, 304 - {"Aeropress", "immersion"}, 305 - {"Clever Dripper", "immersion"}, 306 - {"mokapot", "mokapot"}, 307 - {"Moka Pot", "mokapot"}, 308 - {"coldbrew", "coldbrew"}, 309 - {"Cold Brew", "coldbrew"}, 310 - {"cupping", "cupping"}, 311 - {"other", "other"}, 312 - {"SomeUnknownType", "SomeUnknownType"}, // preserved 313 - {"", ""}, 314 - } 315 - 316 - for _, tt := range tests { 317 - t.Run(tt.input, func(t *testing.T) { 318 - assert.Equal(t, tt.expected, NormalizeBrewerType(tt.input)) 319 - }) 320 - } 321 - } 322 - ``` 323 - 324 - **Step 7: Run tests** 325 - 326 - ```bash 327 - go test ./internal/models/... -v -run TestNormalizeBrewerType 328 - ``` 329 - 330 - **Step 8: Run vet and build** 331 - 332 - ```bash 333 - go vet ./... && go build ./... 334 - ``` 335 - 336 - **Step 9: Commit** 337 - 338 - ``` 339 - feat: add brewer type constants, method param models, and NormalizeBrewerType 340 - ``` 341 - 342 - --- 343 - 344 - ## Task 4: Update AT Protocol Record Conversion 345 - 346 - **Files:** 347 - - Modify: `internal/atproto/records.go` 348 - - Test: `internal/atproto/records_test.go` 349 - 350 - **Step 1: Update BrewToRecord to serialize new params** 351 - 352 - In the `BrewToRecord` function, after the pours serialization block (after `record["pours"] = pours`), add: 353 - 354 - ```go 355 - // Espresso-specific params 356 - if brew.EspressoParams != nil { 357 - ep := map[string]interface{}{} 358 - if brew.EspressoParams.YieldWeight > 0 { 359 - ep["yieldWeight"] = int(brew.EspressoParams.YieldWeight * 10) // tenths of a gram 360 - } 361 - if brew.EspressoParams.Pressure > 0 { 362 - ep["pressure"] = int(brew.EspressoParams.Pressure * 10) // tenths of a bar 363 - } 364 - if brew.EspressoParams.PreInfusionSeconds > 0 { 365 - ep["preInfusionSeconds"] = brew.EspressoParams.PreInfusionSeconds 366 - } 367 - if len(ep) > 0 { 368 - record["espressoParams"] = ep 369 - } 370 - } 371 - 372 - // Pour-over-specific params 373 - if brew.PouroverParams != nil { 374 - pp := map[string]interface{}{} 375 - if brew.PouroverParams.BloomWater > 0 { 376 - pp["bloomWater"] = brew.PouroverParams.BloomWater 377 - } 378 - if brew.PouroverParams.BloomSeconds > 0 { 379 - pp["bloomSeconds"] = brew.PouroverParams.BloomSeconds 380 - } 381 - if brew.PouroverParams.DrawdownSeconds > 0 { 382 - pp["drawdownSeconds"] = brew.PouroverParams.DrawdownSeconds 383 - } 384 - if brew.PouroverParams.BypassWater > 0 { 385 - pp["bypassWater"] = brew.PouroverParams.BypassWater 386 - } 387 - if len(pp) > 0 { 388 - record["pouroverParams"] = pp 389 - } 390 - } 391 - ``` 392 - 393 - **Step 2: Update RecordToBrew to deserialize new params** 394 - 395 - In the `RecordToBrew` function, after the pours deserialization block, add: 396 - 397 - ```go 398 - // Espresso params 399 - if epRaw, ok := record["espressoParams"].(map[string]interface{}); ok { 400 - ep := &models.EspressoParams{} 401 - if v, ok := epRaw["yieldWeight"].(float64); ok { 402 - ep.YieldWeight = v / 10.0 // tenths back to grams 403 - } 404 - if v, ok := epRaw["pressure"].(float64); ok { 405 - ep.Pressure = v / 10.0 // tenths back to bar 406 - } 407 - if v, ok := epRaw["preInfusionSeconds"].(float64); ok { 408 - ep.PreInfusionSeconds = int(v) 409 - } 410 - brew.EspressoParams = ep 411 - } 412 - 413 - // Pour-over params 414 - if ppRaw, ok := record["pouroverParams"].(map[string]interface{}); ok { 415 - pp := &models.PouroverParams{} 416 - if v, ok := ppRaw["bloomWater"].(float64); ok { 417 - pp.BloomWater = int(v) 418 - } 419 - if v, ok := ppRaw["bloomSeconds"].(float64); ok { 420 - pp.BloomSeconds = int(v) 421 - } 422 - if v, ok := ppRaw["drawdownSeconds"].(float64); ok { 423 - pp.DrawdownSeconds = int(v) 424 - } 425 - if v, ok := ppRaw["bypassWater"].(float64); ok { 426 - pp.BypassWater = int(v) 427 - } 428 - brew.PouroverParams = pp 429 - } 430 - ``` 431 - 432 - **Step 3: Add round-trip tests** 433 - 434 - In `internal/atproto/records_test.go`, add tests for brew records with espresso and pourover params. Follow the existing `TestBrewRoundTrip` pattern: 435 - 436 - ```go 437 - func TestBrewRoundTrip_EspressoParams(t *testing.T) { 438 - original := &models.Brew{ 439 - BeanRKey: "abc123", 440 - Temperature: 93.5, 441 - Rating: 8, 442 - CreatedAt: time.Now().Truncate(time.Second), 443 - EspressoParams: &models.EspressoParams{ 444 - YieldWeight: 36.0, 445 - Pressure: 9.0, 446 - PreInfusionSeconds: 5, 447 - }, 448 - } 449 - 450 - record, err := BrewToRecord(original, "at://did:plc:test/social.arabica.alpha.bean/abc123", "", "", "") 451 - assert.NoError(t, err) 452 - 453 - // Verify espressoParams is in the record 454 - ep, ok := record["espressoParams"].(map[string]interface{}) 455 - assert.True(t, ok) 456 - assert.Equal(t, 360, ep["yieldWeight"]) // 36.0 * 10 457 - assert.Equal(t, 90, ep["pressure"]) // 9.0 * 10 458 - assert.Equal(t, 5, ep["preInfusionSeconds"]) 459 - 460 - restored, err := RecordToBrew(record, "at://did:plc:test/social.arabica.alpha.brew/tid123") 461 - assert.NoError(t, err) 462 - assert.NotNil(t, restored.EspressoParams) 463 - assert.InDelta(t, 36.0, restored.EspressoParams.YieldWeight, 0.1) 464 - assert.InDelta(t, 9.0, restored.EspressoParams.Pressure, 0.1) 465 - assert.Equal(t, 5, restored.EspressoParams.PreInfusionSeconds) 466 - } 467 - 468 - func TestBrewRoundTrip_PouroverParams(t *testing.T) { 469 - original := &models.Brew{ 470 - BeanRKey: "abc123", 471 - CreatedAt: time.Now().Truncate(time.Second), 472 - PouroverParams: &models.PouroverParams{ 473 - BloomWater: 50, 474 - BloomSeconds: 45, 475 - DrawdownSeconds: 30, 476 - BypassWater: 100, 477 - }, 478 - } 479 - 480 - record, err := BrewToRecord(original, "at://did:plc:test/social.arabica.alpha.bean/abc123", "", "", "") 481 - assert.NoError(t, err) 482 - 483 - pp, ok := record["pouroverParams"].(map[string]interface{}) 484 - assert.True(t, ok) 485 - assert.Equal(t, 50, pp["bloomWater"]) 486 - assert.Equal(t, 45, pp["bloomSeconds"]) 487 - assert.Equal(t, 30, pp["drawdownSeconds"]) 488 - assert.Equal(t, 100, pp["bypassWater"]) 489 - 490 - restored, err := RecordToBrew(record, "at://did:plc:test/social.arabica.alpha.brew/tid123") 491 - assert.NoError(t, err) 492 - assert.NotNil(t, restored.PouroverParams) 493 - assert.Equal(t, 50, restored.PouroverParams.BloomWater) 494 - assert.Equal(t, 45, restored.PouroverParams.BloomSeconds) 495 - assert.Equal(t, 30, restored.PouroverParams.DrawdownSeconds) 496 - assert.Equal(t, 100, restored.PouroverParams.BypassWater) 497 - } 498 - ``` 499 - 500 - **Step 4: Run tests** 501 - 502 - ```bash 503 - go test ./internal/atproto/... -v -run TestBrewRoundTrip 504 - ``` 505 - 506 - **Step 5: Commit** 507 - 508 - ``` 509 - feat: serialize/deserialize espresso and pourover params in AT Protocol records 510 - ``` 511 - 512 - --- 513 - 514 - ## Task 5: Update Brewer Form to Use Dropdown 515 - 516 - **Files:** 517 - - Modify: `internal/web/components/dialog_modals.templ` 518 - - Modify: `internal/web/pages/profile.templ` 519 - 520 - 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. 521 - 522 - **Step 1: Update the brewer modal in dialog_modals.templ** 523 - 524 - Find the brewer type text input (around line 367-373): 525 - 526 - ```html 527 - <input 528 - type="text" 529 - name="brewer_type" 530 - value={ getStringValue(brewer, "brewer_type") } 531 - placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 532 - class="w-full form-input" 533 - /> 534 - ``` 535 - 536 - Replace with a select + conditional text input using Alpine.js: 537 - 538 - ```html 539 - <div x-data="{ customType: false }" x-init="customType = !['', 'pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'].includes(document.querySelector('[name=brewer_type_select]')?.value)"> 540 - <select 541 - name="brewer_type_select" 542 - @change="customType = ($event.target.value === '__custom__'); if (!customType) { $el.closest('form').querySelector('[name=brewer_type]').value = $event.target.value; }" 543 - class="w-full form-select" 544 - > 545 - <option value="">Select type...</option> 546 - for _, bt := range models.BrewerTypeKnownValues { 547 - <option 548 - value={ bt } 549 - if getStringValue(brewer, "brewer_type") == bt || models.NormalizeBrewerType(getStringValue(brewer, "brewer_type")) == bt { 550 - selected 551 - } 552 - > 553 - { models.BrewerTypeLabels[bt] } 554 - </option> 555 - } 556 - <option value="__custom__" 557 - if v := getStringValue(brewer, "brewer_type"); v != "" && models.NormalizeBrewerType(v) == v && !isKnownBrewerType(v) { 558 - selected 559 - } 560 - > 561 - Custom... 562 - </option> 563 - </select> 564 - <input 565 - type="hidden" 566 - name="brewer_type" 567 - value={ getBrewerTypeValue(brewer) } 568 - /> 569 - <input 570 - x-show="customType" 571 - x-cloak 572 - type="text" 573 - @input="$el.closest('form').querySelector('[name=brewer_type]').value = $event.target.value" 574 - value={ getCustomBrewerTypeValue(brewer) } 575 - placeholder="Enter custom brewer type..." 576 - class="w-full form-input mt-2" 577 - /> 578 - </div> 579 - ``` 580 - 581 - Note: You'll need to add helper functions to the `getStringValue` helper area at the bottom of `dialog_modals.templ`: 582 - 583 - ```go 584 - func isKnownBrewerType(v string) bool { 585 - for _, bt := range models.BrewerTypeKnownValues { 586 - if v == bt { 587 - return true 588 - } 589 - } 590 - return false 591 - } 592 - 593 - func getBrewerTypeValue(entity interface{}) string { 594 - raw := "" 595 - switch e := entity.(type) { 596 - case *models.Brewer: 597 - if e != nil { 598 - raw = e.BrewerType 599 - } 600 - } 601 - if raw == "" { 602 - return "" 603 - } 604 - normalized := models.NormalizeBrewerType(raw) 605 - if isKnownBrewerType(normalized) { 606 - return normalized 607 - } 608 - return raw 609 - } 610 - 611 - func getCustomBrewerTypeValue(entity interface{}) string { 612 - raw := "" 613 - switch e := entity.(type) { 614 - case *models.Brewer: 615 - if e != nil { 616 - raw = e.BrewerType 617 - } 618 - } 619 - normalized := models.NormalizeBrewerType(raw) 620 - if isKnownBrewerType(normalized) { 621 - return "" 622 - } 623 - return raw 624 - } 625 - ``` 626 - 627 - Also add `"arabica/internal/models"` to the imports if not already present. 628 - 629 - **Step 2: Update the inline brewer form in profile.templ** 630 - 631 - Find the brewer_type text input in profile.templ (around line 390): 632 - 633 - ```html 634 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" class="w-full form-input"/> 635 - ``` 636 - 637 - Replace with a similar select pattern. Since this form uses Alpine.js x-model, it's simpler: 638 - 639 - ```html 640 - <select x-model="brewerForm.brewer_type" class="w-full form-select"> 641 - <option value="">Select type...</option> 642 - <option value="pourover">Pour-over</option> 643 - <option value="espresso">Espresso</option> 644 - <option value="immersion">Immersion</option> 645 - <option value="mokapot">Moka Pot</option> 646 - <option value="coldbrew">Cold Brew</option> 647 - <option value="cupping">Cupping</option> 648 - <option value="other">Other</option> 649 - </select> 650 - ``` 651 - 652 - 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. 653 - 654 - **Step 3: Run templ generate and verify** 655 - 656 - ```bash 657 - templ generate 658 - go vet ./... 659 - go build ./... 660 - ``` 661 - 662 - **Step 4: Commit** 663 - 664 - ``` 665 - feat: replace brewer type free-text with enum dropdown in brewer forms 666 - ``` 667 - 668 - --- 669 - 670 - ## Task 6: Update Brew Handler to Parse Method Params 671 - 672 - **Files:** 673 - - Modify: `internal/handlers/brew.go` 674 - 675 - **Step 1: Add param parsing helper functions** 676 - 677 - Add these helper functions near the existing `validateBrewRequest` function: 678 - 679 - ```go 680 - // parseEspressoParams extracts espresso-specific params from form values. 681 - // Returns nil if no espresso params were provided. 682 - func parseEspressoParams(r *http.Request) *models.EspressoParams { 683 - yieldStr := r.FormValue("espresso_yield_weight") 684 - pressureStr := r.FormValue("espresso_pressure") 685 - preInfStr := r.FormValue("espresso_pre_infusion_seconds") 686 - 687 - if yieldStr == "" && pressureStr == "" && preInfStr == "" { 688 - return nil 689 - } 690 - 691 - ep := &models.EspressoParams{} 692 - if v, err := strconv.ParseFloat(yieldStr, 64); err == nil && v > 0 { 693 - ep.YieldWeight = v 694 - } 695 - if v, err := strconv.ParseFloat(pressureStr, 64); err == nil && v > 0 { 696 - ep.Pressure = v 697 - } 698 - if v, err := strconv.Atoi(preInfStr); err == nil && v > 0 { 699 - ep.PreInfusionSeconds = v 700 - } 701 - return ep 702 - } 703 - 704 - // parsePouroverParams extracts pour-over-specific params from form values. 705 - // Returns nil if no pour-over params were provided. 706 - func parsePouroverParams(r *http.Request) *models.PouroverParams { 707 - bloomWaterStr := r.FormValue("pourover_bloom_water") 708 - bloomSecsStr := r.FormValue("pourover_bloom_seconds") 709 - drawdownStr := r.FormValue("pourover_drawdown_seconds") 710 - bypassStr := r.FormValue("pourover_bypass_water") 711 - 712 - if bloomWaterStr == "" && bloomSecsStr == "" && drawdownStr == "" && bypassStr == "" { 713 - return nil 714 - } 715 - 716 - pp := &models.PouroverParams{} 717 - if v, err := strconv.Atoi(bloomWaterStr); err == nil && v > 0 { 718 - pp.BloomWater = v 719 - } 720 - if v, err := strconv.Atoi(bloomSecsStr); err == nil && v > 0 { 721 - pp.BloomSeconds = v 722 - } 723 - if v, err := strconv.Atoi(drawdownStr); err == nil && v > 0 { 724 - pp.DrawdownSeconds = v 725 - } 726 - if v, err := strconv.Atoi(bypassStr); err == nil && v > 0 { 727 - pp.BypassWater = v 728 - } 729 - return pp 730 - } 731 - ``` 732 - 733 - **Step 2: Wire into HandleBrewCreate** 734 - 735 - In `HandleBrewCreate`, after building the `CreateBrewRequest` (after `Pours: pours,`), add: 736 - 737 - ```go 738 - req.EspressoParams = parseEspressoParams(r) 739 - req.PouroverParams = parsePouroverParams(r) 740 - ``` 741 - 742 - **Step 3: Wire into HandleBrewUpdate** 743 - 744 - Find the equivalent spot in `HandleBrewUpdate` and add the same two lines. 745 - 746 - **Step 4: Ensure strconv is imported** 747 - 748 - Check that `"strconv"` is in the imports of `brew.go`. It likely already is for existing number parsing. 749 - 750 - **Step 5: Run vet and build** 751 - 752 - ```bash 753 - go vet ./... && go build ./... 754 - ``` 755 - 756 - **Step 6: Commit** 757 - 758 - ``` 759 - feat: parse espresso and pourover params from brew form submissions 760 - ``` 761 - 762 - --- 763 - 764 - ## Task 7: Update Store to Pass Method Params Through 765 - 766 - **Files:** 767 - - Modify: `internal/atproto/store.go` 768 - 769 - The store's `CreateBrew` method builds a `Brew` model from the request and calls `BrewToRecord`. We need to pass the new params through. 770 - 771 - **Step 1: Find where CreateBrewRequest is converted to Brew** 772 - 773 - Search for where `CreateBrewRequest` fields are mapped to `Brew` fields in `store.go`. Add after pours mapping: 774 - 775 - ```go 776 - brew.EspressoParams = req.EspressoParams 777 - brew.PouroverParams = req.PouroverParams 778 - ``` 779 - 780 - Do the same for the update path. 781 - 782 - **Step 2: Run vet and build** 783 - 784 - ```bash 785 - go vet ./... && go build ./... 786 - ``` 787 - 788 - **Step 3: Commit** 789 - 790 - ``` 791 - feat: pass method-specific params through store layer 792 - ``` 793 - 794 - --- 795 - 796 - ## Task 8: Add Method-Specific Form Sections to Brew Form 797 - 798 - **Files:** 799 - - Modify: `internal/web/pages/brew_form.templ` 800 - 801 - **Step 1: Add new templ components for method-specific fields** 802 - 803 - Add these after the existing `PoursSection` component: 804 - 805 - ```templ 806 - // EspressoParamsSection renders espresso-specific fields (shown when brewer type is espresso) 807 - templ EspressoParamsSection(props BrewFormProps) { 808 - <div x-show="brewerCategory === 'espresso'" x-cloak> 809 - <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 810 - <legend class="text-sm font-semibold text-brown-800 px-2">Espresso</legend> 811 - @components.FormField( 812 - components.FormFieldProps{ 813 - Label: "Yield Weight (grams)", 814 - HelperText: "Weight of espresso output", 815 - }, 816 - components.NumberInput(components.NumberInputProps{ 817 - Name: "espresso_yield_weight", 818 - Value: getEspressoYieldWeight(props), 819 - Placeholder: "e.g. 36", 820 - Step: "0.1", 821 - Class: "w-full form-input-lg", 822 - }), 823 - ) 824 - @components.FormField( 825 - components.FormFieldProps{ 826 - Label: "Pressure (bar)", 827 - HelperText: "Brewing pressure", 828 - }, 829 - components.NumberInput(components.NumberInputProps{ 830 - Name: "espresso_pressure", 831 - Value: getEspressoPressure(props), 832 - Placeholder: "e.g. 9", 833 - Step: "0.1", 834 - Class: "w-full form-input-lg", 835 - }), 836 - ) 837 - @components.FormField( 838 - components.FormFieldProps{Label: "Pre-infusion Time (seconds)"}, 839 - components.NumberInput(components.NumberInputProps{ 840 - Name: "espresso_pre_infusion_seconds", 841 - Value: getEspressoPreInfusion(props), 842 - Placeholder: "e.g. 5", 843 - Class: "w-full form-input-lg", 844 - }), 845 - ) 846 - </fieldset> 847 - </div> 848 - } 849 - 850 - // PouroverParamsSection renders pour-over-specific fields (shown when brewer type is pourover) 851 - templ PouroverParamsSection(props BrewFormProps) { 852 - <div x-show="brewerCategory === 'pourover'" x-cloak> 853 - <fieldset class="space-y-6 border border-brown-200 rounded-lg p-4 min-w-0"> 854 - <legend class="text-sm font-semibold text-brown-800 px-2">Pour-over Details</legend> 855 - <div class="grid grid-cols-2 gap-4"> 856 - @components.FormField( 857 - components.FormFieldProps{ 858 - Label: "Bloom Water (grams)", 859 - HelperText: "Water for bloom", 860 - }, 861 - components.NumberInput(components.NumberInputProps{ 862 - Name: "pourover_bloom_water", 863 - Value: getPouroverBloomWater(props), 864 - Placeholder: "e.g. 50", 865 - Class: "w-full form-input-lg", 866 - }), 867 - ) 868 - @components.FormField( 869 - components.FormFieldProps{ 870 - Label: "Bloom Time (seconds)", 871 - HelperText: "Bloom wait time", 872 - }, 873 - components.NumberInput(components.NumberInputProps{ 874 - Name: "pourover_bloom_seconds", 875 - Value: getPouroverBloomSeconds(props), 876 - Placeholder: "e.g. 45", 877 - Class: "w-full form-input-lg", 878 - }), 879 - ) 880 - </div> 881 - @components.FormField( 882 - components.FormFieldProps{ 883 - Label: "Drawdown Time (seconds)", 884 - HelperText: "Time after last pour until bed is dry", 885 - }, 886 - components.NumberInput(components.NumberInputProps{ 887 - Name: "pourover_drawdown_seconds", 888 - Value: getPouroverDrawdown(props), 889 - Placeholder: "e.g. 30", 890 - Class: "w-full form-input-lg", 891 - }), 892 - ) 893 - @components.FormField( 894 - components.FormFieldProps{ 895 - Label: "Bypass Water (grams)", 896 - HelperText: "Water added after brewing", 897 - }, 898 - components.NumberInput(components.NumberInputProps{ 899 - Name: "pourover_bypass_water", 900 - Value: getPouroverBypass(props), 901 - Placeholder: "e.g. 100", 902 - Class: "w-full form-input-lg", 903 - }), 904 - ) 905 - </fieldset> 906 - </div> 907 - } 908 - ``` 909 - 910 - **Step 2: Add Go helper functions for reading existing values** 911 - 912 - ```go 913 - func getEspressoYieldWeight(props BrewFormProps) string { 914 - if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.YieldWeight > 0 { 915 - return fmt.Sprintf("%.1f", props.Brew.EspressoParams.YieldWeight) 916 - } 917 - return "" 918 - } 919 - 920 - func getEspressoPressure(props BrewFormProps) string { 921 - if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.Pressure > 0 { 922 - return fmt.Sprintf("%.1f", props.Brew.EspressoParams.Pressure) 923 - } 924 - return "" 925 - } 926 - 927 - func getEspressoPreInfusion(props BrewFormProps) string { 928 - if props.Brew != nil && props.Brew.EspressoParams != nil && props.Brew.EspressoParams.PreInfusionSeconds > 0 { 929 - return fmt.Sprintf("%d", props.Brew.EspressoParams.PreInfusionSeconds) 930 - } 931 - return "" 932 - } 933 - 934 - func getPouroverBloomWater(props BrewFormProps) string { 935 - if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BloomWater > 0 { 936 - return fmt.Sprintf("%d", props.Brew.PouroverParams.BloomWater) 937 - } 938 - return "" 939 - } 940 - 941 - func getPouroverBloomSeconds(props BrewFormProps) string { 942 - if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BloomSeconds > 0 { 943 - return fmt.Sprintf("%d", props.Brew.PouroverParams.BloomSeconds) 944 - } 945 - return "" 946 - } 947 - 948 - func getPouroverDrawdown(props BrewFormProps) string { 949 - if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.DrawdownSeconds > 0 { 950 - return fmt.Sprintf("%d", props.Brew.PouroverParams.DrawdownSeconds) 951 - } 952 - return "" 953 - } 954 - 955 - func getPouroverBypass(props BrewFormProps) string { 956 - if props.Brew != nil && props.Brew.PouroverParams != nil && props.Brew.PouroverParams.BypassWater > 0 { 957 - return fmt.Sprintf("%d", props.Brew.PouroverParams.BypassWater) 958 - } 959 - return "" 960 - } 961 - ``` 962 - 963 - **Step 3: Wire the sections into both form modes** 964 - 965 - In both `RecipeModeSection` and `FreeformModeSection`, add the method-specific sections after the Brewing fieldset and before the Results fieldset: 966 - 967 - ```templ 968 - @EspressoParamsSection(props) 969 - @PouroverParamsSection(props) 970 - ``` 971 - 972 - **Step 4: Run templ generate and verify** 973 - 974 - ```bash 975 - templ generate 976 - go vet ./... 977 - go build ./... 978 - ``` 979 - 980 - **Step 5: Commit** 981 - 982 - ``` 983 - feat: add espresso and pourover parameter sections to brew form 984 - ``` 985 - 986 - --- 987 - 988 - ## Task 9: Add Alpine.js Brewer Category Logic 989 - 990 - **Files:** 991 - - Modify: `static/js/brew-form.js` 992 - 993 - **Step 1: Add brewerCategory state and logic** 994 - 995 - In the Alpine.js `brewForm` data object, add a new reactive property: 996 - 997 - ```js 998 - brewerCategory: '', // 'pourover' | 'espresso' | 'immersion' | ... | '' 999 - ``` 1000 - 1001 - **Step 2: Update the `onBrewerChange` method** 1002 - 1003 - Replace the existing `onBrewerChange` method: 1004 - 1005 - ```js 1006 - onBrewerChange(rkey) { 1007 - const brewerType = this.dropdownManager?.getBrewerType(rkey) || ''; 1008 - this.brewerCategory = this.normalizeBrewerCategory(brewerType); 1009 - 1010 - // Auto-show pours for pour-over brewers 1011 - if (this.brewerCategory === 'pourover') { 1012 - this.showPours = true; 1013 - } 1014 - }, 1015 - 1016 - // Map brewer type strings to canonical categories 1017 - normalizeBrewerCategory(raw) { 1018 - if (!raw) return ''; 1019 - const lower = raw.toLowerCase().trim(); 1020 - 1021 - // Pour-over variants 1022 - if (['pourover', 'pour-over', 'pour over', 'dripper'].includes(lower)) return 'pourover'; 1023 - 1024 - // Espresso variants 1025 - if (['espresso', 'espresso machine', 'lever espresso', 'lever espresso machine'].includes(lower)) return 'espresso'; 1026 - 1027 - // Immersion variants 1028 - if (['immersion', 'french press', 'aeropress', 'siphon', 'clever', 'clever dripper'].includes(lower)) return 'immersion'; 1029 - 1030 - // TODO: future method types 1031 - // if (['mokapot', 'moka pot', 'moka', 'bialetti'].includes(lower)) return 'mokapot'; 1032 - // if (['coldbrew', 'cold brew', 'cold drip'].includes(lower)) return 'coldbrew'; 1033 - // if (lower === 'cupping') return 'cupping'; 1034 - 1035 - // Direct match on canonical values 1036 - if (['pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'].includes(lower)) return lower; 1037 - 1038 - return ''; 1039 - }, 1040 - ``` 1041 - 1042 - **Step 3: Update init to set brewerCategory on load** 1043 - 1044 - 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. 1045 - 1046 - **Step 4: Update applyRecipe to set brewerCategory** 1047 - 1048 - In the `applyRecipe` method, after `this.setFormField(form, 'brewer_rkey', recipe.brewer_rkey || '');`, add: 1049 - 1050 - ```js 1051 - // Update brewer category from recipe's brewer 1052 - if (recipe.brewer_rkey) { 1053 - this.onBrewerChange(recipe.brewer_rkey); 1054 - } 1055 - ``` 1056 - 1057 - **Step 5: Bump the version query param** 1058 - 1059 - In `internal/web/components/layout.templ`, find the brew-form.js script tag and bump its version: 1060 - 1061 - ```html 1062 - <script src="/static/js/brew-form.js?v=0.4.0" defer></script> 1063 - ``` 1064 - 1065 - (Find the current version and increment it.) 1066 - 1067 - **Step 6: Commit** 1068 - 1069 - ``` 1070 - feat: add brewer category detection to Alpine.js brew form for conditional field display 1071 - ``` 1072 - 1073 - --- 1074 - 1075 - ## Task 10: Display Method Params on Brew View Page 1076 - 1077 - **Files:** 1078 - - Modify: `internal/web/pages/brew_view.templ` 1079 - - Modify: `internal/web/components/icons.templ` (if new icons needed) 1080 - 1081 - **Step 1: Add display sections for method params** 1082 - 1083 - In `BrewParametersGrid`, after the brew time field and before the closing `</div>`, add conditional sections: 1084 - 1085 - ```templ 1086 - if brew.EspressoParams != nil { 1087 - if brew.EspressoParams.YieldWeight > 0 { 1088 - @components.DetailField(components.DetailFieldProps{Icon: components.IconScale(), Label: "Yield", Value: fmt.Sprintf("%.1fg", brew.EspressoParams.YieldWeight)}) 1089 - } 1090 - if brew.EspressoParams.Pressure > 0 { 1091 - @components.DetailField(components.DetailFieldProps{Icon: components.IconBarChart(), Label: "Pressure", Value: fmt.Sprintf("%.1f bar", brew.EspressoParams.Pressure)}) 1092 - } 1093 - if brew.EspressoParams.PreInfusionSeconds > 0 { 1094 - @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Pre-infusion", Value: fmt.Sprintf("%ds", brew.EspressoParams.PreInfusionSeconds)}) 1095 - } 1096 - } 1097 - if brew.PouroverParams != nil { 1098 - if brew.PouroverParams.BloomWater > 0 || brew.PouroverParams.BloomSeconds > 0 { 1099 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bloom", Value: formatBloom(brew.PouroverParams)}) 1100 - } 1101 - if brew.PouroverParams.DrawdownSeconds > 0 { 1102 - @components.DetailField(components.DetailFieldProps{Icon: components.IconClock(), Label: "Drawdown", Value: fmt.Sprintf("%ds", brew.PouroverParams.DrawdownSeconds)}) 1103 - } 1104 - if brew.PouroverParams.BypassWater > 0 { 1105 - @components.DetailField(components.DetailFieldProps{Icon: components.IconDroplet(), Label: "Bypass Water", Value: fmt.Sprintf("%dg", brew.PouroverParams.BypassWater)}) 1106 - } 1107 - } 1108 - ``` 1109 - 1110 - **Step 2: Add formatBloom helper** 1111 - 1112 - ```go 1113 - func formatBloom(pp *models.PouroverParams) string { 1114 - if pp.BloomWater > 0 && pp.BloomSeconds > 0 { 1115 - return fmt.Sprintf("%dg for %ds", pp.BloomWater, pp.BloomSeconds) 1116 - } 1117 - if pp.BloomWater > 0 { 1118 - return fmt.Sprintf("%dg", pp.BloomWater) 1119 - } 1120 - if pp.BloomSeconds > 0 { 1121 - return fmt.Sprintf("%ds", pp.BloomSeconds) 1122 - } 1123 - return "" 1124 - } 1125 - ``` 1126 - 1127 - **Step 3: Run templ generate and verify** 1128 - 1129 - ```bash 1130 - templ generate 1131 - go vet ./... 1132 - go build ./... 1133 - ``` 1134 - 1135 - **Step 4: Commit** 1136 - 1137 - ``` 1138 - feat: display espresso and pourover params on brew view page 1139 - ``` 1140 - 1141 - --- 1142 - 1143 - ## Task 11: Update Feed Display for Method Params (Optional) 1144 - 1145 - **Files:** 1146 - - Modify: `internal/web/pages/feed.templ` 1147 - 1148 - This is optional but nice to have — show a small badge or extra detail on feed cards when espresso/pourover params are present. 1149 - 1150 - **Step 1: Check if feed cards already show brew params** 1151 - 1152 - If feed cards show brew variables (coffee, water, temp), consider adding yield weight for espresso brews inline. This is a small cosmetic addition. 1153 - 1154 - **Step 2: Commit if changed** 1155 - 1156 - ``` 1157 - feat: show method-specific params in feed cards 1158 - ``` 1159 - 1160 - --- 1161 - 1162 - ## Task 12: Rebuild CSS and Final Verification 1163 - 1164 - **Files:** 1165 - - None new 1166 - 1167 - **Step 1: Rebuild Tailwind CSS** 1168 - 1169 - ```bash 1170 - just style 1171 - ``` 1172 - 1173 - **Step 2: Run full test suite** 1174 - 1175 - ```bash 1176 - go test ./... 1177 - ``` 1178 - 1179 - **Step 3: Run go vet** 1180 - 1181 - ```bash 1182 - go vet ./... 1183 - ``` 1184 - 1185 - **Step 4: Bump CSS version for cache busting** 1186 - 1187 - In `internal/web/components/layout.templ`, bump the CSS version query parameter. 1188 - 1189 - **Step 5: Final commit** 1190 - 1191 - ``` 1192 - chore: rebuild CSS and bump cache versions 1193 - ``` 1194 - 1195 - --- 1196 - 1197 - ## Summary of Changes 1198 - 1199 - | Area | What changes | 1200 - |---|---| 1201 - | **Lexicons** | `brewer.json` gets `knownValues`, `brew.json` gets `espressoParams` and `pouroverParams` sub-objects | 1202 - | **Models** | Brewer type constants, `NormalizeBrewerType()`, `EspressoParams`/`PouroverParams` structs, new fields on `Brew` and `CreateBrewRequest` | 1203 - | **Records** | Serialization/deserialization for new params in `BrewToRecord`/`RecordToBrew` | 1204 - | **Handlers** | `parseEspressoParams()`/`parsePouroverParams()` helpers, wired into create/update | 1205 - | **Store** | Pass-through of new params | 1206 - | **Brew form** | New `EspressoParamsSection` and `PouroverParamsSection` templ components, conditional on `brewerCategory` | 1207 - | **Alpine.js** | `brewerCategory` state, `normalizeBrewerCategory()` method, updated `onBrewerChange()` | 1208 - | **Brewer form** | Free text → select dropdown with known values + "Custom..." option | 1209 - | **Brew view** | Conditional display of method-specific params in parameters grid | 1210 - 1211 - ### Future TODOs left in code 1212 - 1213 - - `normalizeBrewerCategory()` in JS has commented-out cases for `mokapot`, `coldbrew`, `cupping` 1214 - - Each future method type needs: a form section component, a handler parser, and view display logic 1215 - - The pattern is established — copy `EspressoParamsSection`/`parseEspressoParams` as templates
-985
docs/plans/2026-03-25-inline-entity-typeahead.md
··· 1 - # Inline Entity Typeahead Implementation Plan 2 - 3 - > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 - 5 - **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. 6 - 7 - **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). 8 - 9 - **Tech Stack:** Alpine.js, HTMX, Go/templ, Tailwind CSS 10 - 11 - --- 12 - 13 - ## Context 14 - 15 - ### Current Flow (painful) 16 - 1. User sees a `<select>` dropdown with their existing entities 17 - 2. If entity doesn't exist, user clicks "+ New" button 18 - 3. Modal opens (fetched via HTMX), user fills multi-field form 19 - 4. Modal submits, closes, dropdown refreshes 20 - 5. User must re-select the new entity in the dropdown 21 - 22 - ### New Flow (smooth) 23 - 1. User types in a combo input (e.g., "Yirga...") 24 - 2. Dropdown appears with three sections: 25 - - **Your entities** — filtered matches from user's collection 26 - - **Community** — suggestions from other users (via `/api/suggestions`) 27 - - **Create** — "Create «Yirgacheffe Natural»" option at bottom 28 - 3. Selecting a user entity → sets hidden rkey, shows name in input 29 - 4. Selecting a community suggestion → calls entity creation API with suggestion data (name + fields), sets rkey 30 - 5. Selecting "Create" → calls entity creation API with just the name, sets rkey 31 - 6. All inline, no modal, no page navigation 32 - 33 - ### Existing Infrastructure 34 - - `dropdown-manager.js` — cached user entities, already loaded on brew form init 35 - - `entity-suggest.js` — server-side suggestion search (`/api/suggestions/{type}`) 36 - - `entity-manager.js` — entity CRUD via API (`POST /api/{type}`) 37 - - `/api/list-all` — returns all user entities (beans, grinders, brewers, etc.) 38 - - `/api/suggestions/{type}?q={query}` — returns community suggestions 39 - - `POST /api/{type}` — creates entity, returns JSON with rkey 40 - 41 - ### Key Files 42 - - `static/js/combo-select.js` — **NEW** — reusable combo select component 43 - - `static/js/brew-form.js` — wire combo selects into brew form 44 - - `internal/web/pages/brew_form.templ` — replace select+button with combo input HTML 45 - - `internal/web/components/layout.templ` — add script tag for combo-select.js 46 - - `static/css/app.css` — combo select dropdown styling 47 - 48 - --- 49 - 50 - ## Task 1: Create the Combo Select Alpine.js Component 51 - 52 - **Files:** 53 - - Create: `static/js/combo-select.js` 54 - 55 - This is the core reusable component. It manages: 56 - - Text input state and filtering 57 - - Dropdown visibility and keyboard navigation 58 - - Three result sections (user entities, community suggestions, create option) 59 - - Entity selection (sets hidden rkey input) 60 - - Inline entity creation via API 61 - 62 - **Step 1: Write the combo-select.js component** 63 - 64 - ```js 65 - /** 66 - * Reusable combo select component for entity selection + inline creation. 67 - * 68 - * Usage in templ: 69 - * <div x-data="comboSelect({ 70 - * entityType: 'bean', 71 - * apiEndpoint: '/api/beans', 72 - * suggestEndpoint: '/api/suggestions/beans', 73 - * inputName: 'bean_rkey', 74 - * placeholder: 'Search or create a bean...', 75 - * formatLabel: (e) => e.name + ' (' + e.origin + ')', 76 - * formatCreateData: (name, suggestion) => ({ name, origin: suggestion?.fields?.origin || '' }), 77 - * })"> 78 - * <input type="hidden" :name="inputName" :value="selectedRKey" /> 79 - * <input type="text" x-model="query" @input.debounce.200ms="search()" 80 - * @focus="open()" @keydown.escape="close()" @keydown.arrow-down.prevent="moveDown()" 81 - * @keydown.arrow-up.prevent="moveUp()" @keydown.enter.prevent="selectHighlighted()" /> 82 - * <div x-show="isOpen" class="combo-dropdown"> 83 - * <!-- results rendered here --> 84 - * </div> 85 - * </div> 86 - */ 87 - 88 - document.addEventListener("alpine:init", () => { 89 - Alpine.data("comboSelect", (config) => ({ 90 - // Config 91 - entityType: config.entityType || "", 92 - apiEndpoint: config.apiEndpoint || "", 93 - suggestEndpoint: config.suggestEndpoint || "", 94 - inputName: config.inputName || "", 95 - placeholder: config.placeholder || "Search...", 96 - formatLabel: config.formatLabel || ((e) => e.name || e.Name || ""), 97 - formatCreateData: config.formatCreateData || ((name) => ({ name })), 98 - required: config.required || false, 99 - 100 - // State 101 - query: "", 102 - selectedRKey: "", 103 - selectedLabel: "", 104 - isOpen: false, 105 - highlightIndex: -1, 106 - isCreating: false, 107 - 108 - // Results 109 - userResults: [], 110 - communityResults: [], 111 - 112 - // All items for flat indexing (for keyboard nav) 113 - get allItems() { 114 - const items = []; 115 - for (const r of this.userResults) { 116 - items.push({ type: "user", entity: r }); 117 - } 118 - for (const r of this.communityResults) { 119 - items.push({ type: "community", suggestion: r }); 120 - } 121 - if (this.query.trim() && !this.exactMatch) { 122 - items.push({ type: "create", name: this.query.trim() }); 123 - } 124 - return items; 125 - }, 126 - 127 - // Whether query exactly matches an existing entity 128 - get exactMatch() { 129 - const q = this.query.trim().toLowerCase(); 130 - return this.userResults.some( 131 - (e) => (e.name || e.Name || "").toLowerCase() === q, 132 - ); 133 - }, 134 - 135 - init() { 136 - // If editing, populate from initial value 137 - const initial = config.initialValue; 138 - if (initial) { 139 - this.selectedRKey = initial.rkey || ""; 140 - this.selectedLabel = this.formatLabel(initial); 141 - this.query = this.selectedLabel; 142 - } 143 - }, 144 - 145 - open() { 146 - this.isOpen = true; 147 - this.highlightIndex = -1; 148 - this.search(); 149 - }, 150 - 151 - close() { 152 - // Delay to allow click events on dropdown items 153 - setTimeout(() => { 154 - this.isOpen = false; 155 - // Restore label if user didn't complete selection 156 - if (this.selectedRKey && this.query !== this.selectedLabel) { 157 - this.query = this.selectedLabel; 158 - } 159 - }, 150); 160 - }, 161 - 162 - async search() { 163 - const q = this.query.trim().toLowerCase(); 164 - 165 - // Filter user's entities from cache 166 - const entities = this.getUserEntities(); 167 - if (q) { 168 - this.userResults = entities.filter((e) => { 169 - const label = this.formatLabel(e).toLowerCase(); 170 - return label.includes(q); 171 - }); 172 - } else { 173 - this.userResults = entities.slice(0, 10); 174 - } 175 - 176 - // Fetch community suggestions 177 - if (q.length >= 2 && this.suggestEndpoint) { 178 - try { 179 - const resp = await fetch( 180 - `${this.suggestEndpoint}?q=${encodeURIComponent(q)}&limit=5`, 181 - { credentials: "same-origin" }, 182 - ); 183 - if (resp.ok) { 184 - const data = await resp.json(); 185 - // Filter out entities the user already has (by name match) 186 - const userNames = new Set( 187 - entities.map((e) => (e.name || e.Name || "").toLowerCase()), 188 - ); 189 - this.communityResults = (data || []).filter( 190 - (s) => !userNames.has((s.name || "").toLowerCase()), 191 - ); 192 - } 193 - } catch (e) { 194 - console.error("Suggestion fetch failed:", e); 195 - } 196 - } else { 197 - this.communityResults = []; 198 - } 199 - 200 - this.highlightIndex = -1; 201 - if (!this.isOpen && this.query) { 202 - this.isOpen = true; 203 - } 204 - }, 205 - 206 - getUserEntities() { 207 - const dm = window.ArabicaCache?.getData?.() || {}; 208 - switch (this.entityType) { 209 - case "bean": 210 - return (dm.beans || []).filter((b) => !b.closed && !b.Closed); 211 - case "brewer": 212 - return dm.brewers || []; 213 - case "grinder": 214 - return dm.grinders || []; 215 - default: 216 - return []; 217 - } 218 - }, 219 - 220 - // Select an existing user entity 221 - selectEntity(entity) { 222 - const rkey = entity.rkey || entity.RKey; 223 - this.selectedRKey = rkey; 224 - this.selectedLabel = this.formatLabel(entity); 225 - this.query = this.selectedLabel; 226 - this.isOpen = false; 227 - 228 - // Dispatch change event for other listeners (e.g., onBrewerChange) 229 - this.$nextTick(() => { 230 - this.$dispatch("combo-change", { 231 - entityType: this.entityType, 232 - rkey, 233 - entity, 234 - }); 235 - }); 236 - }, 237 - 238 - // Select a community suggestion — creates the entity first 239 - async selectSuggestion(suggestion) { 240 - this.isCreating = true; 241 - try { 242 - const data = this.formatCreateData( 243 - suggestion.name, 244 - suggestion, 245 - ); 246 - if (suggestion.source_uri) { 247 - data.source_ref = suggestion.source_uri; 248 - } 249 - const resp = await fetch(this.apiEndpoint, { 250 - method: "POST", 251 - headers: { "Content-Type": "application/json" }, 252 - credentials: "same-origin", 253 - body: JSON.stringify(data), 254 - }); 255 - if (!resp.ok) throw new Error(`Create failed: ${resp.status}`); 256 - const created = await resp.json(); 257 - const rkey = created.rkey || created.RKey; 258 - 259 - this.selectedRKey = rkey; 260 - this.selectedLabel = suggestion.name; 261 - this.query = suggestion.name; 262 - this.isOpen = false; 263 - 264 - // Invalidate cache so entity appears in future searches 265 - if (window.ArabicaCache) { 266 - window.ArabicaCache.invalidate(); 267 - } 268 - 269 - this.$nextTick(() => { 270 - this.$dispatch("combo-change", { 271 - entityType: this.entityType, 272 - rkey, 273 - }); 274 - }); 275 - } catch (e) { 276 - console.error("Failed to create from suggestion:", e); 277 - } finally { 278 - this.isCreating = false; 279 - } 280 - }, 281 - 282 - // Create a brand new entity with just the name 283 - async createNew() { 284 - const name = this.query.trim(); 285 - if (!name) return; 286 - 287 - this.isCreating = true; 288 - try { 289 - const data = this.formatCreateData(name, null); 290 - const resp = await fetch(this.apiEndpoint, { 291 - method: "POST", 292 - headers: { "Content-Type": "application/json" }, 293 - credentials: "same-origin", 294 - body: JSON.stringify(data), 295 - }); 296 - if (!resp.ok) throw new Error(`Create failed: ${resp.status}`); 297 - const created = await resp.json(); 298 - const rkey = created.rkey || created.RKey; 299 - 300 - this.selectedRKey = rkey; 301 - this.selectedLabel = name; 302 - this.query = name; 303 - this.isOpen = false; 304 - 305 - if (window.ArabicaCache) { 306 - window.ArabicaCache.invalidate(); 307 - } 308 - 309 - this.$nextTick(() => { 310 - this.$dispatch("combo-change", { 311 - entityType: this.entityType, 312 - rkey, 313 - }); 314 - }); 315 - } catch (e) { 316 - console.error("Failed to create entity:", e); 317 - } finally { 318 - this.isCreating = false; 319 - } 320 - }, 321 - 322 - // Keyboard navigation 323 - moveDown() { 324 - if (this.highlightIndex < this.allItems.length - 1) { 325 - this.highlightIndex++; 326 - } 327 - }, 328 - 329 - moveUp() { 330 - if (this.highlightIndex > 0) { 331 - this.highlightIndex--; 332 - } 333 - }, 334 - 335 - selectHighlighted() { 336 - const item = this.allItems[this.highlightIndex]; 337 - if (!item) return; 338 - if (item.type === "user") this.selectEntity(item.entity); 339 - else if (item.type === "community") 340 - this.selectSuggestion(item.suggestion); 341 - else if (item.type === "create") this.createNew(); 342 - }, 343 - 344 - // Clear selection 345 - clear() { 346 - this.selectedRKey = ""; 347 - this.selectedLabel = ""; 348 - this.query = ""; 349 - this.$dispatch("combo-change", { 350 - entityType: this.entityType, 351 - rkey: "", 352 - }); 353 - }, 354 - })); 355 - }); 356 - ``` 357 - 358 - **Step 2: Commit** 359 - 360 - ``` 361 - feat: add reusable combo-select Alpine.js component for typeahead entity selection 362 - ``` 363 - 364 - --- 365 - 366 - ## Task 2: Add Combo Select Styles 367 - 368 - **Files:** 369 - - Modify: `static/css/app.css` 370 - 371 - **Step 1: Add CSS for the combo dropdown** 372 - 373 - Add in the form styling section (after `.form-select`): 374 - 375 - ```css 376 - /* Combo select dropdown */ 377 - .combo-select { 378 - @apply relative; 379 - } 380 - 381 - .combo-dropdown { 382 - @apply absolute z-50 w-full mt-1 rounded-lg overflow-hidden; 383 - background: var(--card-bg); 384 - border: 1px solid var(--input-border); 385 - box-shadow: var(--shadow-lg); 386 - max-height: 280px; 387 - overflow-y: auto; 388 - } 389 - 390 - .combo-section-label { 391 - @apply text-xs font-medium uppercase tracking-wider px-3 py-1.5; 392 - color: var(--text-muted); 393 - background: var(--surface-bg); 394 - } 395 - 396 - .combo-item { 397 - @apply px-3 py-2 cursor-pointer text-sm; 398 - color: var(--text-primary); 399 - } 400 - 401 - .combo-item:hover, 402 - .combo-item[data-highlighted="true"] { 403 - background: var(--surface-bg); 404 - } 405 - 406 - .combo-item-create { 407 - @apply px-3 py-2 cursor-pointer text-sm font-medium; 408 - color: var(--accent-primary); 409 - border-top: 1px solid var(--surface-border); 410 - } 411 - 412 - .combo-item-create:hover, 413 - .combo-item-create[data-highlighted="true"] { 414 - background: var(--surface-bg); 415 - } 416 - 417 - .combo-item-sub { 418 - @apply text-xs; 419 - color: var(--text-muted); 420 - } 421 - 422 - .combo-creating { 423 - @apply px-3 py-2 text-sm text-center; 424 - color: var(--text-muted); 425 - } 426 - ``` 427 - 428 - **Step 2: Rebuild CSS** 429 - 430 - ```bash 431 - just style 432 - ``` 433 - 434 - **Step 3: Commit** 435 - 436 - ``` 437 - feat: add combo-select dropdown CSS styles 438 - ``` 439 - 440 - --- 441 - 442 - ## Task 3: Add Script Tag and Verify Cache API 443 - 444 - **Files:** 445 - - Modify: `internal/web/components/layout.templ` 446 - - Modify: `static/js/dropdown-manager.js` (if needed) 447 - 448 - The combo-select component reads from `window.ArabicaCache.getData()`. We need 449 - to verify this API exists in the cache layer, and add the script tag. 450 - 451 - **Step 1: Check dropdown-manager.js for getData** 452 - 453 - The combo-select needs `window.ArabicaCache.getData()` to return the cached 454 - entity data. Read `static/js/dropdown-manager.js` and find how the cache stores 455 - data. If there's no `getData()` method, add one that returns the current cached 456 - data object `{ beans, grinders, brewers, roasters, recipes }`. 457 - 458 - Likely the cache already stores data internally. Add a `getData()` method if 459 - missing: 460 - 461 - ```js 462 - getData() { 463 - return this._data || {}; 464 - }, 465 - ``` 466 - 467 - **Step 2: Add script tag in layout.templ** 468 - 469 - Add after the entity-suggest.js script and before brew-form.js: 470 - 471 - ```html 472 - <script src="/static/js/combo-select.js?v=0.1.0"></script> 473 - ``` 474 - 475 - **Step 3: Verify build** 476 - 477 - ```bash 478 - templ generate 479 - go vet ./... 480 - go build ./... 481 - ``` 482 - 483 - **Step 4: Commit** 484 - 485 - ``` 486 - feat: add combo-select script to layout and ensure cache getData API 487 - ``` 488 - 489 - --- 490 - 491 - ## Task 4: Replace Bean Select with Combo Input 492 - 493 - **Files:** 494 - - Modify: `internal/web/pages/brew_form.templ` 495 - - Modify: `static/js/brew-form.js` 496 - 497 - This is the first entity conversion. Bean is the most important because it's 498 - required. 499 - 500 - **Step 1: Replace BeanSelectField in brew_form.templ** 501 - 502 - Replace the current `BeanSelectField` component. The new version uses the 503 - combo-select pattern: 504 - 505 - ```templ 506 - // BeanSelectField renders the bean combo-select with typeahead + inline creation 507 - templ BeanSelectField(props BrewFormProps) { 508 - <div 509 - class="combo-select" 510 - x-data="comboSelect({ 511 - entityType: 'bean', 512 - apiEndpoint: '/api/beans', 513 - suggestEndpoint: '/api/suggestions/beans', 514 - inputName: 'bean_rkey', 515 - placeholder: 'Search or create a bean...', 516 - required: true, 517 - formatLabel: (e) => { 518 - const name = e.name || e.Name || ''; 519 - const origin = e.origin || e.Origin || ''; 520 - const roast = e.roast_level || e.RoastLevel || ''; 521 - if (origin && roast) return name + ' (' + origin + ' - ' + roast + ')'; 522 - if (origin) return name + ' (' + origin + ')'; 523 - return name; 524 - }, 525 - formatCreateData: (name, suggestion) => { 526 - const data = { name }; 527 - if (suggestion && suggestion.fields) { 528 - if (suggestion.fields.origin) data.origin = suggestion.fields.origin; 529 - if (suggestion.fields.roastLevel) data.roast_level = suggestion.fields.roastLevel; 530 - if (suggestion.fields.process) data.process = suggestion.fields.process; 531 - } 532 - return data; 533 - }, 534 - })" 535 - if props.Brew != nil && props.Brew.BeanRKey != "" { 536 - x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BeanRKey, getBeanLabel(props)) } 537 - } 538 - > 539 - <label class="form-label"> 540 - Coffee Bean 541 - <span class="text-red-500">*</span> 542 - </label> 543 - <input type="hidden" :name="inputName" :value="selectedRKey" x-ref="rkey"/> 544 - <div class="relative"> 545 - <input 546 - type="text" 547 - x-model="query" 548 - @input.debounce.200ms="search()" 549 - @focus="open()" 550 - @blur="close()" 551 - @keydown.escape.prevent="close()" 552 - @keydown.arrow-down.prevent="moveDown()" 553 - @keydown.arrow-up.prevent="moveUp()" 554 - @keydown.enter.prevent="selectHighlighted()" 555 - :placeholder="placeholder" 556 - class="w-full form-input-lg" 557 - autocomplete="off" 558 - /> 559 - <!-- Clear button --> 560 - <button 561 - type="button" 562 - x-show="selectedRKey" 563 - @click="clear()" 564 - class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600" 565 - x-cloak 566 - > 567 - @components.IconX() 568 - </button> 569 - </div> 570 - <!-- Dropdown --> 571 - <div x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> 572 - <!-- Creating indicator --> 573 - <div x-show="isCreating" x-cloak class="combo-creating">Creating...</div> 574 - <template x-if="!isCreating"> 575 - <div> 576 - <!-- User's entities --> 577 - <template x-if="userResults.length > 0"> 578 - <div> 579 - <div class="combo-section-label">Your beans</div> 580 - <template x-for="(entity, i) in userResults" :key="entity.rkey || entity.RKey"> 581 - <div 582 - class="combo-item" 583 - :data-highlighted="highlightIndex === i" 584 - @click="selectEntity(entity)" 585 - @mouseenter="highlightIndex = i" 586 - > 587 - <span x-text="formatLabel(entity)"></span> 588 - </div> 589 - </template> 590 - </div> 591 - </template> 592 - <!-- Community suggestions --> 593 - <template x-if="communityResults.length > 0"> 594 - <div> 595 - <div class="combo-section-label">Community</div> 596 - <template x-for="(s, j) in communityResults" :key="s.source_uri || j"> 597 - <div 598 - class="combo-item" 599 - :data-highlighted="highlightIndex === userResults.length + j" 600 - @click="selectSuggestion(s)" 601 - @mouseenter="highlightIndex = userResults.length + j" 602 - > 603 - <div x-text="s.name"></div> 604 - <div class="combo-item-sub"> 605 - <span x-show="s.fields?.origin" x-text="s.fields?.origin"></span> 606 - <span x-show="s.fields?.origin && s.fields?.roastLevel"> · </span> 607 - <span x-show="s.fields?.roastLevel" x-text="s.fields?.roastLevel"></span> 608 - <span x-show="s.count > 1" x-text="' · ' + s.count + ' users'"></span> 609 - </div> 610 - </div> 611 - </template> 612 - </div> 613 - </template> 614 - <!-- Create option --> 615 - <template x-if="query.trim() && !exactMatch"> 616 - <div 617 - class="combo-item-create" 618 - :data-highlighted="highlightIndex === userResults.length + communityResults.length" 619 - @click="createNew()" 620 - @mouseenter="highlightIndex = userResults.length + communityResults.length" 621 - > 622 - Create "<span x-text="query.trim()"></span>" 623 - </div> 624 - </template> 625 - <!-- Empty state --> 626 - <template x-if="allItems.length === 0 && query.trim()"> 627 - <div class="combo-creating">No matches found</div> 628 - </template> 629 - </div> 630 - </template> 631 - </div> 632 - </div> 633 - } 634 - ``` 635 - 636 - Add the helper function: 637 - 638 - ```go 639 - func getBeanLabel(props BrewFormProps) string { 640 - if props.Brew != nil && props.Brew.Bean != nil { 641 - return formatBeanLabel(*props.Brew.Bean) 642 - } 643 - return "" 644 - } 645 - ``` 646 - 647 - **Step 2: Update brew-form.js to listen for combo-change events** 648 - 649 - In the brew form Alpine component's `init()`, add a listener for brewer 650 - combo changes (to update `brewerCategory`): 651 - 652 - ```js 653 - // Listen for combo-select changes 654 - this.$el.addEventListener('combo-change', (e) => { 655 - if (e.detail.entityType === 'brewer') { 656 - const brewerType = e.detail.entity?.brewer_type || e.detail.entity?.BrewerType || ''; 657 - this.brewerCategory = this.normalizeBrewerCategory(brewerType); 658 - if (this.brewerCategory === 'pourover') { 659 - this.showPours = true; 660 - } 661 - } 662 - }); 663 - ``` 664 - 665 - **Step 3: Run templ generate and verify** 666 - 667 - ```bash 668 - templ generate 669 - go vet ./... 670 - go build ./... 671 - ``` 672 - 673 - **Step 4: Commit** 674 - 675 - ``` 676 - feat: replace bean select+modal with inline combo-select typeahead 677 - ``` 678 - 679 - --- 680 - 681 - ## Task 5: Replace Brewer Select with Combo Input 682 - 683 - **Files:** 684 - - Modify: `internal/web/pages/brew_form.templ` 685 - 686 - **Step 1: Replace BrewerSelectField** 687 - 688 - Same pattern as bean, but with brewer-specific config: 689 - 690 - ```templ 691 - templ BrewerSelectField(props BrewFormProps) { 692 - <div 693 - class="combo-select" 694 - x-data="comboSelect({ 695 - entityType: 'brewer', 696 - apiEndpoint: '/api/brewers', 697 - suggestEndpoint: '/api/suggestions/brewers', 698 - inputName: 'brewer_rkey', 699 - placeholder: 'Search or create a brew method...', 700 - formatLabel: (e) => e.name || e.Name || '', 701 - formatCreateData: (name, suggestion) => { 702 - const data = { name }; 703 - if (suggestion && suggestion.fields) { 704 - if (suggestion.fields.brewerType) data.brewer_type = suggestion.fields.brewerType; 705 - } 706 - return data; 707 - }, 708 - })" 709 - if props.Brew != nil && props.Brew.BrewerRKey != "" { 710 - x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.BrewerRKey, getBrewerLabel(props)) } 711 - } 712 - > 713 - <label class="form-label">Brew Method</label> 714 - <input type="hidden" :name="inputName" :value="selectedRKey"/> 715 - <!-- Same dropdown structure as bean, but with "Your brewers" / "Community" sections --> 716 - <!-- ... (identical HTML structure, just different section labels) --> 717 - </div> 718 - } 719 - ``` 720 - 721 - Note: since the HTML dropdown structure is identical across all three entity 722 - types, the implementing engineer should extract the shared dropdown markup into 723 - the templ component itself. The only differences are: 724 - - Section label text ("Your beans" vs "Your brewers" vs "Your grinders") 725 - - Subtitle fields shown in community results 726 - 727 - To avoid duplicating the dropdown HTML three times, create a shared templ 728 - component `ComboSelectDropdown` that accepts a section label prop, or simply 729 - include the dropdown markup inline in each field (it's ~30 lines and the 730 - differences are minor enough that extraction isn't required). 731 - 732 - Add helper: 733 - 734 - ```go 735 - func getBrewerLabel(props BrewFormProps) string { 736 - if props.Brew != nil && props.Brew.BrewerObj != nil { 737 - return props.Brew.BrewerObj.Name 738 - } 739 - return "" 740 - } 741 - ``` 742 - 743 - **Step 2: Commit** 744 - 745 - ``` 746 - feat: replace brewer select+modal with inline combo-select typeahead 747 - ``` 748 - 749 - --- 750 - 751 - ## Task 6: Replace Grinder Select with Combo Input 752 - 753 - **Files:** 754 - - Modify: `internal/web/pages/brew_form.templ` 755 - 756 - **Step 1: Replace GrinderSelectField** 757 - 758 - Same pattern, grinder-specific: 759 - 760 - ```templ 761 - templ GrinderSelectField(props BrewFormProps) { 762 - <div 763 - class="combo-select" 764 - x-data="comboSelect({ 765 - entityType: 'grinder', 766 - apiEndpoint: '/api/grinders', 767 - suggestEndpoint: '/api/suggestions/grinders', 768 - inputName: 'grinder_rkey', 769 - placeholder: 'Search or create a grinder...', 770 - formatLabel: (e) => e.name || e.Name || '', 771 - formatCreateData: (name, suggestion) => { 772 - const data = { name }; 773 - if (suggestion && suggestion.fields) { 774 - if (suggestion.fields.grinderType) data.grinder_type = suggestion.fields.grinderType; 775 - if (suggestion.fields.burrType) data.burr_type = suggestion.fields.burrType; 776 - } 777 - return data; 778 - }, 779 - })" 780 - if props.Brew != nil && props.Brew.GrinderRKey != "" { 781 - x-init={ fmt.Sprintf("selectedRKey = '%s'; query = '%s'; selectedLabel = query", props.Brew.GrinderRKey, getGrinderLabel(props)) } 782 - } 783 - > 784 - <label class="form-label">Grinder</label> 785 - <input type="hidden" :name="inputName" :value="selectedRKey"/> 786 - <!-- Same dropdown structure --> 787 - </div> 788 - } 789 - ``` 790 - 791 - Add helper: 792 - 793 - ```go 794 - func getGrinderLabel(props BrewFormProps) string { 795 - if props.Brew != nil && props.Brew.GrinderObj != nil { 796 - return props.Brew.GrinderObj.Name 797 - } 798 - return "" 799 - } 800 - ``` 801 - 802 - **Step 2: Commit** 803 - 804 - ``` 805 - feat: replace grinder select+modal with inline combo-select typeahead 806 - ``` 807 - 808 - --- 809 - 810 - ## Task 7: Update Recipe Autofill to Work with Combo Selects 811 - 812 - **Files:** 813 - - Modify: `static/js/brew-form.js` 814 - 815 - The recipe autofill currently calls `this.setFormField(form, 'brewer_rkey', 816 - ...)` which sets `<select>` values. With combo selects, we need to dispatch 817 - events or directly update the combo component state. 818 - 819 - **Step 1: Update applyRecipe** 820 - 821 - Replace the `setFormField` calls for `bean_rkey`, `brewer_rkey` with direct 822 - Alpine component communication. The simplest approach: dispatch custom events 823 - that the combo-select listens for. 824 - 825 - In `combo-select.js`, add to `init()`: 826 - 827 - ```js 828 - // Listen for external set events (e.g., from recipe autofill) 829 - this.$el.addEventListener('combo-set', (e) => { 830 - if (e.detail.rkey) { 831 - this.selectedRKey = e.detail.rkey; 832 - this.selectedLabel = e.detail.label || ''; 833 - this.query = this.selectedLabel; 834 - } 835 - }); 836 - ``` 837 - 838 - In `brew-form.js`, update `applyRecipe`: 839 - 840 - ```js 841 - // Instead of: this.setFormField(form, 'brewer_rkey', recipe.brewer_rkey || ''); 842 - // Do: 843 - const brewerCombo = form.querySelector('[x-data*="entityType: \'brewer\'"]'); 844 - if (brewerCombo) { 845 - brewerCombo.dispatchEvent(new CustomEvent('combo-set', { 846 - detail: { rkey: recipe.brewer_rkey || '', label: brewerName }, 847 - bubbles: false, 848 - })); 849 - } 850 - ``` 851 - 852 - **Step 2: Commit** 853 - 854 - ``` 855 - feat: update recipe autofill to work with combo-select components 856 - ``` 857 - 858 - --- 859 - 860 - ## Task 8: Update Entity Creation API for Minimal Records 861 - 862 - **Files:** 863 - - Modify: `internal/handlers/entities.go` 864 - 865 - Currently `HandleBeanCreate` requires both `name` and `origin`. For inline 866 - creation with just a name, we need to relax the bean validation so only `name` 867 - is required. Check if `origin` is enforced in the handler or model validation. 868 - 869 - **Step 1: Check and relax bean creation validation** 870 - 871 - In `internal/models/models.go`, `CreateBeanRequest.Validate()` requires `name` 872 - but does NOT require `origin` (only the handler may check). Verify that the 873 - handler doesn't reject beans without an origin. 874 - 875 - If the handler has additional validation beyond `Validate()`, relax it to allow 876 - name-only beans. 877 - 878 - **Step 2: Verify grinder and brewer only require name** 879 - 880 - Check that `CreateGrinderRequest.Validate()` and `CreateBrewerRequest.Validate()` 881 - only require `name`. They should already be fine. 882 - 883 - **Step 3: Commit (if changes needed)** 884 - 885 - ``` 886 - fix: allow creating entities with just a name for inline typeahead creation 887 - ``` 888 - 889 - --- 890 - 891 - ## Task 9: Clean Up Removed Code 892 - 893 - **Files:** 894 - - Modify: `internal/web/pages/brew_form.templ` 895 - - Modify: `static/js/brew-form.js` 896 - 897 - **Step 1: Remove entity manager initialization from brew-form.js** 898 - 899 - The `initEntityManagers()` method and related `beanManager`, `grinderManager`, 900 - `brewerManager` properties are no longer needed since we're not using modals. 901 - Remove: 902 - - `initEntityManagers()` method 903 - - `beanManager`, `grinderManager`, `brewerManager` properties 904 - - `saveBean()`, `saveGrinder()`, `saveBrewer()` delegates 905 - - `showBeanForm`, `showGrinderForm`, `showBrewerForm` getters/setters 906 - - `editingBean`, `editingGrinder`, `editingBrewer` getters 907 - - `beanForm`, `grinderForm`, `brewerForm` getters/setters 908 - 909 - Keep: `dropdownManager` (still used for recipe data and brewer type lookup). 910 - 911 - **Step 2: Remove "+ New" modal buttons from brew_form.templ** 912 - 913 - These were part of the old select fields and should already be gone after Tasks 914 - 4-6. Verify no remnants exist. 915 - 916 - **Step 3: Remove HTMX modal loading comment** 917 - 918 - Remove the `<!-- Entity modals now loaded via HTMX into #modal-container -->` 919 - comment from `BrewFormContent`. 920 - 921 - **Step 4: Run tests** 922 - 923 - ```bash 924 - go test ./... 925 - ``` 926 - 927 - **Step 5: Commit** 928 - 929 - ``` 930 - refactor: remove entity modal code from brew form (replaced by combo-select) 931 - ``` 932 - 933 - --- 934 - 935 - ## Task 10: Rebuild and Final Verification 936 - 937 - **Files:** 938 - - None new 939 - 940 - **Step 1: Rebuild CSS** 941 - 942 - ```bash 943 - just style 944 - ``` 945 - 946 - **Step 2: Run full test suite** 947 - 948 - ```bash 949 - templ generate 950 - go vet ./... 951 - go test ./... 952 - ``` 953 - 954 - **Step 3: Bump cache versions** 955 - 956 - In `layout.templ`, bump CSS and JS versions as needed. 957 - 958 - **Step 4: Commit** 959 - 960 - ``` 961 - chore: rebuild CSS and bump cache versions for combo-select 962 - ``` 963 - 964 - --- 965 - 966 - ## Summary 967 - 968 - | Task | What | Key files | 969 - |---|---|---| 970 - | 1 | Combo select Alpine.js component | `static/js/combo-select.js` (new) | 971 - | 2 | Dropdown CSS styles | `static/css/app.css` | 972 - | 3 | Script tag + cache API | `layout.templ`, `dropdown-manager.js` | 973 - | 4 | Bean combo input | `brew_form.templ`, `brew-form.js` | 974 - | 5 | Brewer combo input | `brew_form.templ` | 975 - | 6 | Grinder combo input | `brew_form.templ` | 976 - | 7 | Recipe autofill integration | `brew-form.js`, `combo-select.js` | 977 - | 8 | Relax entity creation validation | `handlers/entities.go` | 978 - | 9 | Clean up removed modal code | `brew-form.js`, `brew_form.templ` | 979 - | 10 | Final rebuild + verification | CSS, tests, versions | 980 - 981 - ### What's NOT in scope 982 - - Recipe select (stays as-is — recipes are a different interaction pattern) 983 - - Manage page entity forms (keep modal pattern, different context) 984 - - Profile page entity forms (keep inline Alpine forms, different context) 985 - - The mode chooser removal and section collapsing (separate work items)
-1108
docs/plans/2026-03-27-my-coffee-consolidation.md
··· 1 - # My Coffee Page Consolidation & Home Dashboard 2 - 3 - > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 - 5 - **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. 6 - 7 - **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. 8 - 9 - **Tech Stack:** Go + Templ (server-side), HTMX (dynamic loading), Alpine.js (client state), Tailwind CSS (styling) 10 - 11 - --- 12 - 13 - ## Phase 1: Consolidate My Brews + Manage → "My Coffee" 14 - 15 - ### Task 1: Create My Coffee page template 16 - 17 - This task creates the new unified page that combines brew list + manage tabs. 18 - 19 - **Files:** 20 - - Modify: `internal/web/pages/manage.templ` (rename to my_coffee concept, reuse as-is) 21 - - Create: `internal/web/pages/my_coffee.templ` 22 - 23 - **Step 1: Create the My Coffee page template** 24 - 25 - 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. 26 - 27 - ```templ 28 - package pages 29 - 30 - import "arabica/internal/web/components" 31 - 32 - type MyCoffeeProps struct{} 33 - 34 - templ MyCoffee(layout *components.LayoutData, props MyCoffeeProps) { 35 - @components.Layout(layout, MyCoffeeContent(props)) 36 - } 37 - 38 - templ MyCoffeeContent(props MyCoffeeProps) { 39 - <script src="/static/js/manage-page.js?v=0.4.0"></script> 40 - <div class="page-container-xl" x-data="managePage()"> 41 - <div class="flex items-center gap-3 mb-6"> 42 - <h2 class="text-2xl font-semibold text-brown-900">My Coffee</h2> 43 - <div class="ml-auto flex items-center gap-2"> 44 - <a href="/brews/new" class="btn-primary shadow-lg hover:shadow-xl">+ New Brew</a> 45 - @ManageRefreshButton() 46 - </div> 47 - </div> 48 - @MyCoffeeTabs() 49 - <!-- Brews tab: standalone HTMX loader --> 50 - <div x-show="tab === 'brews'"> 51 - <div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML"> 52 - @BrewListLoadingSkeleton() 53 - </div> 54 - </div> 55 - <!-- Entity tabs: loaded from manage partial --> 56 - <div id="manage-content" x-show="tab !== 'brews'" hx-get="/api/manage" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 57 - @ManageLoadingSkeleton() 58 - </div> 59 - </div> 60 - } 61 - 62 - templ MyCoffeeTabs() { 63 - <div class="mb-6 border-b-2 border-brown-300"> 64 - <nav class="-mb-px flex space-x-8 overflow-x-auto"> 65 - <button 66 - @click="tab = 'brews'" 67 - :class="tab === 'brews' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 68 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 69 - > 70 - Brews 71 - </button> 72 - <button 73 - @click="tab = 'beans'" 74 - :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 75 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 76 - > 77 - Beans 78 - </button> 79 - <button 80 - @click="tab = 'roasters'" 81 - :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 82 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 83 - > 84 - Roasters 85 - </button> 86 - <button 87 - @click="tab = 'grinders'" 88 - :class="tab === 'grinders' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 89 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 90 - > 91 - Grinders 92 - </button> 93 - <button 94 - @click="tab = 'brewers'" 95 - :class="tab === 'brewers' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 96 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 97 - > 98 - Brewers 99 - </button> 100 - <button 101 - @click="tab = 'recipes'" 102 - :class="tab === 'recipes' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 103 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 104 - > 105 - Recipes 106 - </button> 107 - </nav> 108 - </div> 109 - } 110 - ``` 111 - 112 - Key design decisions: 113 - - Reuses existing `ManageRefreshButton()`, `ManageLoadingSkeleton()` from `manage.templ` 114 - - Brews tab uses the same `/api/brews` HTMX endpoint the old brew list used 115 - - Entity tabs use the same `/api/manage` HTMX endpoint the old manage page used 116 - - The `managePage()` Alpine component already handles tab persistence via localStorage — it just needs the default changed to `'brews'` 117 - - `+ New Brew` button is always visible in the header (not tab-dependent) 118 - 119 - **Step 2: Update manage-page.js default tab** 120 - 121 - In `static/js/manage-page.js`, change the default tab from `'beans'` to `'brews'`: 122 - 123 - ```js 124 - // Line 8: change default 125 - tab: localStorage.getItem("manageTab") || "brews", 126 - ``` 127 - 128 - **Step 3: Run templ generate and verify build** 129 - 130 - ```bash 131 - templ generate 132 - go vet ./... 133 - go build ./... 134 - ``` 135 - 136 - **Step 4: Commit** 137 - 138 - ```bash 139 - git add internal/web/pages/my_coffee.templ static/js/manage-page.js 140 - git commit -m "feat: add My Coffee page template combining brews and manage" 141 - ``` 142 - 143 - --- 144 - 145 - ### Task 2: Add handler and route for My Coffee 146 - 147 - Wire up the new page to the router, and add redirects from old URLs. 148 - 149 - **Files:** 150 - - Modify: `internal/handlers/entities.go` (add HandleMyCoffee, or reuse HandleManage) 151 - - Modify: `internal/routing/routing.go` (add `/my-coffee` route, redirect old routes) 152 - 153 - **Step 1: Add HandleMyCoffee handler** 154 - 155 - Add to `internal/handlers/entities.go` (right after or in place of `HandleManage`): 156 - 157 - ```go 158 - // HandleMyCoffee renders the unified My Coffee page (replaces both /brews and /manage) 159 - func (h *Handler) HandleMyCoffee(w http.ResponseWriter, r *http.Request) { 160 - _, authenticated := h.getAtprotoStore(r) 161 - if !authenticated { 162 - http.Redirect(w, r, "/login", http.StatusFound) 163 - return 164 - } 165 - 166 - layoutData, _, _ := h.layoutDataFromRequest(r, "My Coffee") 167 - 168 - if err := pages.MyCoffee(layoutData, pages.MyCoffeeProps{}).Render(r.Context(), w); err != nil { 169 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 170 - log.Error().Err(err).Msg("Failed to render my coffee page") 171 - } 172 - } 173 - ``` 174 - 175 - **Step 2: Update routes in routing.go** 176 - 177 - In `internal/routing/routing.go`, replace the old `/brews` and `/manage` page routes: 178 - 179 - ```go 180 - // Replace: 181 - // mux.HandleFunc("GET /manage", h.HandleManage) 182 - // mux.HandleFunc("GET /brews", h.HandleBrewList) 183 - // With: 184 - mux.HandleFunc("GET /my-coffee", h.HandleMyCoffee) 185 - 186 - // Add redirects for old URLs 187 - mux.HandleFunc("GET /manage", func(w http.ResponseWriter, r *http.Request) { 188 - http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently) 189 - }) 190 - mux.HandleFunc("GET /brews", func(w http.ResponseWriter, r *http.Request) { 191 - http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently) 192 - }) 193 - ``` 194 - 195 - 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. 196 - 197 - **Step 3: Verify build** 198 - 199 - ```bash 200 - templ generate 201 - go vet ./... 202 - go build ./... 203 - ``` 204 - 205 - **Step 4: Commit** 206 - 207 - ```bash 208 - git add internal/handlers/entities.go internal/routing/routing.go 209 - git commit -m "feat: add /my-coffee route with redirects from /brews and /manage" 210 - ``` 211 - 212 - --- 213 - 214 - ### Task 3: Update navigation header 215 - 216 - Replace "My Brews" and "Manage Records" dropdown links with a single "My Coffee" link. 217 - 218 - **Files:** 219 - - Modify: `internal/web/components/header.templ` 220 - 221 - **Step 1: Update header dropdown** 222 - 223 - In `internal/web/components/header.templ`, find the dropdown links section (around lines 74-85) and replace: 224 - 225 - ```templ 226 - // Replace these two links: 227 - <a href="/brews" class="dropdown-item"> 228 - My Brews 229 - </a> 230 - <a href="/recipes" class="dropdown-item"> 231 - Recipes 232 - </a> 233 - <a href="/manage" class="dropdown-item"> 234 - Manage Records 235 - </a> 236 - 237 - // With: 238 - <a href="/my-coffee" class="dropdown-item"> 239 - My Coffee 240 - </a> 241 - <a href="/recipes" class="dropdown-item"> 242 - Recipes 243 - </a> 244 - ``` 245 - 246 - **Step 2: Update welcome card links** 247 - 248 - 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: 249 - 250 - ```templ 251 - // Change href="/brews" to href="/my-coffee" 252 - <a 253 - href="/my-coffee" 254 - class="home-action-secondary block text-center py-4 px-6 rounded-xl" 255 - hx-get="/my-coffee" 256 - hx-target="main" 257 - hx-swap="innerHTML show:top" 258 - hx-select="main > *" 259 - hx-push-url="true" 260 - > 261 - <span class="text-lg font-semibold">My Coffee</span> 262 - </a> 263 - ``` 264 - 265 - **Step 3: Verify build** 266 - 267 - ```bash 268 - templ generate 269 - go vet ./... 270 - go build ./... 271 - ``` 272 - 273 - **Step 4: Commit** 274 - 275 - ```bash 276 - git add internal/web/components/header.templ internal/web/components/shared.templ 277 - git commit -m "feat: update nav to link to /my-coffee instead of /brews and /manage" 278 - ``` 279 - 280 - --- 281 - 282 - ### Task 4: Add modal container to My Coffee page 283 - 284 - 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. 285 - 286 - **Files:** 287 - - Modify: `internal/web/pages/my_coffee.templ` 288 - 289 - **Step 1: Add modal container** 290 - 291 - Add a `#modal-container` div at the end of the page content (inside the `page-container-xl` div but after all tabs): 292 - 293 - ```templ 294 - <!-- Modal container for HTMX-loaded dialogs --> 295 - <div id="modal-container"></div> 296 - ``` 297 - 298 - This is the target for all `hx-get="/api/modals/..."` requests that load entity edit/create dialogs. 299 - 300 - **Step 2: Verify build** 301 - 302 - ```bash 303 - templ generate 304 - go vet ./... 305 - go build ./... 306 - ``` 307 - 308 - **Step 3: Commit** 309 - 310 - ```bash 311 - git add internal/web/pages/my_coffee.templ 312 - git commit -m "feat: add modal container to My Coffee page for entity dialogs" 313 - ``` 314 - 315 - --- 316 - 317 - ## Phase 2: Authenticated Home Dashboard 318 - 319 - ### Task 5: Define incomplete records data model 320 - 321 - Before building the UI, define how to detect incomplete records. An entity is "incomplete" when key fields are empty. 322 - 323 - **Files:** 324 - - Modify: `internal/models/models.go` (add IsIncomplete methods) 325 - 326 - **Step 1: Add IsIncomplete methods to models** 327 - 328 - Add methods to each entity type. These define what "incomplete" means per entity: 329 - 330 - ```go 331 - // IsIncomplete returns true if the bean is missing key fields beyond name/origin. 332 - func (b *Bean) IsIncomplete() bool { 333 - return b.RoasterRKey == "" || b.RoastLevel == "" || b.Process == "" 334 - } 335 - 336 - // MissingFields returns a human-readable list of missing fields. 337 - func (b *Bean) MissingFields() []string { 338 - var missing []string 339 - if b.RoasterRKey == "" { 340 - missing = append(missing, "roaster") 341 - } 342 - if b.RoastLevel == "" { 343 - missing = append(missing, "roast level") 344 - } 345 - if b.Process == "" { 346 - missing = append(missing, "process") 347 - } 348 - return missing 349 - } 350 - 351 - // IsIncomplete returns true if the grinder is missing its type. 352 - func (g *Grinder) IsIncomplete() bool { 353 - return g.GrinderType == "" 354 - } 355 - 356 - // MissingFields returns a human-readable list of missing fields. 357 - func (g *Grinder) MissingFields() []string { 358 - var missing []string 359 - if g.GrinderType == "" { 360 - missing = append(missing, "grinder type") 361 - } 362 - return missing 363 - } 364 - 365 - // IsIncomplete returns true if the brewer is missing its type. 366 - func (b *Brewer) IsIncomplete() bool { 367 - return b.BrewerType == "" 368 - } 369 - 370 - // MissingFields returns a human-readable list of missing fields. 371 - func (b *Brewer) MissingFields() []string { 372 - var missing []string 373 - if b.BrewerType == "" { 374 - missing = append(missing, "brewer type") 375 - } 376 - return missing 377 - } 378 - ``` 379 - 380 - Note: Roasters don't get IsIncomplete — name is the only required field, and location/website are truly optional. 381 - 382 - **Step 2: Write tests** 383 - 384 - Add to `internal/models/models_test.go` (create if it doesn't exist): 385 - 386 - ```go 387 - package models 388 - 389 - import ( 390 - "testing" 391 - 392 - "github.com/stretchr/testify/assert" 393 - ) 394 - 395 - func TestBeanIsIncomplete(t *testing.T) { 396 - // Complete bean 397 - complete := &Bean{Name: "Test", Origin: "Ethiopia", RoasterRKey: "abc", RoastLevel: "Light", Process: "Washed"} 398 - assert.False(t, complete.IsIncomplete()) 399 - assert.Empty(t, complete.MissingFields()) 400 - 401 - // Incomplete bean — missing roaster 402 - incomplete := &Bean{Name: "Test", Origin: "Ethiopia", RoastLevel: "Light", Process: "Washed"} 403 - assert.True(t, incomplete.IsIncomplete()) 404 - assert.Contains(t, incomplete.MissingFields(), "roaster") 405 - 406 - // Stub bean — name only 407 - stub := &Bean{Name: "Test"} 408 - assert.True(t, stub.IsIncomplete()) 409 - assert.Len(t, stub.MissingFields(), 3) 410 - } 411 - 412 - func TestGrinderIsIncomplete(t *testing.T) { 413 - complete := &Grinder{Name: "Test", GrinderType: "Hand"} 414 - assert.False(t, complete.IsIncomplete()) 415 - 416 - incomplete := &Grinder{Name: "Test"} 417 - assert.True(t, incomplete.IsIncomplete()) 418 - assert.Contains(t, incomplete.MissingFields(), "grinder type") 419 - } 420 - 421 - func TestBrewerIsIncomplete(t *testing.T) { 422 - complete := &Brewer{Name: "V60", BrewerType: "pourover"} 423 - assert.False(t, complete.IsIncomplete()) 424 - 425 - incomplete := &Brewer{Name: "V60"} 426 - assert.True(t, incomplete.IsIncomplete()) 427 - assert.Contains(t, incomplete.MissingFields(), "brewer type") 428 - } 429 - ``` 430 - 431 - **Step 3: Run tests** 432 - 433 - ```bash 434 - go test ./internal/models/... -v 435 - ``` 436 - 437 - **Step 4: Commit** 438 - 439 - ```bash 440 - git add internal/models/models.go internal/models/models_test.go 441 - git commit -m "feat: add IsIncomplete and MissingFields methods to entity models" 442 - ``` 443 - 444 - --- 445 - 446 - ### Task 6: Add incomplete records API endpoint 447 - 448 - 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. 449 - 450 - **Files:** 451 - - Modify: `internal/handlers/entities.go` (add handler) 452 - - Modify: `internal/routing/routing.go` (add route) 453 - - Create: `internal/web/components/incomplete_records.templ` (new component) 454 - 455 - **Step 1: Create the incomplete records component** 456 - 457 - Create `internal/web/components/incomplete_records.templ`: 458 - 459 - ```templ 460 - package components 461 - 462 - import ( 463 - "arabica/internal/models" 464 - "fmt" 465 - "strings" 466 - ) 467 - 468 - // IncompleteRecord represents a single entity that needs attention 469 - type IncompleteRecord struct { 470 - EntityType string // "bean", "grinder", "brewer" 471 - RKey string 472 - Name string 473 - MissingFields []string 474 - } 475 - 476 - type IncompleteRecordsProps struct { 477 - Records []IncompleteRecord 478 - } 479 - 480 - templ IncompleteRecords(props IncompleteRecordsProps) { 481 - if len(props.Records) > 0 { 482 - <div class="card p-4 sm:p-6 mb-6"> 483 - <div class="flex items-center gap-2 mb-3"> 484 - <svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 485 - <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> 486 - </svg> 487 - <h3 class="text-lg font-semibold text-brown-900"> 488 - { fmt.Sprintf("%d", len(props.Records)) } { incompleteNoun(len(props.Records)) } need details 489 - </h3> 490 - </div> 491 - <div class="space-y-2"> 492 - for _, rec := range props.Records { 493 - <div class="flex items-center justify-between p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 494 - <div> 495 - <span class="font-medium text-brown-900">{ rec.Name }</span> 496 - <span class="text-sm text-brown-600 ml-2"> 497 - missing { strings.Join(rec.MissingFields, ", ") } 498 - </span> 499 - </div> 500 - <button 501 - hx-get={ fmt.Sprintf("/api/modals/%s/%s", rec.EntityType, rec.RKey) } 502 - hx-target="#modal-container" 503 - hx-swap="innerHTML" 504 - class="text-sm font-medium text-brown-700 hover:text-brown-900 cursor-pointer" 505 - > 506 - Complete 507 - </button> 508 - </div> 509 - } 510 - </div> 511 - if len(props.Records) > 3 { 512 - <a href="/my-coffee" class="block text-center text-sm text-brown-600 hover:text-brown-800 mt-3"> 513 - View all in My Coffee 514 - </a> 515 - } 516 - </div> 517 - <!-- Modal container for HTMX-loaded dialogs --> 518 - <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> 519 - } 520 - } 521 - 522 - func incompleteNoun(count int) string { 523 - if count == 1 { 524 - return "record" 525 - } 526 - return "records" 527 - } 528 - 529 - // CollectIncompleteRecords scans all entities and returns incomplete ones (max limit). 530 - func CollectIncompleteRecords(beans []*models.Bean, grinders []*models.Grinder, brewers []*models.Brewer, limit int) []IncompleteRecord { 531 - var records []IncompleteRecord 532 - 533 - for _, b := range beans { 534 - if b.IsIncomplete() && !b.Closed { 535 - records = append(records, IncompleteRecord{ 536 - EntityType: "bean", 537 - RKey: b.RKey, 538 - Name: b.Name, 539 - MissingFields: b.MissingFields(), 540 - }) 541 - } 542 - } 543 - for _, g := range grinders { 544 - if g.IsIncomplete() { 545 - records = append(records, IncompleteRecord{ 546 - EntityType: "grinder", 547 - RKey: g.RKey, 548 - Name: g.Name, 549 - MissingFields: g.MissingFields(), 550 - }) 551 - } 552 - } 553 - for _, b := range brewers { 554 - if b.IsIncomplete() { 555 - records = append(records, IncompleteRecord{ 556 - EntityType: "brewer", 557 - RKey: b.RKey, 558 - Name: b.Name, 559 - MissingFields: b.MissingFields(), 560 - }) 561 - } 562 - } 563 - 564 - if limit > 0 && len(records) > limit { 565 - return records[:limit] 566 - } 567 - return records 568 - } 569 - ``` 570 - 571 - **Step 2: Add the HTMX partial handler** 572 - 573 - Add to `internal/handlers/entities.go`: 574 - 575 - ```go 576 - // HandleIncompleteRecordsPartial returns HTML fragment for incomplete records section. 577 - func (h *Handler) HandleIncompleteRecordsPartial(w http.ResponseWriter, r *http.Request) { 578 - store, authenticated := h.getAtprotoStore(r) 579 - if !authenticated { 580 - // Return empty — not an error, just no content for unauthenticated 581 - return 582 - } 583 - 584 - ctx := r.Context() 585 - g, ctx := errgroup.WithContext(ctx) 586 - 587 - var beans []*models.Bean 588 - var grinders []*models.Grinder 589 - var brewers []*models.Brewer 590 - 591 - g.Go(func() error { 592 - var err error 593 - beans, err = store.ListBeans(ctx) 594 - return err 595 - }) 596 - g.Go(func() error { 597 - var err error 598 - grinders, err = store.ListGrinders(ctx) 599 - return err 600 - }) 601 - g.Go(func() error { 602 - var err error 603 - brewers, err = store.ListBrewers(ctx) 604 - return err 605 - }) 606 - 607 - if err := g.Wait(); err != nil { 608 - log.Error().Err(err).Msg("Failed to fetch data for incomplete records") 609 - return 610 - } 611 - 612 - records := components.CollectIncompleteRecords(beans, grinders, brewers, 5) 613 - 614 - if err := components.IncompleteRecords(components.IncompleteRecordsProps{ 615 - Records: records, 616 - }).Render(r.Context(), w); err != nil { 617 - log.Error().Err(err).Msg("Failed to render incomplete records") 618 - } 619 - } 620 - ``` 621 - 622 - **Step 3: Add the route** 623 - 624 - In `internal/routing/routing.go`, add with the other HTMX partials: 625 - 626 - ```go 627 - mux.Handle("GET /api/incomplete-records", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleIncompleteRecordsPartial))) 628 - ``` 629 - 630 - **Step 4: Verify build** 631 - 632 - ```bash 633 - templ generate 634 - go vet ./... 635 - go build ./... 636 - ``` 637 - 638 - **Step 5: Commit** 639 - 640 - ```bash 641 - git add internal/web/components/incomplete_records.templ internal/handlers/entities.go internal/routing/routing.go 642 - git commit -m "feat: add incomplete records API endpoint and component" 643 - ``` 644 - 645 - --- 646 - 647 - ### Task 7: Add dashboard section to home page 648 - 649 - Add the dashboard section above the community feed for authenticated users. 650 - 651 - **Files:** 652 - - Modify: `internal/web/pages/home.templ` 653 - - Modify: `internal/web/components/shared.templ` (update WelcomeAuthenticated) 654 - 655 - **Step 1: Update home page content** 656 - 657 - In `internal/web/pages/home.templ`, add a dashboard section between the welcome card and the feed for authenticated users: 658 - 659 - ```templ 660 - templ HomeContent(props HomeProps) { 661 - <div class="page-container-lg"> 662 - @components.WelcomeCard(components.WelcomeCardProps{ 663 - IsAuthenticated: props.IsAuthenticated, 664 - UserDID: props.UserDID, 665 - }) 666 - if props.IsAuthenticated { 667 - <!-- Incomplete records loaded async --> 668 - <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 669 - </div> 670 - } 671 - if !props.IsAuthenticated { 672 - @components.AboutInfoCard() 673 - } 674 - @CommunityFeedSection(props.IsAuthenticated) 675 - if props.IsAuthenticated { 676 - @components.AboutInfoCard() 677 - } 678 - </div> 679 - } 680 - ``` 681 - 682 - 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. 683 - 684 - **Step 2: Update WelcomeAuthenticated quick actions** 685 - 686 - In `internal/web/components/shared.templ`, update the `WelcomeAuthenticated` component to have three action buttons instead of two: 687 - 688 - ```templ 689 - templ WelcomeAuthenticated(userDID string) { 690 - <div class="mb-6"> 691 - <p class="text-sm text-brown-700"> 692 - Logged in as: <span class="font-mono text-brown-900 font-semibold">{ userDID }</span> 693 - <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a> 694 - </p> 695 - </div> 696 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> 697 - <a 698 - href="/brews/new" 699 - class="home-action-primary block text-center py-4 px-6 rounded-xl" 700 - > 701 - <span class="text-lg font-semibold">Log Brew</span> 702 - </a> 703 - <a 704 - href="/my-coffee" 705 - class="home-action-secondary block text-center py-4 px-6 rounded-xl" 706 - > 707 - <span class="text-lg font-semibold">My Coffee</span> 708 - </a> 709 - <a 710 - href={ templ.SafeURL("/profile/" + userDID) } 711 - class="home-action-secondary block text-center py-4 px-6 rounded-xl" 712 - > 713 - <span class="text-lg font-semibold">Profile</span> 714 - </a> 715 - </div> 716 - } 717 - ``` 718 - 719 - 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. 720 - 721 - **Step 3: Verify build** 722 - 723 - ```bash 724 - templ generate 725 - go vet ./... 726 - go build ./... 727 - ``` 728 - 729 - **Step 4: Commit** 730 - 731 - ```bash 732 - git add internal/web/pages/home.templ internal/web/components/shared.templ 733 - git commit -m "feat: add dashboard with incomplete records nudge to home page" 734 - ``` 735 - 736 - --- 737 - 738 - ### Task 8: Handle modal refresh on home page 739 - 740 - 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. 741 - 742 - 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. 743 - 744 - **Files:** 745 - - Modify: `internal/web/pages/home.templ` 746 - 747 - **Step 1: Add fallback modal container** 748 - 749 - Add a `#modal-container` div to the home page that always exists (the one inside `IncompleteRecords` will overwrite it when loaded): 750 - 751 - ```templ 752 - if props.IsAuthenticated { 753 - <!-- Incomplete records loaded async --> 754 - <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 755 - </div> 756 - <!-- Modal container for entity edit dialogs opened from dashboard --> 757 - <div id="modal-container"></div> 758 - } 759 - ``` 760 - 761 - 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). 762 - 763 - **Step 2: Update IncompleteRecords component** 764 - 765 - In `internal/web/components/incomplete_records.templ`, remove the `#modal-container` div from inside the component. The parent page is responsible for providing it. 766 - 767 - **Step 3: Verify build** 768 - 769 - ```bash 770 - templ generate 771 - go vet ./... 772 - go build ./... 773 - ``` 774 - 775 - **Step 4: Commit** 776 - 777 - ```bash 778 - git add internal/web/pages/home.templ internal/web/components/incomplete_records.templ 779 - git commit -m "fix: ensure modal container exists on home page for entity edit dialogs" 780 - ``` 781 - 782 - --- 783 - 784 - ## Phase 3: Expandable Inline Creation in Brew Form 785 - 786 - ### Task 9: Add "more details" toggle to combo-select create flow 787 - 788 - 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. 789 - 790 - 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. 791 - 792 - **Files:** 793 - - Modify: `static/js/combo-select.js` (add create-with-details flow) 794 - - Modify: `internal/web/pages/brew_form.templ` (add extra field config to comboSelectInit) 795 - 796 - **Step 1: Add expandable create fields to combo-select** 797 - 798 - In `static/js/combo-select.js`, add new state and methods: 799 - 800 - ```js 801 - // Add to the Alpine.data("comboSelect") return object: 802 - 803 - // New state for inline creation with details 804 - showCreateForm: false, 805 - createFormData: {}, 806 - 807 - // Modified createNew — shows inline form instead of immediately creating 808 - createNewWithDetails() { 809 - const name = this.query.trim(); 810 - if (!name) return; 811 - 812 - // Initialize form data based on entity type 813 - this.createFormData = { name }; 814 - if (this.extraFields) { 815 - for (const field of this.extraFields) { 816 - this.createFormData[field.name] = ""; 817 - } 818 - } 819 - this.showCreateForm = true; 820 - this.isOpen = false; 821 - }, 822 - 823 - // Submit the create form with all details 824 - async submitCreateForm() { 825 - const data = { ...this.createFormData }; 826 - this.isCreating = true; 827 - try { 828 - const resp = await fetch(this.apiEndpoint, { 829 - method: "POST", 830 - headers: { "Content-Type": "application/json" }, 831 - credentials: "same-origin", 832 - body: JSON.stringify(data), 833 - }); 834 - if (!resp.ok) throw new Error(`Create failed: ${resp.status}`); 835 - const created = await resp.json(); 836 - const rkey = created.rkey || created.RKey; 837 - 838 - this.selectedRKey = rkey; 839 - this.selectedLabel = data.name; 840 - this.query = data.name; 841 - this.showCreateForm = false; 842 - 843 - if (window.ArabicaCache) { 844 - window.ArabicaCache.invalidateCache(); 845 - } 846 - 847 - this.$nextTick(() => { 848 - this.$dispatch("combo-change", { 849 - entityType: this.entityType, 850 - rkey, 851 - }); 852 - }); 853 - } catch (e) { 854 - console.error("Failed to create entity:", e); 855 - } finally { 856 - this.isCreating = false; 857 - } 858 - }, 859 - 860 - cancelCreateForm() { 861 - this.showCreateForm = false; 862 - this.createFormData = {}; 863 - }, 864 - ``` 865 - 866 - The `extraFields` config is provided per entity type (see Step 2). 867 - 868 - **Step 2: Add extra field config to brew form combo-select init** 869 - 870 - In `internal/web/pages/brew_form.templ`, update the `comboSelectInit` function to include extra fields config per entity type. Add to the config object: 871 - 872 - For beans: 873 - ```js 874 - extraFields: [ 875 - { name: 'roast_level', label: 'Roast Level', type: 'select', options: ['Light', 'Medium-Light', 'Medium', 'Medium-Dark', 'Dark'] }, 876 - { name: 'process', label: 'Process', type: 'text', placeholder: 'e.g. Washed, Natural, Honey' }, 877 - { name: 'variety', label: 'Variety', type: 'text', placeholder: 'e.g. SL28, Typica, Gesha' }, 878 - ] 879 - ``` 880 - 881 - For grinders: 882 - ```js 883 - extraFields: [ 884 - { name: 'grinder_type', label: 'Type', type: 'select', options: ['Hand', 'Electric', 'Portable Electric'] }, 885 - { name: 'burr_type', label: 'Burr Type', type: 'select', options: ['Conical', 'Flat', 'Blade'] }, 886 - ] 887 - ``` 888 - 889 - For brewers: 890 - ```js 891 - extraFields: [ 892 - { name: 'brewer_type', label: 'Type', type: 'select', options: ['pourover', 'espresso', 'immersion', 'mokapot', 'coldbrew', 'cupping', 'other'] }, 893 - ] 894 - ``` 895 - 896 - **Step 3: Add create form template to combo-select markup** 897 - 898 - In `internal/web/pages/brew_form.templ`, update the `comboSelectInput` template to include a create form section that shows when `showCreateForm` is true: 899 - 900 - ```templ 901 - <!-- After the dropdown list, add: --> 902 - <div x-show="showCreateForm" x-transition class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 903 - <p class="text-sm font-medium text-brown-900 mb-2"> 904 - Creating: <span x-text="createFormData.name" class="font-semibold"></span> 905 - </p> 906 - <template x-if="extraFields && extraFields.length > 0"> 907 - <div class="space-y-2"> 908 - <template x-for="field in extraFields" :key="field.name"> 909 - <div> 910 - <template x-if="field.type === 'select'"> 911 - <select 912 - :name="field.name" 913 - x-model="createFormData[field.name]" 914 - class="w-full form-input text-sm" 915 - > 916 - <option value="" x-text="field.label + ' (optional)'"></option> 917 - <template x-for="opt in field.options" :key="opt"> 918 - <option :value="opt" x-text="opt"></option> 919 - </template> 920 - </select> 921 - </template> 922 - <template x-if="field.type === 'text'"> 923 - <input 924 - type="text" 925 - :placeholder="field.placeholder || field.label" 926 - x-model="createFormData[field.name]" 927 - class="w-full form-input text-sm" 928 - /> 929 - </template> 930 - </div> 931 - </template> 932 - <div class="flex gap-2 mt-2"> 933 - <button 934 - type="button" 935 - @click="submitCreateForm()" 936 - class="flex-1 btn-primary text-sm py-1.5" 937 - :disabled="isCreating" 938 - > 939 - <span x-show="!isCreating">Save</span> 940 - <span x-show="isCreating">Saving...</span> 941 - </button> 942 - <button 943 - type="button" 944 - @click="cancelCreateForm()" 945 - class="flex-1 btn-secondary text-sm py-1.5" 946 - > 947 - Cancel 948 - </button> 949 - </div> 950 - </div> 951 - </template> 952 - </div> 953 - ``` 954 - 955 - **Step 4: Update createNew to use createNewWithDetails when extraFields exist** 956 - 957 - In the combo-select dropdown, change the "Create [name]" button to call `createNewWithDetails()` when extra fields are configured, and `createNew()` when not: 958 - 959 - ```js 960 - // In the allItems getter, the "create" type still appears. 961 - // In selectHighlighted, change: 962 - else if (item.type === "create") { 963 - if (this.extraFields && this.extraFields.length > 0) { 964 - this.createNewWithDetails(); 965 - } else { 966 - this.createNew(); 967 - } 968 - } 969 - ``` 970 - 971 - **Step 5: Verify build** 972 - 973 - ```bash 974 - templ generate 975 - go vet ./... 976 - go build ./... 977 - ``` 978 - 979 - **Step 6: Commit** 980 - 981 - ```bash 982 - git add static/js/combo-select.js internal/web/pages/brew_form.templ 983 - git commit -m "feat: add expandable details to inline entity creation in brew form" 984 - ``` 985 - 986 - --- 987 - 988 - ## Phase 4: Post-Save Nudge (Optional) 989 - 990 - ### Task 10: Show toast after brew save if entities are incomplete 991 - 992 - 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. 993 - 994 - **Files:** 995 - - Modify: `internal/handlers/brew.go` (add incomplete check to HandleBrewCreate response) 996 - - Modify: `static/js/brew-form.js` (handle toast response) 997 - 998 - **Step 1: Add incomplete info to brew create response** 999 - 1000 - In `internal/handlers/brew.go`, after successfully creating a brew, check if the referenced entities are incomplete. Add a JSON field to the response: 1001 - 1002 - ```go 1003 - // After successful brew creation, check for incomplete entities 1004 - type brewCreateResponse struct { 1005 - RKey string `json:"rkey"` 1006 - Incomplete []incompleteEntityInfo `json:"incomplete,omitempty"` 1007 - } 1008 - 1009 - type incompleteEntityInfo struct { 1010 - EntityType string `json:"entity_type"` 1011 - RKey string `json:"rkey"` 1012 - Name string `json:"name"` 1013 - MissingFields []string `json:"missing_fields"` 1014 - } 1015 - ``` 1016 - 1017 - After creating the brew, fetch the referenced bean/grinder/brewer and check: 1018 - 1019 - ```go 1020 - var incomplete []incompleteEntityInfo 1021 - 1022 - if req.BeanRKey != "" { 1023 - if bean, err := store.GetBean(ctx, req.BeanRKey); err == nil && bean != nil && bean.IsIncomplete() { 1024 - incomplete = append(incomplete, incompleteEntityInfo{ 1025 - EntityType: "bean", 1026 - RKey: bean.RKey, 1027 - Name: bean.Name, 1028 - MissingFields: bean.MissingFields(), 1029 - }) 1030 - } 1031 - } 1032 - // Similarly for grinder and brewer... 1033 - 1034 - resp := brewCreateResponse{RKey: rkey, Incomplete: incomplete} 1035 - ``` 1036 - 1037 - **Step 2: Show toast in brew form JS** 1038 - 1039 - In `static/js/brew-form.js`, after a successful brew save, check the response for incomplete entities and show a toast: 1040 - 1041 - ```js 1042 - // After successful save: 1043 - if (data.incomplete && data.incomplete.length > 0) { 1044 - const item = data.incomplete[0]; 1045 - const msg = `${item.name} is missing ${item.missing_fields.join(", ")}`; 1046 - showToast(msg, `/api/modals/${item.entity_type}/${item.rkey}`); 1047 - } 1048 - ``` 1049 - 1050 - 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. 1051 - 1052 - **Step 3: Verify build** 1053 - 1054 - ```bash 1055 - templ generate 1056 - go vet ./... 1057 - go build ./... 1058 - ``` 1059 - 1060 - **Step 4: Commit** 1061 - 1062 - ```bash 1063 - git add internal/handlers/brew.go static/js/brew-form.js 1064 - git commit -m "feat: show toast nudge after brew save if entities are incomplete" 1065 - ``` 1066 - 1067 - --- 1068 - 1069 - ## Bump JS/CSS Versions 1070 - 1071 - ### Task 11: Bump script versions for cache busting 1072 - 1073 - After all changes, bump the version query params on JS files to bust Cloudflare and service worker caches. 1074 - 1075 - **Files:** 1076 - - Modify: `internal/web/components/layout.templ` (bump version for combo-select.js, brew-form.js, manage-page.js) 1077 - - Modify: `internal/web/pages/my_coffee.templ` (set version for manage-page.js) 1078 - 1079 - **Step 1: Update versions** 1080 - 1081 - Find all `?v=` query strings on the modified JS files and increment them. 1082 - 1083 - **Step 2: Commit** 1084 - 1085 - ```bash 1086 - git add internal/web/components/layout.templ internal/web/pages/my_coffee.templ 1087 - git commit -m "chore: bump JS versions for cache busting" 1088 - ``` 1089 - 1090 - --- 1091 - 1092 - ## Verification Checklist 1093 - 1094 - After all tasks: 1095 - 1096 - 1. `go vet ./...` passes 1097 - 2. `go build ./...` passes 1098 - 3. `go test ./...` passes 1099 - 4. Visiting `/brews` redirects to `/my-coffee` 1100 - 5. Visiting `/manage` redirects to `/my-coffee` 1101 - 6. `/my-coffee` shows Brews tab by default with brew list 1102 - 7. Switching to Beans/Grinders/Brewers/Roasters/Recipes tabs works 1103 - 8. Entity create/edit modals work from My Coffee page 1104 - 9. Home page shows incomplete records section when records exist 1105 - 10. Clicking "Complete" on home page opens edit modal 1106 - 11. After saving in modal, incomplete records section refreshes 1107 - 12. Header dropdown shows "My Coffee" instead of "My Brews" and "Manage Records" 1108 - 13. Inline creation in brew form shows expandable details section
-97
docs/recipes.norg
··· 1 - @document.meta 2 - title: Arabica Recipes 3 - authors: @pdewey.com 4 - created: 03/17/26 5 - categories: [spec, lexicon] 6 - @end 7 - 8 - * Recipe Records 9 - 10 - ** Motivation 11 - 12 - When inputting a new brew, it is tedious to type out all the fields when many 13 - of them are always the same. A "recipe" is supposed to solve parts of this 14 - tedium by auto-filling a number of the fields (most notably pours; count, 15 - water amount, and time). A user should be able to create/save a recipe from 16 - one of their brews (or maybe another users brews as well?). 17 - 18 - Recipes are also have potential as a social construct that can be easily 19 - shared around and used. Saving another users recipe is something I would like 20 - to build around in the future. Analytics would also be super cool for 21 - recipes. 22 - 23 - ** Lexicon Fields 24 - 25 - - `name` (string) 26 - - `brewerRef` (ref) 27 - - `brewerType` (string) 28 - - `coffeeAmount` (int -- should be * 10 but isn't) 29 - - `waterAmount` (int * 10) 30 - - `pours` (array of `#pour`) (references {*** Pour Schema})` 31 - - `notes`: (string) reeform description field 32 - 33 - It would probably also make sense to ceraete a `brewRef` field that draws a 34 - link to the brew that the original version of the recipe was created from. 35 - 36 - *** Pour Schema 37 - 38 - Both required: 39 - - `waterAmount` (int - multiplied by 10) 40 - - `timeSeconds` (int) 41 - 42 - ** Implementation 43 - 44 - - Once selected, a recipe should autofill all non-null fields in the brew 45 - - A user should be able to save a recipe off of any brew (by them or by 46 - another user) 47 - - Users should be able to create new recipes from scratch (requires new modal 48 - and view page) 49 - 50 - ** Debugging and Changes 51 - 52 - *** Explore page 53 - 54 - The explore page filters are a bit weird, and values between certain ones 55 - seem a bit wonky (i.e. a 15g dose brew shows up in single, small, and large 56 - filters when it should probably only show up in the single cup one (small 57 - should probably be 12 or less? -- not sure about the exact values). 58 - 59 - Recipes should also show the profile picture and username of the creator of 60 - the recipe. 61 - 62 - Some recipe fields should be interpolated from other fields when missing 63 - (i.e. water amount and ratio, from pours and coffee amount. Ratio may be 64 - transitive) 65 - 66 - "Search recipes" entry doesn't currently work. This should be retooled to 67 - show a list of matches in the same style as login handle entry, and either 68 - show just the user's recipes, or maybe show suggested recipes from other 69 - users (not sure how they would be rated though, since that would probably 70 - need to be part of this) 71 - 72 - *** Using Other User's Recipes In Brews 73 - 74 - Currently, this behavior does not work as expected, as the server tries to 75 - look up the record in the logged-in user's PDS, rather than the one 76 - belonging to the owner. This prevents other users from using recipes that 77 - don't belong to them and is not the intended behavior. 78 - 79 - (Brewer fuzzy finding might also not work, but its hard to say since the 80 - recipe lookup fails first) 81 - 82 - ** Open Questions 83 - 84 - For links between a brew and recipe, which should have the optional ref? 85 - Probably brew, but should a recipe contain a ref to the original brew that it 86 - /may/ have been saved from. 87 - 88 - ** Alternate Recipe Design 89 - 90 - Recipes only contain minimal structure and are more freeform, (build a custom 91 - recipe), with just a content field in the lexicon that contains arbitrary 92 - content that can be customized as desired. This leads to more utility that 93 - what just a pourover recipe provides (e.g. maybe milk drink stuff for 94 - espresso?). 95 - 96 - This would require some sort of recipe customizer page that allows adding 97 - arbitrary key-value pairs (or pours/refs to gear/beans).
-45
docs/schema-design.md
··· 1 - # Lexicon Schemas 2 - 3 - ## Record Types 4 - 5 - Arabica defines 5 lexicon schemas: 6 - 7 - ### social.arabica.alpha.bean 8 - Coffee bean records with origin, roast level, process, and roaster reference. 9 - 10 - ### social.arabica.alpha.roaster 11 - Coffee roaster records with name, location, and website. 12 - 13 - ### social.arabica.alpha.grinder 14 - Grinder records with type (hand/electric), burr type (conical/flat), and notes. 15 - 16 - ### social.arabica.alpha.brewer 17 - Brewing device records with name and description. 18 - 19 - ### social.arabica.alpha.brew 20 - Brew session records including: 21 - - Bean reference (AT-URI) 22 - - Brewing parameters (temperature, time, water, coffee amounts) 23 - - Grinder and brewer references (optional) 24 - - Grind size, method, tasting notes, rating 25 - - Pours array (embedded, not separate records) 26 - 27 - ## Design Decisions 28 - 29 - ### References 30 - All references use AT-URIs pointing to user's own records. 31 - Example: `at://did:plc:abc123/social.arabica.alpha.bean/3jxy123` 32 - 33 - ### Temperature Storage 34 - Stored as integer in tenths of degrees Celsius. 35 - Example: 935 represents 93.5°C 36 - 37 - ### Pours 38 - Embedded in brew records as an array rather than separate collection. 39 - 40 - ### Required Fields 41 - Minimal requirements - most fields are optional for flexibility. 42 - 43 - ## Schema Files 44 - 45 - See `lexicons/` directory for complete JSON schemas.