diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-12 20:54:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-12 20:54:13 +0300 |
| commit | 09ad1901e93158ccbbfd64033849e1835fb3ecb8 (patch) | |
| tree | 8bc13caeefd0e231531265ae2973121ed9a16a0a | |
| parent | c9ae38674e91eeddf9f26fc64d4ddd3a3a3fbbfe (diff) | |
improve stats calculation to exclude pause periods
- Modify newStats() and gatherPostedStats() to accept config parameter
- Add calculatePausedDays() helper function for pause period overlap calculation
- Update postsPerDay and totalPostsPerDay calculations to exclude paused days
- Prevent artificially low throughput during pause periods
- Add comprehensive unit tests for pause-aware stats functionality
- Maintain backward compatibility when no pause is configured
Fixes issue where Gos would post aggressively after vacation periods
due to including pause days in throughput calculations.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | internal/schedule/schedule.go | 2 | ||||
| -rw-r--r-- | internal/schedule/stats.go | 71 | ||||
| -rw-r--r-- | internal/schedule/stats_test.go | 319 |
3 files changed, 386 insertions, 6 deletions
diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 9ec892c..bf86bcc 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -23,7 +23,7 @@ var ( func Run(args config.Args, platform platforms.Platform) (entry.Entry, error) { dir := fmt.Sprintf("%s/db/platforms/%s", args.GosDir, platform.String()) - stats, err := newStats(dir, args.Lookback, args.Target, args.PauseDays, args.MaxDaysQueued) + stats, err := newStats(dir, args.Lookback, args.Target, args.PauseDays, args.MaxDaysQueued, args.Config) if err != nil { return entry.Zero, err } diff --git a/internal/schedule/stats.go b/internal/schedule/stats.go index eba2eee..b0f35d1 100644 --- a/internal/schedule/stats.go +++ b/internal/schedule/stats.go @@ -7,6 +7,7 @@ import ( "time" "codeberg.org/snonux/gos/internal/colour" + "codeberg.org/snonux/gos/internal/config" "codeberg.org/snonux/gos/internal/entry" "codeberg.org/snonux/gos/internal/oi" "codeberg.org/snonux/gos/internal/platforms" @@ -31,10 +32,10 @@ type stats struct { pauseDays int } -func newStats(dir string, lookback time.Duration, target, pauseDays, maxQueuedDays int) (stats, error) { +func newStats(dir string, lookback time.Duration, target, pauseDays, maxQueuedDays int, cfg config.Config) (stats, error) { s := stats{postsPerDayTarget: float64(target) / 7, pauseDays: pauseDays} - if err := s.gatherPostedStats(dir, pastTime(lookback)); err != nil { + if err := s.gatherPostedStats(dir, pastTime(lookback), cfg); err != nil { return s, err } if err := s.gatherQueuedStats(dir); err != nil { @@ -71,7 +72,7 @@ func (s stats) targetHit() bool { return false } -func (s *stats) gatherPostedStats(dir string, lookbackTime time.Time) error { +func (s *stats) gatherPostedStats(dir string, lookbackTime time.Time, cfg config.Config) error { var ( now time.Time = timestamp.NowTime() newest time.Time = timestamp.OldestValidTime() @@ -114,12 +115,26 @@ func (s *stats) gatherPostedStats(dir string, lookbackTime time.Time) error { since := now.Sub(oldest) s.sinceDays = since.Abs().Hours() / 24.0 - s.postsPerDay = float64(s.posted) / float64(s.sinceDays) + + // Subtract paused days from the calculation period + pausedDays := calculatePausedDays(oldest, now, cfg) + activeDays := s.sinceDays - pausedDays + if activeDays > 0 { + s.postsPerDay = float64(s.posted) / activeDays + } else { + s.postsPerDay = 0 + } s.lastPostDaysAgo = now.Sub(newest).Hours() / 24.0 since = now.Sub(totalOldest) s.totalSinceDays = since.Abs().Hours() / 24.0 - s.totalPostsPerDay = float64(s.totalPosted) / float64(s.totalSinceDays) + totalPausedDays := calculatePausedDays(totalOldest, now, cfg) + totalActiveDays := s.totalSinceDays - totalPausedDays + if totalActiveDays > 0 { + s.totalPostsPerDay = float64(s.totalPosted) / totalActiveDays + } else { + s.totalPostsPerDay = 0 + } return nil } @@ -164,3 +179,49 @@ func (s stats) RenderTable(platform platforms.Platform) { func pastTime(duration time.Duration) time.Time { return timestamp.NowTime().Add(-duration) } + +// calculatePausedDays calculates the number of days that fall within pause periods +// between startTime and endTime +func calculatePausedDays(startTime, endTime time.Time, cfg config.Config) float64 { + if cfg.PauseStart == "" || cfg.PauseEnd == "" { + return 0 + } + + pauseStart, err := time.Parse("2006-01-02", cfg.PauseStart) + if err != nil { + return 0 // If parse fails, assume no pause + } + + pauseEnd, err := time.Parse("2006-01-02", cfg.PauseEnd) + if err != nil { + return 0 // If parse fails, assume no pause + } + + // Set to start and end of day + pauseStart = time.Date(pauseStart.Year(), pauseStart.Month(), pauseStart.Day(), 0, 0, 0, 0, startTime.Location()) + pauseEnd = time.Date(pauseEnd.Year(), pauseEnd.Month(), pauseEnd.Day(), 23, 59, 59, 999999999, endTime.Location()) + + // Find intersection of [startTime, endTime] with [pauseStart, pauseEnd] + intersectionStart := maxTime(startTime, pauseStart) + intersectionEnd := minTime(endTime, pauseEnd) + + if intersectionStart.Before(intersectionEnd) || intersectionStart.Equal(intersectionEnd) { + return intersectionEnd.Sub(intersectionStart).Hours() / 24.0 + } + + return 0 +} + +func maxTime(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func minTime(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} diff --git a/internal/schedule/stats_test.go b/internal/schedule/stats_test.go new file mode 100644 index 0000000..af71222 --- /dev/null +++ b/internal/schedule/stats_test.go @@ -0,0 +1,319 @@ +package schedule + +import ( + "os" + "path/filepath" + "testing" + "time" + + "codeberg.org/snonux/gos/internal/config" + "codeberg.org/snonux/gos/internal/timestamp" +) + +func TestGatherPostedStats(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "gos_stats_test_*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test posted files with different timestamps + now := time.Now() + testFiles := []struct { + filename string + content string + timestamp time.Time + }{ + { + // Posted entry from 5 days ago + filename: "post1.txt." + now.AddDate(0, 0, -5).Format(timestamp.Format) + ".posted", + content: "Test post 1", + timestamp: now.AddDate(0, 0, -5), + }, + { + // Posted entry from 3 days ago + filename: "post2.txt." + now.AddDate(0, 0, -3).Format(timestamp.Format) + ".posted", + content: "Test post 2", + timestamp: now.AddDate(0, 0, -3), + }, + { + // Posted entry from 1 day ago + filename: "post3.txt." + now.AddDate(0, 0, -1).Format(timestamp.Format) + ".posted", + content: "Test post 3", + timestamp: now.AddDate(0, 0, -1), + }, + { + // Posted entry from 10 days ago (outside lookback period) + filename: "old_post.txt." + now.AddDate(0, 0, -10).Format(timestamp.Format) + ".posted", + content: "Old test post", + timestamp: now.AddDate(0, 0, -10), + }, + { + // Queued entry (should be ignored) + filename: "queued_post.txt." + now.AddDate(0, 0, -2).Format(timestamp.Format) + ".queued", + content: "Queued post", + timestamp: now.AddDate(0, 0, -2), + }, + { + // Posted entry with .now. tag (should be ignored) + filename: "now_post.now.txt." + now.AddDate(0, 0, -2).Format(timestamp.Format) + ".posted", + content: "Now post", + timestamp: now.AddDate(0, 0, -2), + }, + } + + // Create test files + for _, tf := range testFiles { + filePath := filepath.Join(tmpDir, tf.filename) + if err := os.WriteFile(filePath, []byte(tf.content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", tf.filename, err) + } + } + + // Initialize stats and run gatherPostedStats + s := &stats{} + lookbackTime := now.AddDate(0, 0, -7) // 7 days lookback + cfg := config.Config{} // Empty config (no pause) + + err = s.gatherPostedStats(tmpDir, lookbackTime, cfg) + if err != nil { + t.Fatalf("gatherPostedStats failed: %v", err) + } + + // Verify results + expectedPosted := 3 // post1, post2, post3 (excludes old_post, queued_post, now_post) + if s.posted != expectedPosted { + t.Errorf("Expected posted=%d, got posted=%d", expectedPosted, s.posted) + } + + expectedTotalPosted := 5 // post1, post2, post3, old_post, now_post (excludes only queued_post) + if s.totalPosted != expectedTotalPosted { + t.Errorf("Expected totalPosted=%d, got totalPosted=%d", expectedTotalPosted, s.totalPosted) + } + + // Check sinceDays calculation (should be approximately 5 days from oldest to now) + expectedSinceDays := 5.0 + if s.sinceDays < expectedSinceDays-0.1 || s.sinceDays > expectedSinceDays+0.1 { + t.Errorf("Expected sinceDays≈%.1f, got sinceDays=%.1f", expectedSinceDays, s.sinceDays) + } + + // Check postsPerDay calculation + expectedPostsPerDay := float64(expectedPosted) / s.sinceDays + if s.postsPerDay < expectedPostsPerDay-0.01 || s.postsPerDay > expectedPostsPerDay+0.01 { + t.Errorf("Expected postsPerDay≈%.2f, got postsPerDay=%.2f", expectedPostsPerDay, s.postsPerDay) + } + + // Check lastPostDaysAgo (should be approximately 1 day) + expectedLastPostDaysAgo := 1.0 + if s.lastPostDaysAgo < expectedLastPostDaysAgo-0.1 || s.lastPostDaysAgo > expectedLastPostDaysAgo+0.1 { + t.Errorf("Expected lastPostDaysAgo≈%.1f, got lastPostDaysAgo=%.1f", expectedLastPostDaysAgo, s.lastPostDaysAgo) + } + + // Check totalSinceDays (should be approximately 10 days from oldest to now) + expectedTotalSinceDays := 10.0 + if s.totalSinceDays < expectedTotalSinceDays-0.1 || s.totalSinceDays > expectedTotalSinceDays+0.1 { + t.Errorf("Expected totalSinceDays≈%.1f, got totalSinceDays=%.1f", expectedTotalSinceDays, s.totalSinceDays) + } + + // Check totalPostsPerDay calculation + expectedTotalPostsPerDay := float64(expectedTotalPosted) / s.totalSinceDays + if s.totalPostsPerDay < expectedTotalPostsPerDay-0.01 || s.totalPostsPerDay > expectedTotalPostsPerDay+0.01 { + t.Errorf("Expected totalPostsPerDay≈%.2f, got totalPostsPerDay=%.2f", expectedTotalPostsPerDay, s.totalPostsPerDay) + } +} + +func TestGatherPostedStatsEmptyDir(t *testing.T) { + // Create a temporary directory with no files + tmpDir, err := os.MkdirTemp("", "gos_stats_empty_test_*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + s := &stats{} + lookbackTime := time.Now().AddDate(0, 0, -7) + cfg := config.Config{} // Empty config (no pause) + + err = s.gatherPostedStats(tmpDir, lookbackTime, cfg) + if err != nil { + t.Fatalf("gatherPostedStats failed: %v", err) + } + + // All stats should be zero or NaN for division by zero + if s.posted != 0 { + t.Errorf("Expected posted=0, got posted=%d", s.posted) + } + if s.totalPosted != 0 { + t.Errorf("Expected totalPosted=0, got totalPosted=%d", s.totalPosted) + } + // postsPerDay and totalPostsPerDay will be NaN due to division by zero when no posts exist + // This is the current behavior of the code +} + +func TestGatherPostedStatsWithPause(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "gos_stats_pause_test_*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test scenario: + // - Analysis period: 10 days ago to now + // - Pause period: 6 days ago to 2 days ago (4 days pause) + // - Posts: 8 days ago, 7 days ago, 1 day ago (3 posts) + // - Expected: 3 posts over 6 active days (10 - 4 pause days) = 0.5 posts/day + + now := time.Now() + testFiles := []struct { + filename string + content string + }{ + { + // Posted entry from 8 days ago (before pause) + filename: "post1.txt." + now.AddDate(0, 0, -8).Format(timestamp.Format) + ".posted", + content: "Test post 1", + }, + { + // Posted entry from 7 days ago (before pause) + filename: "post2.txt." + now.AddDate(0, 0, -7).Format(timestamp.Format) + ".posted", + content: "Test post 2", + }, + { + // Posted entry from 1 day ago (after pause) + filename: "post3.txt." + now.AddDate(0, 0, -1).Format(timestamp.Format) + ".posted", + content: "Test post 3", + }, + } + + // Create test files + for _, tf := range testFiles { + filePath := filepath.Join(tmpDir, tf.filename) + if err := os.WriteFile(filePath, []byte(tf.content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", tf.filename, err) + } + } + + // Configure pause period (6 days ago to 2 days ago) + pauseStart := now.AddDate(0, 0, -6).Format("2006-01-02") + pauseEnd := now.AddDate(0, 0, -2).Format("2006-01-02") + cfg := config.Config{ + PauseStart: pauseStart, + PauseEnd: pauseEnd, + } + + // Initialize stats and run gatherPostedStats + s := &stats{} + lookbackTime := now.AddDate(0, 0, -10) // 10 days lookback + + err = s.gatherPostedStats(tmpDir, lookbackTime, cfg) + if err != nil { + t.Fatalf("gatherPostedStats failed: %v", err) + } + + // Verify results + expectedPosted := 3 // All 3 posts are within lookback period + if s.posted != expectedPosted { + t.Errorf("Expected posted=%d, got posted=%d", expectedPosted, s.posted) + } + + // Check sinceDays should be around 8 days (oldest post to now) + expectedSinceDays := 8.0 + if s.sinceDays < expectedSinceDays-0.1 || s.sinceDays > expectedSinceDays+0.1 { + t.Errorf("Expected sinceDays≈%.1f, got sinceDays=%.1f", expectedSinceDays, s.sinceDays) + } + + // Key test: postsPerDay should exclude the pause period + // Let's calculate this step by step: + + // 1. Find the actual time range of our posts + oldestPost := now.AddDate(0, 0, -8) + + // 2. Calculate paused days using our helper function + actualPausedDays := calculatePausedDays(oldestPost, now, cfg) + + // 3. Calculate expected values + expectedActiveDays := s.sinceDays - actualPausedDays + expectedPostsPerDay := float64(expectedPosted) / expectedActiveDays + + // Debug info for understanding the calculation + // t.Logf("Debug: sinceDays=%.1f, actualPausedDays=%.1f, expectedActiveDays=%.1f", + // s.sinceDays, actualPausedDays, expectedActiveDays) + + if s.postsPerDay < expectedPostsPerDay-0.01 || s.postsPerDay > expectedPostsPerDay+0.01 { + t.Errorf("Expected postsPerDay≈%.2f (%.0f posts / %.1f active days), got postsPerDay=%.2f", + expectedPostsPerDay, float64(expectedPosted), expectedActiveDays, s.postsPerDay) + } +} + +func TestCalculatePausedDays(t *testing.T) { + cfg := config.Config{ + PauseStart: "2024-07-01", + PauseEnd: "2024-07-10", + } + + tests := []struct { + name string + startTime string + endTime string + expected float64 + description string + }{ + { + name: "No overlap - before pause", + startTime: "2024-06-20", + endTime: "2024-06-30", + expected: 0, + description: "Period entirely before pause", + }, + { + name: "No overlap - after pause", + startTime: "2024-07-15", + endTime: "2024-07-20", + expected: 0, + description: "Period entirely after pause", + }, + { + name: "Full pause period overlap", + startTime: "2024-06-25", + endTime: "2024-07-15", + expected: 10, + description: "Period encompasses entire pause", + }, + { + name: "Partial overlap - start during pause", + startTime: "2024-07-05", + endTime: "2024-07-15", + expected: 6, // 2024-07-05 to 2024-07-10 inclusive = 6 days + description: "Period starts during pause", + }, + { + name: "Partial overlap - end during pause", + startTime: "2024-06-25", + endTime: "2024-07-05", + expected: 5, + description: "Period ends during pause", + }, + { + name: "Exact pause period", + startTime: "2024-07-01", + endTime: "2024-07-10", + expected: 10, + description: "Period exactly matches pause", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + startTime, _ := time.Parse("2006-01-02", tt.startTime) + endTime, _ := time.Parse("2006-01-02", tt.endTime) + endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second) // End of day + + result := calculatePausedDays(startTime, endTime, cfg) + if result < tt.expected-0.1 || result > tt.expected+0.1 { + t.Errorf("Expected %.1f paused days, got %.1f", tt.expected, result) + } + }) + } +}
\ No newline at end of file |
