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: add OpenGraph metadata to brew pages (#7)

Add dynamic OpenGraph and Twitter Card metadata support for brew pages
to enable rich social sharing previews.

Changes:
- Extend LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)
- Add helper methods with fallbacks to site defaults
- Update layout.templ to render dynamic meta tags
- Add populateBrewOGMetadata() helper in handlers
- Pass PublicURL through handler config for absolute URLs
- Add comprehensive tests for OG metadata generation

When sharing brew links, previews now show:
- Bean name and origin as title
- Rating, tasting notes, and roaster in description
- Proper article type for social platforms

authored by

Patrick Dewey and committed by
GitHub
c535dd9f af46f7c4

+270 -12
+1
.cells/cells.jsonl
··· 48 48 {"id":"01KGGJFSZ4YDNF6ZE8F9WRT79F","title":"Replace profile brew table with cards","description":"## Task\n\nReplace the table-based brew list on the profile page with card-based layout matching the feed.\n\n## Changes\n\n### profile.templ / profile_partial.templ\n1. Replace BrewListTablePartial with card-based layout\n2. Reuse FeedItem/FeedBrewContent components or create ProfileBrewCard\n3. Include ActionBar with owner actions enabled\n4. Maintain grid/list layout for multiple brews\n\n### Layout Considerations\n- Cards should stack vertically (like feed)\n- Consider max-width for readability\n- Mobile: single column\n- Desktop: could be single column or 2-column grid\n\n### Props to Pass\n- Full brew data\n- Bean/roaster/grinder/brewer references\n- IsOwner: true (it's the user's profile)\n- Like/share state for each brew\n\n## Acceptance Criteria\n- [ ] Brew table replaced with cards on profile\n- [ ] Cards show all brew info (bean, method, variables, notes, rating)\n- [ ] ActionBar with Edit/Delete functional for owner\n- [ ] Cards link to brew detail view\n- [ ] Mobile responsive\n- [ ] templ generate runs successfully","status":"completed","priority":"high","assignee":"patrick","labels":["frontend"],"created_at":"2026-02-03T01:39:49.348915829Z","updated_at":"2026-02-03T01:49:13.11140332Z","completed_at":"2026-02-03T01:49:13.098282125Z"} 49 49 {"id":"01KGGJG2FDK2NN8H059PTHNME0","title":"Add placeholder report endpoint","description":"## Task\n\nAdd a placeholder report endpoint for the More menu Report action.\n\n## Endpoint\n\n**Route:** POST /api/report\n**Handler:** HandleReport\n\n## Request Body\n```json\n{\n \"subject_uri\": \"at://did:plc:xxx/social.arabica.alpha.brew/rkey\",\n \"subject_cid\": \"bafyrei...\",\n \"reason\": \"spam|inappropriate|other\"\n}\n```\n\n## Response\n- 200 OK with `{\"status\": \"received\"}`\n- Log the report for now\n- TODO comment indicating future implementation needs\n\n## Implementation Notes\n- Add route to routing.go\n- Create minimal handler that logs and returns success\n- No actual moderation system yet - just capture the intent\n\n## Acceptance Criteria\n- [ ] POST /api/report endpoint exists\n- [ ] Accepts subject_uri, subject_cid, reason\n- [ ] Logs report details\n- [ ] Returns success response\n- [ ] TODO comment for future moderation system","status":"completed","priority":"normal","assignee":"patrick","labels":["backend","api"],"created_at":"2026-02-03T01:39:58.061904095Z","updated_at":"2026-02-03T01:43:12.681518812Z","completed_at":"2026-02-03T01:43:12.667221097Z"} 50 50 {"id":"01KGGK6V4WMC3X67E72JTE27A7","title":"Fix 'failed to convert record to feed item' warning for likes","description":"The firehose index logs a warning 'failed to convert record to feed item' when processing like records. This is expected behavior since likes are not displayed as standalone feed items - they're indexed for like counts but shouldn't appear in the feed.\n\nThe warning appears in internal/firehose/index.go:433 when recordToFeedItem returns an error for NSIDLike records (line 582-584).\n\nFix: Handle the like case silently without logging a warning, since this is expected behavior. Either:\n1. Skip like records before calling recordToFeedItem\n2. Return a special sentinel error from recordToFeedItem that indicates 'skip without warning'\n3. Check the collection type at the call site and skip likes there\n\nThis applies to both authenticated and unauthenticated users.","status":"completed","priority":"normal","labels":["bug"],"created_at":"2026-02-03T01:52:24.220349056Z","updated_at":"2026-02-03T01:54:54.698106017Z","completed_at":"2026-02-03T01:54:54.686934901Z"} 51 + {"id":"01KGGNPG25M2AJ619P65FPMEH4","title":"Add OpenGraph metadata to brew pages","description":"Add dynamic OpenGraph metadata support for brew pages to enable rich social sharing previews.\n\n## Background\n\nCurrently, the site uses static OpenGraph tags in layout.templ:\n- og:title: \"Arabica - Coffee Brew Tracker\" (static)\n- og:description: \"Track your coffee brewing journey...\" (static) \n- og:type: \"website\" (static)\n- og:image: NOT PRESENT\n\nWhen users share brew links on social media, all previews look identical and provide no context about the specific brew.\n\n## Requirements\n\n### 1. Extend LayoutData struct\n\nAdd optional OpenGraph fields to `internal/web/components/layout.templ`:\n\n```go\ntype LayoutData struct {\n Title string\n IsAuthenticated bool\n UserDID string\n UserProfile *bff.UserProfile\n CSPNonce string\n \n // OpenGraph metadata (optional, falls back to defaults)\n OGTitle string\n OGDescription string\n OGImage string\n OGType string // \"website\", \"article\"\n OGUrl string // Canonical URL\n}\n```\n\n### 2. Update layout.templ head section\n\nModify meta tag rendering to use dynamic values with fallbacks:\n- If OGTitle set, use it; otherwise use site default\n- If OGDescription set, use it; otherwise use site default\n- If OGImage set, render og:image tag (currently missing entirely)\n- If OGUrl set, render og:url tag\n- Support og:type (default \"website\", brew pages use \"article\")\n\n### 3. Update buildLayoutData helper\n\nExtend `handlers.go` buildLayoutData() to accept optional OG parameters, or create a new helper `buildLayoutDataWithOG()`.\n\n### 4. Update HandleBrewView handler\n\nConstruct descriptive OG metadata from brew data:\n- **og:title**: \"{Bean Name} from {Origin} - Arabica\" or similar\n- **og:description**: \"{Rating}/10 - {Tasting Notes preview}\" or brew summary\n- **og:type**: \"article\"\n- **og:url**: The share URL (already computed as `shareURL`)\n- **og:image**: Static default for now (e.g., /static/og-brew-default.png)\n\nAvailable data for description construction:\n- brew.Rating, brew.TastingNotes\n- brew.Bean.Name, brew.Bean.Origin, brew.Bean.RoastLevel\n- brew.Bean.Roaster.Name\n- brew.CoffeeAmount, brew.WaterAmount, brew.Temperature\n\n### 5. Add Twitter Card support\n\nAdd Twitter-specific meta tags:\n- twitter:card = \"summary\" (or \"summary_large_image\" if we have images)\n- twitter:title, twitter:description (same as OG values)\n\n### 6. Static fallback image\n\nCreate or source a default OG image for brews:\n- Location: /static/og-brew-default.png or similar\n- Size: 1200x630px (recommended OG image size)\n- Design: Coffee-themed, includes Arabica branding\n\n## Acceptance Criteria\n\n- [ ] LayoutData extended with optional OG fields\n- [ ] layout.templ renders dynamic OG tags with fallbacks\n- [ ] Brew view pages include descriptive OG metadata\n- [ ] Twitter Card tags included\n- [ ] Static default OG image created and served\n- [ ] Existing pages (home, about, etc.) continue working with defaults\n- [ ] templ generate runs successfully\n- [ ] Manual testing: share brew URL to social platform preview tool\n\n## Implementation Notes\n\n- Follow existing patterns in handlers.go for data flow\n- Keep backwards compatible - pages not setting OG fields should use defaults\n- Consider creating a helper function to build OG description from brew data\n- The shareURL is already constructed in HandleBrewView (lines 695-701)\n\n## Future Enhancements (out of scope)\n\n- Dynamic image generation showing brew stats\n- Profile page OG metadata\n- Feed page OG metadata with recent brew preview","status":"completed","priority":"normal","assignee":"patrick","labels":["frontend","social"],"created_at":"2026-02-03T02:35:54.309356038Z","updated_at":"2026-02-03T02:53:43.170537883Z","completed_at":"2026-02-03T02:53:43.15817599Z","notes":[{"timestamp":"2026-02-03T02:53:36.100130152Z","author":"patrick","message":"Implementation complete:\n- Extended LayoutData with OG fields (OGTitle, OGDescription, OGImage, OGType, OGUrl)\n- Added helper methods ogTitle(), ogDescription(), ogType() with fallbacks\n- Updated layout.templ to render dynamic OG and Twitter Card meta tags\n- Added populateBrewOGMetadata() helper in handlers.go\n- Updated HandleBrewView to populate OG metadata from brew data\n- Added PublicURL to handler Config for absolute URLs\n- Added comprehensive tests for OG metadata generation\n- All tests pass"}]}
+11
BACKLOG.md
··· 55 55 56 56 - Maybe having a way of nesting modals, so a roaster can be created from within the bean modal? 57 57 - Maybe have a transition that moves the bean modal to the left, and opens a roaster modal to the right 58 + 59 + ## Notes 60 + 61 + - Popup menu for feed card extras should be centered on the button 62 + - Maybe use a different background color (maybe the button color?) 63 + 64 + - Add a copy AT URI to extras popup 65 + 66 + - Firehose maybe not backfilling likes 67 + 68 + - TODO: add OpenGraph embeds (mainly for brews; beans and roasters can come later)
+1
cmd/server/main.go
··· 289 289 feedRegistry, 290 290 handlers.Config{ 291 291 SecureCookies: secureCookies, 292 + PublicURL: publicURL, 292 293 }, 293 294 ) 294 295
+70 -9
internal/handlers/handlers.go
··· 29 29 // SecureCookies sets the Secure flag on authentication cookies 30 30 // Should be true in production (HTTPS), false for local development (HTTP) 31 31 SecureCookies bool 32 + 33 + // PublicURL is the public-facing URL for the server (e.g., https://arabica.social) 34 + // Used for constructing absolute URLs in OpenGraph metadata 35 + PublicURL string 32 36 } 33 37 34 38 // Handler contains all HTTP handler methods and their dependencies. ··· 193 197 } 194 198 } 195 199 200 + // populateBrewOGMetadata sets OpenGraph metadata on layoutData for a brew page. 201 + // This enriches social media previews when brew links are shared. 202 + func (h *Handler) populateBrewOGMetadata(layoutData *components.LayoutData, brew *models.Brew, shareURL string) { 203 + if brew == nil { 204 + return 205 + } 206 + 207 + // Build OG title from bean info 208 + var ogTitle string 209 + if brew.Bean != nil { 210 + if brew.Bean.Origin != "" { 211 + ogTitle = fmt.Sprintf("%s from %s", brew.Bean.Name, brew.Bean.Origin) 212 + } else { 213 + ogTitle = brew.Bean.Name 214 + } 215 + } else { 216 + ogTitle = "Coffee Brew" 217 + } 218 + 219 + // Build OG description with rating and tasting notes 220 + var descParts []string 221 + if brew.Rating > 0 { 222 + descParts = append(descParts, fmt.Sprintf("Rated %d/10", brew.Rating)) 223 + } 224 + if brew.TastingNotes != "" { 225 + // Truncate tasting notes if too long 226 + notes := brew.TastingNotes 227 + if len(notes) > 100 { 228 + notes = notes[:97] + "..." 229 + } 230 + descParts = append(descParts, notes) 231 + } 232 + if brew.Bean != nil && brew.Bean.Roaster != nil { 233 + descParts = append(descParts, fmt.Sprintf("Roasted by %s", brew.Bean.Roaster.Name)) 234 + } 235 + 236 + var ogDescription string 237 + if len(descParts) > 0 { 238 + ogDescription = strings.Join(descParts, " · ") 239 + } else { 240 + ogDescription = "A coffee brew tracked on Arabica" 241 + } 242 + 243 + // Build absolute URL if public URL is configured 244 + var ogURL string 245 + if h.config.PublicURL != "" && shareURL != "" { 246 + ogURL = h.config.PublicURL + shareURL 247 + } 248 + 249 + layoutData.OGTitle = ogTitle 250 + layoutData.OGDescription = ogDescription 251 + layoutData.OGType = "article" 252 + layoutData.OGUrl = ogURL 253 + } 254 + 196 255 // ProfileDataBundle holds all user data fetched from their PDS for profile display 197 256 type ProfileDataBundle struct { 198 257 Beans []*models.Bean ··· 378 437 Brews: brews, 379 438 }, nil 380 439 } 440 + 381 441 // Home page 382 442 func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 383 443 // Check if user is authenticated ··· 679 739 isOwner = true 680 740 } 681 741 682 - // Create layout data 742 + // Construct share URL (needed for both OG metadata and props) 743 + var shareURL string 744 + if owner != "" { 745 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) 746 + } else if userProfile != nil && userProfile.Handle != "" { 747 + shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) 748 + } 749 + 750 + // Create layout data with OpenGraph metadata 683 751 layoutData := h.buildLayoutData(r, "Brew Details", isAuthenticated, didStr, userProfile) 752 + h.populateBrewOGMetadata(layoutData, brew, shareURL) 684 753 685 754 // Get like data 686 755 var isLiked bool ··· 690 759 if isAuthenticated { 691 760 isLiked = h.feedIndex.HasUserLiked(didStr, subjectURI) 692 761 } 693 - } 694 - 695 - // Construct share URL 696 - var shareURL string 697 - if owner != "" { 698 - shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, owner) 699 - } else if userProfile != nil && userProfile.Handle != "" { 700 - shareURL = fmt.Sprintf("/brews/%s?owner=%s", rkey, userProfile.Handle) 701 762 } 702 763 703 764 // Create brew view props
+137
internal/handlers/handlers_test.go
··· 12 12 "testing" 13 13 14 14 "arabica/internal/models" 15 + "arabica/internal/web/components" 15 16 16 17 "github.com/stretchr/testify/assert" 17 18 ) ··· 443 444 }) 444 445 } 445 446 } 447 + 448 + // TestPopulateBrewOGMetadata tests OpenGraph metadata generation for brew pages 449 + func TestPopulateBrewOGMetadata(t *testing.T) { 450 + tests := []struct { 451 + name string 452 + brew *models.Brew 453 + shareURL string 454 + publicURL string 455 + wantTitle string 456 + wantDescription string 457 + wantType string 458 + wantURL string 459 + }{ 460 + { 461 + name: "nil brew", 462 + brew: nil, 463 + shareURL: "/brews/123?owner=test", 464 + publicURL: "https://arabica.example.com", 465 + wantTitle: "", // unchanged 466 + wantDescription: "", // unchanged 467 + wantType: "", // unchanged 468 + wantURL: "", // unchanged 469 + }, 470 + { 471 + name: "brew with bean and origin", 472 + brew: &models.Brew{ 473 + Rating: 8, 474 + TastingNotes: "Fruity and bright", 475 + Bean: &models.Bean{ 476 + Name: "Ethiopian Yirgacheffe", 477 + Origin: "Ethiopia", 478 + }, 479 + }, 480 + shareURL: "/brews/123?owner=test", 481 + publicURL: "https://arabica.example.com", 482 + wantTitle: "Ethiopian Yirgacheffe from Ethiopia", 483 + wantDescription: "Rated 8/10 · Fruity and bright", 484 + wantType: "article", 485 + wantURL: "https://arabica.example.com/brews/123?owner=test", 486 + }, 487 + { 488 + name: "brew with bean without origin", 489 + brew: &models.Brew{ 490 + Rating: 7, 491 + Bean: &models.Bean{ 492 + Name: "House Blend", 493 + }, 494 + }, 495 + shareURL: "/brews/456", 496 + publicURL: "https://arabica.example.com", 497 + wantTitle: "House Blend", 498 + wantDescription: "Rated 7/10", 499 + wantType: "article", 500 + wantURL: "https://arabica.example.com/brews/456", 501 + }, 502 + { 503 + name: "brew without bean", 504 + brew: &models.Brew{ 505 + Rating: 5, 506 + }, 507 + shareURL: "/brews/789", 508 + publicURL: "https://arabica.example.com", 509 + wantTitle: "Coffee Brew", 510 + wantDescription: "Rated 5/10", 511 + wantType: "article", 512 + wantURL: "https://arabica.example.com/brews/789", 513 + }, 514 + { 515 + name: "brew with roaster", 516 + brew: &models.Brew{ 517 + TastingNotes: "Chocolatey", 518 + Bean: &models.Bean{ 519 + Name: "Dark Roast", 520 + Origin: "Brazil", 521 + Roaster: &models.Roaster{ 522 + Name: "Local Roasters", 523 + }, 524 + }, 525 + }, 526 + shareURL: "/brews/abc", 527 + publicURL: "https://arabica.example.com", 528 + wantTitle: "Dark Roast from Brazil", 529 + wantDescription: "Chocolatey · Roasted by Local Roasters", 530 + wantType: "article", 531 + wantURL: "https://arabica.example.com/brews/abc", 532 + }, 533 + { 534 + name: "no public URL configured", 535 + brew: &models.Brew{ 536 + Rating: 9, 537 + Bean: &models.Bean{ 538 + Name: "Premium Blend", 539 + }, 540 + }, 541 + shareURL: "/brews/xyz", 542 + publicURL: "", 543 + wantTitle: "Premium Blend", 544 + wantDescription: "Rated 9/10", 545 + wantType: "article", 546 + wantURL: "", // no absolute URL without public URL 547 + }, 548 + { 549 + name: "long tasting notes truncated", 550 + brew: &models.Brew{ 551 + TastingNotes: strings.Repeat("This is a very long tasting note that should be truncated. ", 5), 552 + Bean: &models.Bean{ 553 + Name: "Test Bean", 554 + }, 555 + }, 556 + shareURL: "/brews/long", 557 + publicURL: "https://arabica.example.com", 558 + wantTitle: "Test Bean", 559 + wantDescription: "This is a very long tasting note that should be truncated. This is a very long tasting note that ...", 560 + wantType: "article", 561 + wantURL: "https://arabica.example.com/brews/long", 562 + }, 563 + } 564 + 565 + for _, tt := range tests { 566 + t.Run(tt.name, func(t *testing.T) { 567 + h := &Handler{ 568 + config: Config{ 569 + PublicURL: tt.publicURL, 570 + }, 571 + } 572 + layoutData := &components.LayoutData{} 573 + 574 + h.populateBrewOGMetadata(layoutData, tt.brew, tt.shareURL) 575 + 576 + assert.Equal(t, tt.wantTitle, layoutData.OGTitle) 577 + assert.Equal(t, tt.wantDescription, layoutData.OGDescription) 578 + assert.Equal(t, tt.wantType, layoutData.OGType) 579 + assert.Equal(t, tt.wantURL, layoutData.OGUrl) 580 + }) 581 + } 582 + }
+50 -3
internal/web/components/layout.templ
··· 9 9 UserDID string 10 10 UserProfile *bff.UserProfile 11 11 CSPNonce string 12 + 13 + // OpenGraph metadata (optional, uses defaults if empty) 14 + OGTitle string // Falls back to Title + " - Arabica" 15 + OGDescription string // Falls back to site description 16 + OGImage string // If set, renders og:image tag 17 + OGType string // Falls back to "website" 18 + OGUrl string // Canonical URL for the page 19 + } 20 + 21 + // ogTitle returns the OpenGraph title, falling back to the page title 22 + func (d *LayoutData) ogTitle() string { 23 + if d.OGTitle != "" { 24 + return d.OGTitle 25 + } 26 + return d.Title + " - Arabica" 27 + } 28 + 29 + // ogDescription returns the OpenGraph description, falling back to site default 30 + func (d *LayoutData) ogDescription() string { 31 + if d.OGDescription != "" { 32 + return d.OGDescription 33 + } 34 + return "Track your coffee brewing journey. Built on AT Protocol, your data stays yours." 35 + } 36 + 37 + // ogType returns the OpenGraph type, falling back to "website" 38 + func (d *LayoutData) ogType() string { 39 + if d.OGType != "" { 40 + return d.OGType 41 + } 42 + return "website" 12 43 } 13 44 14 45 templ Layout(data *LayoutData, content templ.Component) { ··· 18 49 <meta charset="UTF-8"/> 19 50 <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 20 51 <meta name="description" content="Arabica is a coffee brew tracking app built on AT Protocol. Your brewing data is stored in your own Personal Data Server, giving you full ownership and portability."/> 21 - <meta property="og:title" content="Arabica - Coffee Brew Tracker"/> 22 - <meta property="og:description" content="Track your coffee brewing journey. Built on AT Protocol, your data stays yours."/> 23 - <meta property="og:type" content="website"/> 52 + <!-- OpenGraph metadata --> 53 + <meta property="og:title" content={ data.ogTitle() }/> 54 + <meta property="og:description" content={ data.ogDescription() }/> 55 + <meta property="og:type" content={ data.ogType() }/> 56 + <meta property="og:site_name" content="Arabica"/> 57 + if data.OGUrl != "" { 58 + <meta property="og:url" content={ data.OGUrl }/> 59 + } 60 + if data.OGImage != "" { 61 + <meta property="og:image" content={ data.OGImage }/> 62 + <meta property="og:image:alt" content={ data.ogTitle() }/> 63 + } 64 + <!-- Twitter Card metadata --> 65 + <meta name="twitter:card" content="summary"/> 66 + <meta name="twitter:title" content={ data.ogTitle() }/> 67 + <meta name="twitter:description" content={ data.ogDescription() }/> 68 + if data.OGImage != "" { 69 + <meta name="twitter:image" content={ data.OGImage }/> 70 + } 24 71 <meta name="theme-color" content="#4a2c2a"/> 25 72 <title>{ data.Title } - Arabica</title> 26 73 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>