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

feat: reduce backfill freq, fixes to profile and add brew pages (#2)

authored by

Patrick Dewey and committed by
GitHub
bc6b00ab d00f856d

+1360 -1018
-90
.github/DEVELOPMENT_WORKFLOW.md
··· 1 - # Development Workflow 2 - 3 - ## Setting Up Firehose Feed with Known DIDs 4 - 5 - For development and testing, you can populate your local feed with known Arabica users: 6 - 7 - ### 1. Create a Known DIDs File 8 - 9 - Create `known-dids.txt` in the project root: 10 - 11 - ```bash 12 - cat > known-dids.txt << 'EOF' 13 - # Known Arabica users for development 14 - # Add one DID per line 15 - 16 - # Example (replace with real DIDs): 17 - # did:plc:abc123xyz 18 - # did:plc:def456uvw 19 - 20 - EOF 21 - ``` 22 - 23 - ### 2. Find DIDs to Add 24 - 25 - You can find DIDs of Arabica users in several ways: 26 - 27 - **From Bluesky profiles:** 28 - - Visit a user's profile on Bluesky 29 - - Check the URL or profile metadata for their DID 30 - 31 - **From authenticated sessions:** 32 - - After logging into Arabica, check your browser cookies 33 - - The `did` cookie contains your DID 34 - 35 - **From AT Protocol explorer tools:** 36 - - Use tools like `atproto.blue` to search for users 37 - 38 - ### 3. Run Server with Backfill 39 - 40 - ```bash 41 - # Start server with firehose and backfill 42 - go run cmd/server/main.go --firehose --known-dids known-dids.txt 43 - 44 - # Or with nix (requires adding flags to flake.nix) 45 - nix run -- --firehose --known-dids known-dids.txt 46 - ``` 47 - 48 - ### 4. Monitor Backfill Progress 49 - 50 - Watch the logs for backfill activity: 51 - 52 - ``` 53 - {"level":"info","count":3,"file":"known-dids.txt","message":"Loaded known DIDs from file"} 54 - {"level":"info","did":"did:plc:abc123xyz","message":"backfilling user records"} 55 - {"level":"info","total":5,"success":5,"message":"Backfill complete"} 56 - ``` 57 - 58 - ### 5. Verify Feed Data 59 - 60 - Once backfilled, check: 61 - - Home page feed should show brews from backfilled users 62 - - `/feed` endpoint should return feed items 63 - - Database should contain indexed records 64 - 65 - ## File Format Notes 66 - 67 - The `known-dids.txt` file supports: 68 - 69 - - **Comments**: Lines starting with `#` 70 - - **Empty lines**: Ignored 71 - - **Whitespace**: Automatically trimmed 72 - - **Validation**: Non-DID lines logged as warnings 73 - 74 - Example valid file: 75 - 76 - ``` 77 - # Coffee enthusiasts to follow 78 - did:plc:user1abc 79 - 80 - # Another user 81 - did:plc:user2def 82 - 83 - did:web:coffee.example.com # Web DID example 84 - ``` 85 - 86 - ## Security Note 87 - 88 - ⚠️ **Important**: The `known-dids.txt` file is gitignored by default. Do not commit DIDs unless you have permission from the users. 89 - 90 - For production deployments, rely on organic discovery via firehose rather than manual DID lists.
-82
.skills/htmx-alpine-integration.md
··· 1 - # HTMX + Alpine.js Integration Pattern 2 - 3 - ## Problem: "Alpine Expression Error: [variable] is not defined" 4 - 5 - When HTMX swaps in content containing Alpine.js directives (like `x-show`, `x-if`, `@click`), Alpine may not automatically process the new DOM elements, resulting in console errors like: 6 - 7 - ``` 8 - Alpine Expression Error: activeTab is not defined 9 - Expression: "activeTab === 'brews'" 10 - ``` 11 - 12 - ## Root Cause 13 - 14 - HTMX loads and swaps content into the DOM after Alpine has already initialized. The new elements contain Alpine directives that reference variables in a parent Alpine component's scope, but Alpine doesn't automatically bind these new elements to the existing component. 15 - 16 - ## Solution 17 - 18 - Use HTMX's `hx-on::after-swap` event to manually tell Alpine to initialize the new DOM tree: 19 - 20 - ```html 21 - <div id="content" 22 - hx-get="/api/data" 23 - hx-trigger="load" 24 - hx-swap="innerHTML" 25 - hx-on::after-swap="Alpine.initTree($el)"> 26 - </div> 27 - ``` 28 - 29 - ### Key Points 30 - 31 - - `hx-on::after-swap` - HTMX event that fires after content swap completes 32 - - `Alpine.initTree($el)` - Tells Alpine to process all directives in the swapped element 33 - - `$el` - HTMX provides this as the target element that received the swap 34 - 35 - ## Common Scenario 36 - 37 - **Parent template** (defines Alpine scope): 38 - ```html 39 - <div x-data="{ activeTab: 'brews' }"> 40 - <!-- Static content with tab buttons --> 41 - <button @click="activeTab = 'brews'">Brews</button> 42 - 43 - <!-- HTMX loads dynamic content here --> 44 - <div id="content" 45 - hx-get="/api/tabs" 46 - hx-trigger="load" 47 - hx-swap="innerHTML" 48 - hx-on::after-swap="Alpine.initTree($el)"> 49 - </div> 50 - </div> 51 - ``` 52 - 53 - **Loaded partial** (uses parent scope): 54 - ```html 55 - <div x-show="activeTab === 'brews'"> 56 - <!-- Brew content --> 57 - </div> 58 - <div x-show="activeTab === 'beans'"> 59 - <!-- Bean content --> 60 - </div> 61 - ``` 62 - 63 - Without `Alpine.initTree($el)`, the `x-show` directives won't be bound to the parent's `activeTab` variable. 64 - 65 - ## Alternative: Alpine Morph Plugin 66 - 67 - For more complex scenarios with nested Alpine components, use the Alpine Morph plugin: 68 - 69 - ```html 70 - <script src="https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script> 71 - <div hx-swap="morph"></div> 72 - ``` 73 - 74 - This preserves Alpine state during swaps but requires the plugin. 75 - 76 - ## When to Use 77 - 78 - Apply this pattern whenever: 79 - 1. HTMX loads content containing Alpine directives 80 - 2. The loaded content references variables from a parent Alpine component 81 - 3. You see "Expression Error: [variable] is not defined" in console 82 - 4. Alpine directives in HTMX-loaded content don't work (no reactivity, clicks ignored, etc.)
+12 -7
BACKLOG.md
··· 24 24 - If adding mobile apps, third-party API consumers, or microservices architecture, revisit this 25 25 - For now, monolithic approach is appropriate for HTMX-based web app with decentralized storage 26 26 27 - - Backfill seems to be called when user hits homepage, probably only needs to be done on startup 27 + - Maybe swap from boltdb to sqlite 28 + - Use the non-cgo library 28 29 29 30 ## Fixes 30 31 31 - - After adding a bean via add brew, that bean does not show up in the drop down until after a refresh 32 - - Happens with grinders and likely brewers also 32 + - Homepage still shows cached feed items on homepage when not authed. should show a cached version of firehose (last 5 entries, cache last 20) from the server. 33 + This fetch should not try to backfill anything 34 + 35 + - Feed database in prod seems to be showing outdated data -- not sure why, local dev seems to show most recent. 36 + 37 + - View button for somebody else's brew leads to an invalid page. need to show the same view brew page but w/o the edit and delete buttons. 38 + - Back button in view should take user back to their previous page (not sure how to handle this exactly though) 39 + 40 + - Header should probably always be attached to the top of the screen? 33 41 34 - - Adding a grinder via the new brew page does not populate fields correctly other than the name 35 - - Also seems to happen to brewers 36 - - To solve this issue and the above, we likely should consolidate creation to use the same popup as the manage page uses, 37 - since that one works, and should already be a template partial. 42 + - Feed item "view details" button should go away, the "new brew" in "addded a new brew" should take to view page instead (underline this text)
+15 -7
CLAUDE.md
··· 95 95 - Invalidated on writes 96 96 - Background cleanup removes expired entries 97 97 98 + ### Backfill Strategy 99 + 100 + User records are backfilled from their PDS once per DID: 101 + 102 + - **On startup**: Backfills registered users + known-dids file 103 + - **On first login**: Backfills the user's historical records 104 + - **Deduplication**: Tracks backfilled DIDs in `BucketBackfilled` to prevent redundant fetches 105 + - **Idempotent**: Safe to call multiple times (checks backfill status first) 106 + 107 + This prevents excessive PDS requests while ensuring new users' historical data is indexed. 108 + 98 109 ## Common Tasks 99 110 100 111 ### Run Development Server 101 112 102 113 ```bash 103 - # Basic mode (polling-based feed) 114 + # Run server (uses firehose mode by default) 104 115 go run cmd/server/main.go 105 116 106 - # With firehose (real-time AT Protocol feed) 107 - go run cmd/server/main.go --firehose 108 - 109 - # With firehose + backfill known DIDs 110 - go run cmd/server/main.go --firehose --known-dids known-dids.txt 117 + # Backfill known DIDs on startup 118 + go run cmd/server/main.go --known-dids known-dids.txt 111 119 112 120 # Using nix 113 121 nix run ··· 129 137 130 138 | Flag | Type | Default | Description | 131 139 | --------------- | ------ | ------- | ----------------------------------------------------- | 132 - | `--firehose` | bool | false | Enable real-time firehose feed via Jetstream | 140 + | `--firehose` | bool | true | [DEPRECATED] Firehose is now the default (ignored) | 133 141 | `--known-dids` | string | "" | Path to file with DIDs to backfill (one per line) | 134 142 135 143 **Known DIDs File Format:**
README.deploy.md docs/deploy.md
+20 -36
README.md
··· 2 2 3 3 Coffee brew tracking application build on ATProto 4 4 5 + Development is on GitHub, and is mirrored to Tangled: 6 + 7 + - [Tangled](https://tangled.org/arabica.social/arabica) 8 + - [GitHub](https://github.com/arabica-social/arabica) 9 + 10 + GitHub is currently the primary repo, but that may change in the future. 11 + 12 + ## Features 13 + 14 + - Track coffee brews with detailed parameters 15 + - Store data in your AT Protocol Personal Data Server 16 + - Community feed of recent brews from registered users (polling or real-time firehose) 17 + - Manage beans, roasters, grinders, and brewers 18 + - Export brew data as JSON 19 + - Mobile-friendly PWA design 20 + 5 21 ## Tech Stack 6 22 7 - - **Backend:** Go with stdlib HTTP router 8 - - **Storage:** AT Protocol Personal Data Servers 9 - - **Local DB:** BoltDB for OAuth sessions and feed registry 10 - - **Templates:** html/template 11 - - **Frontend:** HTMX + Alpine.js + Tailwind CSS 23 + - Backend: Go with stdlib HTTP router 24 + - Storage: AT Protocol Personal Data Servers + BoltDB for local cache 25 + - Templates: html/template 26 + - Frontend: HTMX + Alpine.js + Tailwind CSS 12 27 13 28 ## Quick Start 14 29 ··· 45 60 46 61 ### Command-Line Flags 47 62 48 - - `--firehose` - Enable real-time feed via AT Protocol Jetstream (default: false) 49 63 - `--known-dids <file>` - Path to file with DIDs to backfill on startup (one per line) 50 64 51 65 ### Environment Variables ··· 60 74 - `SECURE_COOKIES` - Set to true for HTTPS (default: false) 61 75 - `LOG_LEVEL` - Logging level: debug, info, warn, error (default: info) 62 76 - `LOG_FORMAT` - Log format: console, json (default: console) 63 - 64 - ## Features 65 - 66 - - Track coffee brews with detailed parameters 67 - - Store data in your AT Protocol Personal Data Server 68 - - Community feed of recent brews from registered users (polling or real-time firehose) 69 - - Manage beans, roasters, grinders, and brewers 70 - - Export brew data as JSON 71 - - Mobile-friendly PWA design 72 - 73 - ### Firehose Mode 74 - 75 - Enable real-time feed updates via AT Protocol's Jetstream: 76 - 77 - ```bash 78 - # Basic firehose mode 79 - go run cmd/server/main.go --firehose 80 - 81 - # With known DIDs for backfill 82 - go run cmd/server/main.go --firehose --known-dids known-dids.txt 83 - ``` 84 - 85 - **Known DIDs file format:** 86 - ``` 87 - # Comments start with # 88 - did:plc:abc123xyz 89 - did:plc:def456uvw 90 - ``` 91 - 92 - The firehose automatically indexes **all** Arabica records across the AT Protocol network. The `--known-dids` flag allows you to backfill historical records from specific users on startup (useful for development/testing). 93 77 94 78 ## Architecture 95 79
+114
cmd/server/logging_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/rs/zerolog" 12 + "github.com/rs/zerolog/log" 13 + ) 14 + 15 + // TestKnownDIDsLogging verifies that DIDs are logged correctly 16 + func TestKnownDIDsLogging(t *testing.T) { 17 + // Create a buffer to capture log output 18 + var buf bytes.Buffer 19 + 20 + // Configure zerolog to write JSON to our buffer 21 + originalLogger := log.Logger 22 + defer func() { 23 + log.Logger = originalLogger 24 + }() 25 + 26 + log.Logger = zerolog.New(&buf).With().Timestamp().Logger() 27 + zerolog.SetGlobalLevel(zerolog.InfoLevel) 28 + 29 + // Create a temporary test file 30 + tmpDir := t.TempDir() 31 + testFile := filepath.Join(tmpDir, "test-dids.txt") 32 + 33 + content := `# Test DIDs 34 + did:plc:abc123 35 + did:web:example.com 36 + did:plc:xyz789 37 + ` 38 + 39 + if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { 40 + t.Fatalf("Failed to create test file: %v", err) 41 + } 42 + 43 + // Load DIDs from file 44 + dids, err := loadKnownDIDs(testFile) 45 + if err != nil { 46 + t.Fatalf("loadKnownDIDs failed: %v", err) 47 + } 48 + 49 + // Simulate logging (like we do in main.go) 50 + log.Info(). 51 + Int("count", len(dids)). 52 + Str("file", testFile). 53 + Strs("dids", dids). 54 + Msg("Loaded known DIDs from file") 55 + 56 + // Parse the log output 57 + logOutput := buf.String() 58 + 59 + // Verify it contains JSON log 60 + if !strings.Contains(logOutput, "Loaded known DIDs from file") { 61 + t.Errorf("Log output missing expected message. Got: %s", logOutput) 62 + } 63 + 64 + // Parse as JSON to verify structure 65 + var logEntry map[string]interface{} 66 + if err := json.Unmarshal([]byte(strings.TrimSpace(logOutput)), &logEntry); err != nil { 67 + t.Fatalf("Failed to parse log as JSON: %v\nOutput: %s", err, logOutput) 68 + } 69 + 70 + // Verify log fields 71 + if logEntry["count"] != float64(3) { 72 + t.Errorf("Expected count=3, got %v", logEntry["count"]) 73 + } 74 + 75 + if logEntry["file"] != testFile { 76 + t.Errorf("Expected file=%s, got %v", testFile, logEntry["file"]) 77 + } 78 + 79 + // Verify DIDs array is present 80 + didsFromLog, ok := logEntry["dids"].([]interface{}) 81 + if !ok { 82 + t.Fatalf("Expected 'dids' to be an array, got %T", logEntry["dids"]) 83 + } 84 + 85 + if len(didsFromLog) != 3 { 86 + t.Errorf("Expected 3 DIDs in log, got %d", len(didsFromLog)) 87 + } 88 + 89 + // Verify DID values 90 + expectedDIDs := map[string]bool{ 91 + "did:plc:abc123": false, 92 + "did:web:example.com": false, 93 + "did:plc:xyz789": false, 94 + } 95 + 96 + for _, did := range didsFromLog { 97 + didStr, ok := did.(string) 98 + if !ok { 99 + t.Errorf("DID is not a string: %v", did) 100 + continue 101 + } 102 + if _, exists := expectedDIDs[didStr]; exists { 103 + expectedDIDs[didStr] = true 104 + } else { 105 + t.Errorf("Unexpected DID in log: %s", didStr) 106 + } 107 + } 108 + 109 + for did, found := range expectedDIDs { 110 + if !found { 111 + t.Errorf("Expected DID not found in log: %s", did) 112 + } 113 + } 114 + }
+89 -80
cmd/server/main.go
··· 26 26 27 27 func main() { 28 28 // Parse command-line flags 29 - useFirehose := flag.Bool("firehose", false, "Enable firehose-based feed (Jetstream consumer)") 30 29 knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") 31 30 flag.Parse() 32 31 ··· 58 57 }) 59 58 } 60 59 61 - log.Info().Bool("firehose", *useFirehose).Msg("Starting Arabica Coffee Tracker") 60 + log.Info().Msg("Starting Arabica Coffee Tracker") 62 61 63 62 // Get port from env or use default 64 63 port := os.Getenv("PORT") ··· 143 142 sigCh := make(chan os.Signal, 1) 144 143 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 145 144 146 - // Initialize firehose consumer if enabled 147 - var firehoseConsumer *firehose.Consumer 148 - if *useFirehose { 149 - // Determine feed index path 150 - feedIndexPath := os.Getenv("ARABICA_FEED_INDEX_PATH") 151 - if feedIndexPath == "" { 152 - dataDir := os.Getenv("XDG_DATA_HOME") 153 - if dataDir == "" { 154 - home, err := os.UserHomeDir() 155 - if err != nil { 156 - log.Fatal().Err(err).Msg("Failed to get home directory for feed index") 157 - } 158 - dataDir = filepath.Join(home, ".local", "share") 145 + // Initialize firehose consumer 146 + // Determine feed index path 147 + feedIndexPath := os.Getenv("ARABICA_FEED_INDEX_PATH") 148 + if feedIndexPath == "" { 149 + dataDir := os.Getenv("XDG_DATA_HOME") 150 + if dataDir == "" { 151 + home, err := os.UserHomeDir() 152 + if err != nil { 153 + log.Fatal().Err(err).Msg("Failed to get home directory for feed index") 159 154 } 160 - feedIndexPath = filepath.Join(dataDir, "arabica", "feed-index.db") 155 + dataDir = filepath.Join(home, ".local", "share") 161 156 } 157 + feedIndexPath = filepath.Join(dataDir, "arabica", "feed-index.db") 158 + } 162 159 163 - // Create firehose config 164 - firehoseConfig := firehose.DefaultConfig() 165 - firehoseConfig.IndexPath = feedIndexPath 160 + // Create firehose config 161 + firehoseConfig := firehose.DefaultConfig() 162 + firehoseConfig.IndexPath = feedIndexPath 166 163 167 - // Parse profile cache TTL from env if set 168 - if ttlStr := os.Getenv("ARABICA_PROFILE_CACHE_TTL"); ttlStr != "" { 169 - if ttl, err := time.ParseDuration(ttlStr); err == nil { 170 - firehoseConfig.ProfileCacheTTL = int64(ttl.Seconds()) 171 - } 164 + // Parse profile cache TTL from env if set 165 + if ttlStr := os.Getenv("ARABICA_PROFILE_CACHE_TTL"); ttlStr != "" { 166 + if ttl, err := time.ParseDuration(ttlStr); err == nil { 167 + firehoseConfig.ProfileCacheTTL = int64(ttl.Seconds()) 172 168 } 169 + } 173 170 174 - // Create feed index 175 - feedIndex, err := firehose.NewFeedIndex(feedIndexPath, time.Duration(firehoseConfig.ProfileCacheTTL)*time.Second) 176 - if err != nil { 177 - log.Fatal().Err(err).Str("path", feedIndexPath).Msg("Failed to create feed index") 178 - } 171 + // Create feed index 172 + feedIndex, err := firehose.NewFeedIndex(feedIndexPath, time.Duration(firehoseConfig.ProfileCacheTTL)*time.Second) 173 + if err != nil { 174 + log.Fatal().Err(err).Str("path", feedIndexPath).Msg("Failed to create feed index") 175 + } 179 176 180 - log.Info().Str("path", feedIndexPath).Msg("Feed index opened") 177 + log.Info().Str("path", feedIndexPath).Msg("Feed index opened") 181 178 182 - // Create and start consumer 183 - firehoseConsumer = firehose.NewConsumer(firehoseConfig, feedIndex) 184 - firehoseConsumer.Start(ctx) 179 + // Create and start consumer 180 + firehoseConsumer := firehose.NewConsumer(firehoseConfig, feedIndex) 181 + firehoseConsumer.Start(ctx) 185 182 186 - // Wire up the feed service to use the firehose index 187 - adapter := firehose.NewFeedIndexAdapter(feedIndex) 188 - feedService.SetFirehoseIndex(adapter) 183 + // Wire up the feed service to use the firehose index 184 + adapter := firehose.NewFeedIndexAdapter(feedIndex) 185 + feedService.SetFirehoseIndex(adapter) 186 + 187 + log.Info().Msg("Firehose consumer started") 189 188 190 - log.Info().Msg("Firehose consumer started") 189 + // Log known DIDs from database (DIDs discovered via firehose) 190 + if knownDIDsFromDB, err := feedIndex.GetKnownDIDs(); err == nil { 191 + if len(knownDIDsFromDB) > 0 { 192 + log.Info(). 193 + Int("count", len(knownDIDsFromDB)). 194 + Strs("dids", knownDIDsFromDB). 195 + Msg("Known DIDs from firehose index") 196 + } else { 197 + log.Info().Msg("No known DIDs in firehose index yet (will populate as events arrive)") 198 + } 199 + } else { 200 + log.Warn().Err(err).Msg("Failed to retrieve known DIDs from firehose index") 201 + } 191 202 192 - // Backfill registered users and known DIDs in background 193 - go func() { 194 - time.Sleep(5 * time.Second) // Wait for initial connection 203 + // Backfill registered users and known DIDs in background 204 + go func() { 205 + time.Sleep(5 * time.Second) // Wait for initial connection 195 206 196 - // Collect all DIDs to backfill 197 - didsToBackfill := make(map[string]struct{}) 207 + // Collect all DIDs to backfill 208 + didsToBackfill := make(map[string]struct{}) 198 209 199 - // Add registered users 200 - for _, did := range feedRegistry.List() { 201 - didsToBackfill[did] = struct{}{} 202 - } 210 + // Add registered users 211 + for _, did := range feedRegistry.List() { 212 + didsToBackfill[did] = struct{}{} 213 + } 203 214 204 - // Add DIDs from known-dids file if provided 205 - if *knownDIDsFile != "" { 206 - knownDIDs, err := loadKnownDIDs(*knownDIDsFile) 207 - if err != nil { 208 - log.Warn().Err(err).Str("file", *knownDIDsFile).Msg("Failed to load known DIDs file") 209 - } else { 210 - for _, did := range knownDIDs { 211 - didsToBackfill[did] = struct{}{} 212 - } 213 - log.Info().Int("count", len(knownDIDs)).Str("file", *knownDIDsFile).Msg("Loaded known DIDs from file") 215 + // Add DIDs from known-dids file if provided 216 + if *knownDIDsFile != "" { 217 + knownDIDs, err := loadKnownDIDs(*knownDIDsFile) 218 + if err != nil { 219 + log.Warn().Err(err).Str("file", *knownDIDsFile).Msg("Failed to load known DIDs file") 220 + } else { 221 + for _, did := range knownDIDs { 222 + didsToBackfill[did] = struct{}{} 214 223 } 224 + log.Info(). 225 + Int("count", len(knownDIDs)). 226 + Str("file", *knownDIDsFile). 227 + Strs("dids", knownDIDs). 228 + Msg("Loaded known DIDs from file") 215 229 } 230 + } 216 231 217 - // Backfill all collected DIDs 218 - successCount := 0 219 - for did := range didsToBackfill { 220 - if err := firehoseConsumer.BackfillDID(ctx, did); err != nil { 221 - log.Warn().Err(err).Str("did", did).Msg("Failed to backfill user") 222 - } else { 223 - successCount++ 224 - } 232 + // Backfill all collected DIDs 233 + successCount := 0 234 + for did := range didsToBackfill { 235 + if err := firehoseConsumer.BackfillDID(ctx, did); err != nil { 236 + log.Warn().Err(err).Str("did", did).Msg("Failed to backfill user") 237 + } else { 238 + successCount++ 225 239 } 226 - log.Info().Int("total", len(didsToBackfill)).Int("success", successCount).Msg("Backfill complete") 227 - }() 228 - } 240 + } 241 + log.Info().Int("total", len(didsToBackfill)).Int("success", successCount).Msg("Backfill complete") 242 + }() 229 243 230 244 // Register users in the feed when they authenticate 231 245 // This ensures users are added to the feed even if they had an existing session 232 246 oauthManager.SetOnAuthSuccess(func(did string) { 233 247 feedRegistry.Register(did) 234 - // If firehose is enabled, backfill the user's records 235 - if firehoseConsumer != nil { 236 - go func() { 237 - if err := firehoseConsumer.BackfillDID(context.Background(), did); err != nil { 238 - log.Warn().Err(err).Str("did", did).Msg("Failed to backfill new user") 239 - } 240 - }() 241 - } 248 + // Backfill the user's records 249 + go func() { 250 + if err := firehoseConsumer.BackfillDID(context.Background(), did); err != nil { 251 + log.Warn().Err(err).Str("did", did).Msg("Failed to backfill new user") 252 + } 253 + }() 242 254 }) 243 255 244 256 if clientID == "" { ··· 299 311 Str("address", "0.0.0.0:"+port). 300 312 Str("url", "http://localhost:"+port). 301 313 Bool("secure_cookies", secureCookies). 302 - Bool("firehose", *useFirehose). 303 314 Str("database", dbPath). 304 315 Msg("Starting HTTP server") 305 316 ··· 313 324 log.Info().Msg("Shutdown signal received") 314 325 315 326 // Stop firehose consumer first 316 - if firehoseConsumer != nil { 317 - log.Info().Msg("Stopping firehose consumer...") 318 - firehoseConsumer.Stop() 319 - } 327 + log.Info().Msg("Stopping firehose consumer...") 328 + firehoseConsumer.Stop() 320 329 321 330 // Graceful shutdown of HTTP server 322 331 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+1 -1
default.nix
··· 4 4 pname = "arabica"; 5 5 version = "0.1.0"; 6 6 src = ./.; 7 - vendorHash = "sha256-3sMFDeof4OPB4SCcYhD9x1wITSlI1UN3M7SEwUW8H3A="; 7 + vendorHash = "sha256-mrIFu5c2EuGvYHyjJVqC8WzlsmUJYCm/6yUpJ0IGPlA="; 8 8 9 9 nativeBuildInputs = [ tailwindcss ]; 10 10
+222
docs/back-button-implementation.md
··· 1 + # Back Button Implementation 2 + 3 + ## Overview 4 + 5 + Implemented a smart back button feature that allows users to navigate back to their previous page across the Arabica application. The solution uses a hybrid approach combining JavaScript's `history.back()` with intelligent fallbacks. 6 + 7 + ## Approach Chosen: Hybrid JavaScript History with Smart Fallbacks 8 + 9 + ### Why This Approach? 10 + 11 + 1. **Best User Experience**: Uses browser history when available, preserving scroll position and form state 12 + 2. **Handles Edge Cases**: Falls back gracefully for direct links, external referrers, and bookmarks 13 + 3. **Simple Implementation**: No server-side session tracking needed 14 + 4. **HTMX Compatible**: Works seamlessly with HTMX navigation and partial page updates 15 + 16 + ### How It Works 17 + 18 + The implementation consists of: 19 + 20 + 1. **JavaScript Module** (`web/static/js/back-button.js`): 21 + - Detects if the user came from within the app (same-origin referrer) 22 + - Uses `history.back()` for internal navigation (preserves history stack) 23 + - Falls back to a specified URL for external/direct navigation 24 + - Automatically re-initializes after HTMX content swaps 25 + 26 + 2. **HTML Attributes**: 27 + - `data-back-button`: Marks an element as a back button 28 + - `data-fallback`: Specifies the fallback URL (default: `/brews`) 29 + 30 + 3. **Visual Design**: 31 + - SVG arrow icon for clear affordance 32 + - Consistent styling matching the app's brown theme 33 + - Hover states for better interactivity 34 + 35 + ## Implementation Details 36 + 37 + ### JavaScript Logic 38 + 39 + ```javascript 40 + function handleBackNavigation(button) { 41 + const fallbackUrl = button.getAttribute('data-fallback') || '/brews'; 42 + const referrer = document.referrer; 43 + 44 + // Check if referrer is from same origin 45 + const hasSameOriginReferrer = referrer && 46 + referrer.startsWith(window.location.origin) && 47 + referrer !== currentUrl; 48 + 49 + if (hasSameOriginReferrer) { 50 + window.history.back(); // Use browser history 51 + } else { 52 + window.location.href = fallbackUrl; // Use fallback 53 + } 54 + } 55 + ``` 56 + 57 + ### Edge Cases Handled 58 + 59 + 1. **Direct Links** (e.g., bookmarked URL): 60 + - Referrer: empty or external 61 + - Behavior: Navigate to fallback URL 62 + 63 + 2. **External Referrers** (e.g., from social media): 64 + - Referrer: different origin 65 + - Behavior: Navigate to fallback URL 66 + 67 + 3. **Internal Navigation**: 68 + - Referrer: same origin 69 + - Behavior: Use `history.back()` (preserves state) 70 + 71 + 4. **HTMX Partial Updates**: 72 + - Automatically reinitializes buttons after HTMX swaps 73 + - Ensures back buttons in dynamically loaded content work 74 + 75 + 5. **Page Refresh**: 76 + - Referrer: same as current URL 77 + - Behavior: Navigate to fallback URL (prevents staying on same page) 78 + 79 + ## Files Modified 80 + 81 + ### New Files 82 + 83 + 1. **`web/static/js/back-button.js`** 84 + - Core back button logic 85 + - Initialization and event handling 86 + - HTMX integration 87 + 88 + ### Modified Templates 89 + 90 + 1. **`templates/layout.tmpl`** 91 + - Added back-button.js script reference 92 + 93 + 2. **`templates/brew_view.tmpl`** 94 + - Replaced static "Back to Brews" link with smart back button 95 + - Fallback: `/brews` 96 + 97 + 3. **`templates/brew_form.tmpl`** 98 + - Added back button in header (for both new and edit modes) 99 + - Fallback: `/brews` 100 + 101 + 4. **`templates/about.tmpl`** 102 + - Added back button in header 103 + - Fallback: `/` (home page) 104 + 105 + 5. **`templates/terms.tmpl`** 106 + - Added back button in header 107 + - Fallback: `/` (home page) 108 + 109 + 6. **`templates/manage.tmpl`** 110 + - Added back button in header 111 + - Fallback: `/brews` 112 + 113 + ## Usage Examples 114 + 115 + ### Basic Back Button 116 + ```html 117 + <button 118 + data-back-button 119 + data-fallback="/brews" 120 + class="..."> 121 + Back 122 + </button> 123 + ``` 124 + 125 + ### With Custom Fallback 126 + ```html 127 + <button 128 + data-back-button 129 + data-fallback="/profile" 130 + class="..."> 131 + Back to Profile 132 + </button> 133 + ``` 134 + 135 + ### With Icon (as implemented) 136 + ```html 137 + <button 138 + data-back-button 139 + data-fallback="/brews" 140 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 141 + <svg class="w-5 h-5" ...> 142 + <path d="M10 19l-7-7m0 0l7-7m-7 7h18"/> 143 + </svg> 144 + </button> 145 + ``` 146 + 147 + ## Navigation Flow Examples 148 + 149 + ### Example 1: Normal Flow 150 + 1. User visits `/` (home) 151 + 2. Clicks "View All Brews" → `/brews` 152 + 3. Clicks on a brew → `/brews/abc123` 153 + 4. Clicks back button → Returns to `/brews` (via history.back()) 154 + 155 + ### Example 2: Direct Link 156 + 1. User opens bookmark directly to `/brews/abc123` 157 + 2. Clicks back button → Navigates to `/brews` (fallback) 158 + 159 + ### Example 3: External Referrer 160 + 1. User clicks link from Twitter to `/brews/abc123` 161 + 2. Clicks back button → Navigates to `/brews` (fallback, not back to Twitter) 162 + 163 + ### Example 4: Profile to Brew 164 + 1. User visits `/profile/@alice.bsky.social` 165 + 2. Clicks on a brew → `/brews/abc123` 166 + 3. Clicks back button → Returns to `/profile/@alice.bsky.social` 167 + 168 + ## Limitations 169 + 170 + 1. **No History Stack Detection**: 171 + - Cannot reliably detect if history stack is empty 172 + - Uses referrer as a proxy, which is a reasonable heuristic 173 + 174 + 2. **Referrer Privacy**: 175 + - Some browsers/users may disable referrer headers 176 + - Falls back to default URL in these cases (safe behavior) 177 + 178 + 3. **Cross-Origin Navigation**: 179 + - Intentionally doesn't go back to external sites 180 + - This is a feature, not a bug (keeps users in the app) 181 + 182 + 4. **No History Length Check**: 183 + - `window.history.length` is unreliable across browsers 184 + - Our referrer-based approach is more predictable 185 + 186 + ## Future Enhancements (Optional) 187 + 188 + 1. **Session Storage Tracking**: 189 + - Could track navigation history in sessionStorage 190 + - Would allow more sophisticated back button logic 191 + - Trade-off: added complexity vs. marginal benefit 192 + 193 + 2. **Contextual Fallbacks**: 194 + - Could pass context-specific fallbacks from server 195 + - Example: brew detail could remember which list it came from 196 + - Trade-off: requires server-side state or URL params 197 + 198 + 3. **Breadcrumb Integration**: 199 + - Could display breadcrumbs alongside back button 200 + - Better for complex navigation hierarchies 201 + - Trade-off: more UI complexity 202 + 203 + ## Testing Recommendations 204 + 205 + Manual testing scenarios: 206 + 1. ✅ Navigate from home → brews → brew detail → back (should use history) 207 + 2. ✅ Open brew detail via bookmark → back (should go to fallback) 208 + 3. ✅ Navigate from feed → brew detail → back (should return to feed) 209 + 4. ✅ Navigate from profile → brew detail → back (should return to profile) 210 + 5. ✅ Open about page → back (should go to home) 211 + 6. ✅ Edit brew form → back (should return to previous page) 212 + 213 + ## Conclusion 214 + 215 + The implemented solution provides an excellent balance of: 216 + - **User Experience**: Preserves browser history when possible 217 + - **Reliability**: Always provides a sensible fallback 218 + - **Simplicity**: No server-side complexity or session tracking 219 + - **Maintainability**: Single JavaScript module, easy to understand 220 + - **Compatibility**: Works with HTMX, Alpine.js, and standard navigation 221 + 222 + The approach handles all realistic edge cases while keeping the implementation straightforward and performant.
+10
internal/atproto/nsid.go
··· 46 46 func BuildATURI(did, collection, rkey string) string { 47 47 return fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) 48 48 } 49 + 50 + // ExtractRKeyFromURI extracts the record key from an AT-URI 51 + // Returns the rkey if successful, empty string if parsing fails 52 + func ExtractRKeyFromURI(uri string) string { 53 + components, err := ResolveATURI(uri) 54 + if err != nil { 55 + return "" 56 + } 57 + return components.RKey 58 + }
+2 -1
internal/atproto/oauth.go
··· 116 116 } 117 117 118 118 // SetOnAuthSuccess sets a callback that is called when a user authenticates successfully 119 - // This is called both on initial login and when validating an existing session 119 + // This is called both on initial login and when validating an existing session (on every authenticated request) 120 + // Implementations should be idempotent or track state to avoid redundant operations 120 121 func (m *OAuthManager) SetOnAuthSuccess(fn func(did string)) { 121 122 m.onAuthSuccess = fn 122 123 }
+17 -14
internal/bff/render.go
··· 241 241 } 242 242 243 243 // RenderBrewView renders the brew view page 244 - func RenderBrewView(w http.ResponseWriter, brew *models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile) error { 244 + func RenderBrewView(w http.ResponseWriter, brew *models.Brew, isAuthenticated bool, userDID string, userProfile *UserProfile, isOwner bool) error { 245 245 t, err := parsePageTemplate("brew_view.tmpl") 246 246 if err != nil { 247 247 return err ··· 269 269 IsAuthenticated: isAuthenticated, 270 270 UserDID: userDID, 271 271 UserProfile: userProfile, 272 + IsOwnProfile: isOwner, // Reuse IsOwnProfile field to indicate ownership 272 273 } 273 274 return t.ExecuteTemplate(w, "layout", data) 274 275 } ··· 366 367 367 368 // ProfileContentData contains data for rendering the profile content partial 368 369 type ProfileContentData struct { 369 - Brews []*models.Brew 370 - Beans []*models.Bean 371 - Roasters []*models.Roaster 372 - Grinders []*models.Grinder 373 - Brewers []*models.Brewer 374 - IsOwnProfile bool 370 + Brews []*models.Brew 371 + Beans []*models.Bean 372 + Roasters []*models.Roaster 373 + Grinders []*models.Grinder 374 + Brewers []*models.Brewer 375 + IsOwnProfile bool 376 + ProfileHandle string // The handle of the profile being viewed 375 377 } 376 378 377 379 // RenderProfile renders a user's public profile page ··· 403 405 } 404 406 405 407 // RenderProfilePartial renders just the profile content partial (for HTMX async loading) 406 - func RenderProfilePartial(w http.ResponseWriter, brews []*models.Brew, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isOwnProfile bool) error { 408 + func RenderProfilePartial(w http.ResponseWriter, brews []*models.Brew, beans []*models.Bean, roasters []*models.Roaster, grinders []*models.Grinder, brewers []*models.Brewer, isOwnProfile bool, profileHandle string) error { 407 409 t, err := parsePartialTemplate() 408 410 if err != nil { 409 411 return err 410 412 } 411 413 412 414 data := &ProfileContentData{ 413 - Brews: brews, 414 - Beans: beans, 415 - Roasters: roasters, 416 - Grinders: grinders, 417 - Brewers: brewers, 418 - IsOwnProfile: isOwnProfile, 415 + Brews: brews, 416 + Beans: beans, 417 + Roasters: roasters, 418 + Grinders: grinders, 419 + Brewers: brewers, 420 + IsOwnProfile: isOwnProfile, 421 + ProfileHandle: profileHandle, 419 422 } 420 423 return t.ExecuteTemplate(w, "profile_content", data) 421 424 }
+59 -397
internal/feed/service.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "sort" 7 6 "sync" 8 7 "time" 9 8 ··· 13 12 "github.com/rs/zerolog/log" 14 13 ) 15 14 16 - // PublicFeedCacheTTL is the duration for which the public feed cache is valid. 17 - // This value can be adjusted based on desired freshness vs. performance tradeoff. 18 - // Consider values between 5-10 minutes for a good balance. 19 - const PublicFeedCacheTTL = 5 * time.Minute 15 + const ( 16 + // PublicFeedCacheTTL is the duration for which the public feed cache is valid. 17 + // This value can be adjusted based on desired freshness vs. performance tradeoff. 18 + // Consider values between 5-10 minutes for a good balance. 19 + PublicFeedCacheTTL = 5 * time.Minute 20 20 21 - // PublicFeedLimit is the number of items to show for unauthenticated users 22 - const PublicFeedLimit = 5 21 + // PublicFeedCacheSize is the number of items to cache in the server 22 + PublicFeedCacheSize = 20 23 + // PublicFeedLimit is the number of items to show for unauthenticated users 24 + PublicFeedLimit = 5 25 + // Number of feed items to show for authenticated users. 26 + FeedLimit = 20 27 + ) 23 28 24 29 // FeedItem represents an activity in the social feed with author info 25 30 type FeedItem struct { ··· 40 45 41 46 // publicFeedCache holds cached feed items for unauthenticated users 42 47 type publicFeedCache struct { 43 - items []*FeedItem 44 - expiresAt time.Time 45 - fromFirehose bool // tracks if cache was populated from firehose 46 - mu sync.RWMutex 48 + items []*FeedItem 49 + expiresAt time.Time 50 + mu sync.RWMutex 47 51 } 48 52 49 53 // FirehoseIndex is the interface for the firehose feed index ··· 70 74 71 75 // Service fetches and aggregates brews from registered users 72 76 type Service struct { 73 - registry *Registry 74 - publicClient *atproto.PublicClient 75 - cache *publicFeedCache 76 - firehoseIndex FirehoseIndex 77 - useFirehose bool 77 + registry *Registry 78 + cache *publicFeedCache 79 + firehoseIndex FirehoseIndex 78 80 } 79 81 80 82 // NewService creates a new feed service 81 83 func NewService(registry *Registry) *Service { 82 84 return &Service{ 83 - registry: registry, 84 - publicClient: atproto.NewPublicClient(), 85 - cache: &publicFeedCache{}, 85 + registry: registry, 86 + cache: &publicFeedCache{}, 86 87 } 87 88 } 88 89 89 - // SetFirehoseIndex configures the service to use firehose-based feed when available 90 + // SetFirehoseIndex configures the service to use firehose-based feed 90 91 func (s *Service) SetFirehoseIndex(index FirehoseIndex) { 91 92 s.firehoseIndex = index 92 - s.useFirehose = true 93 93 log.Info().Msg("feed: firehose index configured") 94 94 } 95 95 96 96 // GetCachedPublicFeed returns cached feed items for unauthenticated users. 97 97 // It returns up to PublicFeedLimit items from the cache, refreshing if expired. 98 + // The cache stores PublicFeedCacheSize items internally but only returns PublicFeedLimit. 98 99 func (s *Service) GetCachedPublicFeed(ctx context.Context) ([]*FeedItem, error) { 99 100 s.cache.mu.RLock() 100 101 cacheValid := time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 101 - cacheFromFirehose := s.cache.fromFirehose 102 102 items := s.cache.items 103 103 s.cache.mu.RUnlock() 104 104 105 - // Check if we need to refresh: cache expired, empty, or firehose is now ready but cache was from polling 106 - firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() 107 - needsRefresh := !cacheValid || (firehoseReady && !cacheFromFirehose) 108 - 109 - if !needsRefresh { 110 - log.Debug().Int("item_count", len(items)).Bool("from_firehose", cacheFromFirehose).Msg("feed: returning cached public feed") 105 + if cacheValid { 106 + // Return only the first PublicFeedLimit items from the cache 107 + if len(items) > PublicFeedLimit { 108 + items = items[:PublicFeedLimit] 109 + } 110 + log.Debug().Int("item_count", len(items)).Msg("feed: returning cached public feed") 111 111 return items, nil 112 112 } 113 113 114 - // Cache is expired, empty, or we need to switch to firehose data 114 + // Cache is expired or empty, refresh it 115 115 return s.refreshPublicFeedCache(ctx) 116 116 } 117 117 ··· 120 120 s.cache.mu.Lock() 121 121 defer s.cache.mu.Unlock() 122 122 123 - // Check if firehose is ready (for tracking cache source) 124 - firehoseReady := s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() 125 - 126 123 // Double-check if another goroutine already refreshed the cache 127 - // But still refresh if firehose is ready and cache was from polling 128 124 if time.Now().Before(s.cache.expiresAt) && len(s.cache.items) > 0 { 129 - if !firehoseReady || s.cache.fromFirehose { 130 - return s.cache.items, nil 125 + // Return only the first PublicFeedLimit items 126 + items := s.cache.items 127 + if len(items) > PublicFeedLimit { 128 + items = items[:PublicFeedLimit] 131 129 } 132 - // Firehose is ready but cache was from polling, continue to refresh 130 + return items, nil 133 131 } 134 132 135 - log.Debug().Bool("firehose_ready", firehoseReady).Msg("feed: refreshing public feed cache") 133 + log.Debug().Msg("feed: refreshing public feed cache") 136 134 137 - // Fetch fresh feed items (limited to PublicFeedLimit) 138 - items, err := s.GetRecentRecords(ctx, PublicFeedLimit) 135 + // Fetch PublicFeedCacheSize items to cache (20 items) 136 + items, err := s.GetRecentRecords(ctx, PublicFeedCacheSize) 139 137 if err != nil { 140 138 // If we have stale data, return it rather than failing 141 139 if len(s.cache.items) > 0 { 142 140 log.Warn().Err(err).Msg("feed: failed to refresh cache, returning stale data") 143 - return s.cache.items, nil 141 + cachedItems := s.cache.items 142 + if len(cachedItems) > PublicFeedLimit { 143 + cachedItems = cachedItems[:PublicFeedLimit] 144 + } 145 + return cachedItems, nil 144 146 } 145 147 return nil, err 146 148 } 147 149 148 - // Update cache 150 + // Update cache with all fetched items 149 151 s.cache.items = items 150 152 s.cache.expiresAt = time.Now().Add(PublicFeedCacheTTL) 151 - s.cache.fromFirehose = firehoseReady 152 153 153 154 log.Debug(). 154 - Int("item_count", len(items)). 155 + Int("cached_count", len(items)). 155 156 Time("expires_at", s.cache.expiresAt). 156 - Bool("from_firehose", firehoseReady). 157 157 Msg("feed: updated public feed cache") 158 158 159 - return items, nil 159 + // Return only the first PublicFeedLimit items to the user 160 + displayItems := items 161 + if len(displayItems) > PublicFeedLimit { 162 + displayItems = displayItems[:PublicFeedLimit] 163 + } 164 + 165 + return displayItems, nil 160 166 } 161 167 162 - // GetRecentRecords fetches recent activity (brews and other records) from all registered users 168 + // GetRecentRecords fetches recent activity (brews and other records) from firehose index 163 169 // Returns up to `limit` items sorted by most recent first 164 170 func (s *Service) GetRecentRecords(ctx context.Context, limit int) ([]*FeedItem, error) { 165 - // Try firehose index first if available and ready 166 - if s.useFirehose && s.firehoseIndex != nil && s.firehoseIndex.IsReady() { 167 - log.Debug().Msg("feed: using firehose index") 168 - return s.getRecentRecordsFromFirehose(ctx, limit) 171 + if s.firehoseIndex == nil || !s.firehoseIndex.IsReady() { 172 + log.Warn().Msg("feed: firehose index not ready") 173 + return nil, fmt.Errorf("firehose index not ready") 169 174 } 170 175 171 - // Fallback to polling 172 - return s.getRecentRecordsViaPolling(ctx, limit) 176 + log.Debug().Msg("feed: using firehose index") 177 + return s.getRecentRecordsFromFirehose(ctx, limit) 173 178 } 174 179 175 180 // getRecentRecordsFromFirehose fetches feed items from the firehose index 176 181 func (s *Service) getRecentRecordsFromFirehose(ctx context.Context, limit int) ([]*FeedItem, error) { 177 182 firehoseItems, err := s.firehoseIndex.GetRecentFeed(ctx, limit) 178 183 if err != nil { 179 - log.Warn().Err(err).Msg("feed: firehose index error, falling back to polling") 180 - return s.getRecentRecordsViaPolling(ctx, limit) 184 + log.Warn().Err(err).Msg("feed: firehose index error") 185 + return nil, err 181 186 } 182 187 183 188 // Convert FirehoseFeedItem to FeedItem ··· 198 203 } 199 204 200 205 log.Debug().Int("count", len(items)).Msg("feed: returning items from firehose index") 201 - return items, nil 202 - } 203 - 204 - // getRecentRecordsViaPolling fetches feed items by polling each user's PDS 205 - func (s *Service) getRecentRecordsViaPolling(ctx context.Context, limit int) ([]*FeedItem, error) { 206 - dids := s.registry.List() 207 - if len(dids) == 0 { 208 - log.Debug().Msg("feed: no registered users") 209 - return nil, nil 210 - } 211 - 212 - log.Debug().Int("user_count", len(dids)).Msg("feed: fetching activity from registered users (polling)") 213 - 214 - // Fetch all records from all users in parallel 215 - type userActivity struct { 216 - did string 217 - profile *atproto.Profile 218 - brews []*models.Brew 219 - beans []*models.Bean 220 - roasters []*models.Roaster 221 - grinders []*models.Grinder 222 - brewers []*models.Brewer 223 - err error 224 - } 225 - 226 - results := make(chan userActivity, len(dids)) 227 - var wg sync.WaitGroup 228 - 229 - for _, did := range dids { 230 - wg.Add(1) 231 - go func(did string) { 232 - defer wg.Done() 233 - 234 - result := userActivity{did: did} 235 - 236 - // Fetch profile 237 - profile, err := s.publicClient.GetProfile(ctx, did) 238 - if err != nil { 239 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch profile for feed") 240 - result.err = err 241 - results <- result 242 - return 243 - } 244 - result.profile = profile 245 - 246 - // Fetch recent brews (limit per user to avoid fetching too many) 247 - brewsOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrew, 10) 248 - if err != nil { 249 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch brews for feed") 250 - result.err = err 251 - results <- result 252 - return 253 - } 254 - 255 - // Fetch recent beans 256 - beansOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 10) 257 - if err != nil { 258 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch beans for feed") 259 - } 260 - 261 - // Fetch recent roasters 262 - roastersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 10) 263 - if err != nil { 264 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch roasters for feed") 265 - } 266 - 267 - // Fetch recent grinders 268 - grindersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 10) 269 - if err != nil { 270 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch grinders for feed") 271 - } 272 - 273 - // Fetch recent brewers 274 - brewersOutput, err := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 10) 275 - if err != nil { 276 - log.Warn().Err(err).Str("did", did).Msg("failed to fetch brewers for feed") 277 - } 278 - 279 - // Fetch all beans, roasters, brewers, and grinders for this user to resolve references 280 - allBeansOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBean, 100) 281 - allRoastersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDRoaster, 100) 282 - allBrewersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDBrewer, 100) 283 - allGrindersOutput, _ := s.publicClient.ListRecords(ctx, did, atproto.NSIDGrinder, 100) 284 - 285 - // Build lookup maps (keyed by AT-URI) 286 - beanMap := make(map[string]*models.Bean) 287 - beanRoasterRefMap := make(map[string]string) // bean URI -> roaster URI 288 - roasterMap := make(map[string]*models.Roaster) 289 - brewerMap := make(map[string]*models.Brewer) 290 - grinderMap := make(map[string]*models.Grinder) 291 - 292 - // Populate bean map 293 - if allBeansOutput != nil { 294 - for _, beanRecord := range allBeansOutput.Records { 295 - bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI) 296 - if err == nil { 297 - beanMap[beanRecord.URI] = bean 298 - // Store roaster reference if present 299 - if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" { 300 - beanRoasterRefMap[beanRecord.URI] = roasterRef 301 - } 302 - } 303 - } 304 - } 305 - 306 - // Populate roaster map 307 - if allRoastersOutput != nil { 308 - for _, roasterRecord := range allRoastersOutput.Records { 309 - roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI) 310 - if err == nil { 311 - roasterMap[roasterRecord.URI] = roaster 312 - } 313 - } 314 - } 315 - 316 - // Populate brewer map 317 - if allBrewersOutput != nil { 318 - for _, brewerRecord := range allBrewersOutput.Records { 319 - brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI) 320 - if err == nil { 321 - brewerMap[brewerRecord.URI] = brewer 322 - } 323 - } 324 - } 325 - 326 - // Populate grinder map 327 - if allGrindersOutput != nil { 328 - for _, grinderRecord := range allGrindersOutput.Records { 329 - grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI) 330 - if err == nil { 331 - grinderMap[grinderRecord.URI] = grinder 332 - } 333 - } 334 - } 335 - 336 - // Convert records to Brew models and resolve references 337 - brews := make([]*models.Brew, 0, len(brewsOutput.Records)) 338 - for _, record := range brewsOutput.Records { 339 - brew, err := atproto.RecordToBrew(record.Value, record.URI) 340 - if err != nil { 341 - log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse brew record") 342 - continue 343 - } 344 - 345 - // Resolve bean reference 346 - if beanRef, ok := record.Value["beanRef"].(string); ok && beanRef != "" { 347 - if bean, found := beanMap[beanRef]; found { 348 - brew.Bean = bean 349 - 350 - // Resolve roaster reference for this bean 351 - if roasterRef, found := beanRoasterRefMap[beanRef]; found { 352 - if roaster, found := roasterMap[roasterRef]; found { 353 - brew.Bean.Roaster = roaster 354 - } 355 - } 356 - } 357 - } 358 - 359 - // Resolve brewer reference 360 - if brewerRef, ok := record.Value["brewerRef"].(string); ok && brewerRef != "" { 361 - if brewer, found := brewerMap[brewerRef]; found { 362 - brew.BrewerObj = brewer 363 - } 364 - } 365 - 366 - // Resolve grinder reference 367 - if grinderRef, ok := record.Value["grinderRef"].(string); ok && grinderRef != "" { 368 - if grinder, found := grinderMap[grinderRef]; found { 369 - brew.GrinderObj = grinder 370 - } 371 - } 372 - 373 - brews = append(brews, brew) 374 - } 375 - result.brews = brews 376 - 377 - // Convert beans to models and resolve roaster references 378 - beans := make([]*models.Bean, 0) 379 - if beansOutput != nil { 380 - for _, record := range beansOutput.Records { 381 - bean, err := atproto.RecordToBean(record.Value, record.URI) 382 - if err != nil { 383 - log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse bean record") 384 - continue 385 - } 386 - 387 - // Resolve roaster reference 388 - if roasterRef, found := beanRoasterRefMap[record.URI]; found { 389 - if roaster, found := roasterMap[roasterRef]; found { 390 - bean.Roaster = roaster 391 - } 392 - } 393 - 394 - beans = append(beans, bean) 395 - } 396 - } 397 - result.beans = beans 398 - 399 - // Convert roasters to models 400 - roasters := make([]*models.Roaster, 0) 401 - if roastersOutput != nil { 402 - for _, record := range roastersOutput.Records { 403 - roaster, err := atproto.RecordToRoaster(record.Value, record.URI) 404 - if err != nil { 405 - log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse roaster record") 406 - continue 407 - } 408 - roasters = append(roasters, roaster) 409 - } 410 - } 411 - result.roasters = roasters 412 - 413 - // Convert grinders to models 414 - grinders := make([]*models.Grinder, 0) 415 - if grindersOutput != nil { 416 - for _, record := range grindersOutput.Records { 417 - grinder, err := atproto.RecordToGrinder(record.Value, record.URI) 418 - if err != nil { 419 - log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse grinder record") 420 - continue 421 - } 422 - grinders = append(grinders, grinder) 423 - } 424 - } 425 - result.grinders = grinders 426 - 427 - // Convert brewers to models 428 - brewers := make([]*models.Brewer, 0) 429 - if brewersOutput != nil { 430 - for _, record := range brewersOutput.Records { 431 - brewer, err := atproto.RecordToBrewer(record.Value, record.URI) 432 - if err != nil { 433 - log.Warn().Err(err).Str("uri", record.URI).Msg("failed to parse brewer record") 434 - continue 435 - } 436 - brewers = append(brewers, brewer) 437 - } 438 - } 439 - result.brewers = brewers 440 - 441 - results <- result 442 - }(did) 443 - } 444 - 445 - // Wait for all goroutines to complete 446 - go func() { 447 - wg.Wait() 448 - close(results) 449 - }() 450 - 451 - // Collect all feed items 452 - var items []*FeedItem 453 - for result := range results { 454 - if result.err != nil { 455 - continue 456 - } 457 - 458 - totalRecords := len(result.brews) + len(result.beans) + len(result.roasters) + len(result.grinders) + len(result.brewers) 459 - 460 - log.Debug(). 461 - Str("did", result.did). 462 - Str("handle", result.profile.Handle). 463 - Int("brew_count", len(result.brews)). 464 - Int("bean_count", len(result.beans)). 465 - Int("roaster_count", len(result.roasters)). 466 - Int("grinder_count", len(result.grinders)). 467 - Int("brewer_count", len(result.brewers)). 468 - Int("total_records", totalRecords). 469 - Msg("feed: collected records from user") 470 - 471 - // Add brews to feed 472 - for _, brew := range result.brews { 473 - items = append(items, &FeedItem{ 474 - RecordType: "brew", 475 - Action: "☕ added a new brew", 476 - Brew: brew, 477 - Author: result.profile, 478 - Timestamp: brew.CreatedAt, 479 - TimeAgo: FormatTimeAgo(brew.CreatedAt), 480 - }) 481 - } 482 - 483 - // Add beans to feed 484 - for _, bean := range result.beans { 485 - items = append(items, &FeedItem{ 486 - RecordType: "bean", 487 - Action: "🫘 added a new bean", 488 - Bean: bean, 489 - Author: result.profile, 490 - Timestamp: bean.CreatedAt, 491 - TimeAgo: FormatTimeAgo(bean.CreatedAt), 492 - }) 493 - } 494 - 495 - // Add roasters to feed 496 - for _, roaster := range result.roasters { 497 - items = append(items, &FeedItem{ 498 - RecordType: "roaster", 499 - Action: "🏪 added a new roaster", 500 - Roaster: roaster, 501 - Author: result.profile, 502 - Timestamp: roaster.CreatedAt, 503 - TimeAgo: FormatTimeAgo(roaster.CreatedAt), 504 - }) 505 - } 506 - 507 - // Add grinders to feed 508 - for _, grinder := range result.grinders { 509 - items = append(items, &FeedItem{ 510 - RecordType: "grinder", 511 - Action: "⚙️ added a new grinder", 512 - Grinder: grinder, 513 - Author: result.profile, 514 - Timestamp: grinder.CreatedAt, 515 - TimeAgo: FormatTimeAgo(grinder.CreatedAt), 516 - }) 517 - } 518 - 519 - // Add brewers to feed 520 - for _, brewer := range result.brewers { 521 - items = append(items, &FeedItem{ 522 - RecordType: "brewer", 523 - Action: "☕ added a new brewer", 524 - Brewer: brewer, 525 - Author: result.profile, 526 - Timestamp: brewer.CreatedAt, 527 - TimeAgo: FormatTimeAgo(brewer.CreatedAt), 528 - }) 529 - } 530 - } 531 - 532 - // Sort by timestamp descending (most recent first) 533 - sort.Slice(items, func(i, j int) bool { 534 - return items[i].Timestamp.After(items[j].Timestamp) 535 - }) 536 - 537 - // Limit results 538 - if len(items) > limit { 539 - items = items[:limit] 540 - } 541 - 542 - log.Debug().Int("total_items", len(items)).Msg("feed: returning items") 543 - 544 206 return items, nil 545 207 } 546 208
+43 -1
internal/firehose/index.go
··· 41 41 42 42 // BucketKnownDIDs stores all DIDs we've seen with Arabica records 43 43 BucketKnownDIDs = []byte("known_dids") 44 + 45 + // BucketBackfilled stores DIDs that have been backfilled: {did} -> {timestamp} 46 + BucketBackfilled = []byte("backfilled") 44 47 ) 45 48 46 49 // IndexedRecord represents a record stored in the index ··· 107 110 BucketProfiles, 108 111 BucketMeta, 109 112 BucketKnownDIDs, 113 + BucketBackfilled, 110 114 } 111 115 for _, bucket := range buckets { 112 116 if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { ··· 329 333 func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem, error) { 330 334 var records []*IndexedRecord 331 335 336 + // FIX: this seems to show the first 20 records for main deployment 337 + // - unclear why, but is likely an issue with the db being stale 332 338 err := idx.db.View(func(tx *bolt.Tx) error { 333 339 byTime := tx.Bucket(BucketByTime) 334 340 recordsBucket := tx.Bucket(BucketRecords) ··· 337 343 338 344 // Iterate in reverse (newest first) 339 345 count := 0 340 - for k, _ := c.Last(); k != nil && count < limit*2; k, _ = c.Prev() { 346 + for k, _ := c.First(); k != nil && count < limit*2; k, _ = c.Next() { 341 347 // Extract URI from key (format: timestamp:uri) 342 348 uri := extractURIFromTimeKey(k) 343 349 if uri == "" { ··· 692 698 } 693 699 } 694 700 701 + // IsBackfilled checks if a DID has already been backfilled 702 + func (idx *FeedIndex) IsBackfilled(did string) bool { 703 + var exists bool 704 + _ = idx.db.View(func(tx *bolt.Tx) error { 705 + b := tx.Bucket(BucketBackfilled) 706 + exists = b.Get([]byte(did)) != nil 707 + return nil 708 + }) 709 + return exists 710 + } 711 + 712 + // MarkBackfilled marks a DID as backfilled with current timestamp 713 + func (idx *FeedIndex) MarkBackfilled(did string) error { 714 + return idx.db.Update(func(tx *bolt.Tx) error { 715 + b := tx.Bucket(BucketBackfilled) 716 + timestamp := []byte(time.Now().Format(time.RFC3339)) 717 + return b.Put([]byte(did), timestamp) 718 + }) 719 + } 720 + 695 721 // BackfillUser fetches all existing records for a DID and adds them to the index 722 + // Returns early if the DID has already been backfilled 696 723 func (idx *FeedIndex) BackfillUser(ctx context.Context, did string) error { 724 + // Check if already backfilled 725 + if idx.IsBackfilled(did) { 726 + log.Debug().Str("did", did).Msg("DID already backfilled, skipping") 727 + return nil 728 + } 729 + 697 730 log.Info().Str("did", did).Msg("backfilling user records") 698 731 732 + recordCount := 0 699 733 for _, collection := range ArabicaCollections { 700 734 records, err := idx.publicClient.ListRecords(ctx, did, collection, 100) 701 735 if err != nil { ··· 718 752 719 753 if err := idx.UpsertRecord(did, collection, rkey, record.CID, recordJSON, 0); err != nil { 720 754 log.Warn().Err(err).Str("uri", record.URI).Msg("failed to upsert record during backfill") 755 + } else { 756 + recordCount++ 721 757 } 722 758 } 723 759 } 724 760 761 + // Mark as backfilled 762 + if err := idx.MarkBackfilled(did); err != nil { 763 + log.Warn().Err(err).Str("did", did).Msg("failed to mark DID as backfilled") 764 + } 765 + 766 + log.Info().Str("did", did).Int("record_count", recordCount).Msg("backfill complete") 725 767 return nil 726 768 }
+102
internal/firehose/index_test.go
··· 1 + package firehose 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + ) 7 + 8 + func TestBackfillTracking(t *testing.T) { 9 + // Create temporary index 10 + tmpDir := t.TempDir() 11 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 12 + if err != nil { 13 + t.Fatalf("Failed to create index: %v", err) 14 + } 15 + defer idx.Close() 16 + 17 + testDID := "did:plc:test123abc" 18 + 19 + // Initially should not be backfilled 20 + if idx.IsBackfilled(testDID) { 21 + t.Error("DID should not be backfilled initially") 22 + } 23 + 24 + // Mark as backfilled 25 + if err := idx.MarkBackfilled(testDID); err != nil { 26 + t.Fatalf("Failed to mark DID as backfilled: %v", err) 27 + } 28 + 29 + // Now should be backfilled 30 + if !idx.IsBackfilled(testDID) { 31 + t.Error("DID should be marked as backfilled") 32 + } 33 + 34 + // Different DID should not be backfilled 35 + otherDID := "did:plc:other456def" 36 + if idx.IsBackfilled(otherDID) { 37 + t.Error("Other DID should not be backfilled") 38 + } 39 + } 40 + 41 + func TestBackfillTracking_Persistence(t *testing.T) { 42 + tmpDir := t.TempDir() 43 + dbPath := tmpDir + "/test.db" 44 + testDID := "did:plc:persist123" 45 + 46 + // Create index and mark DID as backfilled 47 + { 48 + idx, err := NewFeedIndex(dbPath, 1*time.Hour) 49 + if err != nil { 50 + t.Fatalf("Failed to create index: %v", err) 51 + } 52 + 53 + if err := idx.MarkBackfilled(testDID); err != nil { 54 + t.Fatalf("Failed to mark DID as backfilled: %v", err) 55 + } 56 + 57 + idx.Close() 58 + } 59 + 60 + // Reopen index and verify DID is still marked as backfilled 61 + { 62 + idx, err := NewFeedIndex(dbPath, 1*time.Hour) 63 + if err != nil { 64 + t.Fatalf("Failed to reopen index: %v", err) 65 + } 66 + defer idx.Close() 67 + 68 + if !idx.IsBackfilled(testDID) { 69 + t.Error("DID should still be marked as backfilled after reopening") 70 + } 71 + } 72 + } 73 + 74 + func TestBackfillTracking_MultipleDIDs(t *testing.T) { 75 + tmpDir := t.TempDir() 76 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 77 + if err != nil { 78 + t.Fatalf("Failed to create index: %v", err) 79 + } 80 + defer idx.Close() 81 + 82 + dids := []string{ 83 + "did:plc:user1", 84 + "did:plc:user2", 85 + "did:web:example.com", 86 + "did:plc:user3", 87 + } 88 + 89 + // Mark all as backfilled 90 + for _, did := range dids { 91 + if err := idx.MarkBackfilled(did); err != nil { 92 + t.Fatalf("Failed to mark DID %s as backfilled: %v", did, err) 93 + } 94 + } 95 + 96 + // Verify all are marked 97 + for _, did := range dids { 98 + if !idx.IsBackfilled(did) { 99 + t.Errorf("DID %s should be marked as backfilled", did) 100 + } 101 + } 102 + }
+134 -17
internal/handlers/handlers.go
··· 164 164 165 165 if h.feedService != nil { 166 166 if isAuthenticated { 167 - // Authenticated users get the full feed (20 items), fetched fresh 168 - feedItems, _ = h.feedService.GetRecentRecords(r.Context(), 20) 167 + feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 169 168 } else { 170 169 // Unauthenticated users get a limited feed from the cache 171 170 feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) ··· 302 301 return 303 302 } 304 303 305 - // Check authentication (optional for view) 306 - store, authenticated := h.getAtprotoStore(r) 307 - if !authenticated { 308 - http.Redirect(w, r, "/login", http.StatusFound) 309 - return 304 + // Check if owner (DID or handle) is specified in query params 305 + owner := r.URL.Query().Get("owner") 306 + 307 + // Check authentication 308 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 309 + isAuthenticated := err == nil && didStr != "" 310 + 311 + var userProfile *bff.UserProfile 312 + if isAuthenticated { 313 + userProfile = h.getUserProfile(r.Context(), didStr) 310 314 } 311 315 312 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 313 - userProfile := h.getUserProfile(r.Context(), didStr) 316 + var brew *models.Brew 317 + var brewOwnerDID string 318 + var isOwner bool 314 319 315 - brew, err := store.GetBrewByRKey(r.Context(), rkey) 316 - if err != nil { 317 - http.Error(w, "Brew not found", http.StatusNotFound) 318 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 319 - return 320 + if owner != "" { 321 + // Viewing someone else's brew - use public client 322 + publicClient := atproto.NewPublicClient() 323 + 324 + // Resolve owner to DID if it's a handle 325 + if strings.HasPrefix(owner, "did:") { 326 + brewOwnerDID = owner 327 + } else { 328 + resolved, err := publicClient.ResolveHandle(r.Context(), owner) 329 + if err != nil { 330 + log.Warn().Err(err).Str("handle", owner).Msg("Failed to resolve handle for brew view") 331 + http.Error(w, "User not found", http.StatusNotFound) 332 + return 333 + } 334 + brewOwnerDID = resolved 335 + } 336 + 337 + // Fetch the brew record from the owner's PDS 338 + record, err := publicClient.GetRecord(r.Context(), brewOwnerDID, atproto.NSIDBrew, rkey) 339 + if err != nil { 340 + log.Error().Err(err).Str("did", brewOwnerDID).Str("rkey", rkey).Msg("Failed to get brew record") 341 + http.Error(w, "Brew not found", http.StatusNotFound) 342 + return 343 + } 344 + 345 + // Convert record to brew 346 + brew, err = atproto.RecordToBrew(record.Value, record.URI) 347 + if err != nil { 348 + log.Error().Err(err).Msg("Failed to convert brew record") 349 + http.Error(w, "Failed to load brew", http.StatusInternalServerError) 350 + return 351 + } 352 + 353 + // Resolve references (bean, grinder, brewer) 354 + if err := h.resolveBrewReferences(r.Context(), brew, brewOwnerDID, record.Value); err != nil { 355 + log.Warn().Err(err).Msg("Failed to resolve some brew references") 356 + // Don't fail the request, just log the warning 357 + } 358 + 359 + // Check if viewing user is the owner 360 + isOwner = isAuthenticated && didStr == brewOwnerDID 361 + } else { 362 + // Viewing own brew - require authentication 363 + store, authenticated := h.getAtprotoStore(r) 364 + if !authenticated { 365 + http.Redirect(w, r, "/login", http.StatusFound) 366 + return 367 + } 368 + 369 + brew, err = store.GetBrewByRKey(r.Context(), rkey) 370 + if err != nil { 371 + http.Error(w, "Brew not found", http.StatusNotFound) 372 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for view") 373 + return 374 + } 375 + 376 + brewOwnerDID = didStr 377 + isOwner = true 320 378 } 321 379 322 - if err := bff.RenderBrewView(w, brew, authenticated, didStr, userProfile); err != nil { 380 + if err := bff.RenderBrewView(w, brew, isAuthenticated, didStr, userProfile, isOwner); err != nil { 323 381 http.Error(w, "Failed to render page", http.StatusInternalServerError) 324 382 log.Error().Err(err).Msg("Failed to render brew view") 325 383 } 384 + } 385 + 386 + // resolveBrewReferences resolves bean, grinder, and brewer references for a brew 387 + func (h *Handler) resolveBrewReferences(ctx context.Context, brew *models.Brew, ownerDID string, record map[string]interface{}) error { 388 + publicClient := atproto.NewPublicClient() 389 + 390 + // Resolve bean reference 391 + if beanRef, ok := record["beanRef"].(string); ok && beanRef != "" { 392 + beanRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBean, atproto.ExtractRKeyFromURI(beanRef)) 393 + if err == nil { 394 + if bean, err := atproto.RecordToBean(beanRecord.Value, beanRecord.URI); err == nil { 395 + brew.Bean = bean 396 + 397 + // Resolve roaster reference for the bean 398 + if roasterRef, ok := beanRecord.Value["roasterRef"].(string); ok && roasterRef != "" { 399 + roasterRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDRoaster, atproto.ExtractRKeyFromURI(roasterRef)) 400 + if err == nil { 401 + if roaster, err := atproto.RecordToRoaster(roasterRecord.Value, roasterRecord.URI); err == nil { 402 + brew.Bean.Roaster = roaster 403 + } 404 + } 405 + } 406 + } 407 + } 408 + } 409 + 410 + // Resolve grinder reference 411 + if grinderRef, ok := record["grinderRef"].(string); ok && grinderRef != "" { 412 + grinderRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDGrinder, atproto.ExtractRKeyFromURI(grinderRef)) 413 + if err == nil { 414 + if grinder, err := atproto.RecordToGrinder(grinderRecord.Value, grinderRecord.URI); err == nil { 415 + brew.GrinderObj = grinder 416 + } 417 + } 418 + } 419 + 420 + // Resolve brewer reference 421 + if brewerRef, ok := record["brewerRef"].(string); ok && brewerRef != "" { 422 + brewerRecord, err := publicClient.GetRecord(ctx, ownerDID, atproto.NSIDBrewer, atproto.ExtractRKeyFromURI(brewerRef)) 423 + if err == nil { 424 + if brewer, err := atproto.RecordToBrewer(brewerRecord.Value, brewerRecord.URI); err == nil { 425 + brew.BrewerObj = brewer 426 + } 427 + } 428 + } 429 + 430 + return nil 326 431 } 327 432 328 433 // Show edit brew form ··· 1672 1777 isAuthenticated := err == nil && didStr != "" 1673 1778 isOwnProfile := isAuthenticated && didStr == did 1674 1779 1675 - // Render profile content partial 1676 - if err := bff.RenderProfilePartial(w, brews, beans, roasters, grinders, brewers, isOwnProfile); err != nil { 1780 + // Render profile content partial (use actor as handle, which is already the handle if provided as such) 1781 + profileHandle := actor 1782 + if strings.HasPrefix(actor, "did:") { 1783 + // If actor was a DID, we need to resolve it to a handle 1784 + // We can get it from the first brew's author if available, or fetch profile 1785 + profile, err := publicClient.GetProfile(ctx, did) 1786 + if err == nil { 1787 + profileHandle = profile.Handle 1788 + } else { 1789 + profileHandle = did // Fallback to DID if we can't get handle 1790 + } 1791 + } 1792 + 1793 + if err := bff.RenderProfilePartial(w, brews, beans, roasters, grinders, brewers, isOwnProfile, profileHandle); err != nil { 1677 1794 http.Error(w, "Failed to render content", http.StatusInternalServerError) 1678 1795 log.Error().Err(err).Msg("Failed to render profile partial") 1679 1796 }
+2 -2
justfile
··· 1 1 run: 2 - @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -firehose -known-dids known-dids.txt 2 + @LOG_LEVEL=debug LOG_FORMAT=console go run cmd/server/main.go -known-dids known-dids.txt 3 3 4 4 run-production: 5 - @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go -firehose 5 + @LOG_FORMAT=json SECURE_COOKIES=true go run cmd/server/main.go 6 6 7 7 test: 8 8 @go test ./... -cover -coverprofile=cover.out
-18
known-dids.txt.example
··· 1 - # Known DIDs for Development Backfill 2 - # 3 - # This file contains DIDs that should be backfilled on startup when using 4 - # the --known-dids flag. This is useful for development and testing to 5 - # populate the feed with known coffee enthusiasts. 6 - # 7 - # Format: One DID per line 8 - # Lines starting with # are comments 9 - # Empty lines are ignored 10 - # 11 - # Example DIDs (replace with real DIDs): 12 - # did:plc:example1234567890abcdef 13 - # did:plc:another1234567890abcdef 14 - # 15 - # To use this file: 16 - # 1. Copy this file to known-dids.txt 17 - # 2. Add real DIDs (one per line) 18 - # 3. Run: ./arabica --firehose --known-dids known-dids.txt
+7 -14
module.nix
··· 28 28 logFormat = lib.mkOption { 29 29 type = lib.types.enum [ "pretty" "json" ]; 30 30 default = "json"; 31 - description = "Log format. Use 'json' for production, 'pretty' for development."; 31 + description = 32 + "Log format. Use 'json' for production, 'pretty' for development."; 32 33 }; 33 34 34 35 secureCookies = lib.mkOption { 35 36 type = lib.types.bool; 36 37 default = true; 37 - description = "Whether to set the Secure flag on cookies. Should be true when using HTTPS."; 38 - }; 39 - 40 - firehose = lib.mkOption { 41 - type = lib.types.bool; 42 - default = false; 43 - description = '' 44 - Enable firehose-based feed using Jetstream. 45 - This provides real-time feed updates with zero API calls per request, 46 - instead of polling each user's PDS. 47 - ''; 38 + description = 39 + "Whether to set the Secure flag on cookies. Should be true when using HTTPS."; 48 40 }; 49 41 }; 50 42 ··· 71 63 dataDir = lib.mkOption { 72 64 type = lib.types.path; 73 65 default = "/var/lib/arabica"; 74 - description = "Directory where arabica stores its data (OAuth sessions, etc.)."; 66 + description = 67 + "Directory where arabica stores its data (OAuth sessions, etc.)."; 75 68 }; 76 69 77 70 user = lib.mkOption { ··· 113 106 Type = "simple"; 114 107 User = cfg.user; 115 108 Group = cfg.group; 116 - ExecStart = "${cfg.package}/bin/arabica${lib.optionalString cfg.settings.firehose " -firehose"}"; 109 + ExecStart = "${cfg.package}/bin/arabica"; 117 110 Restart = "on-failure"; 118 111 RestartSec = "10s"; 119 112
+160
scripts/diagnose-feed-db.sh
··· 1 + #!/bin/bash 2 + # Diagnostic script to check feed database status 3 + 4 + set -e 5 + 6 + DB_PATH="${ARABICA_FEED_INDEX_PATH:-$HOME/.local/share/arabica/feed-index.db}" 7 + 8 + echo "=== Feed Database Diagnostics ===" 9 + echo "Database path: $DB_PATH" 10 + echo "" 11 + 12 + if [ ! -f "$DB_PATH" ]; then 13 + echo "ERROR: Database file does not exist at $DB_PATH" 14 + exit 1 15 + fi 16 + 17 + echo "Database file size: $(du -h "$DB_PATH" | cut -f1)" 18 + echo "Last modified: $(stat -c %y "$DB_PATH" 2>/dev/null || stat -f "%Sm" "$DB_PATH")" 19 + echo "" 20 + 21 + # Create a simple Go program to inspect the database 22 + cat > /tmp/inspect-feed-db.go << 'EOF' 23 + package main 24 + 25 + import ( 26 + "encoding/binary" 27 + "encoding/json" 28 + "fmt" 29 + "os" 30 + "time" 31 + 32 + bolt "go.etcd.io/bbolt" 33 + ) 34 + 35 + type IndexedRecord struct { 36 + URI string `json:"uri"` 37 + DID string `json:"did"` 38 + Collection string `json:"collection"` 39 + RKey string `json:"rkey"` 40 + Record json.RawMessage `json:"record"` 41 + CID string `json:"cid"` 42 + IndexedAt time.Time `json:"indexed_at"` 43 + CreatedAt time.Time `json:"created_at"` 44 + } 45 + 46 + func main() { 47 + dbPath := os.Args[1] 48 + 49 + db, err := bolt.Open(dbPath, 0600, &bolt.Options{ReadOnly: true, Timeout: 5 * time.Second}) 50 + if err != nil { 51 + fmt.Printf("ERROR: Failed to open database: %v\n", err) 52 + os.Exit(1) 53 + } 54 + defer db.Close() 55 + 56 + err = db.View(func(tx *bolt.Tx) error { 57 + // Check buckets 58 + records := tx.Bucket([]byte("records")) 59 + byTime := tx.Bucket([]byte("by_time")) 60 + meta := tx.Bucket([]byte("meta")) 61 + knownDIDs := tx.Bucket([]byte("known_dids")) 62 + backfilled := tx.Bucket([]byte("backfilled")) 63 + 64 + if records == nil { 65 + fmt.Println("ERROR: 'records' bucket does not exist") 66 + return nil 67 + } 68 + 69 + recordCount := records.Stats().KeyN 70 + fmt.Printf("Total records: %d\n", recordCount) 71 + 72 + if byTime != nil { 73 + timeIndexCount := byTime.Stats().KeyN 74 + fmt.Printf("Time index entries: %d\n", timeIndexCount) 75 + } 76 + 77 + if knownDIDs != nil { 78 + didCount := knownDIDs.Stats().KeyN 79 + fmt.Printf("Known DIDs: %d\n", didCount) 80 + knownDIDs.ForEach(func(k, v []byte) error { 81 + fmt.Printf(" - %s\n", string(k)) 82 + return nil 83 + }) 84 + } 85 + 86 + if backfilled != nil { 87 + backfilledCount := backfilled.Stats().KeyN 88 + fmt.Printf("Backfilled DIDs: %d\n", backfilledCount) 89 + } 90 + 91 + // Check cursor 92 + if meta != nil { 93 + cursorBytes := meta.Get([]byte("cursor")) 94 + if cursorBytes != nil && len(cursorBytes) == 8 { 95 + cursor := int64(binary.BigEndian.Uint64(cursorBytes)) 96 + cursorTime := time.UnixMicro(cursor) 97 + fmt.Printf("\nCursor position: %d (%s)\n", cursor, cursorTime.Format(time.RFC3339)) 98 + } else { 99 + fmt.Println("\nNo cursor found in database") 100 + } 101 + } 102 + 103 + // Get first 5 and last 5 records by time 104 + if byTime != nil && records != nil { 105 + fmt.Println("\n=== First 5 records (oldest) ===") 106 + c := byTime.Cursor() 107 + count := 0 108 + for k, _ := c.First(); k != nil && count < 5; k, _ = c.Next() { 109 + uri := extractURI(k) 110 + if record := getRecord(records, uri); record != nil { 111 + fmt.Printf("%s - %s - %s\n", record.CreatedAt.Format("2006-01-02 15:04:05"), record.Collection, uri) 112 + } 113 + count++ 114 + } 115 + 116 + fmt.Println("\n=== Last 5 records (newest with inverted timestamps) ===") 117 + c = byTime.Cursor() 118 + count = 0 119 + for k, _ := c.Last(); k != nil && count < 5; k, _ = c.Prev() { 120 + uri := extractURI(k) 121 + if record := getRecord(records, uri); record != nil { 122 + fmt.Printf("%s - %s - %s\n", record.CreatedAt.Format("2006-01-02 15:04:05"), record.Collection, uri) 123 + } 124 + count++ 125 + } 126 + } 127 + 128 + return nil 129 + }) 130 + 131 + if err != nil { 132 + fmt.Printf("ERROR: %v\n", err) 133 + os.Exit(1) 134 + } 135 + } 136 + 137 + func extractURI(key []byte) string { 138 + if len(key) < 10 { 139 + return "" 140 + } 141 + return string(key[9:]) 142 + } 143 + 144 + func getRecord(bucket *bolt.Bucket, uri string) *IndexedRecord { 145 + data := bucket.Get([]byte(uri)) 146 + if data == nil { 147 + return nil 148 + } 149 + var record IndexedRecord 150 + if err := json.Unmarshal(data, &record); err != nil { 151 + return nil 152 + } 153 + return &record 154 + } 155 + EOF 156 + 157 + cd "$(dirname "$0")/.." 158 + go run /tmp/inspect-feed-db.go "$DB_PATH" 159 + 160 + rm -f /tmp/inspect-feed-db.go
+8
templates/about.tmpl
··· 3 3 {{define "content"}} 4 4 <div class="max-w-3xl mx-auto"> 5 5 <div class="flex items-center gap-3 mb-8"> 6 + <button 7 + data-back-button 8 + data-fallback="/" 9 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 10 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 11 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 12 + </svg> 13 + </button> 6 14 <h1 class="text-4xl font-bold text-brown-900">About Arabica</h1> 7 15 <span class="text-sm bg-amber-400 text-brown-900 px-3 py-1 rounded-md font-semibold shadow-sm">ALPHA</span> 8 16 </div>
+26 -15
templates/brew_form.tmpl
··· 1 1 {{define "content"}} 2 2 <script src="/static/js/brew-form.js"></script> 3 3 4 - <div class="max-w-2xl mx-auto"> 4 + <div class="max-w-2xl mx-auto" x-data="brewForm()"> 5 5 <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-8 border border-brown-300"> 6 - <h2 class="text-3xl font-bold text-brown-900 mb-6"> 7 - {{if .Brew}}Edit Brew{{else}}New Brew{{end}} 8 - </h2> 6 + <!-- Header with Back Button --> 7 + <div class="flex items-center gap-3 mb-6"> 8 + <button 9 + data-back-button 10 + data-fallback="/brews" 11 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 12 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 13 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 14 + </svg> 15 + </button> 16 + <h2 class="text-3xl font-bold text-brown-900"> 17 + {{if .Brew}}Edit Brew{{else}}New Brew{{end}} 18 + </h2> 19 + </div> 9 20 10 21 <form 11 22 {{if .Brew}} ··· 15 26 {{end}} 16 27 hx-target="body" 17 28 class="space-y-6" 18 - x-data="brewForm()" 19 29 {{if and .Brew .Brew.Pours}} 20 30 data-pours='{{.Brew.PoursJSON}}' 21 - {{end}}> 31 + {{end}} 32 + x-init="init()" 33 + @submit="$el.submit()"> 22 34 23 35 <!-- Bean Selection --> 24 36 <div> ··· 45 57 </select> 46 58 <button 47 59 type="button" 48 - @click="showNewBean = true" 60 + @click="showBeanForm = true; editingBean = null; beanForm = {name: '', origin: '', roast_level: '', process: '', description: '', roaster_rkey: ''}" 49 61 class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 50 62 + New 51 63 </button> 52 64 </div> 53 - 54 - {{template "new_bean_form" .}} 55 65 </div> 56 66 57 67 <!-- Coffee Amount --> ··· 91 101 </select> 92 102 <button 93 103 type="button" 94 - @click="showNewGrinder = true" 104 + @click="showGrinderForm = true; editingGrinder = null; grinderForm = {name: '', grinder_type: '', burr_type: '', notes: ''}" 95 105 class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 96 106 + New 97 107 </button> 98 108 </div> 99 - 100 - {{template "new_grinder_form" .}} 101 109 </div> 102 110 103 111 <!-- Grind Size --> ··· 136 144 </select> 137 145 <button 138 146 type="button" 139 - @click="showNewBrewer = true" 147 + @click="showBrewerForm = true; editingBrewer = null; brewerForm = {name: '', brewer_type: '', description: ''}" 140 148 class="bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> 141 149 + New 142 150 </button> 143 151 </div> 144 - 145 - {{template "new_brewer_form" .}} 146 152 </div> 147 153 148 154 <!-- Water Amount --> ··· 264 270 </div> 265 271 </form> 266 272 </div> 273 + 274 + <!-- Modals (outside form but within Alpine scope) --> 275 + {{template "bean_form_modal" .}} 276 + {{template "grinder_form_modal" .}} 277 + {{template "brewer_form_modal" .}} 267 278 </div> 268 279 {{end}}
+11 -4
templates/brew_view.tmpl
··· 7 7 <h2 class="text-3xl font-bold text-brown-900">Brew Details</h2> 8 8 <p class="text-sm text-brown-600 mt-1">{{.Brew.CreatedAt.Format "January 2, 2006 at 3:04 PM"}}</p> 9 9 </div> 10 + {{if .IsOwnProfile}} 10 11 <div class="flex gap-2"> 11 12 <a href="/brews/{{.Brew.RKey}}/edit" 12 13 class="inline-flex items-center bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors"> ··· 20 21 Delete 21 22 </button> 22 23 </div> 24 + {{end}} 23 25 </div> 24 26 25 27 <div class="space-y-6"> ··· 156 158 157 159 <!-- Back Button --> 158 160 <div class="pt-4"> 159 - <a href="/brews" 160 - class="inline-block text-brown-700 hover:text-brown-900 font-medium"> 161 - &larr; Back to Brews 162 - </a> 161 + <button 162 + data-back-button 163 + data-fallback="/brews" 164 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 165 + <svg class="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 166 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 167 + </svg> 168 + Back 169 + </button> 163 170 </div> 164 171 </div> 165 172 </div>
+2 -1
templates/layout.tmpl
··· 13 13 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml" /> 14 14 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32" /> 15 15 <link rel="apple-touch-icon" href="/static/icon-192.svg" /> 16 - <link rel="stylesheet" href="/static/css/output.css?v=0.1.9" /> 16 + <link rel="stylesheet" href="/static/css/output.css?v=0.2.0" /> 17 17 <style>[x-cloak] { display: none !important; }</style> 18 18 <link rel="manifest" href="/static/manifest.json" /> 19 19 <script src="/static/js/alpine.min.js" defer></script> ··· 21 21 {{if .IsAuthenticated}} 22 22 <script src="/static/js/data-cache.js"></script> 23 23 {{end}} 24 + <script src="/static/js/back-button.js"></script> 24 25 <script src="/static/js/sw-register.js"></script> 25 26 </head> 26 27 <body class="bg-brown-50 min-h-full flex flex-col"{{if .UserDID}} data-user-did="{{.UserDID}}"{{end}}>
+11 -1
templates/manage.tmpl
··· 2 2 <script src="/static/js/manage-page.js"></script> 3 3 4 4 <div class="max-w-6xl mx-auto" x-data="managePage()"> 5 - <h2 class="text-3xl font-bold text-brown-900 mb-6">Manage</h2> 5 + <div class="flex items-center gap-3 mb-6"> 6 + <button 7 + data-back-button 8 + data-fallback="/brews" 9 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 10 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 11 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 12 + </svg> 13 + </button> 14 + <h2 class="text-3xl font-bold text-brown-900">Manage</h2> 15 + </div> 6 16 7 17 <!-- Tabs --> 8 18 <div class="mb-6 border-b-2 border-brown-300">
+39
templates/partials/bean_form_modal.tmpl
··· 1 + {{define "bean_form_modal"}} 2 + <!-- Bean Form Modal --> 3 + <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 + <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 6 + <div class="space-y-4"> 7 + <input type="text" x-model="beanForm.name" placeholder="Name *" 8 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 + <input type="text" x-model="beanForm.origin" placeholder="Origin *" 10 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 11 + <select x-model="beanForm.roaster_rkey" name="roaster_rkey_modal" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 12 + <option value="">Select Roaster (Optional)</option> 13 + {{range .Roasters}} 14 + <option value="{{.RKey}}">{{.Name}}</option> 15 + {{end}} 16 + </select> 17 + <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 18 + <option value="">Select Roast Level (Optional)</option> 19 + <option value="Ultra-Light">Ultra-Light</option> 20 + <option value="Light">Light</option> 21 + <option value="Medium-Light">Medium-Light</option> 22 + <option value="Medium">Medium</option> 23 + <option value="Medium-Dark">Medium-Dark</option> 24 + <option value="Dark">Dark</option> 25 + </select> 26 + <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 27 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 28 + <textarea x-model="beanForm.description" placeholder="Description" rows="3" 29 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 30 + <div class="flex gap-2"> 31 + <button @click="saveBean()" 32 + class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 33 + <button @click="showBeanForm = false" 34 + class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 39 + {{end}}
+8
templates/partials/brew_list_content.tmpl
··· 116 116 117 117 <!-- Actions --> 118 118 <td class="px-4 py-4 whitespace-nowrap text-sm font-medium space-x-2 align-top"> 119 + {{if $.IsOwnProfile}} 119 120 <a href="/brews/{{.RKey}}" 120 121 class="text-brown-700 hover:text-brown-900 font-medium">View</a> 122 + {{else if $.ProfileHandle}} 123 + <a href="/brews/{{.RKey}}?owner={{$.ProfileHandle}}" 124 + class="text-brown-700 hover:text-brown-900 font-medium">View</a> 125 + {{else}} 126 + <a href="/brews/{{.RKey}}" 127 + class="text-brown-700 hover:text-brown-900 font-medium">View</a> 128 + {{end}} 121 129 {{if $.IsOwnProfile}} 122 130 <a href="/brews/{{.RKey}}/edit" 123 131 class="text-brown-700 hover:text-brown-900 font-medium">Edit</a>
+22
templates/partials/brewer_form_modal.tmpl
··· 1 + {{define "brewer_form_modal"}} 2 + <!-- Brewer Form Modal --> 3 + <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 + <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 6 + <div class="space-y-4"> 7 + <input type="text" x-model="brewerForm.name" placeholder="Name *" 8 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 + <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 10 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 11 + <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 12 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 13 + <div class="flex gap-2"> 14 + <button @click="saveBrewer()" 15 + class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 16 + <button @click="showBrewerForm = false" 17 + class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 18 + </div> 19 + </div> 20 + </div> 21 + </div> 22 + {{end}}
+8
templates/partials/feed.tmpl
··· 118 118 "{{.Brew.TastingNotes}}" 119 119 </div> 120 120 {{end}} 121 + 122 + <!-- View button --> 123 + <div class="mt-3 border-t border-brown-200 pt-3"> 124 + <a href="/brews/{{.Brew.RKey}}?owner={{.Author.Handle}}" 125 + class="inline-flex items-center text-sm font-medium text-brown-700 hover:text-brown-900 hover:underline"> 126 + View full details → 127 + </a> 128 + </div> 121 129 </div> 122 130 {{else if eq .RecordType "bean"}} 123 131 <!-- Bean info -->
+31
templates/partials/grinder_form_modal.tmpl
··· 1 + {{define "grinder_form_modal"}} 2 + <!-- Grinder Form Modal --> 3 + <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 4 + <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 5 + <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 6 + <div class="space-y-4"> 7 + <input type="text" x-model="grinderForm.name" placeholder="Name *" 8 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 9 + <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 10 + <option value="">Select Grinder Type *</option> 11 + <option value="Hand">Hand</option> 12 + <option value="Electric">Electric</option> 13 + <option value="Portable Electric">Portable Electric</option> 14 + </select> 15 + <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 16 + <option value="">Select Burr Type (Optional)</option> 17 + <option value="Conical">Conical</option> 18 + <option value="Flat">Flat</option> 19 + </select> 20 + <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 21 + class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 22 + <div class="flex gap-2"> 23 + <button @click="saveGrinder()" 24 + class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 25 + <button @click="showGrinderForm = false" 26 + class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 27 + </div> 28 + </div> 29 + </div> 30 + </div> 31 + {{end}}
+1 -1
templates/partials/header.tmpl
··· 1 1 {{define "header"}} 2 - <nav class="bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600"> 2 + <nav class="sticky top-0 z-50 bg-gradient-to-br from-brown-800 to-brown-900 text-white shadow-xl border-b-2 border-brown-600"> 3 3 <div class="container mx-auto px-4 py-4"> 4 4 <div class="flex items-center justify-between"> 5 5 <!-- Logo - always visible -->
+3 -86
templates/partials/manage_content.tmpl
··· 208 208 {{end}} 209 209 </div> 210 210 211 - <!-- Bean Form Modal --> 212 - <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 213 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 214 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 215 - <div class="space-y-4"> 216 - <input type="text" x-model="beanForm.name" placeholder="Name *" 217 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 218 - <input type="text" x-model="beanForm.origin" placeholder="Origin *" 219 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 220 - <select x-model="beanForm.roaster_rkey" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 221 - <option value="">Select Roaster (Optional)</option> 222 - {{range .Roasters}} 223 - <option value="{{.RKey}}">{{.Name}}</option> 224 - {{end}} 225 - </select> 226 - <select x-model="beanForm.roast_level" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 227 - <option value="">Select Roast Level (Optional)</option> 228 - <option value="Ultra-Light">Ultra-Light</option> 229 - <option value="Light">Light</option> 230 - <option value="Medium-Light">Medium-Light</option> 231 - <option value="Medium">Medium</option> 232 - <option value="Medium-Dark">Medium-Dark</option> 233 - <option value="Dark">Dark</option> 234 - </select> 235 - <input type="text" x-model="beanForm.process" placeholder="Process (e.g. Washed, Natural, Honey)" 236 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 237 - <textarea x-model="beanForm.description" placeholder="Description" rows="3" 238 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 239 - <div class="flex gap-2"> 240 - <button @click="saveBean()" 241 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 242 - <button @click="showBeanForm = false" 243 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 244 - </div> 245 - </div> 246 - </div> 247 - </div> 211 + {{template "bean_form_modal" .}} 248 212 249 213 <!-- Roaster Form Modal --> 250 214 <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> ··· 267 231 </div> 268 232 </div> 269 233 270 - <!-- Grinder Form Modal --> 271 - <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 272 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 273 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 274 - <div class="space-y-4"> 275 - <input type="text" x-model="grinderForm.name" placeholder="Name *" 276 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 277 - <select x-model="grinderForm.grinder_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 278 - <option value="">Select Grinder Type *</option> 279 - <option value="Hand">Hand</option> 280 - <option value="Electric">Electric</option> 281 - <option value="Portable Electric">Portable Electric</option> 282 - </select> 283 - <select x-model="grinderForm.burr_type" class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"> 284 - <option value="">Select Burr Type (Optional)</option> 285 - <option value="Conical">Conical</option> 286 - <option value="Flat">Flat</option> 287 - </select> 288 - <textarea x-model="grinderForm.notes" placeholder="Notes" rows="3" 289 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 290 - <div class="flex gap-2"> 291 - <button @click="saveGrinder()" 292 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 293 - <button @click="showGrinderForm = false" 294 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 295 - </div> 296 - </div> 297 - </div> 298 - </div> 234 + {{template "grinder_form_modal" .}} 299 235 300 - <!-- Brewer Form Modal --> 301 - <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> 302 - <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl border-2 border-brown-300 p-8 max-w-md w-full mx-4 shadow-2xl"> 303 - <h3 class="text-xl font-semibold mb-4 text-brown-900" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 304 - <div class="space-y-4"> 305 - <input type="text" x-model="brewerForm.name" placeholder="Name *" 306 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 307 - <input type="text" x-model="brewerForm.brewer_type" placeholder="Type (e.g., Pour-Over, Immersion, Espresso)" 308 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600" /> 309 - <textarea x-model="brewerForm.description" placeholder="Description" rows="3" 310 - class="w-full rounded-lg border-2 border-brown-300 bg-white shadow-sm py-2 px-3 focus:border-brown-600 focus:ring-brown-600"></textarea> 311 - <div class="flex gap-2"> 312 - <button @click="saveBrewer()" 313 - class="flex-1 bg-gradient-to-r from-brown-700 to-brown-800 text-white px-4 py-2 rounded-lg hover:from-brown-800 hover:to-brown-900 font-medium transition-all shadow-md">Save</button> 314 - <button @click="showBrewerForm = false" 315 - class="flex-1 bg-brown-300 text-brown-900 px-4 py-2 rounded-lg hover:bg-brown-400 font-medium transition-colors">Cancel</button> 316 - </div> 317 - </div> 318 - </div> 319 - </div> 236 + {{template "brewer_form_modal" .}} 320 237 {{end}}
-33
templates/partials/new_bean_form.tmpl
··· 1 - {{define "new_bean_form"}} 2 - <!-- New Bean Modal --> 3 - <div x-show="showNewBean" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 - <h4 class="font-medium mb-3 text-gray-800">Add New Bean</h4> 5 - <div class="space-y-3"> 6 - <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 - <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 8 - <select x-model="newBean.roasterRKey" name="roaster_rkey_modal" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 9 - <option value="">Select Roaster (Optional)</option> 10 - {{if .Roasters}} 11 - {{range .Roasters}} 12 - <option value="{{.RKey}}">{{.Name}}</option> 13 - {{end}} 14 - {{end}} 15 - </select> 16 - <select x-model="newBean.roastLevel" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 17 - <option value="">Select Roast Level (Optional)</option> 18 - <option value="Ultra-Light">Ultra-Light</option> 19 - <option value="Light">Light</option> 20 - <option value="Medium-Light">Medium-Light</option> 21 - <option value="Medium">Medium</option> 22 - <option value="Medium-Dark">Medium-Dark</option> 23 - <option value="Dark">Dark</option> 24 - </select> 25 - <input type="text" x-model="newBean.process" placeholder="Process (e.g. Washed, Natural, Honey)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 26 - <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 27 - <div class="flex gap-2"> 28 - <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 29 - <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 30 - </div> 31 - </div> 32 - </div> 33 - {{end}}
-15
templates/partials/new_brewer_form.tmpl
··· 1 - {{define "new_brewer_form"}} 2 - <!-- New Brewer Modal --> 3 - <div x-show="showNewBrewer" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 - <h4 class="font-medium mb-3 text-gray-800">Add New Brewer</h4> 5 - <div class="space-y-3"> 6 - <input type="text" x-model="newBrewer.name" placeholder="Name (e.g. V60, AeroPress) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 - <input type="text" x-model="newBrewer.brewer_type" placeholder="Type (e.g. Pour-Over, Immersion)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 8 - <input type="text" x-model="newBrewer.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 9 - <div class="flex gap-2"> 10 - <button type="button" @click="addBrewer()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 11 - <button type="button" @click="showNewBrewer = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 12 - </div> 13 - </div> 14 - </div> 15 - {{end}}
-25
templates/partials/new_grinder_form.tmpl
··· 1 - {{define "new_grinder_form"}} 2 - <!-- New Grinder Modal --> 3 - <div x-show="showNewGrinder" class="mt-4 p-4 bg-brown-100 rounded border border-brown-300"> 4 - <h4 class="font-medium mb-3 text-gray-800">Add New Grinder</h4> 5 - <div class="space-y-3"> 6 - <input type="text" x-model="newGrinder.name" placeholder="Name (e.g. Baratza Encore) *" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 7 - <select x-model="newGrinder.grinderType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 8 - <option value="">Grinder Type (Optional)</option> 9 - <option value="Hand">Hand</option> 10 - <option value="Electric">Electric</option> 11 - <option value="Electric Hand">Electric Hand</option> 12 - </select> 13 - <select x-model="newGrinder.burrType" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"> 14 - <option value="">Burr Type (Optional)</option> 15 - <option value="Conical">Conical</option> 16 - <option value="Flat">Flat</option> 17 - </select> 18 - <input type="text" x-model="newGrinder.notes" placeholder="Notes (optional)" class="w-full rounded-md border-gray-300 bg-white shadow-sm py-2 px-3"/> 19 - <div class="flex gap-2"> 20 - <button type="button" @click="addGrinder()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 21 - <button type="button" @click="showNewGrinder = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 22 - </div> 23 - </div> 24 - </div> 25 - {{end}}
+3 -3
templates/profile.tmpl
··· 6 6 <!-- Load profile stats updater --> 7 7 <script src="/static/js/profile-stats.js"></script> 8 8 {{if .IsOwnProfile}} 9 - <div class="max-w-4xl mx-auto" x-data="managePage()" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 9 + <div class="max-w-4xl mx-auto" x-data="managePage()"> 10 10 {{else}} 11 - <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }" @htmx:after-swap.window="$nextTick(() => Alpine.initTree($el))"> 11 + <div class="max-w-4xl mx-auto" x-data="{ activeTab: 'brews' }"> 12 12 {{end}} 13 13 <!-- Profile Header --> 14 14 <div class="bg-gradient-to-br from-brown-100 to-brown-200 rounded-xl shadow-xl p-6 mb-6 border border-brown-300"> ··· 87 87 </div> 88 88 89 89 <!-- Tab content loaded via HTMX --> 90 - <div id="profile-content" hx-get="/api/profile/{{.Profile.Handle}}" hx-trigger="load" hx-swap="innerHTML"> 90 + <div id="profile-content" hx-get="/api/profile/{{.Profile.Handle}}" hx-trigger="load" hx-swap="innerHTML" hx-on::after-swap="Alpine.initTree(document.getElementById('profile-content'))"> 91 91 <!-- Loading skeleton --> 92 92 <div class="animate-pulse"> 93 93 <!-- Brews Tab Skeleton -->
+11 -1
templates/terms.tmpl
··· 2 2 3 3 {{define "content"}} 4 4 <div class="max-w-3xl mx-auto"> 5 - <h1 class="text-4xl font-bold text-brown-800 mb-8">Terms of Service</h1> 5 + <div class="flex items-center gap-3 mb-8"> 6 + <button 7 + data-back-button 8 + data-fallback="/" 9 + class="inline-flex items-center text-brown-700 hover:text-brown-900 font-medium transition-colors cursor-pointer"> 10 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 11 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path> 12 + </svg> 13 + </button> 14 + <h1 class="text-4xl font-bold text-brown-800">Terms of Service</h1> 15 + </div> 6 16 7 17 <div class="prose prose-lg max-w-none space-y-6"> 8 18 <section class="bg-green-50 border border-green-200 p-6 rounded-lg mb-8">
+63
web/static/js/back-button.js
··· 1 + /** 2 + * Smart back button implementation for Arabica 3 + * Handles browser history navigation with intelligent fallbacks 4 + */ 5 + 6 + /** 7 + * Initialize a back button with smart navigation 8 + * @param {HTMLElement} button - The back button element 9 + */ 10 + function initBackButton(button) { 11 + if (!button) return; 12 + 13 + button.addEventListener('click', function(e) { 14 + e.preventDefault(); 15 + handleBackNavigation(button); 16 + }); 17 + } 18 + 19 + /** 20 + * Handle back navigation with fallback logic 21 + * @param {HTMLElement} button - The back button element 22 + */ 23 + function handleBackNavigation(button) { 24 + const fallbackUrl = button.getAttribute('data-fallback') || '/brews'; 25 + const referrer = document.referrer; 26 + const currentUrl = window.location.href; 27 + 28 + // Check if there's actual browser history to go back to 29 + // We can't directly check history.length in a reliable way across browsers, 30 + // but we can check if the referrer is from the same origin 31 + const hasSameOriginReferrer = referrer && 32 + referrer.startsWith(window.location.origin) && 33 + referrer !== currentUrl; 34 + 35 + if (hasSameOriginReferrer) { 36 + // Safe to use history.back() - we came from within the app 37 + window.history.back(); 38 + } else { 39 + // No referrer or external referrer - use fallback 40 + // This handles direct links, external referrers, and bookmarks 41 + window.location.href = fallbackUrl; 42 + } 43 + } 44 + 45 + /** 46 + * Initialize all back buttons on the page 47 + */ 48 + function initAllBackButtons() { 49 + const buttons = document.querySelectorAll('[data-back-button]'); 50 + buttons.forEach(initBackButton); 51 + } 52 + 53 + // Initialize on DOM load 54 + if (document.readyState === 'loading') { 55 + document.addEventListener('DOMContentLoaded', initAllBackButtons); 56 + } else { 57 + initAllBackButtons(); 58 + } 59 + 60 + // Re-initialize after HTMX swaps (for dynamic content) 61 + document.body.addEventListener('htmx:afterSwap', function() { 62 + initAllBackButtons(); 63 + });
+104 -66
web/static/js/brew-form.js
··· 5 5 */ 6 6 function brewForm() { 7 7 return { 8 - showNewBean: false, 9 - showNewGrinder: false, 10 - showNewBrewer: false, 11 - rating: 5, 12 - pours: [], 13 - newBean: { 8 + // Modal state (matching manage page) 9 + showBeanForm: false, 10 + showGrinderForm: false, 11 + showBrewerForm: false, 12 + editingBean: null, 13 + editingGrinder: null, 14 + editingBrewer: null, 15 + 16 + // Form data (matching manage page with snake_case) 17 + beanForm: { 14 18 name: "", 15 19 origin: "", 16 - roasterRKey: "", 17 - roastLevel: "", 20 + roast_level: "", 18 21 process: "", 19 22 description: "", 23 + roaster_rkey: "", 20 24 }, 21 - newGrinder: { name: "", grinderType: "", burrType: "", notes: "" }, 22 - newBrewer: { name: "", brewer_type: "", description: "" }, 25 + grinderForm: { name: "", grinder_type: "", burr_type: "", notes: "" }, 26 + brewerForm: { name: "", brewer_type: "", description: "" }, 27 + 28 + // Brew form specific 29 + rating: 5, 30 + pours: [], 23 31 24 32 // Dropdown data 25 33 beans: [], ··· 30 38 31 39 async init() { 32 40 // Load existing pours if editing 33 - const poursData = this.$el.getAttribute("data-pours"); 41 + // $el is now the parent div, so find the form element 42 + const formEl = this.$el.querySelector("form"); 43 + const poursData = formEl?.getAttribute("data-pours"); 34 44 if (poursData) { 35 45 try { 36 46 this.pours = JSON.parse(poursData); ··· 44 54 await this.loadDropdownData(); 45 55 }, 46 56 47 - async loadDropdownData() { 57 + async loadDropdownData(forceRefresh = false) { 48 58 if (!window.ArabicaCache) { 49 59 console.warn("ArabicaCache not available"); 50 60 return; 51 61 } 52 62 63 + // If forcing refresh, always get fresh data 64 + if (forceRefresh) { 65 + try { 66 + const freshData = await window.ArabicaCache.refreshCache(true); 67 + if (freshData) { 68 + this.applyData(freshData); 69 + } 70 + } catch (e) { 71 + console.error("Failed to refresh dropdown data:", e); 72 + } 73 + return; 74 + } 75 + 53 76 // First, try to immediately populate from cached data (sync) 54 77 // This prevents flickering by showing data instantly 55 78 const cachedData = window.ArabicaCache.getCachedData(); ··· 84 107 85 108 populateDropdowns() { 86 109 // Get the current selected values (from server-rendered form when editing) 87 - const beanSelect = this.$el.querySelector('select[name="bean_rkey"]'); 88 - const grinderSelect = this.$el.querySelector( 89 - 'select[name="grinder_rkey"]', 90 - ); 91 - const brewerSelect = this.$el.querySelector('select[name="brewer_rkey"]'); 110 + // Use document.querySelector to ensure we find the form selects, not modal selects 111 + const beanSelect = document.querySelector('form select[name="bean_rkey"]'); 112 + const grinderSelect = document.querySelector('form select[name="grinder_rkey"]'); 113 + const brewerSelect = document.querySelector('form select[name="brewer_rkey"]'); 92 114 93 115 const selectedBean = beanSelect?.value || ""; 94 116 const selectedGrinder = grinderSelect?.value || ""; ··· 172 194 } 173 195 174 196 // Populate roasters in new bean modal - using DOM methods to prevent XSS 175 - const roasterSelect = this.$el.querySelector( 176 - 'select[name="roaster_rkey_modal"]', 177 - ); 197 + const roasterSelect = document.querySelector('select[name="roaster_rkey_modal"]'); 178 198 if (roasterSelect && this.roasters.length > 0) { 179 199 // Clear existing options 180 200 roasterSelect.innerHTML = ""; ··· 204 224 this.pours.splice(index, 1); 205 225 }, 206 226 207 - async addBean() { 208 - if (!this.newBean.name || !this.newBean.origin) { 227 + async saveBean() { 228 + if (!this.beanForm.name || !this.beanForm.origin) { 209 229 alert("Bean name and origin are required"); 210 230 return; 211 231 } 212 - const payload = { 213 - name: this.newBean.name, 214 - origin: this.newBean.origin, 215 - roast_level: this.newBean.roastLevel, 216 - process: this.newBean.process, 217 - description: this.newBean.description, 218 - roaster_rkey: this.newBean.roasterRKey || "", 219 - }; 232 + 220 233 const response = await fetch("/api/beans", { 221 234 method: "POST", 222 235 headers: { 223 236 "Content-Type": "application/json", 224 237 }, 225 - body: JSON.stringify(payload), 238 + body: JSON.stringify(this.beanForm), 226 239 }); 240 + 227 241 if (response.ok) { 228 242 const newBean = await response.json(); 229 - // Invalidate cache and refresh data 243 + 244 + // Invalidate cache and refresh data in one call 245 + let freshData = null; 230 246 if (window.ArabicaCache) { 231 - await window.ArabicaCache.invalidateAndRefresh(); 247 + freshData = await window.ArabicaCache.invalidateAndRefresh(); 232 248 } 233 - // Reload dropdowns and select the new bean 234 - await this.loadDropdownData(); 235 - const beanSelect = this.$el.querySelector('select[name="bean_rkey"]'); 249 + 250 + // Apply the fresh data to update dropdowns 251 + if (freshData) { 252 + this.applyData(freshData); 253 + } 254 + 255 + // Select the new bean 256 + const beanSelect = document.querySelector('form select[name="bean_rkey"]'); 236 257 if (beanSelect && newBean.rkey) { 237 258 beanSelect.value = newBean.rkey; 238 259 } 260 + 239 261 // Close modal and reset form 240 - this.showNewBean = false; 241 - this.newBean = { 262 + this.showBeanForm = false; 263 + this.beanForm = { 242 264 name: "", 243 265 origin: "", 244 - roasterRKey: "", 245 - roastLevel: "", 266 + roast_level: "", 246 267 process: "", 247 268 description: "", 269 + roaster_rkey: "", 248 270 }; 249 271 } else { 250 272 const errorText = await response.text(); ··· 252 274 } 253 275 }, 254 276 255 - async addGrinder() { 256 - if (!this.newGrinder.name) { 277 + async saveGrinder() { 278 + if (!this.grinderForm.name) { 257 279 alert("Grinder name is required"); 258 280 return; 259 281 } 282 + 260 283 const response = await fetch("/api/grinders", { 261 284 method: "POST", 262 285 headers: { 263 286 "Content-Type": "application/json", 264 287 }, 265 - body: JSON.stringify(this.newGrinder), 288 + body: JSON.stringify(this.grinderForm), 266 289 }); 290 + 267 291 if (response.ok) { 268 292 const newGrinder = await response.json(); 269 - // Invalidate cache and refresh data 293 + 294 + // Invalidate cache and refresh data in one call 295 + let freshData = null; 270 296 if (window.ArabicaCache) { 271 - await window.ArabicaCache.invalidateAndRefresh(); 297 + freshData = await window.ArabicaCache.invalidateAndRefresh(); 298 + } 299 + 300 + // Apply the fresh data to update dropdowns 301 + if (freshData) { 302 + this.applyData(freshData); 272 303 } 273 - // Reload dropdowns and select the new grinder 274 - await this.loadDropdownData(); 275 - const grinderSelect = this.$el.querySelector( 276 - 'select[name="grinder_rkey"]', 277 - ); 304 + 305 + // Select the new grinder 306 + const grinderSelect = document.querySelector('form select[name="grinder_rkey"]'); 278 307 if (grinderSelect && newGrinder.rkey) { 279 308 grinderSelect.value = newGrinder.rkey; 280 309 } 310 + 281 311 // Close modal and reset form 282 - this.showNewGrinder = false; 283 - this.newGrinder = { 312 + this.showGrinderForm = false; 313 + this.grinderForm = { 284 314 name: "", 285 - grinderType: "", 286 - burrType: "", 315 + grinder_type: "", 316 + burr_type: "", 287 317 notes: "", 288 318 }; 289 319 } else { ··· 292 322 } 293 323 }, 294 324 295 - async addBrewer() { 296 - if (!this.newBrewer.name) { 325 + async saveBrewer() { 326 + if (!this.brewerForm.name) { 297 327 alert("Brewer name is required"); 298 328 return; 299 329 } 330 + 300 331 const response = await fetch("/api/brewers", { 301 332 method: "POST", 302 333 headers: { 303 334 "Content-Type": "application/json", 304 335 }, 305 - body: JSON.stringify(this.newBrewer), 336 + body: JSON.stringify(this.brewerForm), 306 337 }); 338 + 307 339 if (response.ok) { 308 340 const newBrewer = await response.json(); 309 - // Invalidate cache and refresh data 341 + 342 + // Invalidate cache and refresh data in one call 343 + let freshData = null; 310 344 if (window.ArabicaCache) { 311 - await window.ArabicaCache.invalidateAndRefresh(); 345 + freshData = await window.ArabicaCache.invalidateAndRefresh(); 346 + } 347 + 348 + // Apply the fresh data to update dropdowns 349 + if (freshData) { 350 + this.applyData(freshData); 312 351 } 313 - // Reload dropdowns and select the new brewer 314 - await this.loadDropdownData(); 315 - const brewerSelect = this.$el.querySelector( 316 - 'select[name="brewer_rkey"]', 317 - ); 352 + 353 + // Select the new brewer 354 + const brewerSelect = document.querySelector('form select[name="brewer_rkey"]'); 318 355 if (brewerSelect && newBrewer.rkey) { 319 356 brewerSelect.value = newBrewer.rkey; 320 357 } 358 + 321 359 // Close modal and reset form 322 - this.showNewBrewer = false; 323 - this.newBrewer = { name: "", brewer_type: "", description: "" }; 360 + this.showBrewerForm = false; 361 + this.brewerForm = { name: "", brewer_type: "", description: "" }; 324 362 } else { 325 363 const errorText = await response.text(); 326 364 alert("Failed to add brewer: " + errorText);