diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 5f4a6d0c..fb8fb77c 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -35,7 +35,7 @@ {"id":"bd-bwk2","content_hash":"c34c5f6281f61bd3298a0cfdaf11594a91e95decf680d655edf35854d1c94fe2","title":"Centralize error handling patterns in storage layer","description":"80+ instances of inconsistent error handling across sqlite.go with mix of %w, %v, and no wrapping.\n\nLocation: internal/storage/sqlite/sqlite.go (throughout)\n\nProblem:\n- Some use fmt.Errorf(\"op failed: %w\", err) - correct wrapping\n- Some use fmt.Errorf(\"op failed: %v\", err) - loses error chain\n- Some return err directly - no context\n- Hard to debug production issues\n- Can't distinguish error types\n\nSolution: Create internal/storage/sqlite/errors.go:\n- Define sentinel errors (ErrNotFound, ErrInvalidID, etc.)\n- Create wrapDBError(op string, err error) helper\n- Convert sql.ErrNoRows to ErrNotFound\n- Always wrap with operation context\n\nImpact: Lost error context; inconsistent messages; hard to debug\n\nEffort: 5-7 hours","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-16T14:51:54.974909-08:00","updated_at":"2025-11-24T01:05:29.606993-08:00","closed_at":"2025-11-24T00:57:47.359519-08:00","source_repo":".","dependencies":[{"issue_id":"bd-bwk2","depends_on_id":"bd-vfe","type":"blocks","created_at":"2025-11-24T00:53:28.831021-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-z8z","type":"blocks","created_at":"2025-11-24T00:53:28.897593-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-w8h","type":"blocks","created_at":"2025-11-24T00:53:28.957487-08:00","created_by":"daemon"},{"issue_id":"bd-bwk2","depends_on_id":"bd-r71","type":"blocks","created_at":"2025-11-24T00:53:29.023262-08:00","created_by":"daemon"}]} {"id":"bd-c362","content_hash":"c9b1c62aad1db56a65ae6b628ea6e3d54923c71436fe5eec1ea4daddfcd68fc2","title":"Extract database search logic into helper function","description":"The logic for finding a database in a beads directory is duplicated:\n- FindDatabasePath() BEADS_DIR section (beads.go:141-169)\n- findDatabaseInTree() (beads.go:248-280)\n\nBoth implement the same search order:\n1. Check config.json first (single source of truth)\n2. Fall back to canonical beads.db\n3. Search for *.db files, filtering backups and vc.db\n\nRefactoring suggestion:\nExtract to a helper function like:\n func findDatabaseInBeadsDir(beadsDir string) string\n\nBenefits:\n- Single source of truth for database search logic\n- Easier to maintain and update search order\n- Reduces code duplication\n\nRelated to [deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:[deleted:bd-e16b]]]]]]]]] implementation.","status":"open","priority":3,"issue_type":"chore","created_at":"2025-11-02T18:34:02.831543-08:00","updated_at":"2025-11-23T22:30:55.479057-08:00","source_repo":"."} {"id":"bd-c4rq","content_hash":"4c096e1d84c3ba5b5b4e107692b990a99166b4c99a4262fd26ec08297fb81046","title":"Refactor: Move staleness check inside daemon branch","description":"## Problem\n\nCurrently ensureDatabaseFresh() is called before the daemon mode check, but it checks daemonClient != nil internally and returns early. This is redundant.\n\n**Location:** All read commands (list.go:196, show.go:27, ready.go:102, status.go:80, etc.)\n\n## Current Pattern\n\nCall happens before daemon check, function checks daemonClient internally.\n\n## Better Pattern\n\nMove staleness check to direct mode branch only, after daemon check.\n\n## Impact\nLow - minor performance improvement (avoids one function call per command in daemon mode)\n\n## Effort\nMedium - requires refactoring 8 command files\n\n## Priority\nLow - can defer to future cleanup PR","status":"open","priority":3,"issue_type":"chore","created_at":"2025-11-20T20:17:45.119583-05:00","updated_at":"2025-11-20T20:17:45.119583-05:00","source_repo":"."} -{"id":"bd-d4i","content_hash":"41cafb4bfa5377a84005b08cddd3e703c1317e98ef32b050ddaabf1bdc7718c9","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","design":"## Implementation\n\nCreate `cmd/bd/tips.go` with:\n\n### Core Infrastructure\n```go\ntype Tip struct {\n ID string\n Condition func() bool // Should this tip be eligible?\n Message string\n Frequency time.Duration // Minimum gap between showings\n Priority int // Higher = shown first when eligible\n Probability float64 // 0.0 to 1.0 - chance of showing\n}\n\nfunc maybeShowTip(store storage.Storage) {\n if jsonOutput || quietMode {\n return // Respect output flags\n }\n \n tip := selectNextTip(store)\n if tip != nil {\n fmt.Fprintf(os.Stdout, \"\\n💡 Tip: %s\\n\", tip.Message)\n recordTipShown(store, tip.ID)\n }\n}\n\nfunc selectNextTip(store storage.Storage) *Tip {\n now := time.Now()\n var eligibleTips []Tip\n \n // Filter to eligible tips (condition + frequency check)\n for _, tip := range tips {\n if !tip.Condition() {\n continue\n }\n \n lastShown := getLastShown(store, tip.ID)\n if !lastShown.IsZero() \u0026\u0026 now.Sub(lastShown) \u003c tip.Frequency {\n continue\n }\n \n eligibleTips = append(eligibleTips, tip)\n }\n \n if len(eligibleTips) == 0 {\n return nil\n }\n \n // Sort by priority (highest first)\n sort.Slice(eligibleTips, func(i, j int) bool {\n return eligibleTips[i].Priority \u003e eligibleTips[j].Priority\n })\n \n // Apply probability roll (in priority order)\n for _, tip := range eligibleTips {\n if rand.Float64() \u003c tip.Probability {\n return \u0026tip\n }\n }\n \n return nil // No tips won probability roll\n}\n```\n\n### Probability Examples\n\n```go\n// High priority, high probability = shows often\n{Priority: 90, Probability: 0.8} // 80% chance when eligible\n\n// High priority, medium probability = important but not spammy\n{Priority: 100, Probability: 0.6} // 60% chance\n\n// Low priority, low probability = rare suggestion\n{Priority: 30, Probability: 0.3} // 30% chance\n```\n\n### Metadata Storage\nUse existing metadata table to track:\n- `tip_{id}_last_shown` - Timestamp of last display (RFC3339 format)\n- `tip_{id}_dismissed` - User permanently dismissed (future feature)\n\n### Integration Points\nCall `maybeShowTip()` at end of:\n- `bd list` - After showing issues\n- `bd ready` - After showing ready work\n- `bd create` - After creating issue\n- `bd show` - After showing issue details\n\n## Design Decisions\n- Tips shown on stdout (informational, not errors)\n- Respects `--json` and `--quiet` flags\n- Frequency enforces minimum gap between showings\n- Priority determines evaluation order\n- Probability reduces spam (not every eligible tip shows)\n- Store state in metadata table (no new files)\n- Deterministic seed for testing (optional BEADS_TIP_SEED env var)","acceptance_criteria":"- Tip infrastructure exists in cmd/bd/tips.go\n- Tips respect --json and --quiet flags\n- Frequency tracking works (no spam)\n- Metadata table stores tip state\n- Unit tests for tip selection logic\n- Documentation in code comments","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-11T23:49:50.812933-08:00","source_repo":"."} +{"id":"bd-d4i","content_hash":"0f4a6fc78e5f1647bec436637537899331a3c56cbec637202a12e4b51612f5fb","title":"Create tip system infrastructure for contextual hints","description":"Implement a tip/hint system that shows helpful contextual messages after successful commands. This is different from the existing error-path \"Hint:\" messages - tips appear on success paths to educate users about features they might not know about.","design":"## Implementation\n\nCreate `cmd/bd/tips.go` with:\n\n### Core Infrastructure\n```go\ntype Tip struct {\n ID string\n Condition func() bool // Should this tip be eligible?\n Message string\n Frequency time.Duration // Minimum gap between showings\n Priority int // Higher = shown first when eligible\n Probability float64 // 0.0 to 1.0 - chance of showing\n}\n\nfunc maybeShowTip(store storage.Storage) {\n if jsonOutput || quietMode {\n return // Respect output flags\n }\n \n tip := selectNextTip(store)\n if tip != nil {\n fmt.Fprintf(os.Stdout, \"\\n💡 Tip: %s\\n\", tip.Message)\n recordTipShown(store, tip.ID)\n }\n}\n\nfunc selectNextTip(store storage.Storage) *Tip {\n now := time.Now()\n var eligibleTips []Tip\n \n // Filter to eligible tips (condition + frequency check)\n for _, tip := range tips {\n if !tip.Condition() {\n continue\n }\n \n lastShown := getLastShown(store, tip.ID)\n if !lastShown.IsZero() \u0026\u0026 now.Sub(lastShown) \u003c tip.Frequency {\n continue\n }\n \n eligibleTips = append(eligibleTips, tip)\n }\n \n if len(eligibleTips) == 0 {\n return nil\n }\n \n // Sort by priority (highest first)\n sort.Slice(eligibleTips, func(i, j int) bool {\n return eligibleTips[i].Priority \u003e eligibleTips[j].Priority\n })\n \n // Apply probability roll (in priority order)\n for _, tip := range eligibleTips {\n if rand.Float64() \u003c tip.Probability {\n return \u0026tip\n }\n }\n \n return nil // No tips won probability roll\n}\n```\n\n### Probability Examples\n\n```go\n// High priority, high probability = shows often\n{Priority: 90, Probability: 0.8} // 80% chance when eligible\n\n// High priority, medium probability = important but not spammy\n{Priority: 100, Probability: 0.6} // 60% chance\n\n// Low priority, low probability = rare suggestion\n{Priority: 30, Probability: 0.3} // 30% chance\n```\n\n### Metadata Storage\nUse existing metadata table to track:\n- `tip_{id}_last_shown` - Timestamp of last display (RFC3339 format)\n- `tip_{id}_dismissed` - User permanently dismissed (future feature)\n\n### Integration Points\nCall `maybeShowTip()` at end of:\n- `bd list` - After showing issues\n- `bd ready` - After showing ready work\n- `bd create` - After creating issue\n- `bd show` - After showing issue details\n\n## Design Decisions\n- Tips shown on stdout (informational, not errors)\n- Respects `--json` and `--quiet` flags\n- Frequency enforces minimum gap between showings\n- Priority determines evaluation order\n- Probability reduces spam (not every eligible tip shows)\n- Store state in metadata table (no new files)\n- Deterministic seed for testing (optional BEADS_TIP_SEED env var)","acceptance_criteria":"- Tip infrastructure exists in cmd/bd/tips.go\n- Tips respect --json and --quiet flags\n- Frequency tracking works (no spam)\n- Metadata table stores tip state\n- Unit tests for tip selection logic\n- Documentation in code comments","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-11-11T23:29:15.693956-08:00","updated_at":"2025-11-24T10:50:46.824307-08:00","source_repo":"."} {"id":"bd-e1085716","content_hash":"a9385e9f00bc41e5e2258fdfccd9f2cbd5a702764b5f1d036274e6026f8c3e38","title":"bd validate - Comprehensive health check","description":"Run all validation checks in one command.\n\nChecks:\n- Duplicates\n- Orphaned dependencies\n- Test pollution\n- Git conflicts\n\nSupports --fix-all for auto-repair.\n\nDepends on bd-cbed9619.1, bd-0dcea000, bd-31aab707, bd-9826b69a.\n\nFiles: cmd/bd/validate.go (new)","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-29T23:05:13.980679-07:00","updated_at":"2025-11-23T23:52:29.99642-08:00","closed_at":"2025-11-23T23:20:39.694984-08:00","source_repo":"."} {"id":"bd-e166","content_hash":"000f4f9d069ffedceae13894d967ec30fa4a89e318bfcac4847f3c3b16d44a89","title":"Improve timestamp comparison readability in import","description":"The timestamp comparison logic uses double-negative which can be confusing:\n\nCurrent code:\nif !incoming.UpdatedAt.After(existing.UpdatedAt) {\n // skip update\n}\n\nMore readable:\nif incoming.UpdatedAt.After(existing.UpdatedAt) {\n // perform update\n} else {\n // skip (local is newer)\n}\n\nThis is a minor refactor for code clarity.\n\nRelated: bd-1022\nFiles: internal/importer/importer.go:411, 488","status":"open","priority":4,"issue_type":"chore","created_at":"2025-11-02T15:32:12.27108-08:00","updated_at":"2025-11-02T15:32:12.27108-08:00","source_repo":"."} {"id":"bd-e92","content_hash":"12073b3293b06f99051bc9c00188aeb520cd2e4792cf4694f1fa4b784e625e54","title":"Add test coverage for internal/autoimport package","description":"","design":"The autoimport package has only 1 test file. Need comprehensive tests. Target: 70% coverage","acceptance_criteria":"- At least 3 test files\n- Package coverage \u003e= 70%\n- Tests cover main functionality, error paths, edge cases","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-20T21:21:22.338577-05:00","updated_at":"2025-11-20T21:21:22.338577-05:00","source_repo":".","dependencies":[{"issue_id":"bd-e92","depends_on_id":"bd-ge7","type":"blocks","created_at":"2025-11-20T21:21:31.128625-05:00","created_by":"daemon"}]} diff --git a/cmd/bd/create.go b/cmd/bd/create.go index e18f984a..9eae8260 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -395,6 +395,9 @@ var createCmd = &cobra.Command{ fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) + + // Show tip after successful create (direct mode only) + maybeShowTip(store) } }, } diff --git a/cmd/bd/list.go b/cmd/bd/list.go index cf63fce5..20964b10 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -447,6 +447,9 @@ var listCmd = &cobra.Command{ assigneeStr, labelsStr, issue.Title) } } + + // Show tip after successful list (direct mode only) + maybeShowTip(store) }, } diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 76cf2201..681445dd 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -142,6 +142,8 @@ var readyCmd = &cobra.Command{ yellow := color.New(color.FgYellow).SprintFunc() fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n", yellow("✨")) + // Show tip even when no ready work found + maybeShowTip(store) return } cyan := color.New(color.FgCyan).SprintFunc() @@ -156,6 +158,9 @@ var readyCmd = &cobra.Command{ } } fmt.Println() + + // Show tip after successful ready (direct mode only) + maybeShowTip(store) }, } var blockedCmd = &cobra.Command{ diff --git a/cmd/bd/show.go b/cmd/bd/show.go index 499886f4..4ff81afd 100644 --- a/cmd/bd/show.go +++ b/cmd/bd/show.go @@ -335,6 +335,9 @@ var showCmd = &cobra.Command{ if jsonOutput && len(allDetails) > 0 { outputJSON(allDetails) + } else if len(allDetails) > 0 { + // Show tip after successful show (non-JSON mode) + maybeShowTip(store) } }, } diff --git a/cmd/bd/tips.go b/cmd/bd/tips.go new file mode 100644 index 00000000..6267ef8c --- /dev/null +++ b/cmd/bd/tips.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "os" + "sort" + "strconv" + "sync" + "time" + + "github.com/steveyegge/beads/internal/storage" +) + +// Tip represents a contextual hint that can be shown to users after successful commands +type Tip struct { + ID string + Condition func() bool // Should this tip be eligible? + Message string // The tip message to display + Frequency time.Duration // Minimum gap between showings + Priority int // Higher = shown first when eligible + Probability float64 // 0.0 to 1.0 - chance of showing when eligible +} + +var ( + // tips is the registry of all available tips + tips []Tip + + // tipsMutex protects the tips registry for thread-safe access + tipsMutex sync.RWMutex + + // tipRand is the random number generator for probability rolls + // Can be seeded deterministically via BEADS_TIP_SEED for testing + tipRand *rand.Rand + + // tipRandOnce ensures we only initialize the RNG once + tipRandOnce sync.Once +) + +// initTipRand initializes the random number generator for tip selection +// Uses BEADS_TIP_SEED env var for deterministic testing if set +func initTipRand() { + tipRandOnce.Do(func() { + seed := time.Now().UnixNano() + if seedStr := os.Getenv("BEADS_TIP_SEED"); seedStr != "" { + if parsedSeed, err := strconv.ParseInt(seedStr, 10, 64); err == nil { + seed = parsedSeed + } + } + // Use deprecated rand.NewSource for Go 1.19 compatibility + // nolint:gosec,staticcheck // G404: deterministic seed via env var is intentional for testing + tipRand = rand.New(rand.NewSource(seed)) + }) +} + +// maybeShowTip selects and displays an eligible tip based on priority and probability +// Respects --json and --quiet flags +func maybeShowTip(store storage.Storage) { + // Skip tips in JSON output mode or quiet mode + if jsonOutput || quietFlag { + return + } + + // Initialize RNG if needed + initTipRand() + + // Select next tip + tip := selectNextTip(store) + if tip == nil { + return + } + + // Display tip to stdout (informational, not an error) + fmt.Fprintf(os.Stdout, "\n💡 Tip: %s\n", tip.Message) + + // Record that we showed this tip + recordTipShown(store, tip.ID) +} + +// selectNextTip finds the next tip to show based on conditions, frequency, priority, and probability +// Returns nil if no tip should be shown +func selectNextTip(store storage.Storage) *Tip { + if store == nil { + return nil + } + + now := time.Now() + var eligibleTips []Tip + + // Lock for reading the tip registry + tipsMutex.RLock() + defer tipsMutex.RUnlock() + + // Filter to eligible tips (condition + frequency check) + for _, tip := range tips { + // Check if tip's condition is met + if !tip.Condition() { + continue + } + + // Check if enough time has passed since last showing + lastShown := getLastShown(store, tip.ID) + if !lastShown.IsZero() && now.Sub(lastShown) < tip.Frequency { + continue + } + + eligibleTips = append(eligibleTips, tip) + } + + if len(eligibleTips) == 0 { + return nil + } + + // Sort by priority (highest first) + sort.Slice(eligibleTips, func(i, j int) bool { + return eligibleTips[i].Priority > eligibleTips[j].Priority + }) + + // Apply probability roll (in priority order) + // Higher priority tips get first chance to show + for i := range eligibleTips { + if tipRand.Float64() < eligibleTips[i].Probability { + return &eligibleTips[i] + } + } + + return nil // No tips won probability roll +} + +// getLastShown retrieves the timestamp when a tip was last shown +// Returns zero time if never shown +func getLastShown(store storage.Storage, tipID string) time.Time { + key := fmt.Sprintf("tip_%s_last_shown", tipID) + value, err := store.GetMetadata(context.Background(), key) + if err != nil || value == "" { + return time.Time{} + } + + // Parse RFC3339 timestamp + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + + return t +} + +// recordTipShown records the timestamp when a tip was shown +func recordTipShown(store storage.Storage, tipID string) { + key := fmt.Sprintf("tip_%s_last_shown", tipID) + value := time.Now().Format(time.RFC3339) + _ = store.SetMetadata(context.Background(), key, value) +} diff --git a/cmd/bd/tips_example_test.go b/cmd/bd/tips_example_test.go new file mode 100644 index 00000000..61f1f25d --- /dev/null +++ b/cmd/bd/tips_example_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "testing" + "time" +) + +// This file demonstrates example tip definitions for documentation purposes + +func TestExampleTipDefinitions(t *testing.T) { + // Example 1: High priority, high probability tip + // Shows frequently when condition is met + highPriorityTip := Tip{ + ID: "example_high_priority", + Condition: func() bool { return true }, + Message: "This is an important tip that shows often", + Frequency: 24 * time.Hour, // Show at most once per day + Priority: 100, // Highest priority + Probability: 0.8, // 80% chance when eligible + } + + // Example 2: Medium priority, medium probability tip + // General feature discovery + mediumPriorityTip := Tip{ + ID: "example_medium_priority", + Condition: func() bool { return true }, + Message: "Try using 'bd ready' to see available work", + Frequency: 7 * 24 * time.Hour, // Show at most once per week + Priority: 50, // Medium priority + Probability: 0.5, // 50% chance when eligible + } + + // Example 3: Low priority, low probability tip + // Nice-to-know information + lowPriorityTip := Tip{ + ID: "example_low_priority", + Condition: func() bool { return true }, + Message: "You can filter issues by label with --label flag", + Frequency: 30 * 24 * time.Hour, // Show at most once per month + Priority: 10, // Low priority + Probability: 0.2, // 20% chance when eligible + } + + // Example 4: Conditional tip + // Only shows when specific condition is true + conditionalTip := Tip{ + ID: "example_conditional", + Condition: func() bool { + // Example: Only show if some condition is met + // In real usage, this might check for specific state + return false // Disabled for this example + }, + Message: "This tip only shows when condition is met", + Frequency: 24 * time.Hour, + Priority: 80, + Probability: 0.6, + } + + // Verify tips are properly structured (basic validation) + tips := []Tip{highPriorityTip, mediumPriorityTip, lowPriorityTip, conditionalTip} + + for _, tip := range tips { + if tip.ID == "" { + t.Error("Tip ID should not be empty") + } + if tip.Message == "" { + t.Error("Tip message should not be empty") + } + if tip.Condition == nil { + t.Error("Tip condition function should not be nil") + } + if tip.Frequency < 0 { + t.Error("Tip frequency should not be negative") + } + if tip.Probability < 0 || tip.Probability > 1 { + t.Errorf("Tip probability should be between 0 and 1, got %f", tip.Probability) + } + } +} + +// Example showing probability guidelines +func TestProbabilityGuidelines(t *testing.T) { + examples := []struct { + name string + probability float64 + useCase string + }{ + {"Critical", 1.0, "Security alerts, breaking changes"}, + {"High", 0.8, "Important updates, major features"}, + {"Medium", 0.5, "General tips, workflow improvements"}, + {"Low", 0.2, "Nice-to-know, advanced features"}, + } + + for _, ex := range examples { + if ex.probability < 0 || ex.probability > 1 { + t.Errorf("%s: probability %f out of range", ex.name, ex.probability) + } + } +} diff --git a/cmd/bd/tips_test.go b/cmd/bd/tips_test.go new file mode 100644 index 00000000..2f2fadeb --- /dev/null +++ b/cmd/bd/tips_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage/memory" +) + +func TestTipSelection(t *testing.T) { + // Set deterministic seed for testing + os.Setenv("BEADS_TIP_SEED", "12345") + defer os.Unsetenv("BEADS_TIP_SEED") + + // Reset RNG + tipRandOnce = sync.Once{} + initTipRand() + + // Reset tip registry for testing + tipsMutex.Lock() + tips = []Tip{} + tipsMutex.Unlock() + + store := memory.New("") + + // Test 1: No tips registered + tip := selectNextTip(store) + if tip != nil { + t.Errorf("Expected nil with no tips registered, got %v", tip) + } + + // Test 2: Single tip with condition = true + tipsMutex.Lock() + tips = append(tips, Tip{ + ID: "test_tip_1", + Condition: func() bool { return true }, + Message: "Test tip 1", + Frequency: 1 * time.Hour, + Priority: 100, + Probability: 1.0, // Always show + }) + tipsMutex.Unlock() + + tip = selectNextTip(store) + if tip == nil { + t.Fatal("Expected tip to be selected") + } + if tip.ID != "test_tip_1" { + t.Errorf("Expected tip ID 'test_tip_1', got %q", tip.ID) + } + + // Test 3: Frequency limit - should not show again immediately + recordTipShown(store, "test_tip_1") + tip = selectNextTip(store) + if tip != nil { + t.Errorf("Expected nil due to frequency limit, got %v", tip) + } + + // Test 4: Multiple tips - priority order + tipsMutex.Lock() + tips = []Tip{ + { + ID: "low_priority", + Condition: func() bool { return true }, + Message: "Low priority tip", + Frequency: 1 * time.Hour, + Priority: 10, + Probability: 1.0, + }, + { + ID: "high_priority", + Condition: func() bool { return true }, + Message: "High priority tip", + Frequency: 1 * time.Hour, + Priority: 100, + Probability: 1.0, + }, + } + tipsMutex.Unlock() + + tip = selectNextTip(store) + if tip == nil { + t.Fatal("Expected tip to be selected") + } + if tip.ID != "high_priority" { + t.Errorf("Expected high_priority tip to be selected first, got %q", tip.ID) + } + + // Test 5: Condition = false + tipsMutex.Lock() + tips = []Tip{ + { + ID: "never_show", + Condition: func() bool { return false }, + Message: "Never shown", + Frequency: 1 * time.Hour, + Priority: 100, + Probability: 1.0, + }, + } + tipsMutex.Unlock() + + tip = selectNextTip(store) + if tip != nil { + t.Errorf("Expected nil due to condition=false, got %v", tip) + } +} + +func TestTipProbability(t *testing.T) { + // Set deterministic seed + os.Setenv("BEADS_TIP_SEED", "99999") + defer os.Unsetenv("BEADS_TIP_SEED") + + // Reset RNG by creating a new Once + tipRandOnce = sync.Once{} + initTipRand() + + tipsMutex.Lock() + tips = []Tip{ + { + ID: "rare_tip", + Condition: func() bool { return true }, + Message: "Rare tip", + Frequency: 1 * time.Hour, + Priority: 100, + Probability: 0.01, // 1% chance + }, + } + tipsMutex.Unlock() + + store := memory.New("") + + // Run selection multiple times + shownCount := 0 + for i := 0; i < 100; i++ { + // Clear last shown timestamp to make tip eligible + _ = store.SetMetadata(context.Background(), "tip_rare_tip_last_shown", "") + + tip := selectNextTip(store) + if tip != nil { + shownCount++ + } + } + + // With 1% probability, we expect ~1 show out of 100 + // Allow some variance (0-10 is reasonable for low probability) + if shownCount > 10 { + t.Errorf("Expected ~1 tip shown with 1%% probability, got %d", shownCount) + } +} + +func TestGetLastShown(t *testing.T) { + store := memory.New("") + + // Test 1: Never shown + lastShown := getLastShown(store, "never_shown") + if !lastShown.IsZero() { + t.Errorf("Expected zero time for never shown tip, got %v", lastShown) + } + + // Test 2: Recently shown + now := time.Now() + _ = store.SetMetadata(context.Background(), "tip_test_last_shown", now.Format(time.RFC3339)) + + lastShown = getLastShown(store, "test") + if lastShown.IsZero() { + t.Error("Expected non-zero time for shown tip") + } + + // Should be within 1 second (accounting for rounding) + diff := now.Sub(lastShown) + if diff < 0 { + diff = -diff + } + if diff > time.Second { + t.Errorf("Expected last shown time to be close to now, got diff %v", diff) + } +} + +func TestRecordTipShown(t *testing.T) { + store := memory.New("") + + recordTipShown(store, "test_tip") + + // Verify it was recorded + lastShown := getLastShown(store, "test_tip") + if lastShown.IsZero() { + t.Error("Expected tip to be recorded as shown") + } + + // Should be very recent + if time.Since(lastShown) > time.Second { + t.Errorf("Expected recent timestamp, got %v", lastShown) + } +} + +func TestMaybeShowTip_RespectsFlags(t *testing.T) { + // Set deterministic seed + os.Setenv("BEADS_TIP_SEED", "54321") + defer os.Unsetenv("BEADS_TIP_SEED") + + tipsMutex.Lock() + tips = []Tip{ + { + ID: "always_show", + Condition: func() bool { return true }, + Message: "Always show tip", + Frequency: 1 * time.Hour, + Priority: 100, + Probability: 1.0, + }, + } + tipsMutex.Unlock() + + store := memory.New("") + + // Test 1: Should not show in JSON mode + jsonOutput = true + maybeShowTip(store) // Should not panic or show output + jsonOutput = false + + // Test 2: Should not show in quiet mode + quietFlag = true + maybeShowTip(store) // Should not panic or show output + quietFlag = false + + // Test 3: Should show in normal mode (no assertions, just testing it doesn't panic) + maybeShowTip(store) +} + +func TestTipFrequency(t *testing.T) { + store := memory.New("") + + tipsMutex.Lock() + tips = []Tip{ + { + ID: "frequent_tip", + Condition: func() bool { return true }, + Message: "Frequent tip", + Frequency: 5 * time.Second, + Priority: 100, + Probability: 1.0, + }, + } + tipsMutex.Unlock() + + // First selection should work + tip := selectNextTip(store) + if tip == nil { + t.Fatal("Expected tip to be selected") + } + + // Record it as shown + recordTipShown(store, tip.ID) + + // Should not show again immediately (within frequency window) + tip = selectNextTip(store) + if tip != nil { + t.Errorf("Expected nil due to frequency limit, got %v", tip) + } + + // Manually set last shown to past (simulate time passing) + past := time.Now().Add(-10 * time.Second) + _ = store.SetMetadata(context.Background(), "tip_frequent_tip_last_shown", past.Format(time.RFC3339)) + + // Should show again now + tip = selectNextTip(store) + if tip == nil { + t.Error("Expected tip to be selected after frequency window passed") + } +}