From 5b70b351dff16e4c256ae3f0521685a7cb212721 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Wed, 3 Jun 2026 21:02:38 -0500 Subject: [PATCH 1/7] fix(view): round goal rate to a readable precision The Beeminder API returns rates at full float precision (e.g. 0.21317778888888886), which `buzz view` dumped verbatim via %g. Add a formatRateValue helper that rounds to 4 decimal places, trims trailing zeros, and avoids scientific notation so large whole-number rates stay readable. Closes #260 Co-Authored-By: Claude Opus 4.8 (1M context) --- review.go | 21 +++++++++++++++++++-- review_test.go | 5 +++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/review.go b/review.go index 16eaa80..30eaebc 100644 --- a/review.go +++ b/review.go @@ -3,10 +3,12 @@ package main import ( "context" "fmt" + "math" "net/url" "os" "os/exec" "runtime" + "strconv" "time" tea "github.com/charmbracelet/bubbletea" @@ -230,10 +232,25 @@ func formatRate(rate float64, runits, gunits string) string { unitName = runits } + value := formatRateValue(rate) if gunits != "" { - return fmt.Sprintf("%g %s / %s", rate, gunits, unitName) + return fmt.Sprintf("%s %s / %s", value, gunits, unitName) } - return fmt.Sprintf("%g/%s", rate, unitName) + return fmt.Sprintf("%s/%s", value, unitName) +} + +// rateDisplayDecimals caps how many decimal places a rate is shown with. The +// Beeminder API returns rates at full float precision (e.g. +// 0.21317778888888886), which is noise to a human reading `buzz view`. +const rateDisplayDecimals = 4 + +// formatRateValue renders a rate as a clean decimal string: rounded to +// rateDisplayDecimals places, with trailing zeros trimmed and no scientific +// notation (so large whole-number rates like 100000 stay readable). +func formatRateValue(rate float64) string { + scale := math.Pow(10, rateDisplayDecimals) + rounded := math.Round(rate*scale) / scale + return strconv.FormatFloat(rounded, 'f', -1, 64) } // formatRecentDatapoints formats up to 5 of the most recent datapoints for diff --git a/review_test.go b/review_test.go index d42169f..ab3d004 100644 --- a/review_test.go +++ b/review_test.go @@ -259,6 +259,11 @@ func TestFormatRate(t *testing.T) { {12345.0, "w", "", "12345/week"}, {100000.0, "y", "", "100000/year"}, {9800.0, "d", "steps", "9800 steps / day"}, + // Full-precision API rates are rounded to a readable number of decimals + // rather than dumping the raw float (issue #260). + {0.21317778888888886, "d", "hours", "0.2132 hours / day"}, + {0.1900775022222092, "d", "", "0.1901/day"}, + {0.0, "d", "", "0/day"}, } for _, tt := range tests { From 3f8badd980b86f07de06870ed09eae6633d1af53 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Wed, 3 Jun 2026 21:02:55 -0500 Subject: [PATCH 2/7] feat(view): show current rate alongside end rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buzz view`/`review` showed only the goal's end rate (the final road segment). Capture the API's `rcur` (current rate) and, when it differs from the end rate, display both — e.g. "0 hours / day (current), 0.21 (end)". Flat roads where the rates match still show a single rate. Closes #259 Co-Authored-By: Claude Opus 4.8 (1M context) --- beeminder.go | 3 ++- review.go | 9 +++++++- review_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/beeminder.go b/beeminder.go index 6903696..8e7da74 100644 --- a/beeminder.go +++ b/beeminder.go @@ -19,7 +19,8 @@ type Goal struct { Baremin string `json:"baremin"` Autodata string `json:"autodata"` Autoratchet *float64 `json:"autoratchet"` // Pointer to handle null values from API - Rate *float64 `json:"rate"` // Pointer to handle null values from API + Rate *float64 `json:"rate"` // End rate of the goal's bright line (final segment). Pointer to handle null values from API + Rcur *float64 `json:"rcur"` // Current rate of the bright line at today's date. Pointer to handle null values from API Runits string `json:"runits"` Gunits string `json:"gunits"` // Goal units, like "hours" or "pushups" or "pages" Deadline int `json:"deadline"` // Seconds by which deadline differs from midnight diff --git a/review.go b/review.go index 30eaebc..ed65bcb 100644 --- a/review.go +++ b/review.go @@ -332,9 +332,16 @@ func formatGoalDetails(goal *Goal, config *Config) string { } details += fmt.Sprintf("Pledge: %s\n", pledgeDisplay) - // Display current rate (n / unit) + // Display rate (n / unit). When the current rate differs from the end + // rate (a non-flat road), show both so the user sees what they're held to + // today versus where the goal is heading. if goal.Rate != nil && goal.Runits != "" { rateStr := formatRate(*goal.Rate, goal.Runits, goal.Gunits) + if goal.Rcur != nil && *goal.Rcur != *goal.Rate { + rateStr = fmt.Sprintf("%s (current), %s (end)", + formatRate(*goal.Rcur, goal.Runits, goal.Gunits), + formatRateValue(*goal.Rate)) + } details += fmt.Sprintf("Rate: %s\n", rateStr) } diff --git a/review_test.go b/review_test.go index ab3d004..5b2155e 100644 --- a/review_test.go +++ b/review_test.go @@ -339,6 +339,66 @@ func TestReviewModelViewWithRateAndGunits(t *testing.T) { } } +func TestReviewModelViewWithCurrentAndEndRate(t *testing.T) { + // When the current rate differs from the end rate, both are shown so the + // user sees today's rate and where the goal is heading (issue #259). + endRate := 0.21317778888888886 + curRate := 0.0 + goals := []Goal{ + { + Slug: "testgoal", + Title: "Test Goal", + Safebuf: 5, + Pledge: 10.0, + Losedate: 1234567890, + Limsum: "+1 in 2 days", + Baremin: "+2 in 1 day", + Rate: &endRate, + Rcur: &curRate, + Runits: "d", + Gunits: "hours", + }, + } + + config := &Config{Username: "testuser", AuthToken: "testtoken"} + view := initialReviewModel(goals, config).View() + + expectedRate := "Rate: 0 hours / day (current), 0.2132 (end)" + if !strings.Contains(view, expectedRate) { + t.Errorf("Expected view to contain '%s', but got:\n%s", expectedRate, view) + } +} + +func TestReviewModelViewWithEqualCurrentAndEndRate(t *testing.T) { + // On a flat road the current and end rates match, so only a single rate is + // shown rather than redundantly repeating it (issue #259). + rate := 2.0 + goals := []Goal{ + { + Slug: "testgoal", + Title: "Test Goal", + Safebuf: 5, + Pledge: 10.0, + Losedate: 1234567890, + Limsum: "+1 in 2 days", + Baremin: "+2 in 1 day", + Rate: &rate, + Rcur: &rate, + Runits: "d", + }, + } + + config := &Config{Username: "testuser", AuthToken: "testtoken"} + view := initialReviewModel(goals, config).View() + + if !strings.Contains(view, "Rate: 2/day") { + t.Errorf("Expected single rate 'Rate: 2/day', but got:\n%s", view) + } + if strings.Contains(view, "(current)") { + t.Errorf("Expected no current/end split when rates are equal, but got:\n%s", view) + } +} + func TestReviewModelViewWithoutRate(t *testing.T) { goals := []Goal{ { From bce3c45d089c246f16604ea2e22ff9a01f028939 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Wed, 3 Jun 2026 21:03:03 -0500 Subject: [PATCH 3/7] fix(next): skip overdue goals when showing the next due goal `buzz next` surfaced the most urgent goal even when it was already overdue, printing "OVERDUE" instead of a countdown. Add filterOutOverdue and apply it (after the completed-goal filter) so `next` points at the soonest goal that still has time left. Closes #257 Co-Authored-By: Claude Opus 4.8 (1M context) --- beeminder.go | 16 ++++++++++++++++ beeminder_test.go | 24 ++++++++++++++++++++++++ next.go | 5 +++++ 3 files changed, 45 insertions(+) diff --git a/beeminder.go b/beeminder.go index 8e7da74..0023219 100644 --- a/beeminder.go +++ b/beeminder.go @@ -3,6 +3,7 @@ package main import ( "sort" "strings" + "time" ) // Goal represents a Beeminder goal with relevant fields @@ -77,6 +78,21 @@ func filterOutEndValueReached(goals []Goal) []Goal { return out } +// filterOutOverdue returns a new slice containing only goals whose losedate is +// not in the past relative to now. Used by "next" so an already-overdue goal — +// which would render as OVERDUE rather than a countdown — doesn't get surfaced +// as the next thing due; the soonest goal that still has time left is shown. +func filterOutOverdue(goals []Goal, now time.Time) []Goal { + out := make([]Goal, 0, len(goals)) + for _, g := range goals { + if time.Unix(g.Losedate, 0).Before(now) { + continue + } + out = append(out, g) + } + return out +} + // SortGoals sorts goals by: 1. Due ascending, 2. Stakes descending, 3. Name ascending func SortGoals(goals []Goal) { sort.Slice(goals, func(i, j int) bool { diff --git a/beeminder_test.go b/beeminder_test.go index be0c969..41def71 100644 --- a/beeminder_test.go +++ b/beeminder_test.go @@ -965,6 +965,30 @@ func TestFilterOutEndValueReached(t *testing.T) { } } +// TestFilterOutOverdue verifies that goals whose losedate is already in the +// past are dropped, so "next" surfaces the soonest goal that still has time +// left rather than an OVERDUE goal (issue #257). +func TestFilterOutOverdue(t *testing.T) { + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + past := now.Add(-1 * time.Hour).Unix() + future := now.Add(1 * time.Hour).Unix() + + goals := []Goal{ + {Slug: "overdue", Losedate: past}, + {Slug: "soon", Losedate: future}, + {Slug: "also-overdue", Losedate: now.Add(-5 * time.Minute).Unix()}, + {Slug: "later", Losedate: now.Add(48 * time.Hour).Unix()}, + } + + got := filterOutOverdue(goals, now) + if len(got) != 2 { + t.Fatalf("filterOutOverdue returned %d goals, want 2", len(got)) + } + if got[0].Slug != "soon" || got[1].Slug != "later" { + t.Errorf("filterOutOverdue returned unexpected goals: %q, %q", got[0].Slug, got[1].Slug) + } +} + // TestFormatGoalDueDateAt verifies that goals which have reached their end // value render as "COMPLETE" regardless of how far past their losedate is, // while still-active goals fall through to the normal losedate-based render. diff --git a/next.go b/next.go index a14d0f2..2af72aa 100644 --- a/next.go +++ b/next.go @@ -59,6 +59,11 @@ func displayNextGoal() error { // acting on a completed goal. goals = filterOutEndValueReached(goals) + // Skip overdue goals: "next" should point at the soonest goal that still + // has time left, not one that's already past its deadline (which would + // render as OVERDUE rather than a countdown). + goals = filterOutOverdue(goals, time.Now()) + // If no goals, return error if len(goals) == 0 { return fmt.Errorf("no goals found") From 8bc2427be393908cca7938fb5978a756a206b358 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Wed, 3 Jun 2026 21:13:11 -0500 Subject: [PATCH 4/7] fix(view): read current rate from the `currate` field The live Beeminder goal endpoint returns the current rate as `currate`, not `rcur` (the field name only `rcur`/`ravg` appeared in an older API dump). As written, the current/end rate split would never trigger because `Rcur` was always nil. Map `currate`, fall back to `rcur` via a CurrentRate() helper for any payload that still uses the old name, and verify against the live API: Rate: 0.1623 hours / day (current), 0.1104 (end) Follow-up to #259. Co-Authored-By: Claude Opus 4.8 (1M context) --- beeminder.go | 14 +++++++++++++- review.go | 4 ++-- review_test.go | 31 +++++++++++++++++++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/beeminder.go b/beeminder.go index 0023219..a3c8b20 100644 --- a/beeminder.go +++ b/beeminder.go @@ -21,7 +21,8 @@ type Goal struct { Autodata string `json:"autodata"` Autoratchet *float64 `json:"autoratchet"` // Pointer to handle null values from API Rate *float64 `json:"rate"` // End rate of the goal's bright line (final segment). Pointer to handle null values from API - Rcur *float64 `json:"rcur"` // Current rate of the bright line at today's date. Pointer to handle null values from API + Currate *float64 `json:"currate"` // Current rate: slope of the road segment in effect today. Pointer to handle null values from API + Rcur *float64 `json:"rcur"` // Legacy alias for the current rate seen in some API payloads; CurrentRate prefers Currate Runits string `json:"runits"` Gunits string `json:"gunits"` // Goal units, like "hours" or "pushups" or "pages" Deadline int `json:"deadline"` // Seconds by which deadline differs from midnight @@ -93,6 +94,17 @@ func filterOutOverdue(goals []Goal, now time.Time) []Goal { return out } +// CurrentRate returns the goal's current rate — the slope of the bright-line +// segment in effect today. Beeminder's goal endpoint exposes this as `currate`; +// some payloads have instead carried it as `rcur`, so we honour either, with +// `currate` taking precedence. Returns nil when neither field is present. +func (g Goal) CurrentRate() *float64 { + if g.Currate != nil { + return g.Currate + } + return g.Rcur +} + // SortGoals sorts goals by: 1. Due ascending, 2. Stakes descending, 3. Name ascending func SortGoals(goals []Goal) { sort.Slice(goals, func(i, j int) bool { diff --git a/review.go b/review.go index ed65bcb..dc02442 100644 --- a/review.go +++ b/review.go @@ -337,9 +337,9 @@ func formatGoalDetails(goal *Goal, config *Config) string { // today versus where the goal is heading. if goal.Rate != nil && goal.Runits != "" { rateStr := formatRate(*goal.Rate, goal.Runits, goal.Gunits) - if goal.Rcur != nil && *goal.Rcur != *goal.Rate { + if cur := goal.CurrentRate(); cur != nil && *cur != *goal.Rate { rateStr = fmt.Sprintf("%s (current), %s (end)", - formatRate(*goal.Rcur, goal.Runits, goal.Gunits), + formatRate(*cur, goal.Runits, goal.Gunits), formatRateValue(*goal.Rate)) } details += fmt.Sprintf("Rate: %s\n", rateStr) diff --git a/review_test.go b/review_test.go index 5b2155e..7a176a6 100644 --- a/review_test.go +++ b/review_test.go @@ -354,7 +354,7 @@ func TestReviewModelViewWithCurrentAndEndRate(t *testing.T) { Limsum: "+1 in 2 days", Baremin: "+2 in 1 day", Rate: &endRate, - Rcur: &curRate, + Currate: &curRate, Runits: "d", Gunits: "hours", }, @@ -383,7 +383,7 @@ func TestReviewModelViewWithEqualCurrentAndEndRate(t *testing.T) { Limsum: "+1 in 2 days", Baremin: "+2 in 1 day", Rate: &rate, - Rcur: &rate, + Currate: &rate, Runits: "d", }, } @@ -399,6 +399,33 @@ func TestReviewModelViewWithEqualCurrentAndEndRate(t *testing.T) { } } +func TestReviewModelViewCurrentRateFromLegacyRcur(t *testing.T) { + // Some API payloads carry the current rate as `rcur` rather than `currate`; + // CurrentRate() falls back to it so the current/end split still renders. + endRate := 1.0 + curRate := 0.5 + goals := []Goal{ + { + Slug: "testgoal", + Safebuf: 5, + Pledge: 10.0, + Losedate: 1234567890, + Limsum: "+1 in 2 days", + Baremin: "+2 in 1 day", + Rate: &endRate, + Rcur: &curRate, + Runits: "d", + }, + } + + config := &Config{Username: "testuser", AuthToken: "testtoken"} + view := initialReviewModel(goals, config).View() + + if !strings.Contains(view, "Rate: 0.5/day (current), 1 (end)") { + t.Errorf("Expected current/end split from rcur fallback, but got:\n%s", view) + } +} + func TestReviewModelViewWithoutRate(t *testing.T) { goals := []Goal{ { From f45b0f4c5b706a2d49024ae94e001fd989d23de2 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Thu, 4 Jun 2026 15:46:14 -0500 Subject: [PATCH 5/7] refactor(view): address bot review feedback on rate/overdue logic - Compare formatted rate strings, not raw floats, when deciding the current/end split, so values that round equal don't render split - Normalize a small negative rate that rounds to zero to "0" (no "-0") - Use math.Pow10 for base-10 scaling in formatRateValue - Compare Unix timestamps directly in filterOutOverdue Co-Authored-By: Claude Opus 4.8 (1M context) --- beeminder.go | 4 +++- review.go | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/beeminder.go b/beeminder.go index a3c8b20..950b4a3 100644 --- a/beeminder.go +++ b/beeminder.go @@ -86,7 +86,9 @@ func filterOutEndValueReached(goals []Goal) []Goal { func filterOutOverdue(goals []Goal, now time.Time) []Goal { out := make([]Goal, 0, len(goals)) for _, g := range goals { - if time.Unix(g.Losedate, 0).Before(now) { + // Losedate is already a Unix timestamp (seconds); compare integers + // directly rather than constructing a time.Time per goal. + if g.Losedate < now.Unix() { continue } out = append(out, g) diff --git a/review.go b/review.go index dc02442..00396a1 100644 --- a/review.go +++ b/review.go @@ -248,8 +248,13 @@ const rateDisplayDecimals = 4 // rateDisplayDecimals places, with trailing zeros trimmed and no scientific // notation (so large whole-number rates like 100000 stay readable). func formatRateValue(rate float64) string { - scale := math.Pow(10, rateDisplayDecimals) + scale := math.Pow10(rateDisplayDecimals) rounded := math.Round(rate*scale) / scale + if rounded == 0 { + // Normalize -0 (a small negative rate that rounds to zero) to "0" so + // do-less / downward-sloping goals don't render a confusing "-0". + return "0" + } return strconv.FormatFloat(rounded, 'f', -1, 64) } @@ -337,7 +342,7 @@ func formatGoalDetails(goal *Goal, config *Config) string { // today versus where the goal is heading. if goal.Rate != nil && goal.Runits != "" { rateStr := formatRate(*goal.Rate, goal.Runits, goal.Gunits) - if cur := goal.CurrentRate(); cur != nil && *cur != *goal.Rate { + if cur := goal.CurrentRate(); cur != nil && formatRateValue(*cur) != formatRateValue(*goal.Rate) { rateStr = fmt.Sprintf("%s (current), %s (end)", formatRate(*cur, goal.Runits, goal.Gunits), formatRateValue(*goal.Rate)) From e6f51702a3e276d96b2e532d22911da0415c9143 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Fri, 5 Jun 2026 13:41:02 -0500 Subject: [PATCH 6/7] fix(next): use a single now snapshot for filtering and rendering filterOutOverdue and the rendered countdown previously each called time.Now() independently, so a goal could pass the overdue filter and then render as OVERDUE moments later. Capture now once and thread it through both via the existing FormatGoalDueDateAt helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- next.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/next.go b/next.go index 2af72aa..5a3496b 100644 --- a/next.go +++ b/next.go @@ -59,10 +59,15 @@ func displayNextGoal() error { // acting on a completed goal. goals = filterOutEndValueReached(goals) + // Snapshot the time once so the overdue filter and the rendered countdown + // share a single reference instant. Otherwise a goal could pass the filter + // here and then render as OVERDUE moments later when formatted. + now := time.Now() + // Skip overdue goals: "next" should point at the soonest goal that still // has time left, not one that's already past its deadline (which would // render as OVERDUE rather than a countdown). - goals = filterOutOverdue(goals, time.Now()) + goals = filterOutOverdue(goals, now) // If no goals, return error if len(goals) == 0 { @@ -73,7 +78,7 @@ func displayNextGoal() error { nextGoal := goals[0] // Format the output: "goalslug baremin timeframe" - timeframe := FormatGoalDueDate(nextGoal) + timeframe := FormatGoalDueDateAt(nextGoal, now) // Output the terse summary fmt.Printf("%s %s %s\n", nextGoal.Slug, nextGoal.Baremin, timeframe) From 73d8b6265ba064dd0706e53d4e10b9b1a168c343 Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Fri, 5 Jun 2026 13:45:21 -0500 Subject: [PATCH 7/7] =?UTF-8?q?test(review):=20cycle=201=20=E2=80=94=20cov?= =?UTF-8?q?er=20formatRateValue=20-0=20and=20overdue=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-loop test-coverage findings (auto-applied, low-risk test-only): - formatRateValue's negative-zero normalization (-0 -> "0") was load-bearing but unexercised; add a -0.00001 case to TestFormatRate. - filterOutOverdue's strict "<" boundary (a goal due exactly at now is kept) was untested; add a due-now goal to TestFilterOutOverdue. Co-Authored-By: Claude Opus 4.8 (1M context) --- beeminder_test.go | 11 +++++++---- review_test.go | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/beeminder_test.go b/beeminder_test.go index 41def71..7731e8c 100644 --- a/beeminder_test.go +++ b/beeminder_test.go @@ -977,15 +977,18 @@ func TestFilterOutOverdue(t *testing.T) { {Slug: "overdue", Losedate: past}, {Slug: "soon", Losedate: future}, {Slug: "also-overdue", Losedate: now.Add(-5 * time.Minute).Unix()}, + // A goal whose losedate is exactly now is not yet past, so it must be + // kept (the filter uses a strict "<" boundary). + {Slug: "due-now", Losedate: now.Unix()}, {Slug: "later", Losedate: now.Add(48 * time.Hour).Unix()}, } got := filterOutOverdue(goals, now) - if len(got) != 2 { - t.Fatalf("filterOutOverdue returned %d goals, want 2", len(got)) + if len(got) != 3 { + t.Fatalf("filterOutOverdue returned %d goals, want 3", len(got)) } - if got[0].Slug != "soon" || got[1].Slug != "later" { - t.Errorf("filterOutOverdue returned unexpected goals: %q, %q", got[0].Slug, got[1].Slug) + if got[0].Slug != "soon" || got[1].Slug != "due-now" || got[2].Slug != "later" { + t.Errorf("filterOutOverdue returned unexpected goals: %q, %q, %q", got[0].Slug, got[1].Slug, got[2].Slug) } } diff --git a/review_test.go b/review_test.go index 7a176a6..261481e 100644 --- a/review_test.go +++ b/review_test.go @@ -264,6 +264,9 @@ func TestFormatRate(t *testing.T) { {0.21317778888888886, "d", "hours", "0.2132 hours / day"}, {0.1900775022222092, "d", "", "0.1901/day"}, {0.0, "d", "", "0/day"}, + // A small negative rate that rounds to zero must render as "0", not + // "-0", for do-less / downward-sloping goals (issue #260). + {-0.00001, "d", "", "0/day"}, } for _, tt := range tests {