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.

feat: my coffee plan, phase 1 and 2

authored by

Patrick Dewey and committed by tangled.org eefecb93 571d3ab1

+1471 -57
+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
+1 -17
internal/handlers/brew.go
··· 102 102 103 103 // List all brews 104 104 func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 105 - // Require authentication 106 - _, authenticated := h.getAtprotoStore(r) 107 - if !authenticated { 108 - http.Redirect(w, r, "/login", http.StatusFound) 109 - return 110 - } 111 - 112 - layoutData, _, _ := h.layoutDataFromRequest(r, "Your Brews") 113 - 114 - // Create brew list props 115 - brewListProps := pages.BrewListProps{} 116 - 117 - // Render using templ component 118 - if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil { 119 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 120 - log.Error().Err(err).Msg("Failed to render brew list page") 121 - } 105 + http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently) 122 106 } 123 107 124 108 // Show new brew form
+52 -8
internal/handlers/entities.go
··· 289 289 290 290 // Manage page 291 291 func (h *Handler) HandleManage(w http.ResponseWriter, r *http.Request) { 292 - // Require authentication 292 + http.Redirect(w, r, "/my-coffee", http.StatusMovedPermanently) 293 + } 294 + 295 + // HandleMyCoffee renders the unified My Coffee page (replaces both /brews and /manage) 296 + func (h *Handler) HandleMyCoffee(w http.ResponseWriter, r *http.Request) { 293 297 _, authenticated := h.getAtprotoStore(r) 294 298 if !authenticated { 295 299 http.Redirect(w, r, "/login", http.StatusFound) 296 300 return 297 301 } 298 302 299 - layoutData, _, _ := h.layoutDataFromRequest(r, "Manage") 303 + layoutData, _, _ := h.layoutDataFromRequest(r, "My Coffee") 300 304 301 - // Create manage props 302 - manageProps := pages.ManageProps{} 303 - 304 - // Render using templ component 305 - if err := pages.Manage(layoutData, manageProps).Render(r.Context(), w); err != nil { 305 + if err := pages.MyCoffee(layoutData, pages.MyCoffeeProps{}).Render(r.Context(), w); err != nil { 306 306 http.Error(w, "Failed to render page", http.StatusInternalServerError) 307 - log.Error().Err(err).Msg("Failed to render manage page") 307 + log.Error().Err(err).Msg("Failed to render my coffee page") 308 + } 309 + } 310 + 311 + // HandleIncompleteRecordsPartial returns an HTML fragment for incomplete records on the home dashboard. 312 + func (h *Handler) HandleIncompleteRecordsPartial(w http.ResponseWriter, r *http.Request) { 313 + store, authenticated := h.getAtprotoStore(r) 314 + if !authenticated { 315 + return 316 + } 317 + 318 + ctx := r.Context() 319 + g, ctx := errgroup.WithContext(ctx) 320 + 321 + var beans []*models.Bean 322 + var grinders []*models.Grinder 323 + var brewers []*models.Brewer 324 + 325 + g.Go(func() error { 326 + var err error 327 + beans, err = store.ListBeans(ctx) 328 + return err 329 + }) 330 + g.Go(func() error { 331 + var err error 332 + grinders, err = store.ListGrinders(ctx) 333 + return err 334 + }) 335 + g.Go(func() error { 336 + var err error 337 + brewers, err = store.ListBrewers(ctx) 338 + return err 339 + }) 340 + 341 + if err := g.Wait(); err != nil { 342 + log.Error().Err(err).Msg("Failed to fetch data for incomplete records") 343 + return 344 + } 345 + 346 + records := components.CollectIncompleteRecords(beans, grinders, brewers, 5) 347 + 348 + if err := components.IncompleteRecords(components.IncompleteRecordsProps{ 349 + Records: records, 350 + }).Render(r.Context(), w); err != nil { 351 + log.Error().Err(err).Msg("Failed to render incomplete records") 308 352 } 309 353 } 310 354
+48
internal/models/models.go
··· 362 362 SourceRef string `json:"source_ref,omitempty"` 363 363 } 364 364 365 + // IsIncomplete returns true if the bean is missing key fields beyond name/origin. 366 + func (b *Bean) IsIncomplete() bool { 367 + return b.RoasterRKey == "" || b.RoastLevel == "" || b.Process == "" 368 + } 369 + 370 + // MissingFields returns a human-readable list of missing fields. 371 + func (b *Bean) MissingFields() []string { 372 + var missing []string 373 + if b.RoasterRKey == "" { 374 + missing = append(missing, "roaster") 375 + } 376 + if b.RoastLevel == "" { 377 + missing = append(missing, "roast level") 378 + } 379 + if b.Process == "" { 380 + missing = append(missing, "process") 381 + } 382 + return missing 383 + } 384 + 385 + // IsIncomplete returns true if the grinder is missing its type. 386 + func (g *Grinder) IsIncomplete() bool { 387 + return g.GrinderType == "" 388 + } 389 + 390 + // MissingFields returns a human-readable list of missing fields. 391 + func (g *Grinder) MissingFields() []string { 392 + var missing []string 393 + if g.GrinderType == "" { 394 + missing = append(missing, "grinder type") 395 + } 396 + return missing 397 + } 398 + 399 + // IsIncomplete returns true if the brewer is missing its type. 400 + func (b *Brewer) IsIncomplete() bool { 401 + return b.BrewerType == "" 402 + } 403 + 404 + // MissingFields returns a human-readable list of missing fields. 405 + func (b *Brewer) MissingFields() []string { 406 + var missing []string 407 + if b.BrewerType == "" { 408 + missing = append(missing, "brewer type") 409 + } 410 + return missing 411 + } 412 + 365 413 // Like represents a like on an Arabica record 366 414 type Like struct { 367 415 RKey string `json:"rkey"`
+34
internal/models/models_test.go
··· 351 351 }) 352 352 } 353 353 354 + func TestBeanIsIncomplete(t *testing.T) { 355 + complete := &Bean{Name: "Test", Origin: "Ethiopia", RoasterRKey: "abc", RoastLevel: "Light", Process: "Washed"} 356 + assert.False(t, complete.IsIncomplete()) 357 + assert.Empty(t, complete.MissingFields()) 358 + 359 + incomplete := &Bean{Name: "Test", Origin: "Ethiopia", RoastLevel: "Light", Process: "Washed"} 360 + assert.True(t, incomplete.IsIncomplete()) 361 + assert.Contains(t, incomplete.MissingFields(), "roaster") 362 + 363 + stub := &Bean{Name: "Test"} 364 + assert.True(t, stub.IsIncomplete()) 365 + assert.Len(t, stub.MissingFields(), 3) 366 + } 367 + 368 + func TestGrinderIsIncomplete(t *testing.T) { 369 + complete := &Grinder{Name: "Test", GrinderType: "Hand"} 370 + assert.False(t, complete.IsIncomplete()) 371 + assert.Empty(t, complete.MissingFields()) 372 + 373 + incomplete := &Grinder{Name: "Test"} 374 + assert.True(t, incomplete.IsIncomplete()) 375 + assert.Contains(t, incomplete.MissingFields(), "grinder type") 376 + } 377 + 378 + func TestBrewerIsIncomplete(t *testing.T) { 379 + complete := &Brewer{Name: "V60", BrewerType: "pourover"} 380 + assert.False(t, complete.IsIncomplete()) 381 + assert.Empty(t, complete.MissingFields()) 382 + 383 + incomplete := &Brewer{Name: "V60"} 384 + assert.True(t, incomplete.IsIncomplete()) 385 + assert.Contains(t, incomplete.MissingFields(), "brewer type") 386 + } 387 + 354 388 func TestNormalizeBrewerType(t *testing.T) { 355 389 tests := []struct { 356 390 input string
+2
internal/routing/routing.go
··· 61 61 mux.Handle("GET /api/feed", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleFeedPartial))) 62 62 mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleBrewListPartial))) 63 63 mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial))) 64 + mux.Handle("GET /api/incomplete-records", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleIncompleteRecordsPartial))) 64 65 mux.Handle("POST /api/manage/refresh", cop.Handler(http.HandlerFunc(h.HandleManageRefresh))) 65 66 mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial))) 66 67 ··· 73 74 mux.HandleFunc("GET /join/create", h.HandleCreateAccount) 74 75 mux.Handle("POST /join/create", cop.Handler(http.HandlerFunc(h.HandleCreateAccountSubmit))) 75 76 mux.HandleFunc("GET /atproto", h.HandleATProto) 77 + mux.HandleFunc("GET /my-coffee", h.HandleMyCoffee) 76 78 mux.HandleFunc("GET /manage", h.HandleManage) 77 79 mux.HandleFunc("GET /brews", h.HandleBrewList) 78 80 mux.HandleFunc("GET /brews/new", h.HandleBrewNew)
+2 -5
internal/web/components/header.templ
··· 74 74 <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item"> 75 75 View Profile 76 76 </a> 77 - <a href="/brews" class="dropdown-item"> 78 - My Brews 77 + <a href="/my-coffee" class="dropdown-item"> 78 + My Coffee 79 79 </a> 80 80 <a href="/recipes" class="dropdown-item"> 81 81 Recipes 82 - </a> 83 - <a href="/manage" class="dropdown-item"> 84 - Manage Records 85 82 </a> 86 83 <a href="/settings" class="dropdown-item"> 87 84 Settings
+109
internal/web/components/incomplete_records.templ
··· 1 + package components 2 + 3 + import ( 4 + "arabica/internal/models" 5 + "fmt" 6 + "strings" 7 + ) 8 + 9 + // IncompleteRecord represents a single entity that needs attention 10 + type IncompleteRecord struct { 11 + EntityType string 12 + RKey string 13 + Name string 14 + MissingFields []string 15 + } 16 + 17 + // IncompleteRecordsProps defines the data for the incomplete records section 18 + type IncompleteRecordsProps struct { 19 + Records []IncompleteRecord 20 + } 21 + 22 + // IncompleteRecords renders the incomplete records nudge section 23 + templ IncompleteRecords(props IncompleteRecordsProps) { 24 + if len(props.Records) > 0 { 25 + <div class="card p-4 sm:p-6 mb-6"> 26 + <div class="flex items-center gap-2 mb-3"> 27 + <svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 28 + <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> 29 + </svg> 30 + <h3 class="text-lg font-semibold text-brown-900"> 31 + { fmt.Sprintf("%d", len(props.Records)) } { incompleteNoun(len(props.Records)) } need details 32 + </h3> 33 + </div> 34 + <div class="space-y-2"> 35 + for _, rec := range props.Records { 36 + <div class="flex items-center justify-between p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 37 + <div> 38 + <span class="font-medium text-brown-900">{ rec.Name }</span> 39 + <span class="text-sm text-brown-600 ml-2"> 40 + missing { strings.Join(rec.MissingFields, ", ") } 41 + </span> 42 + </div> 43 + <button 44 + hx-get={ fmt.Sprintf("/api/modals/%s/%s", rec.EntityType, rec.RKey) } 45 + hx-target="#modal-container" 46 + hx-swap="innerHTML" 47 + class="text-sm font-medium text-brown-700 hover:text-brown-900 cursor-pointer whitespace-nowrap ml-2" 48 + > 49 + Complete 50 + </button> 51 + </div> 52 + } 53 + </div> 54 + if len(props.Records) > 3 { 55 + <a href="/my-coffee" class="block text-center text-sm text-brown-600 hover:text-brown-800 mt-3"> 56 + View all in My Coffee 57 + </a> 58 + } 59 + </div> 60 + } 61 + } 62 + 63 + func incompleteNoun(count int) string { 64 + if count == 1 { 65 + return "record" 66 + } 67 + return "records" 68 + } 69 + 70 + // CollectIncompleteRecords scans all entities and returns incomplete ones (max limit). 71 + func CollectIncompleteRecords(beans []*models.Bean, grinders []*models.Grinder, brewers []*models.Brewer, limit int) []IncompleteRecord { 72 + var records []IncompleteRecord 73 + 74 + for _, b := range beans { 75 + if b.IsIncomplete() && !b.Closed { 76 + records = append(records, IncompleteRecord{ 77 + EntityType: "bean", 78 + RKey: b.RKey, 79 + Name: b.Name, 80 + MissingFields: b.MissingFields(), 81 + }) 82 + } 83 + } 84 + for _, g := range grinders { 85 + if g.IsIncomplete() { 86 + records = append(records, IncompleteRecord{ 87 + EntityType: "grinder", 88 + RKey: g.RKey, 89 + Name: g.Name, 90 + MissingFields: g.MissingFields(), 91 + }) 92 + } 93 + } 94 + for _, b := range brewers { 95 + if b.IsIncomplete() { 96 + records = append(records, IncompleteRecord{ 97 + EntityType: "brewer", 98 + RKey: b.RKey, 99 + Name: b.Name, 100 + MissingFields: b.MissingFields(), 101 + }) 102 + } 103 + } 104 + 105 + if limit > 0 && len(records) > limit { 106 + return records[:limit] 107 + } 108 + return records 109 + }
+2 -2
internal/web/components/profile_partial.templ
··· 63 63 if isOwnProfile { 64 64 @EmptyState(EmptyStateProps{ 65 65 Message: "No open bags yet! Add your first bean.", 66 - ActionURL: "/manage", 67 - ActionText: "Go to Manage", 66 + ActionURL: "/my-coffee", 67 + ActionText: "Go to My Coffee", 68 68 }) 69 69 } else { 70 70 @EmptyState(EmptyStateProps{
+10 -14
internal/web/components/shared.templ
··· 223 223 <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a> 224 224 </p> 225 225 </div> 226 - <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 226 + <div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> 227 227 <a 228 228 href="/brews/new" 229 229 class="home-action-primary block text-center py-4 px-6 rounded-xl" 230 - hx-get="/brews/new" 231 - hx-target="main" 232 - hx-swap="innerHTML show:top" 233 - hx-select="main > *" 234 - hx-push-url="true" 235 230 > 236 - <span class="text-lg font-semibold">Add New Brew</span> 231 + <span class="text-lg font-semibold">Log Brew</span> 237 232 </a> 238 233 <a 239 - href="/brews" 234 + href="/my-coffee" 240 235 class="home-action-secondary block text-center py-4 px-6 rounded-xl" 241 - hx-get="/brews" 242 - hx-target="main" 243 - hx-swap="innerHTML show:top" 244 - hx-select="main > *" 245 - hx-push-url="true" 246 236 > 247 - <span class="text-lg font-semibold">View All Brews</span> 237 + <span class="text-lg font-semibold">My Coffee</span> 238 + </a> 239 + <a 240 + href={ templ.SafeURL("/profile/" + userDID) } 241 + class="home-action-secondary block text-center py-4 px-6 rounded-xl" 242 + > 243 + <span class="text-lg font-semibold">Profile</span> 248 244 </a> 249 245 </div> 250 246 }
+1 -1
internal/web/pages/bean_view.templ
··· 127 127 IsOwner: props.IsOwnProfile, 128 128 EditModalURL: "/api/modals/bean/" + props.Bean.RKey, 129 129 DeleteURL: "/api/beans/" + props.Bean.RKey, 130 - DeleteRedirect: "/manage", 130 + DeleteRedirect: "/my-coffee", 131 131 IsAuthenticated: props.IsAuthenticated, 132 132 IsModerator: props.IsModerator, 133 133 CanHideRecord: props.CanHideRecord,
+1 -1
internal/web/pages/brew_view.templ
··· 78 78 IsOwner: props.IsOwnProfile, 79 79 EditURL: "/brews/" + props.Brew.RKey + "/edit", 80 80 DeleteURL: "/brews/" + props.Brew.RKey, 81 - DeleteRedirect: "/brews", 81 + DeleteRedirect: "/my-coffee", 82 82 IsAuthenticated: props.IsAuthenticated, 83 83 IsModerator: props.IsModerator, 84 84 CanHideRecord: props.CanHideRecord,
+1 -1
internal/web/pages/brewer_view.templ
··· 62 62 IsOwner: props.IsOwnProfile, 63 63 EditModalURL: "/api/modals/brewer/" + props.Brewer.RKey, 64 64 DeleteURL: "/api/brewers/" + props.Brewer.RKey, 65 - DeleteRedirect: "/manage", 65 + DeleteRedirect: "/my-coffee", 66 66 IsAuthenticated: props.IsAuthenticated, 67 67 IsModerator: props.IsModerator, 68 68 CanHideRecord: props.CanHideRecord,
+1 -1
internal/web/pages/grinder_view.templ
··· 65 65 IsOwner: props.IsOwnProfile, 66 66 EditModalURL: "/api/modals/grinder/" + props.Grinder.RKey, 67 67 DeleteURL: "/api/grinders/" + props.Grinder.RKey, 68 - DeleteRedirect: "/manage", 68 + DeleteRedirect: "/my-coffee", 69 69 IsAuthenticated: props.IsAuthenticated, 70 70 IsModerator: props.IsModerator, 71 71 CanHideRecord: props.CanHideRecord,
+7 -3
internal/web/pages/home.templ
··· 17 17 IsAuthenticated: props.IsAuthenticated, 18 18 UserDID: props.UserDID, 19 19 }) 20 - // Move about section after feed for authenticated users 21 - // TODO: maybe leave it as a small section that can be 22 - // opened via a drowdown? 20 + if props.IsAuthenticated { 21 + <!-- Incomplete records loaded async --> 22 + <div id="incomplete-records-section" hx-get="/api/incomplete-records" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 23 + </div> 24 + <!-- Modal container for entity edit dialogs opened from dashboard --> 25 + <div id="modal-container"></div> 26 + } 23 27 if !props.IsAuthenticated { 24 28 @components.AboutInfoCard() 25 29 }
+88
internal/web/pages/my_coffee.templ
··· 1 + package pages 2 + 3 + import "arabica/internal/web/components" 4 + 5 + // MyCoffeeProps defines the data for the unified My Coffee page 6 + type MyCoffeeProps struct{} 7 + 8 + // MyCoffee renders the full My Coffee page 9 + templ MyCoffee(layout *components.LayoutData, props MyCoffeeProps) { 10 + @components.Layout(layout, MyCoffeeContent(props)) 11 + } 12 + 13 + // MyCoffeeContent renders the My Coffee page content 14 + templ MyCoffeeContent(props MyCoffeeProps) { 15 + <script src="/static/js/manage-page.js?v=0.4.0"></script> 16 + <div class="page-container-xl" x-data="managePage()"> 17 + <div class="flex items-center gap-3 mb-6"> 18 + <h2 class="text-2xl font-semibold text-brown-900">My Coffee</h2> 19 + <div class="ml-auto flex items-center gap-2"> 20 + <a href="/brews/new" class="btn-primary shadow-lg hover:shadow-xl">+ New Brew</a> 21 + @ManageRefreshButton() 22 + </div> 23 + </div> 24 + @MyCoffeeTabs() 25 + <!-- Brews tab: standalone HTMX loader --> 26 + <div x-show="tab === 'brews'"> 27 + <div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML"> 28 + @BrewListLoadingSkeleton() 29 + </div> 30 + </div> 31 + <!-- Entity tabs: loaded from manage partial --> 32 + <div id="manage-content" x-show="tab !== 'brews'" hx-get="/api/manage" hx-trigger="load, refreshManage from:body" hx-swap="innerHTML"> 33 + @ManageLoadingSkeleton() 34 + </div> 35 + <!-- Modal container for HTMX-loaded dialogs --> 36 + <div id="modal-container"></div> 37 + </div> 38 + } 39 + 40 + // MyCoffeeTabs renders the tab navigation for My Coffee page 41 + templ MyCoffeeTabs() { 42 + <div class="mb-6 border-b-2 border-brown-300"> 43 + <nav class="-mb-px flex space-x-8 overflow-x-auto"> 44 + <button 45 + @click="tab = 'brews'" 46 + :class="tab === 'brews' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 47 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 48 + > 49 + Brews 50 + </button> 51 + <button 52 + @click="tab = 'beans'" 53 + :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 54 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 55 + > 56 + Beans 57 + </button> 58 + <button 59 + @click="tab = 'roasters'" 60 + :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 61 + class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 62 + > 63 + Roasters 64 + </button> 65 + <button 66 + @click="tab = 'grinders'" 67 + :class="tab === 'grinders' ? '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 + Grinders 71 + </button> 72 + <button 73 + @click="tab = 'brewers'" 74 + :class="tab === 'brewers' ? '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 + Brewers 78 + </button> 79 + <button 80 + @click="tab = 'recipes'" 81 + :class="tab === 'recipes' ? '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 + Recipes 85 + </button> 86 + </nav> 87 + </div> 88 + }
+1 -1
internal/web/pages/profile.templ
··· 26 26 // ProfileContent renders the profile page content 27 27 templ ProfileContent(props ProfileProps) { 28 28 if props.IsOwnProfile { 29 - <script src="/static/js/manage-page.js?v=0.3.0"></script> 29 + <script src="/static/js/manage-page.js?v=0.4.0"></script> 30 30 } 31 31 <script src="/static/js/profile-stats.js?v=0.2.0"></script> 32 32 if props.IsOwnProfile {
+1 -1
internal/web/pages/recipe_view.templ
··· 133 133 IsOwner: props.IsOwnProfile, 134 134 EditModalURL: "/api/modals/recipe/" + props.Recipe.RKey, 135 135 DeleteURL: "/api/recipes/" + props.Recipe.RKey, 136 - DeleteRedirect: "/manage", 136 + DeleteRedirect: "/my-coffee", 137 137 IsAuthenticated: props.IsAuthenticated, 138 138 IsModerator: props.IsModerator, 139 139 CanHideRecord: props.CanHideRecord,
+1 -1
internal/web/pages/roaster_view.templ
··· 70 70 IsOwner: props.IsOwnProfile, 71 71 EditModalURL: "/api/modals/roaster/" + props.Roaster.RKey, 72 72 DeleteURL: "/api/roasters/" + props.Roaster.RKey, 73 - DeleteRedirect: "/manage", 73 + DeleteRedirect: "/my-coffee", 74 74 IsAuthenticated: props.IsAuthenticated, 75 75 IsModerator: props.IsModerator, 76 76 CanHideRecord: props.CanHideRecord,
+1 -1
static/js/manage-page.js
··· 5 5 */ 6 6 function managePage() { 7 7 return { 8 - tab: localStorage.getItem("manageTab") || "beans", 8 + tab: localStorage.getItem("manageTab") || "brews", 9 9 activeTab: "brews", // Always default to brews tab on profile 10 10 11 11 // Entity managers for each entity type