diff --git a/beeminder.go b/beeminder.go index 6903696..950b4a3 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 @@ -19,7 +20,9 @@ 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 + 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 @@ -76,6 +79,34 @@ 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 { + // 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) + } + 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/beeminder_test.go b/beeminder_test.go index be0c969..7731e8c 100644 --- a/beeminder_test.go +++ b/beeminder_test.go @@ -965,6 +965,33 @@ 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()}, + // 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) != 3 { + t.Fatalf("filterOutOverdue returned %d goals, want 3", len(got)) + } + 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) + } +} + // 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..5a3496b 100644 --- a/next.go +++ b/next.go @@ -59,6 +59,16 @@ 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, now) + // If no goals, return error if len(goals) == 0 { return fmt.Errorf("no goals found") @@ -68,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) diff --git a/review.go b/review.go index 16eaa80..00396a1 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,30 @@ 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.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) } // formatRecentDatapoints formats up to 5 of the most recent datapoints for @@ -315,9 +337,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 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)) + } details += fmt.Sprintf("Rate: %s\n", rateStr) } diff --git a/review_test.go b/review_test.go index d42169f..261481e 100644 --- a/review_test.go +++ b/review_test.go @@ -259,6 +259,14 @@ 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"}, + // 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 { @@ -334,6 +342,93 @@ 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, + Currate: &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, + Currate: &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 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{ {